diff --git a/frontend/src/components/feedback/ToastCountdown.vue b/frontend/src/components/feedback/ToastCountdown.vue new file mode 100644 index 0000000..f6e7888 --- /dev/null +++ b/frontend/src/components/feedback/ToastCountdown.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/components/feedback/ToastHost.spec.ts b/frontend/src/components/feedback/ToastHost.spec.ts new file mode 100644 index 0000000..cecfe63 --- /dev/null +++ b/frontend/src/components/feedback/ToastHost.spec.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { useAuthStore } from '@/stores/auth.store' +import { useToastStore } from '@/stores/toast.store' +import { flushPromises } from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import ToastHost from './ToastHost.vue' + +// 화면 오른쪽 아래 알림 — 단추는 한 번만 움직이고(리뷰 #9), 초점이 든 알림이 닫히면 초점을 돌려준다(리뷰 #8) + +describe('ToastHost', () => { + let pinia: Pinia + let wrapper: VueWrapper | null = null + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = null + useToastStore().clear() + document.body.innerHTML = '' + }) + + /** 화면 — main 안의 입력칸 하나 + 알림 틀 */ + async function mountHost() { + const main = document.createElement('main') + const field = document.createElement('input') + main.append(field) + document.body.append(main) + wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } }) + await flushPromises() + return { main, field } + } + + function showUndo(onRun: () => void, expiresAt?: number): number { + return useToastStore().show({ + kind: 'success', + message: '메일을 보냈습니다.', + action: { label: '보내기 취소', run: onRun }, + expiresAt: expiresAt ?? null, + }) + } + + const actionButton = () => document.querySelector('.toast__action') + + it('단추를 누르면 알림을 닫고 할 일을 한 번 한다 — 닫히는 동안 한 번 더 눌러도 또 하지 않는다', async () => { + await mountHost() + let runs = 0 + showUndo(() => { + runs += 1 + }) + await flushPromises() + const button = actionButton() + if (!button) throw new Error('단추가 없다') + + button.click() + // 퇴장 전환 동안 남아 있는 같은 단추를 한 번 더 누른다 + button.click() + await flushPromises() + + expect(runs).toBe(1) + expect(useToastStore().toasts).toEqual([]) + }) + + it('초점이 든 알림이 닫히면(마감 시각 · 시간이 다 됨) 알림에 들어오기 전 자리로 초점을 돌려준다', async () => { + const { field } = await mountHost() + const id = showUndo(() => undefined, Date.now() + 10_000) + await flushPromises() + field.focus() + actionButton()?.focus() + expect(document.activeElement).toBe(actionButton()) + + // 마감이 있는 알림은 초점이 있어도 닫힌다 (그 시각이 지나면 되돌릴 수 없다) + useToastStore().dismiss(id) + await flushPromises() + + expect(document.activeElement).toBe(field) + }) + + it('그 자리가 사라졌으면 화면의 main 으로 — 초점을 받게 잠깐 단 tabindex 는 초점이 떠나면 뗀다', async () => { + const { main, field } = await mountHost() + const id = showUndo(() => undefined) + await flushPromises() + field.focus() + actionButton()?.focus() + field.remove() + + useToastStore().dismiss(id) + await flushPromises() + + expect(document.activeElement).toBe(main) + expect(main.getAttribute('tabindex')).toBe('-1') + expect(main.hasAttribute('data-focus-fallback')).toBe(true) + + main.blur() + expect(main.hasAttribute('tabindex')).toBe(false) + expect(main.hasAttribute('data-focus-fallback')).toBe(false) + }) + + it('단추를 눌러 닫혀도 초점을 돌려준다 — 할 일이 초점을 옮기면 그쪽이 이긴다', async () => { + const { field } = await mountHost() + const other = document.createElement('button') + document.body.append(other) + showUndo(() => other.focus()) + await flushPromises() + field.focus() + actionButton()?.focus() + + actionButton()?.click() + await flushPromises() + + expect(document.activeElement).toBe(other) + }) + + it('초점이 알림 밖에 있으면 닫혀도 초점을 건드리지 않는다', async () => { + const { field } = await mountHost() + const id = showUndo(() => undefined) + await flushPromises() + field.focus() + + useToastStore().dismiss(id) + await flushPromises() + + expect(document.activeElement).toBe(field) + }) + + it('알림 사이를 옮겨 다녀도 처음 들어온 자리를 기억한다', async () => { + const { field } = await mountHost() + const first = showUndo(() => undefined) + const second = showUndo(() => undefined) + await flushPromises() + const buttons = () => [...document.querySelectorAll('.toast__action')] + field.focus() + buttons()[0]?.focus() + buttons()[1]?.focus() + + useToastStore().dismiss(second) + await flushPromises() + expect(document.activeElement).toBe(field) + useToastStore().dismiss(first) + }) +}) diff --git a/frontend/src/components/feedback/ToastHost.vue b/frontend/src/components/feedback/ToastHost.vue index 47500ce..66845ad 100644 --- a/frontend/src/components/feedback/ToastHost.vue +++ b/frontend/src/components/feedback/ToastHost.vue @@ -4,7 +4,14 @@ // 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등) // 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update) // 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1) +// 마감 시각이 있는 알림(9단계 [보내기 취소])은 둘째 줄에 남은 시간 막대(ToastCountdown) — 그 시각에 닫히고 붙잡아도 멈추지 않는다 +// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 — +// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰) +// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction) +import { ref, watch } from 'vue' + import AppIcon from '@/components/AppIcon.vue' +import ToastCountdown from '@/components/feedback/ToastCountdown.vue' import type { IconName } from '@/shared/constants/icons' import { formatTraceCode } from '@/shared/utils/format' import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store' @@ -18,6 +25,13 @@ const TOAST_ICONS: Readonly(null) +/** 알림으로 초점이 들어오기 전 초점이 있던 요소 — 알림 사이를 옮겨 다닌 것은 세지 않는다 */ +let returnFocusTo: HTMLElement | null = null + /** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */ function onFocusOut(event: FocusEvent, id: number) { const next = event.relatedTarget @@ -25,18 +39,70 @@ function onFocusOut(event: FocusEvent, id: number) { toastStore.release(id, 'focus') } -/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */ +/** 바깥에서 알림으로 초점이 들어왔다 — 온 자리를 기억한다 (Tab · F6 · 마우스로 누름 — 온 자리가 없으면 null) */ +function onHostFocusIn(event: FocusEvent) { + const from = event.relatedTarget + if (from instanceof Node && hostRef.value?.contains(from)) return + returnFocusTo = from instanceof HTMLElement ? from : null +} + +/** 화면의 main 에 초점을 둔다 — 초점을 받을 수 없으면 tabindex="-1" 을 잠깐 단다 */ +function focusMainRegion() { + const main = document.querySelector('main') + if (!main) return + if (!main.hasAttribute('tabindex')) { + main.setAttribute('tabindex', '-1') + main.setAttribute(MAIN_FALLBACK_ATTR, '') + main.addEventListener( + 'blur', + () => { + main.removeAttribute('tabindex') + main.removeAttribute(MAIN_FALLBACK_ATTR) + }, + { once: true }, + ) + } + main.focus({ preventScroll: true }) +} + +/** 알림에 들어오기 전 자리로 초점을 돌려준다 — 그 요소가 사라졌거나 초점을 받지 못하면 main */ +function restoreFocus() { + const target = returnFocusTo + returnFocusTo = null + if (target?.isConnected && !hostRef.value?.contains(target)) { + target.focus({ preventScroll: true }) + if (document.activeElement === target) return + } + focusMainRegion() +} + +/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 (이미 닫힌 알림이면 아무것도 하지 않는다) */ function runAction(toast: Toast) { - const action = toast.action - toastStore.dismiss(toast.id) + const action = toastStore.takeAction(toast.id) if (action && 'run' in action) action.run() } + +// 알림이 닫힐 때 — 초점이 그 안에 있었으면 들어오기 전 자리로 (닫는 바로 그때 — 퇴장 전환 동안 남은 단추에 초점이 머물지 않게) +watch( + () => toastStore.toasts.map((toast) => toast.id), + (ids) => { + const host = hostRef.value + const active = document.activeElement + if (!host || !(active instanceof Element) || !host.contains(active)) return + const focusedId = Number(active.closest('[data-toast-id]')?.getAttribute('data-toast-id')) + if (ids.includes(focusedId)) return + restoreFocus() + }, + { flush: 'sync' }, +)