generated from ttipo/project-base
feat: 업데이트 내역 게시판 · 쓰기 화면 [보내기] 숨음과 목록 고르기 칸 고침 #26
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */
|
||||
|
||||
Reference in New Issue
Block a user