From b99ccfbf6cb9698d4b546d49ba8b34c52e671c26 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 22:08:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EB=B3=B4=EB=82=B4=EA=B8=B0?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EC=98=88=EC=95=BD=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=C2=B7=20=EC=99=B8=EB=B6=80=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EC=9D=B8=EC=87=84=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B8=20=EC=82=AC=EB=9E=8C=20=EC=B0=A8=EB=8B=A8=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=20=C2=B7=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]), 인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절. 리뷰로 고친 것: - 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다). 읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다. - 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도). - 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다. - 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도). - 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다. - 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게), 차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역. Co-Authored-By: Claude Opus 5.5 --- .../components/feedback/ToastCountdown.vue | 74 ++ .../src/components/feedback/ToastHost.spec.ts | 148 ++++ .../src/components/feedback/ToastHost.vue | 106 ++- .../src/components/mail/ComposeFooter.spec.ts | 67 ++ .../src/components/mail/ComposeFooter.vue | 193 ++++- .../mail/ComposeScheduleDialog.spec.ts | 146 ++++ .../components/mail/ComposeScheduleDialog.vue | 336 ++++++++ .../src/components/mail/MailBodyFrame.spec.ts | 42 +- .../src/components/mail/MailBodyFrame.vue | 26 +- frontend/src/components/mail/MailCheckbox.vue | 29 +- .../src/components/mail/MailListFrame.vue | 15 +- .../src/components/mail/MailListHeader.vue | 12 +- .../src/components/mail/MailboxMoreButton.vue | 61 +- frontend/src/components/mail/MailboxPanel.vue | 5 + .../components/mail/MailboxScheduledRow.vue | 95 +++ frontend/src/components/mail/MailboxTree.vue | 51 +- .../components/mail/MessageBlockDialog.vue | 239 ++++++ .../components/mail/MessageDeleteButton.vue | 86 +- .../src/components/mail/MessageList.spec.ts | 21 + .../src/components/mail/MessageListItem.vue | 33 +- .../src/components/mail/MessageMoreMenu.vue | 126 ++- .../src/components/mail/MessageReader.spec.ts | 19 +- .../mail/MessageReader.stage9.spec.ts | 738 ++++++++++++++++++ .../src/components/mail/MessageReader.vue | 149 ++-- .../components/mail/MessageReaderNotices.vue | 266 +++++++ .../src/components/mail/MessageSpamButton.vue | 5 +- .../src/components/mail/MessageTable.spec.ts | 35 + .../src/components/mail/MessageTableRow.vue | 29 +- .../components/mail/ScheduledMessageItem.vue | 167 ++++ .../components/mail/ScheduledMessageList.vue | 57 ++ .../components/settings/SettingsPageFrame.vue | 23 +- frontend/src/composables/useMail.spec.ts | 78 ++ frontend/src/composables/useMail.ts | 65 +- frontend/src/composables/useMailPrint.ts | 38 + frontend/src/composables/useMailSettings.ts | 47 ++ frontend/src/composables/useMessageRemoval.ts | 20 +- .../composables/usePendingSendRefresh.spec.ts | 163 ++++ .../src/composables/usePendingSendRefresh.ts | 110 +++ frontend/src/composables/usePopupPlacement.ts | 68 ++ .../composables/useScheduledMessageActions.ts | 118 +++ frontend/src/composables/useSenderBlock.ts | 115 +++ frontend/src/composables/useToast.ts | 25 +- .../pages/mail/ComposePage.sendlater.spec.ts | 402 ++++++++++ frontend/src/pages/mail/ComposePage.spec.ts | 5 +- frontend/src/pages/mail/ComposePage.vue | 145 +++- frontend/src/pages/mail/MailLayout.vue | 19 +- frontend/src/pages/mail/MailPrintPage.spec.ts | 270 +++++++ frontend/src/pages/mail/MailPrintPage.vue | 462 +++++++++++ .../pages/mail/MailboxPage.scheduled.spec.ts | 245 ++++++ frontend/src/pages/mail/MailboxPage.vue | 33 +- .../src/pages/settings/MailBlocksPage.spec.ts | 303 +++++++ .../src/pages/settings/MailBlocksPage.vue | 390 +++++++++ .../pages/settings/MailSendReadPage.spec.ts | 303 +++++++ .../src/pages/settings/MailSendReadPage.vue | 243 ++++++ .../src/pages/settings/SettingsLayout.spec.ts | 28 +- frontend/src/router/routes.ts | 40 +- frontend/src/shared/constants/icons.ts | 12 + frontend/src/shared/constants/mail-block.ts | 112 +++ frontend/src/shared/constants/mail-print.ts | 32 + .../src/shared/constants/mail-send-later.ts | 113 +++ .../src/shared/constants/mail-settings.ts | 34 + frontend/src/shared/constants/mail.ts | 23 + .../src/shared/constants/settings-menu.ts | 9 +- frontend/src/shared/types/mail.ts | 69 +- frontend/src/shared/utils/format.spec.ts | 30 + frontend/src/shared/utils/format.ts | 60 ++ .../shared/utils/keyboard-shortcut.spec.ts | 35 +- .../src/shared/utils/keyboard-shortcut.ts | 21 + frontend/src/shared/utils/mail-block.spec.ts | 136 ++++ frontend/src/shared/utils/mail-block.ts | 82 ++ frontend/src/shared/utils/mail-html.spec.ts | 89 ++- frontend/src/shared/utils/mail-html.ts | 70 +- .../src/shared/utils/mail-list-row.spec.ts | 17 +- frontend/src/shared/utils/mail-list-row.ts | 16 + frontend/src/shared/utils/mail-list-source.ts | 42 +- frontend/src/shared/utils/mail-quote.spec.ts | 16 + .../src/shared/utils/mail-send-result.spec.ts | 22 + frontend/src/shared/utils/mail-send-result.ts | 17 +- .../src/shared/utils/pending-send.spec.ts | 90 +++ frontend/src/shared/utils/pending-send.ts | 128 +++ .../src/shared/utils/schedule-send.spec.ts | 131 ++++ frontend/src/shared/utils/schedule-send.ts | 157 ++++ frontend/src/stores/mail-block.store.spec.ts | 98 +++ frontend/src/stores/mail-block.store.ts | 127 +++ frontend/src/stores/mail-settings.store.ts | 173 ++++ frontend/src/stores/mail.store.stage9.spec.ts | 456 +++++++++++ frontend/src/stores/mail.store.ts | 24 +- frontend/src/stores/mail/list.ts | 70 +- frontend/src/stores/mail/message.ts | 56 +- frontend/src/stores/mail/scheduled.ts | 156 ++++ frontend/src/stores/mail/selection.ts | 49 +- frontend/src/stores/toast.store.spec.ts | 64 ++ frontend/src/stores/toast.store.ts | 78 +- frontend/src/styles/base.css | 5 + frontend/src/styles/tokens.css | 4 + frontend/src/test-utils/router.ts | 21 + 96 files changed, 9749 insertions(+), 329 deletions(-) create mode 100644 frontend/src/components/feedback/ToastCountdown.vue create mode 100644 frontend/src/components/feedback/ToastHost.spec.ts create mode 100644 frontend/src/components/mail/ComposeFooter.spec.ts create mode 100644 frontend/src/components/mail/ComposeScheduleDialog.spec.ts create mode 100644 frontend/src/components/mail/ComposeScheduleDialog.vue create mode 100644 frontend/src/components/mail/MailboxScheduledRow.vue create mode 100644 frontend/src/components/mail/MessageBlockDialog.vue create mode 100644 frontend/src/components/mail/MessageReader.stage9.spec.ts create mode 100644 frontend/src/components/mail/MessageReaderNotices.vue create mode 100644 frontend/src/components/mail/ScheduledMessageItem.vue create mode 100644 frontend/src/components/mail/ScheduledMessageList.vue create mode 100644 frontend/src/composables/useMailPrint.ts create mode 100644 frontend/src/composables/useMailSettings.ts create mode 100644 frontend/src/composables/usePendingSendRefresh.spec.ts create mode 100644 frontend/src/composables/usePendingSendRefresh.ts create mode 100644 frontend/src/composables/usePopupPlacement.ts create mode 100644 frontend/src/composables/useScheduledMessageActions.ts create mode 100644 frontend/src/composables/useSenderBlock.ts create mode 100644 frontend/src/pages/mail/ComposePage.sendlater.spec.ts create mode 100644 frontend/src/pages/mail/MailPrintPage.spec.ts create mode 100644 frontend/src/pages/mail/MailPrintPage.vue create mode 100644 frontend/src/pages/mail/MailboxPage.scheduled.spec.ts create mode 100644 frontend/src/pages/settings/MailBlocksPage.spec.ts create mode 100644 frontend/src/pages/settings/MailBlocksPage.vue create mode 100644 frontend/src/pages/settings/MailSendReadPage.spec.ts create mode 100644 frontend/src/pages/settings/MailSendReadPage.vue create mode 100644 frontend/src/shared/constants/mail-block.ts create mode 100644 frontend/src/shared/constants/mail-print.ts create mode 100644 frontend/src/shared/constants/mail-send-later.ts create mode 100644 frontend/src/shared/constants/mail-settings.ts create mode 100644 frontend/src/shared/utils/mail-block.spec.ts create mode 100644 frontend/src/shared/utils/mail-block.ts create mode 100644 frontend/src/shared/utils/pending-send.spec.ts create mode 100644 frontend/src/shared/utils/pending-send.ts create mode 100644 frontend/src/shared/utils/schedule-send.spec.ts create mode 100644 frontend/src/shared/utils/schedule-send.ts create mode 100644 frontend/src/stores/mail-block.store.spec.ts create mode 100644 frontend/src/stores/mail-block.store.ts create mode 100644 frontend/src/stores/mail-settings.store.ts create mode 100644 frontend/src/stores/mail.store.stage9.spec.ts create mode 100644 frontend/src/stores/mail/scheduled.ts 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' }, +)