From f3586abcd7c5d58cadf1ca150e4660dbcf999518 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 22:14:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=EB=B0=9B=EB=8A=94=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=EC=9D=B4=20=EB=A7=8E=EC=9C=BC=EB=A9=B4=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=98=20[=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0]=EA=B0=80=20=ED=99=94=EB=A9=B4=20=EB=B0=96=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=A8=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 받는 사람 · 참조 · 숨은참조가 여러 줄이 되면 편집 영역(최소 300)이 줄지 않아 쓰기 칸 전체가 화면을 넘쳤다. 바깥 스크롤과 본문 스크롤이 겹치고, 흐름 끝의 [보내기] 줄이 화면 밖으로 밀렸다(사용자 보고 — 전 직원을 숨은참조에 넣었을 때). - [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky). 겹침 차례는 편집기 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 · 메뉴를 연 툴바 4 - 칩 · 입력 칸은 96px 을 넘으면 그 칸 안에서 스크롤한다(초점 · 칩 추가 · 입력 때 맨 아래로). 넓은 화면은 편집 영역이 200 까지 먼저 줄어 보통 높이에서 바깥 스크롤이 생기지 않는다 - 넓은 화면에서 숨긴 낭독 글이 앱 틀 밖에 놓여 문서까지 스크롤하던 것을 고치고(.compose position: relative), [보내기] 줄에 가린 칸으로 옮긴 초점은 가린 만큼 올린다(useFocusAboveFooter) Co-Authored-By: Claude Opus 5.5 --- .../src/components/mail/ComposeFooter.vue | 14 +- .../components/mail/RecipientField.spec.ts | 103 +++++++- .../src/components/mail/RecipientField.vue | 52 +++- .../mail/editor/EditorToolbar.spec.ts | 16 ++ .../components/mail/editor/EditorToolbar.vue | 10 +- .../src/components/mail/editor/MailEditor.vue | 10 + .../composables/useFocusAboveFooter.spec.ts | 216 +++++++++++++++++ .../src/composables/useFocusAboveFooter.ts | 79 ++++++ .../src/pages/mail/ComposePage.scroll.spec.ts | 224 ++++++++++++++++++ frontend/src/pages/mail/ComposePage.vue | 40 +++- frontend/src/styles/tokens.css | 12 + 11 files changed, 767 insertions(+), 9 deletions(-) create mode 100644 frontend/src/composables/useFocusAboveFooter.spec.ts create mode 100644 frontend/src/composables/useFocusAboveFooter.ts create mode 100644 frontend/src/pages/mail/ComposePage.scroll.spec.ts diff --git a/frontend/src/components/mail/ComposeFooter.vue b/frontend/src/components/mail/ComposeFooter.vue index c037a3e..37f16af 100644 --- a/frontend/src/components/mail/ComposeFooter.vue +++ b/frontend/src/components/mail/ComposeFooter.vue @@ -4,8 +4,10 @@ // [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계). // 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다. // 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 편집기를 불러오는 중 등 — canSend · 첨부의 까닭 — blockedReason) -// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로 +// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 화면 맨 아래에 붙으므로 // 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰) +// 줄은 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다(position: sticky) — 받는 사람이 많거나 화면이 낮아 쓰기 칸이 넘쳐도 +// [보내기]가 늘 보인다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 이 줄이 화면 밖으로 숨었다). 겹침 차례는 아래 style 의 주석 // 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일) // [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status). // 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다") @@ -227,13 +229,23 @@ watch(unavailable, (off) => { ')) + return css.replace(/\/\*[\s\S]*?\*\//g, '') +} + +/** { 로 여는 블록의 안쪽 — open 은 그 { 의 자리 */ +function blockAt(css: string, open: number): { inner: string; end: number } { + let depth = 0 + for (let i = open; i < css.length; i += 1) { + if (css[i] === '{') depth += 1 + else if (css[i] === '}') { + depth -= 1 + if (depth === 0) return { inner: css.slice(open + 1, i), end: i } + } + } + return { inner: '', end: css.length } +} + +/** 미디어 쿼리 블록을 뺀 맨 위 단계의 CSS */ +function topLevel(css: string): string { + let out = '' + let from = 0 + for (let at = css.indexOf('@media', from); at >= 0; at = css.indexOf('@media', from)) { + out += css.slice(from, at) + from = blockAt(css, css.indexOf('{', at)).end + 1 + } + return out + css.slice(from) +} + +/** 한 규칙의 본문 — media 를 주면 그 미디어 쿼리 안에서, 주지 않으면 맨 위 단계에서 */ +function ruleBody(source: string, selector: string, media?: string): string { + const css = cssOf(source) + let scope = topLevel(css) + if (media) { + const at = css.indexOf(`@media ${media}`) + scope = at < 0 ? '' : blockAt(css, css.indexOf('{', at)).inner + } + for (const [, found = '', body = ''] of scope.matchAll(/([^{}]+)\{([^}]*)\}/g)) { + if (found.trim() === selector) return body + } + return '' +} + +const WIDE = '(min-width: 70rem)' + +describe('쓰기 화면 스크롤 — 원본 style (2026-11-10 사용자 보고)', () => { + it('[보내기] 줄은 스크롤하는 칸의 아래에 붙는다 — sticky · bottom 0 · 흰 바탕, 위 선 · 여백은 그대로', () => { + const footer = ruleBody(footerSource, '.compose__footer') + expect(footer).toMatch(/position:\s*sticky;/) + expect(footer).toMatch(/bottom:\s*0;/) + expect(footer).toMatch(/z-index:\s*3;/) + expect(footer).toMatch(/background:\s*var\(--color-surface\);/) + expect(footer).toMatch(/border-top:\s*var\(--border-width\) solid var\(--color-border\);/) + expect(footer).toMatch(/padding:\s*var\(--space-10\) var\(--content-inline\);/) + }) + + it('겹침 차례 — 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4 (툴바는 줄 밑으로 지나가고, 툴바의 메뉴와 추천 목록은 줄 위에 뜬다)', () => { + const z = (source: string, selector: string) => Number(ruleBody(source, selector).match(/z-index:\s*(\d+);/)?.[1]) + expect(z(toolbarSource, '.editor-toolbar')).toBe(2) + expect(z(footerSource, '.compose__footer')).toBe(3) + expect(z(toolbarSource, '.editor-toolbar--popup-open')).toBe(4) + expect(z(suggestionListSource, '.recipient-suggestions')).toBe(4) + }) + + it('받는 사람 · 참조 · 숨은참조 — 칩 · 입력 칸은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤한다', () => { + const control = ruleBody(recipientSource, '.recipient-field__control') + expect(control).toMatch(/max-height:\s*calc\(var\(--size-recipient-chips-max\) \+ var\(--space-4\)\);/) + expect(control).toMatch(/overflow-y:\s*auto;/) + expect(control).toMatch(/overflow-x:\s*hidden;/) + // 안쪽 여백 4(위 · 좌우)를 바깥 여백으로 당긴다 — 자리 · 폭 · 줄바꿈은 그대로 + expect(control).toMatch(/margin:\s*calc\(var\(--space-4\) \* -1\) calc\(var\(--space-4\) \* -1\) 0;/) + expect(control).toMatch(/padding:\s*var\(--space-4\) var\(--space-4\) 0;/) + expect(control).toMatch(/flex:\s*1 1 calc\(20rem \+ var\(--space-4\) \* 2\);/) + }) + + it('넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 쓰기 칸 전체가 화면 안에 든다 — 좁은 화면은 300 그대로', () => { + expect(ruleBody(editorSource, '.mail-editor__frame')).toMatch(/min-height:\s*var\(--size-editor-body-min\);/) + expect(ruleBody(editorSource, '.mail-editor__frame', WIDE)).toMatch(/min-height:\s*var\(--size-editor-body-min-fit\);/) + expect(ruleBody(composeSource, '.compose__editor')).toMatch( + /min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min\) \+ var\(--border-width\)\);/, + ) + expect(ruleBody(composeSource, '.compose__editor', WIDE)).toMatch( + /min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min-fit\) \+ var\(--border-width\)\);/, + ) + expect(ruleBody(composeSource, '.compose', WIDE)).toMatch(/overflow-y:\s*auto;/) + }) + + it('쓰기 칸은 위치를 잡는다 — 안의 화면 낭독 전용 요소(.visually-hidden)가 앱 틀을 넘겨 문서까지 스크롤하지 않게', () => { + expect(ruleBody(composeSource, '.compose')).toMatch(/position:\s*relative;/) + }) +}) + +describe('쓰기 화면 — [보내기] 줄 밑에 가린 칸으로 온 초점은 올린다 (WCAG 2.4.11)', () => { + const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } + /** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 (테스트 한도 20초보다 짧게) */ + const EDITOR_LOAD_TIMEOUT_MS = 15_000 + let wrapper: VueWrapper | null = null + let pinia: Pinia + let clock: ManualClock + let frames: FrameRequestCallback[] + + beforeAll(installProseMirrorDom) + beforeAll(async () => { + await import('@/components/mail/editor/MailEditor.vue') + }, EDITOR_LOAD_TIMEOUT_MS) + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated(USER) + clock = createManualClock(1_000_000) + frames = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback) + return frames.length + }) + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = null + vi.restoreAllMocks() + restoreApiAdapter() + Reflect.deleteProperty(document.scrollingElement ?? document.documentElement, 'scrollTop') + document.body.innerHTML = '' + }) + + async function mountCompose() { + installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })) + const router = createTestRouter() + await router.push('/mail/compose') + await router.isReady() + wrapper = mount(ComposePage, { + attachTo: document.body, + global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } }, + }) + await flushPromises() + await vi.waitFor( + () => { + if (!wrapper?.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중') + }, + { timeout: EDITOR_LOAD_TIMEOUT_MS }, + ) + await flushPromises() + return wrapper + } + + function rectOf(top: number, bottom: number): DOMRect { + return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect + } + + function runFrames() { + for (const frame of frames.splice(0)) frame(0) + } + + it('좁은 화면(문서가 스크롤) — Tab 으로 간 제목 줄이 [보내기] 줄에 가렸으면 가린 만큼(+ 8) 문서를 올리고, 누른 칸은 그대로 둔다', async () => { + const page = await mountCompose() + const subject = page.get('#compose-subject').element as HTMLInputElement + const row = subject.closest('.compose-field') + const footer = page.get('.compose__footer').element + // [보내기] 줄 위 500 · 제목 줄 470~514 — 줄 아래 14px 이 가렸다 + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this === footer) return rectOf(500, 565) + if (this === row) return rectOf(470, 514) + return rectOf(0, 0) + }) + const documentScroller = document.scrollingElement ?? document.documentElement + const scroll = { top: 300 } + Object.defineProperty(documentScroller, 'scrollTop', { + configurable: true, + get: () => scroll.top, + set: (value: number) => { + scroll.top = value + }, + }) + runFrames() + + // 누른 칸은 그대로 — 보이는 곳을 누른 것이다 (누른 채 내용이 움직이면 글이 골라진다) + subject.dispatchEvent(new Event('pointerdown', { bubbles: true })) + subject.focus() + runFrames() + expect(scroll.top).toBe(300) + + // 1초 뒤 키보드로 다시 오면 올린다 — 514 + 8 − 500 = 22 + clock.advance(1000) + subject.blur() + subject.focus() + runFrames() + expect(scroll.top).toBe(322) + + // [보내기] 줄 안의 단추는 늘 보인다 — 올리지 않는다 + ;(page.get('.compose__send').element as HTMLButtonElement).focus() + runFrames() + expect(scroll.top).toBe(322) + }) +}) diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue index 7145f9f..f04dc17 100644 --- a/frontend/src/pages/mail/ComposePage.vue +++ b/frontend/src/pages/mail/ComposePage.vue @@ -23,6 +23,11 @@ // 그렇게 맞추지 못한 옛 주소(방문 기록의 ?draft=<옛 id>)로 돌아오면 이 탭이 마지막으로 저장한 저장본을 연다 (useComposeDraftLink). // - 휴지통 · 되돌리기는 useDraftTrash, 머리 · 아래 단추 줄 · 칸 안내 띠는 ComposeHeader · ComposeFooter · ComposeAlert. // 받는 사람 자동 완성: 열 때 추천을 한 번 받아 두고 세 칸이 함께 쓴다 (useRecipientSuggestions → RecipientField). +// 스크롤 (2026-11-10 사용자 보고 — 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다): +// - [보내기] 줄은 스크롤하는 칸(넓은 화면은 이 쓰기 칸, 좁은 화면은 문서)의 아래에 붙어 늘 보인다 (ComposeFooter — sticky) +// - 받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다 (RecipientField) +// - 넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 든다 (아래 style · MailEditor) +// - 그 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다 (useFocusAboveFooter) // 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다. // 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError). // 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정). @@ -91,6 +96,7 @@ import { useComposeDraftLink } from '@/composables/useComposeDraftLink' import { useComposeLargeFiles } from '@/composables/useComposeLargeFiles' import { useDraftSaving, type DraftSaveRejected, type DraftSaveRequest } from '@/composables/useDraftSaving' import { useDraftTrash } from '@/composables/useDraftTrash' +import { useFocusAboveFooter } from '@/composables/useFocusAboveFooter' import { useLeaveConfirm } from '@/composables/useLeaveConfirm' import { useMail } from '@/composables/useMail' import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions' @@ -325,6 +331,19 @@ const subjectInputRef = ref(null) const fileInputRef = ref(null) const attachButtonRef = ref(null) const attachmentListRef = ref | null>(null) +const footerRef = ref | null>(null) + +/** + * 아래에 붙은 [보내기] 줄 밑에 초점이 가리지 않게 (2026-11-10) — 키보드 · 코드로 초점이 들어온 칸이 그 줄에 가렸으면 가린 만큼 올린다. + * 줄은 넘쳐도 늘 보이도록 아래에 붙어 있다 (ComposeFooter · useFocusAboveFooter) + */ +const { onFocusIn: revealFocused, onPointerDown: notePointerDown } = useFocusAboveFooter({ + footer: () => { + const element: unknown = footerRef.value?.$el + return element instanceof HTMLElement ? element : null + }, + clock, +}) function queryId(value: unknown): string | null { return typeof value === 'string' && value !== '' ? value : null @@ -1265,6 +1284,8 @@ watch( aria-labelledby="compose-title" novalidate @submit.prevent="onSubmit" + @focusin="revealFocused" + @pointerdown.capture="notePointerDown" > diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 5bca981..2c0744b 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -252,6 +252,18 @@ --size-editor-size-button: 3.5rem; /* 56px — 글자 크기 단추 */ --size-editor-toolbar: 3.25rem; /* 52px — 툴바 높이 (위아래 4) */ --size-editor-body-min: 18.75rem; /* 300px — 본문 편집 영역의 최소 높이 (편집기를 불러오는 동안의 자리도 같다) */ + /* + * 200px — 넓은 화면(70rem 이상 — 쓰기 칸이 화면 높이에 묶여 따로 스크롤한다)에서 쓰기 칸이 화면보다 길어지려 할 때 본문 편집 영역이 줄어드는 한계. + * 자리가 넉넉하면 지금처럼 300 이상으로 늘어나고, 받는 사람이 많은 낮은 화면에서만 이 값까지 줄어 쓰기 칸 전체가 화면 안에 든다 — + * 그 밑으로는 쓰기 칸이 스크롤한다 (2026-11-10 사용자 보고 — 바깥 스크롤 + 본문 스크롤의 이중 스크롤) + */ + --size-editor-body-min-fit: 12.5rem; + /* + * 96px — 받는 사람 · 참조 · 숨은참조 칸(칩 + 입력)이 보이는 최대 높이: 칩 28 · 입력 34 · 줄 사이 4 로 맨 위에서는 칩 세 줄, + * 맨 아래(입력 자리)에서는 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다 — 반쯤 잘린 칩이 보이지 않게 (칸의 위 여백 4 는 RecipientField 가 더한다). + * 넘으면 그 칸 안에서 스크롤한다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 칸이 늘어 [보내기]가 화면 밖으로 숨었다) + */ + --size-recipient-chips-max: 6rem; --editor-toolbar-inline: 1.25rem; /* 20px — 툴바 좌우 여백. 버튼 안 12 를 더하면 첫 아이콘이 32px 선 */ --color-editor-pressed-hover: #d5ebed; /* 켜진 버튼에 마우스를 올렸을 때 */ --color-quote-bar: #d0d5dd; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */