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
+13 -1
View File
@@ -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) => {
</template>
<style scoped>
/*
* 스크롤하는 칸의 아래에 붙는다 — 모양(위 선 · 흰 바탕 · 여백)은 그대로이고 넘치는 동안만 내용 위에 떠 있다 (2026-11-10 사용자 보고).
* 겹침 차례: 편집기 툴바(2) < 이 줄(3) < 받는 사람 추천 목록 · 메뉴를 연 툴바(4) — 툴바는 이 줄 밑으로 지나가고,
* 추천 목록과 툴바의 메뉴는 이 줄 위에 뜬다. 이 줄의 메뉴([보내기 ▾] · [서명 ▾])는 이 줄 안에서 위로 열려 툴바 위에 온다.
* 위치를 잡은 줄이라 안의 화면 낭독 글(.visually-hidden)도 이 줄 안에 든다 — 넓은 화면에서 문서가 스크롤되지 않게
*/
.compose__footer {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* [보내기 ▾] — 둘을 한 덩어리로 (디자인 원본 MailSendLater: 모서리 8 · 사이에 옅은 흰 선). 메뉴는 이 덩어리 아래(모자라면 위)에서 연다 */
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
@@ -626,3 +626,104 @@ describe('RecipientField', () => {
})
})
})
describe('RecipientField — 칩이 세 줄을 넘으면 칸 안에서 스크롤 (2026-11-10 사용자 보고)', () => {
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘치고 [보내기]가 화면 밖으로 숨었다 — 칩 · 입력 칸(.recipient-field__control)은
// 높이 한도(--size-recipient-chips-max)에서 그 칸 안이 스크롤한다(값은 ComposePage.scroll.spec 이 원본 style 에서 본다).
// jsdom 은 배치를 하지 않으므로 칸의 높이를 정해 주고 scrollTop 을 붙잡는다
const MANY: RecipientToken[] = Array.from({ length: 30 }, (_, i) =>
tokenFromAddress({ name: '', email: `notice-staff${i + 1}@example.com` }),
)
/** 칩 · 입력 칸의 scrollTop — 칸의 내용 높이는 400 */
function scrollOf(wrapper: VueWrapper) {
const control = wrapper.get('.recipient-field__control').element
Object.defineProperty(control, 'scrollHeight', { configurable: true, value: 400 })
const box = { top: 0 }
Object.defineProperty(control, 'scrollTop', {
configurable: true,
get: () => box.top,
set: (value: number) => {
box.top = value
},
})
return box
}
it('초점이 오면 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있어 입력 자리가 보인다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
await wrapper.get('input').trigger('focus')
expect(scroll.top).toBe(400)
})
it('칩이 늘면(확정) 그린 뒤 맨 아래로 · 줄면(빈 입력칸 Backspace) 칸의 자리를 그대로 둔다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('new.one@example.com')
scroll.top = 0
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(31)
expect(scroll.top).toBe(400)
scroll.top = 120
await input.trigger('keydown', { key: 'Backspace' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(30)
expect(scroll.top).toBe(120)
})
it('글을 치면 맨 아래로 — 칸을 위로 올려 본 채 쳐도 치는 자리가 보인다 (한글 조합 중 글자도)', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('kim')
expect(scroll.top).toBe(400)
scroll.top = 0
await input.trigger('compositionupdate')
expect(scroll.top).toBe(400)
})
it('가운데 칩의 ✕ 는 그 칩만 지우고 초점은 지금처럼 입력칸으로 — 칸의 스크롤은 그대로 둔다(이어 지우게)', async () => {
const wrapper = mountField(MANY, suggestionsOf([]))
const scroll = scrollOf(wrapper)
const input = wrapper.get('input').element as HTMLInputElement
const focusSpy = vi.spyOn(input, 'focus')
scroll.top = 120
await wrapper.get('button[aria-label="notice-staff5@example.com 삭제"]').trigger('click')
expect(tokensOf(wrapper)).toHaveLength(29)
expect(tokensOf(wrapper).some((token) => token.address.email === 'notice-staff5@example.com')).toBe(false)
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
expect(document.activeElement).toBe(input)
expect(scroll.top).toBe(120)
// 다시 들어오면(Tab · 누름) 맨 아래로
input.blur()
input.focus()
expect(scroll.top).toBe(400)
wrapper.unmount()
})
it('추천 목록은 칩 · 입력 칸 밖(줄 아래)에 뜬다 — 칸이 스크롤해도 목록이 잘리지 않게', async () => {
const suggestions = suggestionsOf([{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }])
const wrapper = mountField(MANY, suggestions)
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue('도윤')
const list = wrapper.get('.recipient-suggestions')
expect(list.element.closest('.recipient-field')).toBe(wrapper.get('.recipient-field').element)
expect(list.element.closest('.recipient-field__control')).toBeNull()
wrapper.unmount()
})
})
@@ -16,6 +16,10 @@
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 칩이 세 줄을 넘으면 칩 · 입력 칸(.recipient-field__control) 안에서 스크롤한다 (--size-recipient-chips-max — 2026-11-10 사용자 보고:
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘쳤다). 추천 목록은 그 칸 밖(줄 아래)에 뜬다.
// 초점이 오거나 · 칩이 늘거나 · 글을 치면 칸을 맨 아래로(입력 자리가 보이게). 칩의 ✕ 로 지우면 초점은 지금처럼 입력칸으로 가되
// 칸의 스크롤은 그대로 둔다 — 가운데 칩을 이어 지울 때 자리가 맨 아래로 튀지 않게
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -79,6 +83,10 @@ const draft = ref<string>('')
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
const query = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
/** 칩 · 입력 칸 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 */
const controlRef = ref<HTMLElement | null>(null)
/** 칩을 지우며 입력칸에 초점을 줄 때 — 칸의 스크롤을 그대로 둔다 (onFocus 가 맨 아래로 옮기지 않게) */
let keepScrollOnFocus = false
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
const focused = ref<boolean>(false)
/** Esc 로 닫았다 — 다시 치면 연다 */
@@ -260,13 +268,24 @@ function onPaste(event: ClipboardEvent) {
commit(`${draft.value}${text}`)
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
/**
* 칩 · 입력 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있으므로 칩이 세 줄을 넘어 칸 안에서 스크롤할 때 입력 자리가 보인다.
* 넘치지 않으면 아무 일도 없다
*/
function revealInput() {
const control = controlRef.value
if (control) control.scrollTop = control.scrollHeight
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다). 치는 자리가 보이게 칸을 맨 아래로 */
function onTextInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
revealInput()
}
function onFocus() {
focused.value = true
if (!keepScrollOnFocus) revealInput()
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
suggestions?.preload()
}
@@ -282,7 +301,10 @@ function remove(token: RecipientToken) {
'update:modelValue',
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
)
inputRef.value?.focus()
// 초점은 지금처럼 입력칸으로 — 칩이 많아 칸 안에서 스크롤하는 중이면 그 자리를 그대로 둔다 (가운데 칩을 이어 지우게)
keepScrollOnFocus = true
inputRef.value?.focus({ preventScroll: true })
keepScrollOnFocus = false
}
function focus() {
@@ -321,6 +343,15 @@ watch(query, (text) => {
suggestions?.search(text)
})
// 칩이 늘면(확정 · 추천 · 그룹 · 붙여 넣기 · 저장본) 그린 뒤 칸을 맨 아래로 — 입력 자리가 보이게. 줄 때(지우기)는 그대로 둔다
watch(
() => props.modelValue.length,
(count, previous) => {
if (count > previous) revealInput()
},
{ flush: 'post' },
)
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => options.value.length,
@@ -344,7 +375,10 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<div
ref="controlRef"
class="recipient-field__control"
>
<span
v-for="token in modelValue"
:key="recipientTokenKey(token)"
@@ -444,13 +478,23 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
color: var(--color-text-secondary);
}
/*
* 칩 · 입력 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 (--size-recipient-chips-max 96 + 위 여백 4 = 100 · 2026-11-10 사용자 보고).
* 위 · 좌우에 안쪽 여백 4 를 두고 바깥 여백을 같은 만큼 당긴다 — 자리 · 폭 · 줄바꿈은 그대로이고, 칸 가장자리 칩의 ✕ 초점 고리(바깥 4)가
* 잘리지 않는다. 아래는 당기지 않는다 — 입력이 든 마지막 줄(34)은 칩(28)보다 높아 고리가 들고, 줄의 아래 선(초점 선)을 덮지 않게
*/
.recipient-field__control {
flex: 1 1 20rem;
flex: 1 1 calc(20rem + var(--space-4) * 2);
min-width: 0;
max-height: calc(var(--size-recipient-chips-max) + var(--space-4));
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
margin: calc(var(--space-4) * -1) calc(var(--space-4) * -1) 0;
padding: var(--space-4) var(--space-4) 0;
overflow-x: hidden;
overflow-y: auto;
}
.recipient-field__input {
@@ -222,6 +222,22 @@ describe('EditorToolbar (본문 서식 툴바)', () => {
expect(document.activeElement?.getAttribute('data-tool')).toBe('more')
})
it('메뉴 · 팝오버가 열린 동안만 툴바를 쓰기 화면 아래에 붙은 [보내기] 줄(z-index 3) 위로 올린다 — editor-toolbar--popup-open (2026-11-10)', async () => {
const view = mountEditor()
const toolbar = view.get('.editor-toolbar')
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'more').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
await view.get('[role="menu"][aria-label="서식 더보기"]').trigger('keydown', { key: 'Escape' })
await nextTick()
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'textColor').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
})
it('마우스로 열면 메뉴 자체에 초점(고른 항목에 키보드 고리가 뜨지 않게) — 아래 화살표로 항목에 들어간다. 키보드로 열면 켜진 항목에', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
@@ -341,7 +341,7 @@ defineExpose({
<template>
<div
ref="rootRef"
class="editor-toolbar"
:class="['editor-toolbar', { 'editor-toolbar--popup-open': popup !== null }]"
role="toolbar"
aria-label="본문 서식"
:aria-controls="MAIL_EDITOR_BODY_ID"
@@ -676,6 +676,14 @@ defineExpose({
background: var(--color-surface);
}
/*
* 메뉴 · 팝오버를 연 동안은 쓰기 화면 아래에 붙은 단추 줄(z-index 3 — ComposeFooter) 위에 — 툴바 아래로 연 메뉴가 그 줄에 가리지 않게.
* 닫으면 2 로 돌아가 툴바가 그 줄 밑으로 지나간다 (2026-11-10)
*/
.editor-toolbar--popup-open {
z-index: 4;
}
.editor-toolbar__group {
flex: 0 0 auto;
display: flex;
@@ -367,6 +367,16 @@ defineExpose({
overflow-y: auto;
}
/*
* 넓은 화면(70rem 이상)에서는 쓰기 화면이 화면 높이에 묶여 따로 스크롤한다 — 자리가 모자라면 편집 영역이 200 까지 먼저 줄어
* 쓰기 칸 전체가 화면 안에 든다(바깥 스크롤 + 본문 스크롤의 이중 스크롤을 막는다). 자리가 넉넉하면 지금처럼 늘어난다 (ComposePage · 2026-11-10)
*/
@media (min-width: 70rem) {
.mail-editor__frame {
min-height: var(--size-editor-body-min-fit);
}
}
/* 끌어다 놓는 동안의 덮개 — 2px 점선(포커스 선은 실선)이라 모양이 다르다 */
.mail-editor__drop {
position: absolute;
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { useFocusAboveFooter } from './useFocusAboveFooter'
// 아래에 붙은 [보내기] 줄 밑에 가린 칸으로 초점이 오면 가린 만큼 올린다 (2026-11-10 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 그 줄에 가렸다).
// jsdom 은 배치를 하지 않는다 — 상자 크기를 정해 주고, 스크롤 상자의 scrollTop 을 잡아 본다
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
}
/** scrollTop 을 담는 칸 — jsdom 은 스크롤하지 않으므로 값만 붙잡는다 */
function trackScroll(element: Element, start = 0): { value: number } {
const box = { value: start }
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => box.value,
set: (value: number) => {
box.value = value
},
})
return box
}
describe('useFocusAboveFooter — [보내기] 줄 밑에 초점이 가리지 않게', () => {
let clock: ManualClock
let frames: (() => void)[]
let scroller: HTMLElement
let row: HTMLElement
let input: HTMLInputElement
let attach: HTMLButtonElement
let footer: HTMLElement
let send: HTMLButtonElement
let rects: Map<Element, DOMRect>
beforeEach(() => {
clock = createManualClock(10_000)
frames = []
// 넓은 화면 — 쓰기 칸이 스크롤 상자다 (overflow-y: auto · 넘친다)
scroller = document.createElement('main')
scroller.style.overflowY = 'auto'
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
row = document.createElement('div')
row.className = 'compose-field'
input = document.createElement('input')
row.append(input)
attach = document.createElement('button')
footer = document.createElement('div')
send = document.createElement('button')
footer.append(send)
scroller.append(row, attach, footer)
document.body.append(scroller)
rects = new Map<Element, DOMRect>([
[scroller, rectOf(56, 656)],
[footer, rectOf(591, 656)],
[row, rectOf(560, 604)],
[input, rectOf(565, 599)],
[attach, rectOf(600, 644)],
[send, rectOf(601, 645)],
])
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
return rects.get(this) ?? rectOf(0, 0)
})
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
function setup() {
return useFocusAboveFooter({ footer: () => footer, clock, nextFrame: (callback) => frames.push(callback) })
}
/** 초점을 옮기고 그 focusin 을 넘긴다 (재는 것은 다음 그림 — frames) */
function focusIn(handler: ReturnType<typeof setup>, element: HTMLElement) {
element.focus()
const event = new FocusEvent('focusin', { bubbles: true })
Object.defineProperty(event, 'target', { value: element })
handler.onFocusIn(event)
}
function pointerDown(handler: ReturnType<typeof setup>, element: Element) {
const event = new Event('pointerdown', { bubbles: true }) as PointerEvent
Object.defineProperty(event, 'target', { value: element })
handler.onPointerDown(event)
}
function runFrames() {
for (const frame of frames.splice(0)) frame()
}
it('키보드로 간 칸이 그 줄에 가렸으면 가린 만큼(+ 8) 쓰기 칸을 올린다 — 줄 전체(.compose-field)를 드러낸다', () => {
const scroll = trackScroll(scroller, 100)
const handler = setup()
focusIn(handler, input)
// 그 자리에서 재지 않는다 — 브라우저가 초점에 맞춰 스크롤한 다음 그림에
expect(scroll.value).toBe(100)
runFrames()
// 줄 아래 604 + 8 − 그 줄의 위 591 = 21
expect(scroll.value).toBe(121)
})
it('줄 밖의 요소([파일 첨부])는 그 요소를 드러낸다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
it('가리지 않았으면 · 아래 줄 안의 단추면 그대로 둔다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
rects.set(attach, rectOf(500, 544))
focusIn(handler, attach)
focusIn(handler, send)
runFrames()
expect(scroll.value).toBe(0)
})
it('마우스 · 손가락으로 누른 칸은 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다. 1초가 지난 뒤의 키보드 초점은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, input)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(0)
clock.advance(1000)
attach.focus()
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('다른 곳을 누른 뒤 코드가 옮긴 초점(보내기 전 검사 → 받는 사람 칸 등)은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, send)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('칸이 보이는 자리보다 크면 칸의 위를 맞춘다 — 위가 화면 밖으로 나가지 않게', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
// 쓰기 칸 위 56 · 그 줄 위 591 — 보이는 자리는 535. 칸 300..900
rects.set(row, rectOf(300, 900))
focusIn(handler, input)
runFrames()
// 가린 만큼은 900 + 8 − 591 = 317 이지만 칸의 위가 56 + 8 까지만 올라간다 — 300 − 56 − 8 = 236
expect(scroll.value).toBe(236)
})
it('그린 차례 전에 초점이 다른 곳으로 갔으면 올리지 않는다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, input)
send.focus()
runFrames()
expect(scroll.value).toBe(0)
})
it('쓰기 칸이 스크롤하지 않으면(좁은 화면) 문서를 올린다', () => {
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 600 })
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = trackScroll(documentScroller, 40)
const handler = setup()
try {
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(40 + 644 + 8 - 591)
} finally {
// 문서의 scrollTop 은 다른 시험에 남기지 않는다
Reflect.deleteProperty(documentScroller, 'scrollTop')
}
})
it('쓰기 칸에 단 처리기는 안에서 올라온 focusin · pointerdown 을 받는다 (사건의 target 으로 가른다)', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
scroller.addEventListener('focusin', (event) => handler.onFocusIn(event))
scroller.addEventListener('pointerdown', (event) => handler.onPointerDown(event as PointerEvent), { capture: true })
// 누른 [파일 첨부]는 그대로 (focus 가 focusin 을 올려 보낸다)
attach.dispatchEvent(new Event('pointerdown', { bubbles: true }))
attach.focus()
runFrames()
expect(scroll.value).toBe(0)
// 1초 뒤 Tab 으로 온 초점은 올린다 — 그사이 지나간 칸(input)은 초점이 떠나 올리지 않는다
clock.advance(1000)
input.focus()
attach.focus()
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
})
@@ -0,0 +1,79 @@
import type { Clock } from '@/composables/useClock'
// 아래에 붙은 줄(position: sticky — 쓰기 화면의 [보내기] 줄) 밑에 초점이 가리지 않게 한다 (WCAG 2.4.11 — 2026-11-10 사용자 보고의 고침과 함께).
// 브라우저는 초점을 준 요소가 스크롤 상자 안에 있으면 스크롤하지 않는다 — 그 줄 밑에 들어간 요소(좁은 화면에서 Tab 으로 간 [파일 첨부] 등)는
// 가린 채 남는다. 키보드 · 코드(오류 칸으로 옮기는 초점 등)로 초점이 들어오면, 브라우저가 스스로 스크롤한 다음 그림에 재어 가린 만큼 올린다.
// 마우스 · 손가락으로 누른 요소는 보이는 곳을 누른 것이므로 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다.
// 드러내는 것은 쓰기 칸의 줄 전체(.compose-field — 받는 사람 줄 · 제목 줄 · 본문 편집 영역)이고, 줄이 보이는 자리보다 크면 줄의 위를 맞춘다.
// 쓰는 곳: 쓰기 화면의 form 에 @focusin · @pointerdown.capture (ComposePage)
/** 드러낸 요소와 아래 줄 사이 · 화면 위와의 틈 (--space-8) */
const FOCUS_GAP_PX = 8
/** 누른 뒤 이 안에 누른 줄로 온 초점은 누른 것으로 본다 — 손가락은 뗄 때 초점이 온다 */
const POINTER_FOCUS_WINDOW_MS = 1000
export interface FocusAboveFooterOptions {
/** 아래에 붙은 줄 */
footer: () => HTMLElement | null
/** 누른 때를 잰다 — 시험은 손으로 돌리는 시계 */
clock: Clock
/** 다음 그림 차례에 부른다 — 기본은 requestAnimationFrame (브라우저가 초점에 맞춰 스크롤한 뒤) */
nextFrame?: (callback: () => void) => void
}
/** 드러낼 칸 — 쓰기 칸의 줄(.compose-field)이면 줄 전체, 아니면 그 요소 */
function boxOf(target: Element): Element {
return target.closest('.compose-field') ?? target
}
/** 아래 줄이 붙어 있는 스크롤 상자 — 넓은 화면은 쓰기 칸, 좁은 화면은 문서 */
function scrollerOf(element: HTMLElement): Element {
for (let node = element.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node)
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
}
return document.scrollingElement ?? document.documentElement
}
export function useFocusAboveFooter(options: FocusAboveFooterOptions) {
const nextFrame = options.nextFrame ?? ((callback: () => void) => void requestAnimationFrame(() => callback()))
/** 마지막으로 누른 곳과 때 */
let pressed: { target: Node; at: number } | null = null
function onPointerDown(event: PointerEvent) {
if (event.target instanceof Node) pressed = { target: event.target, at: options.clock.now() }
}
/** 방금 이 칸 안을 눌렀다 — 그 누름으로 온 초점이다 */
function pressedInside(box: Element): boolean {
return (
pressed !== null && options.clock.now() - pressed.at < POINTER_FOCUS_WINDOW_MS && box.contains(pressed.target)
)
}
/** 가린 만큼 올린다 — 칸의 위가 보이는 자리 위로 나가지 않을 만큼까지 */
function reveal(target: Element, box: Element, footer: HTMLElement) {
// 그사이 초점이 다른 곳으로 갔거나 화면을 떠났다
if (document.activeElement !== target || !footer.isConnected) return
const rect = box.getBoundingClientRect()
const hidden = rect.bottom + FOCUS_GAP_PX - footer.getBoundingClientRect().top
if (hidden <= 0) return
const scroller = scrollerOf(footer)
const viewTop = scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top
const delta = Math.min(hidden, rect.top - viewTop - FOCUS_GAP_PX)
if (delta > 0) scroller.scrollTop += delta
}
function onFocusIn(event: FocusEvent) {
const target = event.target
if (!(target instanceof Element)) return
const footer = options.footer()
// 아래 줄 안의 단추는 늘 보인다
if (!footer || footer.contains(target)) return
const box = boxOf(target)
if (pressedInside(box)) return
nextFrame(() => reveal(target, box, footer))
}
return { onFocusIn, onPointerDown }
}
@@ -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>
+12
View File
@@ -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; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */