fix(frontend): 15단계 실사용 시험에서 나온 다섯 가지를 고친다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m24s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m55s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m1s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m49s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m34s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m31s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m52s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m15s

- 폭 360 에서 검색 칸이 상단 바 밖으로 넘쳐 화면이 옆으로 밀렸다(14단계부터) — 메일 검색 칸 · 비활성 통합 검색 칸의 최소 폭을 0 으로 둔다
- 읽기 칸 도구 줄이 이름을 숨기는 폭을 570 · 595 에서 580 · 605 로 올린다 — 실측한 필요 폭(569.19 · 594.83)에 여유가 1px 도 없어 글자 · 여백이 조금만 늘어도 두 줄로 꺾였다
- 잘못 놓은 파일 무시를 메일 화면(MailLayout)에서 앱 틀(AppShell)로 옮긴다 — 주소록 · 설정에 놓은 파일은 브라우저가 열어 웹메일을 떠났다. 쓰기 화면 · 서명 설정은 지금처럼 받는다
- 받은편지함 정리를 켠 차단 뒤에는 다음 메일 대신 목록으로 간다 — 다음 메일이 같은 보낸 사람의 것이면 열자마자 읽음이 되고 곧 정리가 스팸메일함으로 옮겼다
- 모달 창의 제목 · 글은 낱말 가운데서 줄을 바꾸지 않고("지울까/요?") 띄어쓰기 없는 긴 글만 아무 데서나 끊는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #23.
This commit is contained in:
2026-11-09 21:42:17 +09:00
parent 3c9b3727c6
commit 071fe9a60f
17 changed files with 271 additions and 37 deletions
@@ -11,6 +11,7 @@
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
// 제목 · 글은 낱말 단위로 줄을 바꾸고(keep-all) 띄어쓰기 없는 긴 글만 아무 데서나 끊는다(15단계 실사용 시험) — 슬롯 안의 글은 쓰는 쪽이 정한다
import { computed, onMounted, ref, watch } from 'vue'
interface Props {
@@ -190,6 +191,16 @@ onMounted(() => {
background: var(--color-backdrop);
}
/*
* 제목 · 글은 낱말 가운데서 줄을 바꾸지 않는다("지울까/요?" — 2026-11-09 실사용 시험). 띄어쓰기 없는 긴 글(긴 주소 · 파일 이름)은
* 창 밖으로 넘치지 않게 아무 데서나 끊는다 — 둘을 함께 둔다
*/
.modal-dialog__title,
.modal-dialog__message {
word-break: keep-all;
overflow-wrap: anywhere;
}
.modal-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
@@ -8,6 +8,7 @@
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
// - 좁은 폭(15단계 실사용 시험 — 360)에서는 상단 바 안쪽 폭에 맞게 칸이 준다(min-width 0 — 입력 칸의 최소 폭에 묶여 바 밖으로 넘치지 않게)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -152,6 +153,8 @@ watch(
.mail-search {
position: relative;
flex: 1 1 17.5rem;
/* 바 안쪽이 280 보다 좁으면(폭 360 — 256) 그만큼 준다. 없으면 입력 칸의 최소 폭에 묶여 바 밖으로 넘치고 화면이 옆으로 밀린다 */
min-width: 0;
max-width: 32.5rem;
display: flex;
align-items: center;
@@ -16,7 +16,8 @@
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
// 차단: 사내 직원(사내 도메인) · 내 주소에는 항목이 없다. 이미 막았으면(주소 또는 도메인) 「보낸 사람 차단 풀기」(바로 푼다 + [되돌리기]),
// 아니면 「보낸 사람 차단」 → 확인 창(MessageBlockDialog). 500개면 끄고 까닭을 아래에. 차단 목록은 메일 화면에 들어올 때 받아 두고,
// 메뉴를 열 때 못 받았으면 다시 받아 본다(알리지 않는다). 막으며 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로 간다
// 메뉴를 열 때 못 받았으면 다시 받아 본다(알리지 않는다). 막으며 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로 간다 —
// 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(15단계 새 칸) 다음 메일을 열지 않고 목록으로(분할 보기는 읽기 칸이 빈다)
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -50,7 +51,7 @@ import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageDetail
/** split: 차단하며 옮기면 다음 메일을 연다 · list: 목록으로 */
/** split: 차단하며 옮기면 다음 메일을 연다(받은편지함 정리를 켰으면 목록으로) · list: 목록으로 */
layout?: 'split' | 'list'
}
@@ -215,6 +216,8 @@ function focusButton() {
/**
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
* 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(cleanInbox — 15단계) 정리는 알림이 이어 보인다(기다리지 않는다).
* 그때는 다음 메일을 열지 않고 목록으로 간다 — 다음 메일이 같은 보낸 사람의 것이면 열자마자 읽음이 되고, 곧 정리가 스팸메일함으로 옮겨
* 받은편지함 주소에 그 메일이 남는다(2026-11-09 실사용 시험). 목록은 정리가 끝나면 다시 맞춘다(useSenderBlockRun).
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
*/
async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean) {
@@ -225,7 +228,7 @@ async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean)
const outcome = await senderBlock.block(message, target, moveToJunk, cleanInbox)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.isListedMessage(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
await removal.leaveRemoved({ removed, nextId: removed && !cleanInbox ? nextId : null }, source, props.layout, message.id)
}
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다 (확인 창도 닫는다 — 다른 메일의 보낸 사람을 막지 않게)
@@ -542,7 +542,7 @@ describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은
return wrapper.get('[data-block-dialog]')
}
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기]', async () => {
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기] · 다음 메일 대신 목록으로', async () => {
const { wrapper, router } = await mountWith({
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 3, remaining: 0, cursor: null }) : undefined,
@@ -571,8 +571,25 @@ describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은
action: { label: '스팸메일함 보기' },
},
])
// 옮겼으니 삭제처럼 다음 메일을 연다
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
// 다음 메일을 열지 않고 목록으로 — 다음 메일이 같은 보낸 사람의 것이면 읽음이 된 채 곧 정리가 옮긴다 (분할 보기는 읽기 칸이 빈다)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(requests.some((request) => request.method === 'GET' && request.url === '/messages/m2')).toBe(false)
})
it('목록 보기에서도 새 칸을 켠 차단은 목록으로 돌아간다 (새 칸을 켜지 않으면 지금처럼 다음 메일 — stage9 시험)', async () => {
const { wrapper, router } = await mountWith({
layout: 'list',
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 0, remaining: 0, cursor: null }) : undefined,
})
const dialog = await openBlockDialog(wrapper)
await dialog.get('[data-block-clean]').setValue(true)
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.url === '/mail-block-runs')).toHaveLength(1)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('이 탭에서 필터를 적용하는 중이면 새 칸이 꺼져 있다 — 까닭 글', async () => {
@@ -10,6 +10,7 @@ import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
import readerSource from './MessageReader.vue?raw'
// 15단계 — 읽기 화면의 [이동 ▾] · 좁은 읽기 칸의 아이콘만 도구 줄 (디자인 원본 Reader15 "1 · 2 · 3")
@@ -306,4 +307,17 @@ describe('MessageReader — 좁은 읽기 칸의 아이콘만 도구 줄 (15단
expect(list.get('.reader__toolbar').classes()).toContain('reader__toolbar--list')
list.unmount()
})
it('접는 폭 — 분할 보기 580 · 「영구 삭제」 605(실측 필요 폭 569.19 · 594.83 에 여유를 둔다), 목록 보기 850 · 875 (15단계 실사용 시험)', () => {
// jsdom 은 컨테이너 쿼리를 계산하지 않는다 — 원본의 기준을 읽는다 (px = rem × 16)
const queries = [...readerSource.matchAll(/@container reader-tools \(width < ([\d.]+)rem\) \{\s*([^\s{]+)/g)].map(
([, rem = '', selector = '']) => [Number(rem) * 16, selector],
)
expect(queries).toEqual([
[580, '.reader__toolbar'],
[605, '.reader__toolbar--long'],
[850, '.reader__toolbar--list'],
[875, '.reader__toolbar--list.reader__toolbar--long'],
])
})
})
@@ -8,8 +8,9 @@
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면" · "15단계").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 이동 · 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// 읽기 칸이 좁으면 도구 줄의 이름을 숨기고 아이콘만 둔다(15단계 — 디자인 원본 Reader15 "2"). 기준은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리 —
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함), 「영구 삭제」가 있으면 595. 목록 보기는 앞에 [목록으로] · 이전/다음이
// 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다. 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭(도구 줄 좌우 여백 포함 — 실측 569.19, 「영구 삭제」가 있으면 594.83)에 여유를 두어 580 · 605.
// 목록 보기는 앞에 [목록으로] · 이전/다음이 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다.
// 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
@@ -378,9 +379,10 @@ onBeforeUnmount(() => {
/*
* 읽기 칸이 단추 이름을 한 줄에 다 보일 폭보다 좁으면 — 이름을 숨기고 아이콘만 40×44 ([이동 ▾]는 ▾ 까지 남긴다).
* 폭(15단계 — 디자인 원본 Reader15 "2" 실측, 도구 줄 좌우 여백 포함): 분할 보기 570 · 「영구 삭제」 595, 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
* 폭(15단계 — 디자인 원본 Reader15 "2", 도구 줄 좌우 여백 포함): 분할 보기 580 · 「영구 삭제」 605 — 보드의 570 · 595 는 실측 필요 폭(569.19 · 594.83)에
* 여유가 0.81 · 0.17px 뿐이라 글자 · 여백이 1px 만 늘어도 좁은 구간에서 두 줄로 꺾인다(15단계 실사용 시험). 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
*/
@container reader-tools (width < 35.625rem) {
@container reader-tools (width < 36.25rem) {
.reader__toolbar :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
@@ -395,7 +397,7 @@ onBeforeUnmount(() => {
}
}
@container reader-tools (width < 37.1875rem) {
@container reader-tools (width < 37.8125rem) {
.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
@@ -0,0 +1,128 @@
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 { defineComponent, h } from 'vue'
import MailSignaturePage from '@/pages/settings/MailSignaturePage.vue'
import SettingsLayout from '@/pages/settings/SettingsLayout.vue'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
// 앱 틀 — 잘못 놓은 파일 무시 (15단계 실사용 시험: 메일 화면(MailLayout)에서만 막아서 주소록 · 설정에 놓은 파일은 브라우저가 열어 웹메일을 떠났다).
// 로그인 뒤 모든 모듈 화면이 이 틀을 쓴다 — 설정 화면(SettingsLayout)으로 본다. 파일을 받는 화면(서명 설정 — useWindowFileDrop)은 지금처럼 받는다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 서명 설정 테스트와 같은 값 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const SIGNATURE: MailSignature = {
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0">김아라 · 인프라팀</p></div>',
body: '김아라 · 인프라팀',
isForNew: true,
isForReply: true,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
const UPLOADED: SignatureImage = {
id: '00000000-0000-4000-8000-000000000001',
type: 'image/png',
size: 1000,
width: 480,
height: 60,
}
/**
* 파일을 받는 곳이 없는 설정 화면(비밀번호 자리). 시험 라우터의 함수형 빈 화면은 인스턴스가 없어
* RouterView 가 떠날 때(vnode.component) 다루지 못한다 — 화면을 옮겨 가는 이 시험에서는 보통 컴포넌트로 둔다
*/
const PasswordScreen = defineComponent({ name: 'PasswordScreen', render: () => h('h1', '비밀번호') })
const server: MockHandler = ({ method, url }) => {
if (method === 'GET' && url === '/mail-signature') return ok(SIGNATURE)
if (method === 'POST' && url === '/mail-signature/images') return ok(UPLOADED, 201)
return ok(null)
}
describe('AppShell — 잘못 놓은 파일 (15단계)', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/signature/SignatureForm.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
useToastStore().clear()
restoreApiAdapter()
})
it('설정 화면에 놓은 파일은 막고(놓을 수 없는 모양 · 브라우저가 열지 않는다), 서명 설정 화면은 지금처럼 그림을 받아 올린다', async () => {
const requests = installMockApi(server)
const router = createTestRouter()
// 같은 이름의 빈 화면을 바꿔 끼운다 — 서명 설정은 진짜 화면 (메뉴의 다른 설정 화면은 빈 화면 그대로)
router.addRoute({
path: '/settings/account/password',
name: 'settings-account-password',
component: PasswordScreen,
meta: { requiresAuth: true },
})
router.addRoute({
path: '/settings/mail/signature',
name: 'settings-mail-signature',
component: MailSignaturePage,
meta: { requiresAuth: true },
})
await router.push('/settings/account/password')
await router.isReady()
const wrapper = mount(SettingsLayout, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
expect(wrapper.get('h1').text()).toBe('비밀번호')
// 설정 › 비밀번호 — 파일을 받는 곳이 없다: 놓을 수 없는 모양이고, 놓아도 기본 동작(브라우저가 파일을 연다)을 막는다
const report = fakeDataTransfer([new File(['x'], '보고서.pdf')])
report.dropEffect = 'copy'
const over = dragEvent('dragover', report)
document.body.dispatchEvent(over)
expect(over.defaultPrevented).toBe(true)
expect(report.dropEffect).toBe('none')
const drop = dragEvent('drop', report)
document.body.dispatchEvent(drop)
expect(drop.defaultPrevented).toBe(true)
// 설정 › 서명 — 같은 앱 틀 안의 받는 화면이 화면 어디에 놓은 그림이든 받아 올린다
await router.push('/settings/mail/signature')
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
const logo = fakeDataTransfer([new File([new Uint8Array(1000)], '로고.png', { type: 'image/png' })])
document.body.dispatchEvent(dragEvent('dragover', logo))
expect(logo.dropEffect).toBe('copy')
document.body.dispatchEvent(dragEvent('drop', logo))
await flushPromises()
// 올린 것은 서명 설정에 놓은 그림 하나뿐 — 비밀번호 화면에 놓은 파일은 어디로도 가지 않았다
expect(requests.filter((request) => request.method === 'POST').map((request) => request.url)).toEqual([
'/mail-signature/images',
])
})
})
@@ -3,10 +3,13 @@
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
// 상단 바 바로 아래 모듈 폭 전체의 안내 줄은 notice 슬롯 (13단계 — 메일 화면의 2단계 인증 유예 줄)
// 잘못 놓은 파일은 무시한다(15단계 — 로그인 뒤 모든 모듈 화면): 받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다.
// 파일을 받는 화면(쓰기 화면 · 서명 설정 — useWindowFileDrop)이 떠 있으면 지금처럼 그 화면이 받는다 (useIgnoredWindowFileDrop)
import { computed } from 'vue'
import AppRail from './AppRail.vue'
import AppTopBar from './AppTopBar.vue'
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
import { findAppModule, type AppModuleId } from '@/shared/constants/app-modules'
interface Props {
@@ -16,6 +19,9 @@ interface Props {
const props = defineProps<Props>()
const moduleLabel = computed(() => findAppModule(props.moduleId).label)
// 메일 · 주소록 · 설정 어디에 파일을 잘못 놓아도 아무 일 없게 (받는 화면이 떠 있으면 그 화면에 맡긴다)
useIgnoredWindowFileDrop()
</script>
<template>
@@ -2,13 +2,29 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import mailSearchBoxSource from '@/components/mail/MailSearchBox.vue?raw'
import { useAuthStore } from '@/stores/auth.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AppTopBar from './AppTopBar.vue'
import topBarSource from './AppTopBar.vue?raw'
// 상단 바 — 15단계 좁은 폭(디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): 이름 · 주소 칸만 줄고 넘치면 "…", 전체는 마우스를 올리면 보인다.
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다.
// 검색 칸도 바 안쪽(폭 360 — 256)에 맞게 준다(15단계 실사용 시험 — 278 로 넘쳐 화면이 옆으로 밀렸다) — 그 규칙은 원본의 style 에서 읽는다
/** 컴포넌트 원본에서 한 규칙의 본문 — jsdom 은 컴포넌트의 style 을 적용하지 않는다 (주석은 뺀다 — MailboxPage.search.spec 과 같은 읽기) */
function ruleBody(source: string, selector: string): string {
const rules = source
.slice(source.indexOf('<style'))
.replace(/\/\*[\s\S]*?\*\//g, '')
.matchAll(/([^{}]+)\{([^}]*)\}/g)
for (const [, found = '', body = ''] of rules) {
if (found.replace('<style scoped>', '').trim() === selector) return body
}
return ''
}
describe('AppTopBar — 계정 칸', () => {
let pinia: Pinia
@@ -48,4 +64,31 @@ describe('AppTopBar — 계정 칸', () => {
expect(who.get('.top-bar__name').text()).toBe('김아라')
expect(who.get('.top-bar__email').text()).toBe('ara.kim@arasystem.it.kr')
})
it('폭 360 에서도 검색 칸이 바 밖으로 넘치지 않는다 — 메일 검색 칸은 바의 줄 칸이고, 칸 · 입력 칸 모두 최소 폭 0 이라 바 안쪽만큼 준다', async () => {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(AppTopBar, {
props: { title: '메일' },
slots: { search: MailSearchBox },
global: { plugins: [pinia, router] },
})
// 감싸는 칸 없이 바로 바의 줄 칸이다 — 감싸는 칸이 있으면 그 칸의 최소 폭(입력 칸의 기본 폭)에 다시 묶인다
const children = [...wrapper.get('.top-bar').element.children].map((child) => child.className)
expect(children).toEqual(['top-bar__title', 'mail-search', 'top-bar__account'])
// 280 에서 시작해 줄 수 있고(flex 1 1 17.5rem), 입력 칸의 기본 폭에 묶이지 않는다(min-width 0) — 비활성 통합 검색 칸도 같다
expect(ruleBody(mailSearchBoxSource, '.mail-search')).toMatch(/flex:\s*1 1 17\.5rem;/)
const shrinking = [
[mailSearchBoxSource, '.mail-search'],
[mailSearchBoxSource, '.mail-search__input'],
[topBarSource, '.top-bar__search'],
[topBarSource, '.top-bar__search-input'],
] as const
for (const [source, selector] of shrinking) {
expect(ruleBody(source, selector), selector).toMatch(/min-width:\s*0;/)
}
})
})
+4 -1
View File
@@ -3,7 +3,8 @@
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
// 좁은 폭(15단계 — 디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): [로그아웃] · 머리글자는 줄지도 꺾이지도 않고 이름 · 주소 칸만 준다 —
// 넘치면 끝을 "…"로 줄이고 전체는 마우스를 올리면 보인다(title). 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다.
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례)
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례).
// 검색 칸(모듈의 search 슬롯 · 비활성 통합 검색)도 바 안쪽보다 넓어지지 않는다 — 폭 360 에서 바 안쪽 256 에 맞게 준다(min-width 0)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -119,6 +120,8 @@ async function onLogout() {
.top-bar__search {
flex: 1 1 17.5rem;
/* 좁은 폭에서는 바 안쪽에 맞게 준다 (메일 검색 칸 MailSearchBox 와 같다) */
min-width: 0;
max-width: 32.5rem;
display: flex;
align-items: center;
+1 -1
View File
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailStore } from '@/stores/mail.store'
/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다) */
/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다 — 받은편지함 정리를 켰으면 목록으로) */
export interface SenderBlockOutcome {
blocked: boolean
moved: boolean
@@ -5,10 +5,10 @@ import { defineComponent, h, type PropType } from 'vue'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
import { useIgnoredWindowFileDrop, useWindowFileDrop } from './useWindowFileDrop'
// 15단계 — 메일 화면에 파일을 잘못 놓으면 브라우저가 그 파일을 열어 웹메일을 떠났다. 메일 화면(MailLayout)은 파일 놓기를 무시하고,
// 쓰기 화면(useWindowFileDrop — 같은 메일 화면 안)이 떠 있으면 그 화면이 지금처럼 첨부로 받는다
// 15단계 — 화면에 파일을 잘못 놓으면 브라우저가 그 파일을 열어 웹메일을 떠났다. 앱 틀(AppShell — 메일 · 주소록 · 설정)은 파일 놓기를 무시하고,
// 받는 화면(useWindowFileDrop — 같은 앱 틀 안의 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면이 지금처럼 받는다 (앱 틀 그대로는 AppShell.spec)
/** 메일 화면(파일 놓기 무시) — receive 를 주면 쓰기 화면(파일을 첨부로 받는다) */
/** 앱 틀(파일 놓기 무시) — receive 를 주면 받는 화면(쓰기 화면처럼 파일을 받는다) */
const Screen = defineComponent({
props: {
receive: { type: Function as PropType<((files: File[]) => void) | null>, default: null },
@@ -20,7 +20,7 @@ const Screen = defineComponent({
},
})
describe('useIgnoredWindowFileDrop — 메일 화면의 파일 놓기 무시', () => {
describe('useIgnoredWindowFileDrop — 앱 틀의 파일 놓기 무시', () => {
const mounted: VueWrapper[] = []
afterEach(() => {
@@ -4,8 +4,8 @@ import { isFileDrag } from '@/shared/utils/editor-files'
/**
* 지금 떠 있는 파일 받는 곳의 수 (쓰기 화면 · 서명 설정 — useWindowFileDrop).
* 메일 화면의 "파일 놓기 무시"(useIgnoredWindowFileDrop)는 받는 곳이 없을 때만 막는다 — 쓰기 화면도 메일 화면 안이라
* 둘의 창 처리기가 함께 걸린다. 어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면의 첨부 놓기가 이기게 (15단계)
* 앱 틀의 "파일 놓기 무시"(useIgnoredWindowFileDrop — AppShell)는 받는 곳이 없을 때만 막는다 — 쓰기 화면 · 서명 설정도 앱 틀 안이라
* 둘의 창 처리기가 함께 걸린다. 어느 쪽이 먼저 걸렸는지와 상관없이 받는 화면이 이기게 (15단계)
*/
let activeReceivers = 0
@@ -67,10 +67,10 @@ export function useWindowFileDrop(onFiles: (files: File[]) => void) {
}
/**
* 이 화면이 떠 있는 동안 창에 놓은 파일을 받지 않고 버린다 (15단계 — 메일 화면 · MailLayout).
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다 — 메일 목록 · 읽기 화면에 파일을 잘못 놓아도 아무 일 없게.
* 이 화면이 떠 있는 동안 창에 놓은 파일을 받지 않고 버린다 (15단계 — 앱 틀 AppShell: 메일 · 주소록 · 설정 등 로그인 뒤 모든 모듈 화면).
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다 — 목록 · 읽기 화면 · 주소록 · 설정에 파일을 잘못 놓아도 아무 일 없게.
* - 놓는 동안 마우스 커서는 놓을 수 없는 모양이다(dropEffect none — 메일함 패널도 파일은 받지 않는다)
* - 파일을 받는 화면(쓰기 화면 — useWindowFileDrop)이 떠 있으면 아무것도 하지 않는다(그 화면의 첨부 놓기에 맡긴다)
* - 파일을 받는 화면(쓰기 화면 · 서명 설정 — useWindowFileDrop)이 떠 있으면 아무것도 하지 않는다(그 화면에 맡긴다)
* - 글 · 메일 끌기(목록 줄을 메일함 패널로 — useMailDrag)는 건드리지 않는다
*/
export function useIgnoredWindowFileDrop() {
+1 -5
View File
@@ -11,8 +11,7 @@
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
// 메일 화면에 파일을 잘못 놓아도 아무 일 없게 한다(15단계 — 브라우저가 파일을 열어 웹메일을 떠나지 않게). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다
// (useIgnoredWindowFileDrop — 받는 화면이 떠 있으면 그쪽에 맡긴다)
// 잘못 놓은 파일은 앱 틀(AppShell)이 무시한다 — 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다 (15단계 — useWindowFileDrop)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
@@ -27,7 +26,6 @@ import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -72,8 +70,6 @@ useMailStatePolling({
useMailTabTitle()
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
usePendingSendRefresh({ clock })
// 목록 · 읽기 화면에 놓은 파일은 받지 않는다 (쓰기 화면이 떠 있으면 그 화면이 첨부로 받는다)
useIgnoredWindowFileDrop()
</script>
<template>
+1 -1
View File
@@ -8,7 +8,7 @@
* 그보다 좁으면 칸이 아래로 접히고 화면 전체가 스크롤된다.
* 목록 보기 표(MessageTable) — 68.6875rem(1099px) 이하에서 크기 칸 숨김, 37.4375rem(599px) 이하에서 칸 축소.
* 상단 바(AppTopBar) — 30rem(480px) 이하에서 계정 칸의 주소를 감추고 이름만 둔다 (15단계).
* 읽기 화면 도구 줄(MessageReader)은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리) — 35.625rem(570px, 휴지통은 37.1875rem 595px)보다 좁으면 아이콘만 (15단계).
* 읽기 화면 도구 줄(MessageReader)은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리) — 36.25rem(580px, 휴지통은 37.8125rem 605px)보다 좁으면 아이콘만 (15단계).
*/
:root {
/* ── 글꼴 ─────────────────────────────── */