fix(frontend): 받는 사람이 많으면 쓰기 화면의 [보내기]가 화면 밖으로 숨던 것을 고친다

받는 사람 · 참조 · 숨은참조가 여러 줄이 되면 편집 영역(최소 300)이 줄지 않아 쓰기 칸 전체가 화면을 넘쳤다. 바깥 스크롤과 본문 스크롤이 겹치고,
흐름 끝의 [보내기] 줄이 화면 밖으로 밀렸다(사용자 보고 — 전 직원을 숨은참조에 넣었을 때).

- [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky). 겹침 차례는 편집기 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 · 메뉴를 연 툴바 4
- 칩 · 입력 칸은 96px 을 넘으면 그 칸 안에서 스크롤한다(초점 · 칩 추가 · 입력 때 맨 아래로). 넓은 화면은 편집 영역이 200 까지 먼저 줄어 보통 높이에서 바깥 스크롤이 생기지 않는다
- 넓은 화면에서 숨긴 낭독 글이 앱 틀 밖에 놓여 문서까지 스크롤하던 것을 고치고(.compose position: relative), [보내기] 줄에 가린 칸으로 옮긴 초점은 가린 만큼 올린다(useFocusAboveFooter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 22:14:52 +09:00
parent 6eba013416
commit f3586abcd7
11 changed files with 767 additions and 9 deletions
@@ -0,0 +1,224 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import footerSource from '@/components/mail/ComposeFooter.vue?raw'
import recipientSource from '@/components/mail/RecipientField.vue?raw'
import suggestionListSource from '@/components/mail/RecipientSuggestionList.vue?raw'
import editorSource from '@/components/mail/editor/MailEditor.vue?raw'
import toolbarSource from '@/components/mail/editor/EditorToolbar.vue?raw'
import { CLOCK } from '@/composables/useClock'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
import composeSource from './ComposePage.vue?raw'
// 쓰기 화면 스크롤 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 받는 사람 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의
// 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다. 본문 위에서 휠을 돌리면 본문만 움직였다).
// - [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky) · 받는 사람 칸은 칩 세 줄을 넘으면 칸 안이 스크롤 · 넓은 화면은 본문 편집 영역이 200 까지 줄어든다
// - 겹침 차례: 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4
// jsdom 은 크기를 재지 않는다 — 배치 값은 원본의 style 을 읽고(ContactTransferDialogs.layout.spec 과 같은 읽기), 초점 올리기는 상자 크기를 정해 준다.
// 토큰 값(tokens.css — 받는 사람 칸 96 · 넓은 화면 편집 영역 200)은 시험에서 읽지 못한다(vitest 는 CSS 파일을 비운다 — ?raw 도) — 컴포넌트가 그 토큰을
// 쓰는지만 보고, 실제 폭 · 높이(1440×900 · 1366×768 · 1000×600 · 360×800)는 브라우저로 확인했다 (디자인 README "메일 쓰기 › 받는 사람이 많을 때")
/** 원본 style 의 CSS — 주석은 뺀다 */
function cssOf(source: string): string {
const start = source.indexOf('<style')
const css = start < 0 ? source : source.slice(source.indexOf('>', start) + 1, source.lastIndexOf('</style>'))
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)
})
})
+38 -2
View File
@@ -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<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentGroups> | null>(null)
const footerRef = ref<InstanceType<typeof ComposeFooter> | 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"
>
<ComposeHeader
:title="title"
@@ -1423,6 +1444,7 @@ watch(
</div>
<ComposeFooter
ref="footerRef"
:can-send="canSend"
:blocked-reason="sendBlock?.text ?? null"
:sending="sending"
@@ -1466,7 +1488,12 @@ watch(
</template>
<style scoped>
/*
* 위치를 잡은 칸 — 안의 화면 낭독 전용 요소(.visually-hidden — 파일 고르기 칸 등)가 이 칸 안에 든다.
* 잡지 않으면 화면 전체가 기준이 되어 넓은 화면에서 앱 틀(100vh)이 넘치고 문서까지 스크롤했다 (2026-11-10 — 이중 스크롤의 한 갈래)
*/
.compose {
position: relative;
flex: 999 1 35rem;
min-width: 0;
display: flex;
@@ -1525,7 +1552,7 @@ watch(
}
/*
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300 — 넓은 화면에서 자리가 모자라면 200 까지, 아래 미디어 쿼리) + 아래 선.
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
*/
.compose__editor {
@@ -1574,10 +1601,19 @@ watch(
color: var(--color-text-muted);
}
/* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
/*
* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준).
* 쓰기 칸이 화면보다 길어지려 하면 본문 편집 영역이 300 에서 200(--size-editor-body-min-fit)까지 먼저 줄어든다 — 받는 사람이 많아도
* 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(편집 영역의 같은 값은 MailEditor). 그래도 넘치면 쓰기 칸이 스크롤하고
* [보내기] 줄은 아래에 붙어 있다 (ComposeFooter — 2026-11-10 사용자 보고)
*/
@media (min-width: 70rem) {
.compose {
overflow-y: auto;
}
.compose__editor {
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min-fit) + var(--border-width));
}
}
</style>