generated from ttipo/project-base
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user