feat(frontend): 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기

- 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만
- [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두),
  같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름"
- 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시
- 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님
- 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창
- 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나
- 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다
- 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:37:55 +09:00
parent 1ca210dec4
commit d02a983d57
89 changed files with 7105 additions and 371 deletions
@@ -5,11 +5,14 @@
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
@@ -55,6 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -85,7 +90,10 @@ function onStar() {
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
]"
:data-row-id="row.id"
:draggable="!held"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
@dragend="drag.end"
>
<MailCheckbox
class="thread-row__check"
@@ -10,6 +10,8 @@
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
// - [모두 펼치기](15단계)로 펼친 줄은 본문을 바로 받지 않는다 — 이 안(data-conversation-panel)이 화면에 들어오면 칸이 스토어에 알려 차례로 받는다.
// 받기 전에는 받는 중과 같은 뼈대(aria-busy)다 (useConversationPanelVisibility)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -70,6 +72,7 @@ function onFrameKeydown(event: KeyboardEvent) {
<div
:id="panelId"
class="conversation-panel"
:data-conversation-panel="row.message.id"
>
<p
v-if="recipients"
@@ -6,6 +6,9 @@
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// - 펼친 줄 머리 오른쪽 [답장](15단계 — 디자인 원본 Reader15 "6", 36 — 안내 줄 단추 크기): 긴 메일을 끝까지 내리지 않고 답장한다.
// 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀(마우스를 올린 바탕은 틀에) 안에 펼침 단추와 [답장]을 나란히 둔다(단추 안에 단추를 넣지 않는다).
// 펼친 안 아래의 [답장] · [이 메일만 열기]는 그대로다. 임시 저장본 · 예약한 메일 · 그사이 지워진 메일의 줄에는 없다(아래 [답장]과 같다)
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
@@ -39,6 +42,13 @@ const emit = defineEmits<Emits>()
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
/** 펼친 줄 머리의 [답장] — 답장할 수 있는 메일이고(row.replyLocation) 그사이 지워지지 않았을 때 (펼친 안 아래 [답장]과 같은 조건) */
const headReply = computed(() => {
if (!props.expanded || !props.row.replyLocation) return null
const detail = props.detail
if (detail?.status === 'error' && detail.error.code === 'RES_001') return null
return props.row.replyLocation
})
</script>
<template>
@@ -69,39 +79,49 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
</div>
<template v-else>
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<div :class="['conversation-row__head', { 'conversation-row__head--reply': headReply }]">
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
<RouterLink
v-if="headReply"
class="conversation-row__reply"
:to="headReply"
data-conversation-head-reply
>
{{ MAIL_CONVERSATION_TEXT.reply }}
</RouterLink>
</div>
<ConversationMessagePanel
v-if="expanded"
:panel-id="panelId"
@@ -132,10 +152,47 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
text-align: left;
}
button.conversation-row__line:hover {
/* 다른 메일의 머리 — 펼침 단추와 [답장]을 감싸고, 마우스를 올린 바탕은 머리 전체에 (디자인 원본 Reader15 "6") */
.conversation-row__head {
display: flex;
align-items: center;
background: var(--color-surface);
}
.conversation-row__head:hover {
background: var(--color-surface-panel);
}
.conversation-row__head .conversation-row__line {
flex: 1 1 auto;
min-width: 0;
background: transparent;
}
/* [답장]이 있으면 단추 오른쪽 안쪽을 줄인다 — [답장]의 안쪽 여백이 시각과 사이를 둔다 */
.conversation-row__head--reply .conversation-row__line {
padding-right: var(--space-6);
}
/* 펼친 줄 머리의 [답장] — 36 글자 단추(안내 줄 단추의 밀도 예외 재사용), 펼친 안 아래의 [답장]과 같은 모양 */
.conversation-row__reply {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
margin-right: var(--space-4);
padding: 0 var(--space-10);
border-radius: var(--radius-md);
color: var(--color-brand);
font-weight: 600;
text-decoration: none;
}
.conversation-row__reply:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
button.conversation-row__line:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
@@ -6,11 +6,14 @@
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
@@ -47,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -80,8 +85,11 @@ function onStar() {
<tr
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="row.id"
:draggable="!held"
@click="onRowClick"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
@dragend="drag.end"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
@@ -3,17 +3,21 @@
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
// 15단계(디자인 원본 Reader15): 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-menu.ts — 읽기 화면의 [이동 ▾]와 같은 모양 · 같은 규칙).
// 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄로 남긴다("고른 메일이 있는 메일함"). 메뉴가 화면 밖으로 넘치면 좌우를 바꿔 연다(EditorPopup)
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
import { moveMenuItems } from '@/components/mail/mail-move-menu'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import { hasMoveTarget, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
interface Props {
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
mailboxes: readonly Mailbox[]
/** 옮길 곳 — 메일함 패널 차례 · 깊이, 고른 메일이 든 메일함 · 임시보관함 · 휴지통은 빼거나 고를 수 없는 줄로 (mail-move-targets.ts) */
mailboxes: readonly MailMoveTarget[]
labelMode: 'always' | 'wide' | 'never'
disabled?: boolean
}
@@ -35,10 +39,10 @@ const open = ref<boolean>(false)
const byKeyboard = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
const items = computed<EditorMenuItem[]>(() => props.mailboxes.map((mailbox) => ({ id: mailbox.id, label: mailbox.name })))
const items = computed<EditorMenuItem[]>(() => moveMenuItems(props.mailboxes, MAIL_BULK_TEXT.moveHereChecked))
function openMenu(keyboard: boolean) {
if (props.disabled || items.value.length === 0) return
if (props.disabled || !hasMoveTarget(props.mailboxes)) return
byKeyboard.value = keyboard
open.value = true
}
@@ -64,10 +68,11 @@ function onClose(restoreFocus: boolean) {
}
function onSelect(id: string) {
const target = props.mailboxes.find((row) => row.mailbox.id === id && !row.disabled)
if (!target) return
open.value = false
trigger.value?.focus()
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
if (target) emit('move', target)
emit('move', target.mailbox)
}
</script>
@@ -88,6 +93,7 @@ function onSelect(id: string) {
/>
<EditorMenu
v-if="open"
class="bulk-move__menu"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
@@ -103,4 +109,13 @@ function onSelect(id: string) {
.bulk-move {
position: relative;
}
/* 옮길 메일함 메뉴 — 이름 길이만큼(폭 200 부터 320 까지), 그보다 긴 이름은 줄을 바꾼다 (디자인 원본 Reader15) */
.bulk-move__menu {
width: max-content;
min-width: 12.5rem;
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
max-width: min(20rem, calc(100vw - 5rem));
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
// 목록 줄을 끄는 동안 마우스를 따라오는 표 — "메일 3통" · "대화 1개" (15단계 — 디자인 원본 ListDrag15 "끌어다 놓는 동안의 모양")
// 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투(메일) · 말풍선(대화) 아이콘. 마우스 오른쪽 아래에 붙는다.
// 브라우저는 끌기를 시작하는 순간 그려진 요소의 모습을 찍는다(setDragImage) — 그래서 메일 화면(MailLayout)에 늘 그려 두되 화면 밖에 두고,
// 수 · 아이콘은 끌기를 시작할 때 useMailDrag 가 이 요소에 바로 적는다(화면 갱신을 기다리면 늦다). 화면 낭독에는 읽히지 않는다(aria-hidden)
import { onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { registerMailDragChip } from '@/composables/useMailDrag'
const hostRef = ref<HTMLElement | null>(null)
onMounted(() => {
registerMailDragChip(hostRef.value)
})
onBeforeUnmount(() => {
registerMailDragChip(null)
})
</script>
<template>
<div
ref="hostRef"
class="drag-chip-host"
aria-hidden="true"
data-kind="messages"
data-mail-drag-chip
>
<span class="drag-chip">
<AppIcon
name="mail"
:size="14"
:stroke-width="2"
class="drag-chip__icon drag-chip__icon--messages"
/>
<AppIcon
name="message-square"
:size="14"
:stroke-width="2"
class="drag-chip__icon drag-chip__icon--conversations"
/>
<span data-drag-chip-text />
</span>
</div>
</template>
<style scoped>
/*
* 화면 밖에 그려 둔다 — 보이지 않아야 하지만 그려져 있어야 브라우저가 모습을 찍는다(display: none 이면 찍히지 않는다).
* 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 16 · 아래 22 만큼 비켜 둔다 (디자인 원본 ListDrag15)
*/
.drag-chip-host {
position: fixed;
top: 0;
left: -100rem;
padding: var(--space-22) 0 0 var(--space-16);
pointer-events: none;
}
.drag-chip {
display: inline-flex;
align-items: center;
gap: var(--space-6);
/* 위아래 5 · 오른쪽 10 · 왼쪽 8 (디자인 원본 ListDrag15) */
padding: 0.3125rem var(--space-10) 0.3125rem var(--space-8);
border-radius: var(--radius-md);
background: var(--color-brand-surface);
color: var(--color-surface);
font-size: var(--font-size-label);
font-weight: 600;
line-height: var(--line-height-tight);
white-space: nowrap;
box-shadow: var(--shadow-toast-strong);
}
/* 끄는 것에 맞는 아이콘만 — 메일 줄은 봉투, 대화 줄은 말풍선 */
.drag-chip-host[data-kind='messages'] .drag-chip__icon--conversations,
.drag-chip-host[data-kind='conversations'] .drag-chip__icon--messages {
display: none;
}
</style>
@@ -0,0 +1,422 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { endMailDrag, MAIL_DRAG_TYPE } from '@/composables/useMailDrag'
import type { ConversationSummary, Mailbox, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer, type FakeDataTransfer } from '@/test-utils/drag'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
import MailDragChip from './MailDragChip.vue'
import MessageList from './MessageList.vue'
import MessageTable from './MessageTable.vue'
// 15단계 — 목록 줄을 메일함 패널로 끌어다 놓기 · 목록 [이동 ▾]의 들여 쓴 나무 · 스팸메일함으로 옮기는 세 길 (디자인 원본 ListDrag15 · Reader15)
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-quotes', null, '견적', { parentId: 'project' }),
]
function summary(id: string, mailboxId = 'inbox', partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '박지훈', email: 'jihun.park@example.com' },
to: [],
subject: `견적 조건 문의드립니다 ${id}`,
preview: '',
receivedAt: '2026-11-08T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '박지훈', email: 'jihun.park@example.com' }],
messageCount: 3,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
describe('목록 줄 끌어다 놓기 · 목록 [이동 ▾] (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
// 끌기 자료는 탭(모듈)에 하나다 — 놓지 않고 끝난 시험의 끌기가 다음 시험에 남지 않게
endMailDrag()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function serve(messages: MessageSummary[], conversations: ConversationSummary[] = []) {
requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 9 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
}
if (request.url === '/conversations') {
return ok({
items: conversations,
pageInfo: { hasNext: false, nextCursor: null, total: conversations.length },
basis: 'index',
})
}
if (request.method === 'PATCH' && request.url === '/messages') {
return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
}
/** 메일 화면처럼 — 메일함 패널 · 목록 · 끄는 동안의 표 */
async function mountScreen(
options: {
boxId?: string
messages?: MessageSummary[]
conversations?: ConversationSummary[]
view?: 'split' | 'list'
} = {},
) {
const boxId = options.boxId ?? 'inbox'
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
serve(messages, options.conversations)
const store = useMailStore()
store.mailboxes = MAILBOXES
store.mailboxesStatus = 'ready'
if (options.conversations) {
store.listSource = { kind: 'conversations', mailboxId: boxId }
store.conversations = options.conversations
} else {
store.currentMailboxId = boxId
store.messages = messages
}
store.pageInfo = { hasNext: false, nextCursor: null, total: options.conversations?.length ?? messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}`)
await router.isReady()
const List = options.view === 'list' ? MessageTable : MessageList
const Screen = defineComponent({
setup: () => () => h('div', [h(MailboxPanel), h(List), h(MailDragChip)]),
})
const wrapper = mount(Screen, { global: { plugins: [pinia, router] }, attachTo: document.body })
mounted.push(wrapper)
await flushPromises()
return { wrapper, store, router }
}
const patches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
function panelRow(wrapper: VueWrapper, name: string, index = 0): DOMWrapper<Element> {
const rows = wrapper.findAll('.mailbox-row').filter((row) => row.get('.mailbox-row__name').text() === name)
const row = rows[index]
if (!row) throw new Error(`${name} 줄이 없다`)
return row
}
function listRow(wrapper: VueWrapper, id: string): DOMWrapper<Element> {
const row = wrapper.find(`[data-row-id="${id}"]`)
if (!row.exists()) throw new Error(`${id} 줄이 없다`)
return row
}
/** 줄을 끌기 시작한다 — 줄 안의 링크에서 시작해도 줄이 받는다 (브라우저는 링크의 주소를 먼저 실어 둔다) */
function startDrag(row: DOMWrapper<Element>): FakeDataTransfer {
const transfer = fakeDataTransfer()
transfer.setData('text/uri-list', 'http://localhost/mail/inbox/m2')
transfer.setData('text/plain', 'http://localhost/mail/inbox/m2')
const origin = row.find('a').exists() ? row.get('a').element : row.element
origin.dispatchEvent(dragEvent('dragstart', transfer))
return transfer
}
/** 패널 줄 위로 들어와 머문다 — dragover 를 받았는가(놓을 수 있는가) */
function hover(row: DOMWrapper<Element>, transfer: FakeDataTransfer): boolean {
row.element.dispatchEvent(dragEvent('dragenter', transfer))
const over = dragEvent('dragover', transfer)
row.element.dispatchEvent(over)
return over.defaultPrevented
}
async function dropOn(row: DOMWrapper<Element>, transfer: FakeDataTransfer) {
hover(row, transfer)
row.element.dispatchEvent(dragEvent('drop', transfer))
await flushPromises()
}
describe('끄는 것과 끄는 동안의 모양', () => {
it('줄을 끌면 우리 끌기 자료만 싣는다 — 링크의 메일 주소는 지우고, 마우스를 따라오는 표는 "메일 1통"', async () => {
const { wrapper } = await mountScreen()
const transfer = startDrag(listRow(wrapper, 'm2'))
expect(transfer.types).toEqual([MAIL_DRAG_TYPE])
expect(transfer.getData(MAIL_DRAG_TYPE)).not.toBe('')
expect(transfer.effectAllowed).toBe('move')
const chip = wrapper.get('[data-mail-drag-chip]')
expect(transfer.dragImage).toBe(chip.element)
expect(chip.text()).toBe('메일 1통')
expect(chip.attributes('data-kind')).toBe('messages')
expect(chip.attributes('aria-hidden')).toBe('true')
// 끈 줄은 흐리게 하지 않는다 — 줄은 그대로
expect(listRow(wrapper, 'm2').classes()).toEqual(['message-row'])
})
it('놓을 수 있는 메일함 위에서는 점선 · 옅은 청록(is-drop-target), 나가면 지운다 — 지금 메일함 · 임시보관함 · 「예약됨」은 받지 않는다', async () => {
const { wrapper, store } = await mountScreen()
store.scheduledCount = 1
await flushPromises()
const transfer = startDrag(listRow(wrapper, 'm2'))
const clients = panelRow(wrapper, '거래처')
expect(hover(clients, transfer)).toBe(true)
expect(transfer.dropEffect).toBe('move')
await flushPromises()
expect(clients.classes()).toContain('is-drop-target')
clients.element.dispatchEvent(dragEvent('dragleave', transfer))
await flushPromises()
expect(clients.classes()).not.toContain('is-drop-target')
for (const name of ['받은편지함', '임시보관함']) {
const row = panelRow(wrapper, name)
expect(hover(row, transfer)).toBe(false)
await flushPromises()
expect(row.classes()).not.toContain('is-drop-target')
}
const scheduled = wrapper.get('[data-scheduled-mailbox]')
const over = dragEvent('dragover', transfer)
scheduled.element.dispatchEvent(over)
expect(over.defaultPrevented).toBe(false)
// 휴지통은 놓을 수 있다 — [삭제]와 같다
expect(hover(panelRow(wrapper, '휴지통'), transfer)).toBe(true)
})
it('파일 끌기는 메일함 패널이 받지 않는다 (쓰기 화면의 첨부 놓기와 겹치지 않게)', async () => {
const { wrapper } = await mountScreen()
const files = fakeDataTransfer([new File(['x'], '보고서.pdf')])
expect(hover(panelRow(wrapper, '거래처'), files)).toBe(false)
})
it('보내기를 기다리는 메일의 줄은 끌 수 없다 — draggable 이 아니고 링크를 끌어도 막는다', async () => {
const held = summary('m9', 'sent', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-10T00:00:00Z' } })
const { wrapper } = await mountScreen({ boxId: 'sent', messages: [summary('m1', 'sent'), held] })
expect(listRow(wrapper, 'm9').attributes('draggable')).toBe('false')
expect(listRow(wrapper, 'm1').attributes('draggable')).toBe('true')
const transfer = fakeDataTransfer()
const start = dragEvent('dragstart', transfer)
listRow(wrapper, 'm9').get('a').element.dispatchEvent(start)
expect(start.defaultPrevented).toBe(true)
expect(transfer.types).toEqual([])
})
})
describe('놓으면', () => {
it('내 메일함에 놓으면 [이동 ▾]와 같다 — "메일 1통을 거래처로 옮겼습니다." + [되돌리기]', async () => {
const { wrapper, store } = await mountScreen()
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
expect(store.messages.map((message) => message.id)).toEqual(['m1', 'm3'])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 거래처로 옮겼습니다.')
expect(toast?.action && 'label' in toast.action ? toast.action.label : null).toBe('되돌리기')
expect(panelRow(wrapper, '거래처').classes()).not.toContain('is-drop-target')
})
it('같은 이름의 메일함이 또 있으면 위 메일함 이름을 붙인다 — "메일 1통을 프로젝트 › 견적으로 옮겼습니다."', async () => {
const { wrapper } = await mountScreen()
await dropOn(panelRow(wrapper, '견적', 1), startDrag(listRow(wrapper, 'm1')))
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project-quotes' }])
expect(toastTexts()).toEqual(['메일 1통을 프로젝트 › 견적으로 옮겼습니다.'])
})
it('고른 줄을 끌면 고른 것 모두 — 표는 "메일 2통", 휴지통에 놓으면 [삭제]와 같고 고른 것을 푼다', async () => {
const { wrapper, store } = await mountScreen()
store.toggleCheck('m1')
store.toggleCheck('m3')
await flushPromises()
const transfer = startDrag(listRow(wrapper, 'm3'))
expect(wrapper.get('[data-mail-drag-chip]').text()).toBe('메일 2통')
await dropOn(panelRow(wrapper, '휴지통'), transfer)
expect(patches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'trash' }])
expect(toastTexts()).toEqual(['메일 2통을 휴지통으로 옮겼습니다.'])
expect(store.checkedCount).toBe(0)
})
it('고르지 않은 줄을 끌면 그 줄만 — 고른 것은 그대로 둔다', async () => {
const { wrapper, store } = await mountScreen()
store.toggleCheck('m1')
await flushPromises()
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
expect([...store.checkedIds]).toEqual(['m1'])
})
it('스팸메일함에 놓으면 [스팸 신고]와 같다 — 스팸 표시를 함께, [되돌리기]는 표시도 되돌린다', async () => {
const { wrapper } = await mountScreen({ view: 'list' })
await dropOn(panelRow(wrapper, '스팸메일함'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'junk', isJunk: true }])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함에서 꺼내 놓으면 [스팸 아님]과 같다 — 휴지통에 놓으면 표시는 그대로', async () => {
const { wrapper } = await mountScreen({ boxId: 'junk', messages: [summary('m1', 'junk'), summary('m2', 'junk')] })
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm1')))
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients', isJunk: false }])
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다.'])
await dropOn(panelRow(wrapper, '휴지통'), startDrag(listRow(wrapper, 'm2')))
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'trash' })
})
it('대화로 묶은 목록의 줄은 그 대화에서 이 메일함에 든 메일 — 표는 "대화 1개", 알림은 "받은편지함에 있는 메일 2통을 …"', async () => {
const rows = [
conversation('c1', ['m6', 'm5']),
conversation('c2', ['m4', 'm3'], { pendingSendIds: ['m3'] }),
conversation('c3', ['m2'], { pendingSendIds: ['m2'] }),
]
const { wrapper } = await mountScreen({ conversations: rows })
// 보내기를 기다리는 메일뿐인 줄은 끌 수 없다
expect(listRow(wrapper, 'c3').attributes('draggable')).toBe('false')
const transfer = startDrag(listRow(wrapper, 'c1'))
const chip = wrapper.get('[data-mail-drag-chip]')
expect(chip.text()).toBe('대화 1개')
expect(chip.attributes('data-kind')).toBe('conversations')
await dropOn(panelRow(wrapper, '견적'), transfer)
expect(patches()).toEqual([{ ids: ['m6', 'm5'], mailboxId: 'quotes' }])
expect(toastTexts()).toEqual(['받은편지함에 있는 메일 2통을 거래처 › 견적으로 옮겼습니다.'])
// 붙잡힌 메일은 빼고 옮긴다 — [이동 ▾]와 같다
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'c2')))
expect(patches().at(-1)).toEqual({ ids: ['m4'], mailboxId: 'clients' })
})
it('다른 탭 · 바깥에서 끌어 온 같은 종류의 자료는 받지 않는다 (이 탭에서 시작한 끌기만)', async () => {
const { wrapper } = await mountScreen()
const foreign = fakeDataTransfer()
foreign.setData(MAIL_DRAG_TYPE, 'other-tab')
expect(hover(panelRow(wrapper, '거래처'), foreign)).toBe(false)
panelRow(wrapper, '거래처').element.dispatchEvent(dragEvent('drop', foreign))
await flushPromises()
expect(patches()).toEqual([])
})
})
describe('목록 [이동 ▾] — 읽기 화면과 같은 나무 · 같은 규칙', () => {
async function openMoveMenu(wrapper: VueWrapper) {
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
return wrapper.get('[role="menu"]')
}
it('메일함 패널처럼 들여 쓰고 화면 낭독은 위 메일함부터 — 고른 메일이 든 메일함에 하위가 있으면 흐린 줄 "고른 메일이 있는 메일함"', async () => {
const { wrapper, store } = await mountScreen({
boxId: 'clients',
messages: [summary('m1', 'clients'), summary('m2', 'clients')],
view: 'list',
})
store.toggleCheck('m1')
await flushPromises()
const menu = await openMoveMenu(wrapper)
const items = menu.findAll('[role="menuitem"]')
expect(items.map((item) => item.attributes('aria-label') ?? item.find('.menu__label').text())).toEqual([
'받은편지함',
'보낸편지함',
'스팸메일함',
'거래처',
'거래처 안의 견적',
'프로젝트',
'프로젝트 안의 견적',
])
const here = items[3]
expect(here?.attributes('aria-disabled')).toBe('true')
expect(here?.get('.menu__note').text()).toBe('고른 메일이 있는 메일함')
expect(menu.findAll('hr')).toHaveLength(1)
})
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 · 알림의 메일함 이름은 같은 이름이 있을 때만 위 메일함까지', async () => {
const { wrapper, store } = await mountScreen({ view: 'list' })
store.toggleCheck('m1')
await flushPromises()
let menu = await openMoveMenu(wrapper)
await menu.findAll('[role="menuitem"]').find((item) => item.text() === '스팸메일함')?.trigger('click')
await flushPromises()
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
expect(toastTexts()).toEqual(['메일 1통을 스팸메일함으로 옮겼습니다.'])
store.toggleCheck('m2')
await flushPromises()
menu = await openMoveMenu(wrapper)
await menu
.findAll('[role="menuitem"]')
.find((item) => item.attributes('aria-label') === '거래처 안의 견적')
?.trigger('click')
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'quotes' })
expect(toastTexts().at(-1)).toBe('메일 1통을 거래처 › 견적으로 옮겼습니다.')
})
})
})
@@ -4,6 +4,11 @@
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
// 누를 수 없는 까닭을 알려야 하면 disabled 대신 unavailable(aria-disabled — 초점은 받고 누르면 아무것도 하지 않는다) + describedby(까닭 글의 id).
// 하는 중이면 busy(aria-busy — 누르면 아무것도 하지 않는다)
// 15단계(디자인 원본 Reader15 "2"):
// - collapsible: 읽기 칸이 좁으면 이름을 숨기고 아이콘만(40×44) 둘 수 있는 단추 — 숨기는 때는 읽기 화면이 정한다(MessageReader 의 컨테이너 쿼리).
// 이름은 숨겨도 화면 낭독(aria-label)과 마우스를 올리면 뜨는 글(title)로 읽힌다
// - menu: 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때도 ▾ 는 남긴다 — [이동 ▾])
import { ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
@@ -26,6 +31,10 @@ interface Props {
/** 까닭 · 설명 글의 id — aria-describedby */
describedby?: string
iconStrokeWidth?: number
/** 좁은 읽기 칸에서 아이콘만 둘 수 있다 (15단계) — 이름은 aria-label · title 로도 */
collapsible?: boolean
/** 메뉴를 여는 단추 — 이름 뒤 ▾ */
menu?: boolean
}
interface Emits {
@@ -41,33 +50,46 @@ const props = withDefaults(defineProps<Props>(), {
busy: false,
describedby: undefined,
iconStrokeWidth: 1.75,
collapsible: false,
menu: false,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
function onButtonClick(event: MouseEvent) {
if (props.unavailable || props.busy) return
emit('click', event)
}
defineExpose({
/** 단추 요소(단추일 때) — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
element: buttonRef,
})
</script>
<template>
<RouterLink
v-if="to"
:to="to"
:class="['tool-button', `tool-button--${tone}`]"
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
</RouterLink>
<a
v-else-if="href"
:href="href"
:class="['tool-button', `tool-button--${tone}`]"
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
@click="emit('click', $event)"
>
<AppIcon
@@ -75,16 +97,23 @@ function onButtonClick(event: MouseEvent) {
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
</a>
<button
v-else
ref="buttonRef"
type="button"
:class="['tool-button', `tool-button--${tone}`]"
:class="[
'tool-button',
`tool-button--${tone}`,
{ 'tool-button--collapsible': collapsible, 'tool-button--menu': menu },
]"
:disabled="disabled"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
:aria-describedby="describedby"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
@click="onButtonClick"
>
<AppIcon
@@ -92,7 +121,14 @@ function onButtonClick(event: MouseEvent) {
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
<AppIcon
v-if="menu"
name="chevron-down"
:size="12"
:stroke-width="2"
class="tool-button__chevron"
/>
</button>
</template>
@@ -125,6 +161,13 @@ function onButtonClick(event: MouseEvent) {
cursor: progress;
}
/* 메뉴를 여는 단추 — ▾ 쪽 안쪽을 줄인다 (디자인 원본 Reader15 [이동 ▾] — 왼쪽 10 · 오른쪽 8) */
.tool-button--menu {
justify-content: center;
min-width: var(--size-icon-button);
padding-right: var(--space-8);
}
.tool-button--accent {
color: var(--color-brand-strong);
font-weight: 600;
@@ -1,8 +1,10 @@
<script setup lang="ts">
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// 메일함 지우기 확인 (디자인 원본 MailboxManage · Mailbox15, 공용 모양 ModalDialog — role=alertdialog)
// - "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
// - 하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계 — 지금까지의 "지울 수 없습니다" 창을 대신한다):
// "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" · "하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 …"
// (메일이 하나도 없으면 "하위 메일함까지 모두 비어 있습니다. …"). 하위 메일함 수 · 메일 수는 모든 깊이를 센다(부르는 쪽 — MailboxDialogs)
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
import { computed } from 'vue'
@@ -14,10 +16,10 @@ import { formatCount } from '@/shared/utils/format'
interface Props {
open: boolean
mailboxName: string
/** 안의 메일 수 */
/** 휴지통으로 옮길 메일 수 — 하위 메일함이 있으면 하위까지 더한 수 */
messageCount: number
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
hasChildren: boolean
/** 모든 깊이의 하위 메일함 수 — 있으면 하위까지 지울지 묻는다 (15단계) */
childCount?: number
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
blocked?: boolean
}
@@ -28,15 +30,22 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
childCount: 0,
blocked: false,
})
const emit = defineEmits<Emits>()
const title = computed(() =>
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
props.childCount > 0
? MAILBOX_DIALOG_TEXT.deleteTreeTitle(props.mailboxName, formatCount(props.childCount))
: MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
)
const message = computed(() => {
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
if (props.childCount > 0) {
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteTreeMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteTreeEmptyMessage
}
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
@@ -56,35 +65,23 @@ const message = computed(() => {
>
<template #actions>
<button
v-if="hasChildren"
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
<template v-else>
<button
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
</template>
</template>
</ModalDialog>
</template>
+86 -27
View File
@@ -1,21 +1,24 @@
<script setup lang="ts">
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · Mailbox15 · useMailboxActions)
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 옮기기(15단계): 옮길 곳 창. 같은 이름(MAIL_009 — 그사이 다른 창에서 만듦)은 목록 아래에 보이고 창을 둔다. 메일함 · 옮길 곳이 사라졌으면(RES_001) ·
// 옮길 수 없는 자리였으면(VAL_001 — 그사이 나무가 바뀜) 알림(인터셉터)을 보고 목록을 다시 맞춘다 — RES_001 은 창을 닫는다. 지금 있는 곳은 고를 수 없다
// - 지우기: 하위 메일함이 있으면 하위까지 지울지 묻는다(15단계 — 하위 메일함 수 · 메일 수는 모든 깊이). 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 비우기: 확인하면 닫고 비우기를 시작한다
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
import MailboxMoveDialog from '@/components/mail/MailboxMoveDialog.vue'
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
import { notifyApiError } from '@/composables/useApi'
import { useMailboxActions } from '@/composables/useMailboxActions'
import { useMailboxActions, type MailboxNameResult } from '@/composables/useMailboxActions'
import type { Mailbox } from '@/shared/types/mail'
import { hasChildMailbox } from '@/shared/utils/mailbox'
import { descendantMailboxes, hasChildMailbox, mailboxMoveTargets } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -34,6 +37,8 @@ const actions = useMailboxActions()
const pending = ref<boolean>(false)
const nameError = ref<string | null>(null)
/** 옮기기 창 목록 아래의 거절 (MAIL_009) */
const moveError = ref<string | null>(null)
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
let unmounted = false
@@ -49,6 +54,16 @@ const target = computed<Mailbox | null>(() => {
})
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
/** 모든 깊이의 하위 메일함 — 지우기 확인 창의 "하위 메일함 2개" · 메일 수 (15단계) */
const descendants = computed(() => (target.value ? descendantMailboxes(mailStore.mailboxes, target.value.id) : []))
/** 지우면 휴지통으로 갈 메일 수 — 하위 메일함까지 더한다 */
const treeMessageCount = computed(() =>
[target.value, ...descendants.value].reduce((sum, mailbox) => sum + Math.max(0, mailbox?.totalCount ?? 0), 0),
)
/** 옮길 곳 — 옮기기 창이 열려 있을 때만 */
const moveTargets = computed(() =>
props.request?.kind === 'move' && target.value ? mailboxMoveTargets(mailStore.mailboxes, target.value) : [],
)
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
@@ -56,9 +71,44 @@ const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', t
function close() {
pending.value = false
nameError.value = null
moveError.value = null
emit('close')
}
/**
* 이름 · 옮기기 창의 결과를 받는다 — 칸 안에 보일 실패(inline)는 보여 주고 창을 둔다, 그사이 사라졌으면(RES_001) 닫고 목록을 다시 맞춘다.
* 기다리는 사이 화면이 사라졌으면 칸 안에 보일 실패를 알림으로, 다른 창으로 바뀌었으면 건드리지 않는다
*/
function settleResult(
request: MailboxDialogRequest,
result: MailboxNameResult | null,
showInline: (message: string) => void,
) {
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
showInline(result.error.message)
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
return
}
// 옮길 수 없는 자리(VAL_001 — 그사이 나무가 바뀜)는 알림을 본 뒤 바뀐 나무로 다시 고를 수 있게 목록만 다시 맞춘다
if (request.kind === 'move' && result.error.code === 'VAL_001') void mailStore.loadMailboxes({ background: true })
}
async function onSubmitName(name: string) {
const request = props.request
if (!request || pending.value) return
@@ -73,34 +123,31 @@ async function onSubmitName(name: string) {
: request.kind === 'create'
? await actions.create(name, request.parent?.id ?? null)
: null
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
nameError.value = result.error.message
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
}
settleResult(request, result, (message) => {
nameError.value = message
})
}
async function onSubmitMove(parentId: string | null) {
const request = props.request
const mailbox = target.value
if (!request || request.kind !== 'move' || !mailbox || pending.value) return
pending.value = true
moveError.value = null
const result = await actions.move(mailbox, parentId)
settleResult(request, result, (message) => {
moveError.value = message
})
}
function onConfirmDelete() {
const mailbox = target.value
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
if (!mailbox || deleteBlocked.value) return
// 하위 메일함이 있으면 확인 창이 하위까지 지운다고 물었다 — 그때만 하위까지 지운다 (15단계)
const withChildren = hasChildren.value
close()
void actions.remove(mailbox)
void actions.remove(mailbox, { withChildren })
}
function onConfirmEmpty() {
@@ -115,6 +162,7 @@ watch(
() => props.request,
() => {
nameError.value = null
moveError.value = null
},
)
@@ -135,11 +183,22 @@ onBeforeUnmount(() => {
@cancel="close"
@clear-error="nameError = null"
/>
<MailboxMoveDialog
:open="request?.kind === 'move'"
:mailbox-name="target?.name ?? ''"
:has-children="hasChildren"
:targets="moveTargets"
:pending="pending"
:error="moveError"
@submit="onSubmitMove"
@cancel="close"
@clear-error="moveError = null"
/>
<MailboxDeleteDialog
:open="request?.kind === 'delete'"
:mailbox-name="target?.name ?? ''"
:message-count="target?.totalCount ?? 0"
:has-children="hasChildren"
:message-count="treeMessageCount"
:child-count="descendants.length"
:blocked="deleteBlocked"
@confirm="onConfirmDelete"
@cancel="close"
@@ -0,0 +1,371 @@
<script setup lang="ts">
// 메일함 옮기기 창 (15단계 — 디자인 원본 Mailbox15 "옮기기 창" · "그사이 같은 이름이 생겼을 때", 공용 모양 ModalDialog — 폭 400 medium)
// 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") →
// 옮길 곳 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56): 맨 위 · 받은편지함(과 그 아래) · "내 메일함" 묶음 → [옮기기] [취소]
// - 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없다. 고른 줄은 청록 바탕 · 굵은 글자 · 오른쪽 체크
// - 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳 · 같은 이름의 메일함이 있는 곳
// (대소문자 무시 — 서버에 묻기 전에 화면이 하는 검사). 맨 위 줄은 평소 "다른 메일함 안에 두지 않습니다"
// - 줄은 radiogroup 의 radio — 화살표 · Home · End 로 줄을 옮기면(꺼진 줄에도 초점은 간다 — 까닭을 읽게) 고를 수 있는 줄은 바로 고른다.
// Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄의 이름은 위 메일함부터 읽는다("거래처 안의 2025")
// - 그사이 같은 이름이 생겨 서버가 거절하면(MAIL_009) 목록 아래 빨간 사전 문구 — 알림 없이 창을 둔다. 다른 곳을 고르면 지운다(clear-error)
// - 옮기는 동안(pending)은 [옮기기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게
// 고정 id — #mailbox-move-dialog · #mailbox-move-title · #mailbox-move-message · #mailbox-move-error · #mailbox-move-confirm · #mailbox-move-cancel
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import type { IconName } from '@/shared/constants/icons'
import { MAILBOX_MOVE_TEXT, MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import { mailboxIcon, mailboxSpokenName, type MailboxMoveTarget } from '@/shared/utils/mailbox'
interface Props {
open: boolean
/** 옮기는 메일함의 이름 */
mailboxName: string
/** 하위 메일함이 있다 — 제목 아래 글이 바뀐다 */
hasChildren: boolean
/** 옮길 곳 (mailbox.ts 의 mailboxMoveTargets) */
targets: readonly MailboxMoveTarget[]
/** 옮기는 중 — [옮기기]를 다시 누를 수 없다 */
pending?: boolean
/** 서버가 거절한 까닭(사전 문구 — MAIL_009). 다른 곳을 고르면 부모가 지운다(clear-error) */
error?: string | null
}
interface Emits {
/** parentId — 새 상위, 맨 위면 null */
(e: 'submit', parentId: string | null): void
(e: 'cancel'): void
(e: 'clear-error'): void
}
const props = withDefaults(defineProps<Props>(), {
pending: false,
error: null,
})
const emit = defineEmits<Emits>()
/** 들여쓰기는 3단계까지 (메일함 패널과 같다) */
const MAX_INDENT_DEPTH = 3
/** 맨 위 줄의 열쇠 — 메일함 id 는 비지 않는다 */
const TOP_KEY = ''
const ERROR_ID = 'mailbox-move-error'
interface MoveRow {
key: string
parentId: string | null
icon: IconName
name: string
/** 화면 낭독 이름 — 들여 쓴 줄은 위 메일함부터 */
spokenName: string
depth: number
/** 이름 오른쪽 작은 글 — 고를 수 없는 까닭 · 맨 위 줄의 설명 */
note: string | null
pickable: boolean
/** 이 줄 앞에 "내 메일함" 묶음 이름을 둔다 */
headingBefore: boolean
}
/** 고를 수 없는 까닭의 글 — 옮기는 메일함 자신 · 하위는 글이 없다 */
const BLOCK_NOTES = {
self: null,
current: MAILBOX_MOVE_TEXT.current,
sameName: MAILBOX_MOVE_TEXT.sameName,
} as const
const listRef = ref<HTMLElement | null>(null)
/** 고른 줄 — 처음에는 없다 */
const chosenKey = ref<string | null>(null)
/** 탭으로 들어올 줄 (로빙 tabindex) — 고른 줄, 없으면 첫 고를 수 있는 줄 */
const focusKey = ref<string>(TOP_KEY)
const message = computed(() => (props.hasChildren ? MAILBOX_MOVE_TEXT.messageWithChildren : MAILBOX_MOVE_TEXT.message))
const rows = computed<MoveRow[]>(() => {
let headingShown = false
return props.targets.map((target) => {
const entry = target.entry
const headingBefore = target.group === 'mine' && !headingShown
if (headingBefore) headingShown = true
const note = target.block ? BLOCK_NOTES[target.block] : entry ? null : MAILBOX_MOVE_TEXT.topNote
return {
key: target.parentId ?? TOP_KEY,
parentId: target.parentId,
icon: entry ? mailboxIcon(entry.mailbox) : 'arrow-up',
name: entry ? entry.mailbox.name : MAILBOX_MOVE_TEXT.top,
spokenName: entry ? mailboxSpokenName(entry) : MAILBOX_MOVE_TEXT.top,
depth: entry ? Math.min(entry.depth, MAX_INDENT_DEPTH) : 0,
note,
pickable: target.block === null,
headingBefore,
}
})
})
/** 고른 줄 — 목록이 바뀌어(다시 받음) 고를 수 없게 됐으면 고르지 않은 것으로 본다 */
const chosen = computed<MoveRow | null>(() => rows.value.find((row) => row.key === chosenKey.value && row.pickable) ?? null)
/** 줄마다 겹치지 않는 이름 — 맨 위 줄과 메일함 줄을 가른다 (메일함 id 가 "top" 이어도 겹치지 않게) */
function rowName(row: MoveRow): string {
return row.parentId === null ? 'top' : `m-${row.key}`
}
function noteId(row: MoveRow): string | undefined {
return row.note ? `mailbox-move-note-${rowName(row)}` : undefined
}
function rowElement(key: string): HTMLElement | null {
const list = listRef.value
if (!list) return null
return [...list.querySelectorAll<HTMLElement>('[data-move-row]')].find((element) => element.dataset.moveRow === key) ?? null
}
function choose(row: MoveRow) {
focusKey.value = row.key
if (!row.pickable || props.pending) return
if (chosenKey.value !== row.key && props.error) emit('clear-error')
chosenKey.value = row.key
}
/** 화살표 · Home · End — 줄을 옮기고 고를 수 있으면 고른다 (꺼진 줄에도 초점은 간다) */
function moveFocus(target: MoveRow | undefined) {
if (!target) return
choose(target)
void nextTick(() => rowElement(target.key)?.focus())
}
function onListKeydown(event: KeyboardEvent) {
const list = rows.value
const index = list.findIndex((row) => row.key === focusKey.value)
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault()
moveFocus(list[(index + 1) % list.length])
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault()
moveFocus(list[(index - 1 + list.length) % list.length])
} else if (event.key === 'Home') {
event.preventDefault()
moveFocus(list[0])
} else if (event.key === 'End') {
event.preventDefault()
moveFocus(list[list.length - 1])
} else if (event.key === 'Enter') {
// Enter 는 [옮기기]와 같다 (줄 단추의 눌림으로 넘기지 않는다)
event.preventDefault()
submit()
}
}
function submit() {
const row = chosen.value
if (!row || props.pending) return
emit('submit', row.parentId)
}
/** 처음 초점 — 고를 수 있는 첫 줄 (없으면 첫 줄) */
function initialFocusKey(): string {
return (rows.value.find((row) => row.pickable) ?? rows.value[0])?.key ?? TOP_KEY
}
// 열 때마다 아무것도 고르지 않은 채로
watch(
() => props.open,
(open) => {
if (!open) return
chosenKey.value = null
focusKey.value = initialFocusKey()
},
{ immediate: true },
)
</script>
<template>
<ModalDialog
id="mailbox-move-dialog"
:open="open"
:title="MAILBOX_MOVE_TEXT.title(mailboxName)"
:message="message"
title-id="mailbox-move-title"
message-id="mailbox-move-message"
medium
@cancel="emit('cancel')"
>
<div
ref="listRef"
role="radiogroup"
class="mailbox-move__list"
:aria-label="MAILBOX_MOVE_TEXT.listLabel"
:aria-describedby="error ? ERROR_ID : undefined"
@keydown="onListKeydown"
>
<template
v-for="row in rows"
:key="rowName(row)"
>
<p
v-if="row.headingBefore"
class="mailbox-move__heading"
>
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
</p>
<button
type="button"
role="radio"
:class="['mailbox-move__row', `mailbox-move__row--depth-${row.depth}`]"
:aria-checked="chosen?.key === row.key ? 'true' : 'false'"
:aria-disabled="row.pickable ? undefined : 'true'"
:aria-label="row.spokenName"
:aria-describedby="noteId(row)"
:tabindex="row.key === focusKey ? 0 : -1"
:autofocus="row.key === focusKey"
:data-move-row="row.key"
@click="choose(row)"
>
<AppIcon
:name="row.icon"
class="mailbox-move__icon"
/>
<span class="mailbox-move__name">{{ row.name }}</span>
<span
v-if="row.note"
:id="noteId(row)"
class="mailbox-move__note"
>{{ row.note }}</span>
<AppIcon
v-if="chosen?.key === row.key"
name="check"
:stroke-width="2"
class="mailbox-move__check"
/>
</button>
</template>
</div>
<p
v-if="error"
:id="ERROR_ID"
class="form-message form-message--error"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
<template #actions>
<button
id="mailbox-move-confirm"
type="button"
class="modal-dialog__action"
:disabled="!chosen"
:aria-disabled="pending ? 'true' : undefined"
:aria-busy="pending"
@click="submit"
>
{{ MAILBOX_MOVE_TEXT.confirm }}
</button>
<button
id="mailbox-move-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAILBOX_MOVE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 옮길 곳 목록 — 테두리 칸 · 안쪽 4 · 줄 사이 1 (디자인 원본 Mailbox15). 메일함이 많으면 칸 안에서 스크롤한다 */
.mailbox-move__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: min(20rem, 50vh);
overflow-y: auto;
margin-top: var(--space-4);
padding: var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
/* "내 메일함" 묶음 이름 — 패널과 같은 12 굵게 회색 */
.mailbox-move__heading {
margin: var(--space-8) var(--space-10) var(--space-2);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
/* 줄 40 — 메일함 패널의 줄과 같은 높이 · 들여쓰기 */
.mailbox-move__row {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-10);
width: 100%;
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-strong);
font: inherit;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.mailbox-move__row--depth-1 {
padding-left: var(--space-28);
}
.mailbox-move__row--depth-2 {
padding-left: var(--space-44);
}
.mailbox-move__row--depth-3 {
padding-left: var(--space-56);
}
.mailbox-move__row:hover:not([aria-disabled='true']):not([aria-checked='true']) {
background: var(--color-surface-muted);
}
/* 고른 줄 — 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 */
.mailbox-move__row[aria-checked='true'] {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
/* 고를 수 없는 줄 — 이름 · 아이콘만 흐리게 (까닭 글은 그대로) */
.mailbox-move__row[aria-disabled='true'] {
cursor: default;
}
.mailbox-move__row[aria-disabled='true'] .mailbox-move__icon,
.mailbox-move__row[aria-disabled='true'] .mailbox-move__name {
opacity: 0.5;
}
.mailbox-move__icon,
.mailbox-move__check {
flex: 0 0 auto;
}
.mailbox-move__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-move__note {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 400;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,437 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
// 메일함 패널 — 15단계 메일함 옮기기 · 하위 메일함까지 지우기 (디자인 원본 Mailbox15 · 계약 2절 "메일함 관리")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
}
/** 보드의 예 — 거래처 안에 2025(그 안에 정산), 보관 안에도 2025, 프로젝트 안에 견적 */
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
box('junk', 'junk', '스팸메일함', { totalCount: 5 }),
box('trash', 'trash', '휴지통', { totalCount: 21 }),
box('c', null, '거래처', { totalCount: 1 }),
box('c25', null, '2025', { parentId: 'c', totalCount: 4 }),
box('c25s', null, '정산', { parentId: 'c25', totalCount: 35 }),
box('b', null, '보관'),
box('b25', null, '2025', { parentId: 'b' }),
box('p', null, '프로젝트', { totalCount: 2 }),
box('pq', null, '견적', { parentId: 'p' }),
]
interface Deferred {
promise: Promise<MockResult>
resolve: (result: MockResult) => void
}
function deferred(): Deferred {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
type Reply = MockResult | Deferred
describe('MailboxPanel — 메일함 옮기기 · 하위까지 지우기 (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
let replies: Map<string, Reply[]>
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
replies = new Map()
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function reply(key: string, ...results: Reply[]) {
replies.set(key, results)
}
async function mountPanel(mailboxes: Mailbox[] = MAILBOXES, viewing = 'inbox') {
requests = installMockApi((request) => {
const queue = replies.get(`${request.method} ${request.url}`)
if (queue && queue.length > 0) {
const next = queue.length > 1 ? queue.shift() : queue[0]
if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next
}
if (request.url === '/mailboxes') {
return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 12 } })
}
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = mailboxes.map((mailbox) => ({ ...mailbox }))
store.mailboxesStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${viewing}`)
await router.isReady()
store.currentMailboxId = viewing
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
await flushPromises()
return { wrapper, router, store }
}
/** 패널의 줄 — 같은 이름이 둘이면 몇 번째인지 */
const row = (wrapper: VueWrapper, name: string, nth = 0) => {
const found = wrapper.findAll('.mailbox-row').filter((item) => item.get('.mailbox-row__name').text() === name)[nth]
if (!found) throw new Error(`${name} 줄이 없다`)
return found
}
async function choose(wrapper: VueWrapper, name: string, label: string, nth = 0) {
await row(wrapper, name, nth).get('[data-mailbox-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === label)
if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`)
await item.trigger('click')
await flushPromises()
}
const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url)
const moveRows = (wrapper: VueWrapper) => wrapper.findAll('[data-move-row]')
const moveRow = (wrapper: VueWrapper, key: string) => wrapper.get(`[data-move-row="${key}"]`)
const panelNames = (wrapper: VueWrapper) =>
wrapper.findAll('.mailbox-tree__list')[1]?.findAll('.mailbox-row__name').map((name) => name.text())
/** 알림의 단추를 누른다 — 알림을 닫고 그 단추의 일을 한다 (ToastHost 와 같은 길 — takeAction) */
function runToastAction(index = 0) {
const toast = useToastStore().toasts[index]
const action = toast ? useToastStore().takeAction(toast.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
}
describe('옮기기', () => {
it('⋯ 「옮기기」 → 창 — 맨 위 · 받은편지함 · 내 메일함 차례, 고를 수 없는 줄은 흐리게 · 까닭, 처음에는 아무것도 고르지 않고 [옮기기]가 꺼져 있다', async () => {
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
expect(wrapper.get('#mailbox-move-title').text()).toBe('‘2025’ 메일함을 어디로 옮길까요?')
expect(wrapper.get('#mailbox-move-message').text()).toBe('하위 메일함과 안의 메일도 함께 옮깁니다.')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-label')).toBe('옮길 곳')
expect(wrapper.get('.mailbox-move__heading').text()).toBe('내 메일함')
expect(
moveRows(wrapper).map((item) => [
item.get('.mailbox-move__name').text(),
item.find('.mailbox-move__note').exists() ? item.get('.mailbox-move__note').text() : null,
item.attributes('aria-disabled') ?? null,
]),
).toEqual([
['맨 위', '다른 메일함 안에 두지 않습니다', null],
['받은편지함', null, null],
['거래처', '지금 있는 곳', 'true'],
['2025', null, 'true'],
['정산', null, 'true'],
['보관', '같은 이름의 메일함이 있습니다', 'true'],
['2025', null, null],
['프로젝트', null, null],
['견적', null, null],
])
// 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 옮길 곳이 아니다
expect(moveRows(wrapper).some((item) => ['sent', 'drafts', 'junk', 'trash'].includes(item.attributes('data-move-row') ?? ''))).toBe(
false,
)
// 들여 쓴 줄은 위 메일함부터 읽는다 · 까닭 글은 설명으로
expect(moveRow(wrapper, 'b25').attributes('aria-label')).toBe('보관 안의 2025')
expect(moveRow(wrapper, 'c25s').attributes('aria-label')).toBe('거래처 안의 2025 안의 정산')
expect(moveRow(wrapper, 'c25s').classes()).toContain('mailbox-move__row--depth-2')
expect(moveRow(wrapper, 'c').attributes('aria-describedby')).toBe('mailbox-move-note-m-c')
expect(moveRows(wrapper).every((item) => item.attributes('aria-checked') === 'false')).toBe(true)
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
// 처음 초점은 고를 수 있는 첫 줄 — 로빙 tabindex
expect(document.activeElement).toBe(moveRow(wrapper, '').element)
expect(moveRows(wrapper).filter((item) => item.attributes('tabindex') === '0')).toHaveLength(1)
})
it('줄을 눌러 고르고 [옮기기] → PATCH { parentId } · 패널에 바로 · "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기]는 원래 자리로', async () => {
const moved = box('c25', null, '2025', { parentId: 'p', totalCount: 4 })
reply('PATCH /mailboxes/c25', ok(moved), ok({ ...moved, parentId: 'c' }))
const { wrapper, store } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
// 고를 수 없는 줄은 눌러도 고르지 않는다
await moveRow(wrapper, 'b').trigger('click')
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
await moveRow(wrapper, 'p').trigger('click')
expect(moveRow(wrapper, 'p').attributes('aria-checked')).toBe('true')
expect(moveRow(wrapper, 'p').find('.mailbox-move__check').exists()).toBe(true)
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }])
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
expect(panelNames(wrapper)).toEqual(['거래처', '보관', '2025', '프로젝트', '2025', '정산', '견적'])
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.'])
expect(useToastStore().toasts[0]?.action?.label).toBe('되돌리기')
runToastAction()
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }, { parentId: 'c' }])
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.'])
expect(store.mailboxes.find((mailbox) => mailbox.id === 'c25')?.parentId).toBe('c')
})
it('맨 위로는 parentId: null — "다른 메일함 밖으로", 받은편지함 안으로는 따옴표 없이', async () => {
reply(
'PATCH /mailboxes/pq',
ok(box('pq', null, '견적')),
ok(box('pq', null, '견적', { parentId: 'inbox' })),
)
const { wrapper } = await mountPanel()
await choose(wrapper, '견적', '옮기기')
// 하위 메일함이 없으면 글이 짧다
expect(wrapper.get('#mailbox-move-message').text()).toBe('안의 메일도 함께 옮깁니다.')
await moveRow(wrapper, '').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/pq').map((request) => request.data)).toEqual([{ parentId: null }])
expect(toastTexts()).toEqual(['‘견적’ 메일함을 다른 메일함 밖으로 옮겼습니다.'])
await choose(wrapper, '견적', '옮기기')
// 맨 위에 있는 메일함 — 맨 위 줄이 지금 있는 곳
expect(moveRow(wrapper, '').get('.mailbox-move__note').text()).toBe('지금 있는 곳')
await moveRow(wrapper, 'inbox').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toContain('‘견적’ 메일함을 받은편지함 안으로 옮겼습니다.')
})
it('키보드 — 화살표로 줄을 옮기며 고른다(꺼진 줄에도 초점은 간다 — 고른 것은 그대로) · Enter 는 [옮기기]', async () => {
reply('PATCH /mailboxes/c25', ok(box('c25', null, '2025', { parentId: 'inbox' })))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'inbox').element)
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
expect(moveRow(wrapper, 'inbox').attributes('tabindex')).toBe('0')
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'c').element)
expect(moveRow(wrapper, 'c').attributes('aria-checked')).toBe('false')
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, 'c').trigger('keydown', { key: 'End' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'pq').element)
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
await flushPromises()
expect(moveRow(wrapper, '').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowUp' })
await flushPromises()
// 처음에서 위로 가면 끝으로 돈다
expect(moveRow(wrapper, 'pq').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'inbox' }])
})
it('그사이 같은 이름(409 MAIL_009) — 알림 없이 목록 아래 빨간 사전 문구 · 창은 그대로, 다른 곳을 고르면 사라진다', async () => {
reply('PATCH /mailboxes/c25', fail(409, 'MAIL_009'))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, 'p').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
const error = wrapper.get('#mailbox-move-error')
expect(error.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
expect(error.attributes('role')).toBe('alert')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-describedby')).toBe('mailbox-move-error')
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
expect(useToastStore().toasts).toEqual([])
await moveRow(wrapper, 'pq').trigger('click')
expect(wrapper.find('#mailbox-move-error').exists()).toBe(false)
})
it('메일함이 그사이 사라졌으면(404 RES_001) — 알림을 보고 창을 닫은 뒤 메일함 목록을 다시 받는다', async () => {
reply('PATCH /mailboxes/c25', fail(404, 'RES_001'))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
const before = sent('GET', '/mailboxes').length
await moveRow(wrapper, 'p').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.RES_001])
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(before)
})
it('옮기는 동안 [옮기기]는 aria-disabled · aria-busy — 다시 눌러도 한 번만 보낸다', async () => {
const pending = deferred()
reply('PATCH /mailboxes/c25', pending)
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, 'p').trigger('click')
const confirm = wrapper.get('#mailbox-move-confirm')
await confirm.trigger('click')
await flushPromises()
expect(confirm.attributes('aria-disabled')).toBe('true')
expect(confirm.attributes('aria-busy')).toBe('true')
await confirm.trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25')).toHaveLength(1)
pending.resolve(ok(box('c25', null, '2025', { parentId: 'p' })))
await flushPromises()
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
})
})
describe('하위 메일함까지 지우기', () => {
it('확인(처음 초점 「취소」) 뒤 DELETE ?withChildren=true 를 deleted 가 될 때까지 — 진행 수는 나무 전체, 지우는 동안 하위 메일함의 메뉴도 꺼지고 끝나면 나무 전체가 빠진다', async () => {
const second = deferred()
reply(
'DELETE /mailboxes/c',
ok({ deleted: false, processed: 12, movedToTrash: 12, remaining: 28 }),
second,
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
)
const { wrapper, store } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함과 하위 메일함 2개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 12 / 40통'],
])
// 지우는 나무의 메일함은 모든 항목을 누를 수 없다
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role^="menuitem"]')
expect(items.length).toBeGreaterThan(0)
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
second.resolve(ok({ deleted: false, processed: 28, movedToTrash: 28, remaining: 0 }))
await flushPromises()
expect(sent('DELETE', '/mailboxes/c').map((request) => request.params)).toEqual([
{ withChildren: true },
{ withChildren: true },
{ withChildren: true },
])
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다.'])
expect(store.mailboxes.map((mailbox) => mailbox.id)).not.toEqual(expect.arrayContaining(['c']))
expect(store.mailboxes.some((mailbox) => ['c', 'c25', 'c25s'].includes(mailbox.id))).toBe(false)
expect(panelNames(wrapper)).toEqual(['보관', '2025', '프로젝트', '견적'])
})
it('하위 메일함을 보고 있었어도 지운 뒤 받은편지함으로 간다', async () => {
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 40, movedToTrash: 40, remaining: 0 }))
const { wrapper, router } = await mountPanel(MAILBOXES, 'c25s')
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('하위 메일함까지 비어 있으면 "하위 메일함까지 모두 비어 있습니다." · 하위가 없는 메일함은 지금 지우기 그대로(withChildren 없이)', async () => {
reply('DELETE /mailboxes/b', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
reply('DELETE /mailboxes/pq', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '보관', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘보관’ 메일함과 하위 메일함 1개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다.',
)
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/b').map((request) => request.params)).toEqual([{ withChildren: true }])
await choose(wrapper, '견적', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘견적’ 메일함을 지울까요?')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/pq').map((request) => request.params)).toEqual([{}])
})
})
describe('차단할 때 받은 메일 옮기기와 스팸메일함 비우기', () => {
it('이 탭에서 차단 정리가 도는 동안은 「스팸메일함 비우기」를 누를 수 없다 · 확인 단추도 꺼지고 스토어도 시작하지 않는다 — 끝나면 다시 켜진다', async () => {
const { wrapper, store } = await mountPanel()
const blockRun = useMailBlockRunStore()
const junk = store.mailboxes.find((mailbox) => mailbox.id === 'junk')
if (!junk) throw new Error('스팸메일함이 없다')
// 확인 창을 연 뒤에 정리가 시작돼도 누르는 순간 다시 본다
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
blockRun.running = true
await flushPromises()
expect(wrapper.get('#mailbox-empty-confirm').attributes('disabled')).toBeDefined()
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/junk/messages')).toEqual([])
await wrapper.get('#mailbox-empty-cancel').trigger('click')
await flushPromises()
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === '스팸메일함 비우기')
expect(item?.attributes('aria-disabled')).toBe('true')
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
expect((await store.runMailboxOperation('empty', junk, () => undefined)).status).toBe('canceled')
// 휴지통 비우기는 상관없다
expect(store.isMailboxOperationBlocked('empty', { role: 'trash' })).toBe(false)
blockRun.running = false
await flushPromises()
expect(store.isMailboxOperationBlocked('empty', junk)).toBe(false)
})
})
})
@@ -169,15 +169,18 @@ describe('MailboxPanel — 메일함 관리', () => {
['스팸메일함 비우기', true],
])
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
// 내 메일함에는 「옮기기」 — 이름 바꾸기 다음 · 지우기 앞 (15단계)
expect(await labels('프로젝트')).toEqual([
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
expect(await labels('견적')).toEqual([
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
@@ -302,16 +305,17 @@ describe('MailboxPanel — 메일함 관리', () => {
})
describe('지우기 — 안의 메일은 휴지통으로', () => {
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
it('하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계) — [취소]면 보내지 않는다', async () => {
const { wrapper } = await mountPanel()
await choose(wrapper, '프로젝트', '지우기')
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다')
expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.')
expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false)
expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함과 하위 메일함 1개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함에 든 메일까지 모두 24통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-cancel').trigger('click')
await flushPromises()
@@ -603,6 +607,7 @@ describe('MailboxPanel — 메일함 관리', () => {
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 옮기기(15단계) · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
// - 「예약됨」(9단계)은 예약한 메일이 있을 때만 보낸편지함 아래 — 수는 메일 화면이 받아 둔다(mail.store 의 scheduledCount)
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
@@ -42,6 +42,7 @@ function onAction(action: MailboxAction, mailbox: Mailbox) {
}
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
else if (action === 'move') dialog.value = { kind: 'move', mailbox }
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
else dialog.value = { kind: 'empty', mailbox }
}
@@ -4,11 +4,22 @@
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
import { computed, ref } from 'vue'
// - 목록 줄을 끌어다 놓는 곳(15단계 — 디자인 원본 ListDrag15): 놓을 수 있는 메일함 위에서는 줄에 2px 청록 점선 · 옅은 청록 바탕
// (쓰기 화면 파일 놓기와 같은 모양 — 초점 선(실선)과 헷갈리지 않게). 놓을 수 없는 곳(메일이 이미 든 메일함 · 임시보관함)은 줄이 그대로이고
// dragover 를 받지 않아 브라우저의 금지 커서가 뜬다. 우리 메일 끌기만 받는다 — 파일 끌기는 받지 않는다(쓰기 화면의 첨부 놓기와 겹치지 않게).
// 놓으면 drop-mail 로 알리고 옮기는 일은 메일함 나무(MailboxTree)가 한다
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import {
activeMailDrag,
canDropDragOn,
mailDragging,
takeMailDrop,
type MailDragPayload,
} from '@/composables/useMailDrag'
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
@@ -23,6 +34,8 @@ interface Props {
interface Emits {
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
/** 목록 줄을 이 메일함에 끌어다 놓았다 (15단계) */
(e: 'drop-mail', mailbox: Mailbox, drag: MailDragPayload): void
}
const props = defineProps<Props>()
@@ -34,6 +47,9 @@ const mailStore = useMailStore()
const MAX_INDENT_DEPTH = 3
const menuOpen = ref<boolean>(false)
/** 놓을 수 있는 끌기가 이 줄 위에 있다 — 들어온 요소 수를 세어 줄 안의 링크 · 단추 사이를 지날 때 깜빡이지 않게 */
const dropDepth = ref<number>(0)
const dropOver = computed(() => dropDepth.value > 0)
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
function isDisabled(action: MailboxAction): boolean {
@@ -67,10 +83,52 @@ function onSelect(id: string) {
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
if (action) emit('action', action, props.mailbox)
}
/** 이 메일함에 놓을 수 있는 끌기인가 — 우리 메일 끌기이고 놓을 수 있는 메일함일 때만 (아니면 null — 브라우저의 금지 커서) */
function droppableDrag(event: DragEvent): MailDragPayload | null {
const drag = activeMailDrag(event)
return drag && canDropDragOn(drag, props.mailbox) ? drag : null
}
function onDragEnter(event: DragEvent) {
if (!droppableDrag(event)) return
event.preventDefault()
dropDepth.value += 1
}
function onDragOver(event: DragEvent) {
if (!droppableDrag(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onDragLeave(event: DragEvent) {
if (!droppableDrag(event)) return
dropDepth.value = Math.max(0, dropDepth.value - 1)
}
function onDrop(event: DragEvent) {
dropDepth.value = 0
if (!droppableDrag(event)) return
event.preventDefault()
const drag = takeMailDrop(event)
if (drag) emit('drop-mail', props.mailbox, drag)
}
// 끌기가 끝나면(다른 곳에 놓음 · Esc 로 그만둠) 점선을 끈다 — 마지막 dragleave 를 놓쳤어도 남지 않게
watch(mailDragging, (dragging) => {
if (!dragging) dropDepth.value = 0
})
</script>
<template>
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
<li
:class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen, 'is-drop-target': dropOver }]"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<RouterLink
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
:class="['mailbox-row__link', depthClass]"
@@ -166,6 +224,19 @@ function onSelect(id: string) {
color: var(--color-brand-strong);
}
/* 끌어다 놓을 수 있는 메일함 위 — 2px 청록 점선 · 옅은 청록 바탕 (쓰기 화면 파일 놓기와 같은 모양, 디자인 원본 ListDrag15) */
.mailbox-row.is-drop-target {
outline: var(--focus-ring-width) dashed var(--color-brand);
outline-offset: calc(var(--focus-ring-width) * -1);
background: var(--color-brand-tint);
}
.mailbox-row.is-drop-target .mailbox-row__link,
.mailbox-row.is-drop-target .mailbox-row__count {
color: var(--color-brand-strong);
font-weight: 600;
}
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
.mailbox-row__more {
opacity: 0;
@@ -4,11 +4,15 @@
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
// 「예약됨」(9단계 — 예약한 메일이 있을 때만)은 기본 메일함 묶음의 보낸편지함(과 그 하위) 바로 아래 — 보낸편지함이 없으면 묶음 끝.
// 예약됨을 보는 동안은 수가 0 이 되어도 남긴다 — 다 취소하거나 보낸 뒤에도 지금 있는 곳이 패널에 보이게
// 목록 줄을 메일함 줄에 끌어다 놓으면(15단계 — 디자인 원본 ListDrag15) 여기서 옮긴다 — 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고
// 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다 — useBulkMailActions 의 moveDragged). 「예약됨」에는 놓을 수 없다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
import MailboxScheduledRow from '@/components/mail/MailboxScheduledRow.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { MailDragPayload } from '@/composables/useMailDrag'
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
@@ -34,6 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
const bulk = useBulkMailActions()
const MY_GROUP_ID = 'mailbox-panel-mine'
/** 보낸편지함이 없을 때 — 「예약됨」을 기본 메일함 묶음 끝에 (메일함 id 는 비지 않는다) */
const AT_GROUP_END = ''
@@ -43,6 +49,11 @@ const groups = computed(() => splitMailboxGroups(props.nodes))
* 「예약됨」을 넣을 자리 — 기본 메일함 묶음에서 이 메일함 줄 뒤(보낸편지함 하위의 끝). 보낸편지함이 없으면 묶음 끝(AT_GROUP_END).
* 예약한 메일이 없으면 null (그리지 않는다) — 다만 예약됨을 보고 있으면 남긴다
*/
/** 끌어다 놓은 메일을 그 메일함으로 옮긴다 */
function onDropMail(mailbox: Mailbox, drag: MailDragPayload) {
void bulk.moveDragged(drag, mailbox)
}
const scheduledAfterId = computed<string | null>(() => {
if (props.scheduledCount <= 0 && !props.scheduledActive) return null
const system = groups.value.system
@@ -67,6 +78,7 @@ const scheduledAfterId = computed<string | null>(() => {
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
@drop-mail="onDropMail"
/>
<MailboxScheduledRow
v-if="scheduledAfterId === node.mailbox.id"
@@ -98,6 +110,7 @@ const scheduledAfterId = computed<string | null>(() => {
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
@drop-mail="onDropMail"
/>
</ul>
<button
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
import MessageBlockDialog from './MessageBlockDialog.vue'
// 보낸 사람 차단 창의 15단계 새 칸 — 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」
// (디자인 원본 Mailbox15 "보낸 사람 차단 창" · 디자인 README "15단계")
interface DialogProps {
open: boolean
senderEmail: string
inJunk?: boolean
inInbox?: boolean
cleanupBlock?: MailBlockRunBlock | null
}
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountDialog(props: Partial<DialogProps> = {}) {
const wrapper = mount(MessageBlockDialog, {
props: { open: true, senderEmail: 'News@Example.com', inInbox: true, ...props },
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
const moveBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-move]')
const cleanBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-clean]')
const confirmed = (wrapper: VueWrapper) => wrapper.emitted('confirm') ?? []
describe('MessageBlockDialog — 받은편지함의 메일도 옮기기 (15단계)', () => {
it('처음에는 첫 칸 켜짐 · 새 칸 꺼짐 — 새 칸 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." · [차단]은 지금처럼', async () => {
const wrapper = mountDialog()
expect(moveBox(wrapper).element.checked).toBe(true)
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
'받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기',
)
expect(cleanBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-clean-note')
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
})
it('새 칸을 켜면 받은편지함의 메일이면 첫 칸은 켠 채 잠긴다(aria-disabled · 까닭) — 눌러도 그대로, 새 칸을 끄면 원래 값으로', async () => {
const wrapper = mountDialog()
// 먼저 첫 칸을 꺼 둔다
await moveBox(wrapper).setValue(false)
expect(moveBox(wrapper).element.checked).toBe(false)
await cleanBox(wrapper).setValue(true)
expect(moveBox(wrapper).element.checked).toBe(true)
expect(moveBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(moveBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-move-lock')
expect(wrapper.get('#block-dialog-move-lock').text()).toBe('받은편지함의 메일을 옮길 때 함께 옮깁니다.')
// 잠긴 칸은 눌러도 바뀌지 않는다
await moveBox(wrapper).trigger('click')
await nextTick()
expect(moveBox(wrapper).element.checked).toBe(true)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper).at(-1)).toEqual(['news@example.com', true, true])
await cleanBox(wrapper).setValue(false)
expect(moveBox(wrapper).element.checked).toBe(false)
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(wrapper.find('#block-dialog-move-lock').exists()).toBe(false)
})
it('이 메일이 받은편지함이 아니면 잠그지 않는다 · 스팸메일함의 메일이면 첫 칸이 없고 새 칸만', async () => {
const elsewhere = mountDialog({ inInbox: false })
await cleanBox(elsewhere).setValue(true)
expect(moveBox(elsewhere).attributes('aria-disabled')).toBeUndefined()
await moveBox(elsewhere).setValue(false)
await elsewhere.get('[data-block-confirm]').trigger('click')
expect(confirmed(elsewhere)).toEqual([['news@example.com', false, true]])
const inJunk = mountDialog({ inInbox: false, inJunk: true })
expect(inJunk.find('[data-block-move]').exists()).toBe(false)
await cleanBox(inJunk).setValue(true)
await inJunk.get('[data-block-confirm]').trigger('click')
expect(confirmed(inJunk)).toEqual([['news@example.com', false, true]])
})
it('「이 도메인 전체」를 고르면 새 칸이 "이 도메인의 메일"로 바뀌고 @도메인으로 막는다', async () => {
const wrapper = mountDialog()
await wrapper.get('[data-block-scope="domain"]').setValue(true)
await cleanBox(wrapper).setValue(true)
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
'받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기',
)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['@example.com', true, true]])
})
it('이 탭에서 받은편지함을 정리하는 중이면 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 — 눌러도 켜지지 않는다', async () => {
const wrapper = mountDialog({ cleanupBlock: 'inbox' })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
await cleanBox(wrapper).trigger('click')
await nextTick()
expect(cleanBox(wrapper).element.checked).toBe(false)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
})
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸을 끄고 칸 아래 글을 그 까닭으로 — 켜 둔 칸도 꺼진 것으로 보낸다, 끝나면 다시 고를 수 있다', async () => {
const wrapper = mountDialog()
await cleanBox(wrapper).setValue(true)
await wrapper.setProps({ cleanupBlock: 'junk' })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
// 첫 칸의 잠금도 풀린다 — 새 칸이 꺼진 것과 같다
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
await wrapper.setProps({ cleanupBlock: null })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
})
it('열 때마다 처음 값으로 — 새 칸은 꺼진 채 시작한다', async () => {
const wrapper = mountDialog()
await cleanBox(wrapper).setValue(true)
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(moveBox(wrapper).element.checked).toBe(true)
})
})
@@ -1,56 +1,108 @@
<script setup lang="ts">
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · README "9단계 › 차단 확인 창") — 모달 400(medium)
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · Mailbox15 · README "9단계 › 차단 확인 창" · "15단계 › 보낸 사람 차단 창") — 모달 400(medium)
// 제목 "보낸 사람 차단" · 설명 → 고르기 둘(「이 주소만」 주소 · 「이 도메인 전체」 @도메인 에서 오는 메일 모두) →
// 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다) → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// 체크 둘(붙여 둔다 — 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다):
// 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다)
// 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」(15단계 — 처음 꺼짐, 「이 도메인 전체」를 고르면 "이 도메인의 메일") +
// 그 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다."
// → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// - 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠그고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." —
// 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다. 이 메일이 다른 메일함에 있으면 잠그지 않는다
// - 이 탭에서 받은편지함을 정리하거나(필터 적용 · 다른 차단 정리) 스팸메일함을 비우는 중이면(cleanupBlock — 15단계 리뷰) 새 칸을 끄고(aria-disabled)
// 칸 아래 글을 그 까닭으로 바꾼다
// 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등 — 서버와 같은 목록)는 「이 도메인 전체」를 끄고 그 아래에 까닭을 둔다.
// 열 때마다 「이 주소만」 · 옮기기 켜짐으로 시작한다. Esc 는 [취소]와 같다
// 열 때마다 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐으로 시작한다. Esc 는 [취소]와 같다
// 칸 아래 글은 고르기 상자 글 선(28)에 맞춰 들여 쓴다 — 고정 id #block-dialog-move-lock · #block-dialog-clean-note
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { domainBlockOf, emailDomainOf, isPublicMailDomain } from '@/shared/utils/mail-block'
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
interface Props {
open: boolean
/** 보낸 사람 주소 */
senderEmail: string
/** 이 메일이 이미 스팸메일함에 있다 — 옮기기 칸을 두지 않는다 */
/** 이 메일이 이미 스팸메일함에 있다 — 첫 칸을 두지 않는다 */
inJunk?: boolean
/** 이 메일이 받은편지함에 있다 — 새 칸을 켜면 첫 칸을 켠 채 잠근다 (15단계) */
inInbox?: boolean
/** 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하는 중(inbox) · 스팸메일함을 비우는 중(junk), 없으면 null (15단계) */
cleanupBlock?: MailBlockRunBlock | null
}
interface Emits {
/** target 은 주소 또는 "@도메인" (소문자) */
(e: 'confirm', target: string, moveToJunk: boolean): void
/** target 은 주소 또는 "@도메인" (소문자) · cleanInbox 는 받은편지함의 그 보낸 사람 메일도 옮긴다 (15단계) */
(e: 'confirm', target: string, moveToJunk: boolean, cleanInbox: boolean): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
inJunk: false,
inInbox: false,
cleanupBlock: null,
})
const emit = defineEmits<Emits>()
const LOCK_NOTE_ID = 'block-dialog-move-lock'
const CLEAN_NOTE_ID = 'block-dialog-clean-note'
const scope = ref<'address' | 'domain'>('address')
/** 첫 칸에 고른 값 — 잠긴 동안에도 기억했다가 새 칸을 끄면 돌아온다 */
const moveToJunk = ref<boolean>(true)
const cleanInbox = ref<boolean>(false)
/** 새 칸을 고를 수 없다 */
const cleanupBusy = computed(() => props.cleanupBlock !== null)
const address = computed(() => normalizeEmail(props.senderEmail))
const domain = computed(() => emailDomainOf(props.senderEmail))
const domainTarget = computed(() => domainBlockOf(domain.value))
/** 「이 도메인 전체」를 고를 수 없다 — 여러 사람이 쓰는 메일 서비스 · 도메인이 없는 주소 */
const domainDisabled = computed(() => domain.value === '' || isPublicMailDomain(domain.value))
const blocksDomain = computed(() => scope.value === 'domain' && !domainDisabled.value)
/** 새 칸이 켜져 있다 — 고를 수 없는 동안은 꺼진 것으로 본다 */
const cleaning = computed(() => cleanInbox.value && !cleanupBusy.value)
/** 첫 칸을 켠 채 잠근다 — 받은편지함의 메일을 옮길 때 이 메일도 함께 옮긴다 */
const moveLocked = computed(() => cleaning.value && props.inInbox && !props.inJunk)
const moveChecked = computed(() => moveLocked.value || moveToJunk.value)
const cleanLabel = computed(() => (blocksDomain.value ? MAIL_BLOCK_TEXT.cleanInboxDomain : MAIL_BLOCK_TEXT.cleanInboxSender))
const cleanNote = computed(() => {
if (props.cleanupBlock === 'junk') return MAIL_BLOCK_TEXT.cleanJunkBusy
return props.cleanupBlock === 'inbox' ? MAIL_BLOCK_TEXT.cleanInboxBusy : MAIL_BLOCK_TEXT.cleanInboxNote
})
function confirm() {
const target = scope.value === 'domain' && !domainDisabled.value ? domainTarget.value : address.value
emit('confirm', target, moveToJunk.value && !props.inJunk)
/** 잠긴 칸 · 고를 수 없는 칸은 눌러도 바꾸지 않는다 (aria-disabled — 초점은 받는다) */
function onMoveClick(event: MouseEvent) {
if (moveLocked.value) event.preventDefault()
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 옮기기 켜짐
function onMoveChange(event: Event) {
moveToJunk.value = (event.target as HTMLInputElement).checked
}
function onCleanClick(event: MouseEvent) {
if (cleanupBusy.value) event.preventDefault()
}
function onCleanChange(event: Event) {
cleanInbox.value = (event.target as HTMLInputElement).checked
}
function confirm() {
const target = blocksDomain.value ? domainTarget.value : address.value
emit('confirm', target, moveChecked.value && !props.inJunk, cleaning.value)
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐
watch(
() => props.open,
(open) => {
if (!open) return
scope.value = 'address'
moveToJunk.value = true
cleanInbox.value = false
},
)
</script>
@@ -117,18 +169,49 @@ watch(
{{ MAIL_BLOCK_TEXT.scopeDomainPublic }}
</p>
</div>
<label
v-if="!inJunk"
class="block-dialog__move"
>
<input
v-model="moveToJunk"
type="checkbox"
class="block-dialog__input"
data-block-move
<div class="block-dialog__checks">
<template v-if="!inJunk">
<label :class="['block-dialog__check', { 'is-locked': moveLocked }]">
<input
type="checkbox"
class="block-dialog__input"
:checked="moveChecked"
:aria-disabled="moveLocked ? 'true' : undefined"
:aria-describedby="moveLocked ? LOCK_NOTE_ID : undefined"
data-block-move
@click="onMoveClick"
@change="onMoveChange"
>
<span class="block-dialog__check-label">{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
<p
v-if="moveLocked"
:id="LOCK_NOTE_ID"
class="form-message block-dialog__note block-dialog__note--lock"
>
{{ MAIL_BLOCK_TEXT.moveToJunkLocked }}
</p>
</template>
<label :class="['block-dialog__check', { 'is-disabled': cleanupBusy }]">
<input
type="checkbox"
class="block-dialog__input"
:checked="cleaning"
:aria-disabled="cleanupBusy ? 'true' : undefined"
:aria-describedby="CLEAN_NOTE_ID"
data-block-clean
@click="onCleanClick"
@change="onCleanChange"
>
<span class="block-dialog__check-label">{{ cleanLabel }}</span>
</label>
<p
:id="CLEAN_NOTE_ID"
class="form-message block-dialog__note block-dialog__note--clean"
>
<span>{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
{{ cleanNote }}
</p>
</div>
<p class="form-message">
{{ MAIL_BLOCK_TEXT.settingsHint }}
</p>
@@ -220,20 +303,45 @@ watch(
color: var(--color-icon-muted);
}
/* 「이 도메인 전체」를 끈 까닭 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
/* 칸 아래 까닭 · 안내 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
.block-dialog__note {
margin-left: var(--space-28);
}
.block-dialog__move {
/* 체크 둘은 붙여 둔다 (디자인 원본 Mailbox15 — 줄 44 · 위 12, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다) */
.block-dialog__checks {
display: flex;
align-items: center;
flex-direction: column;
}
.block-dialog__check {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding-top: var(--space-12);
cursor: pointer;
}
.block-dialog__move .block-dialog__input {
margin: 0;
/* 잠긴 첫 칸 · 고를 수 없는 새 칸 — 상자와 이름을 흐리게 (aria-disabled — 초점은 받는다) */
.block-dialog__check.is-locked,
.block-dialog__check.is-disabled {
cursor: default;
}
.block-dialog__check.is-locked .block-dialog__input,
.block-dialog__check.is-locked .block-dialog__check-label,
.block-dialog__check.is-disabled .block-dialog__input,
.block-dialog__check.is-disabled .block-dialog__check-label {
opacity: 0.5;
}
/* 잠긴 첫 칸의 까닭은 그 칸 바로 아래로 당긴다 (보드 -10) */
.block-dialog__note--lock {
margin-top: calc(var(--space-10) * -1);
}
.block-dialog__note--clean {
margin-top: var(--space-2);
}
</style>
@@ -0,0 +1,354 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 15단계 — 같은 대화 칸의 [모두 펼치기] · [모두 접기]와 펼친 줄 머리의 [답장] (디자인 원본 Reader15 "6")
// - [모두 펼치기]는 불러온 줄을 모두 펼친다 — 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(한꺼번에 부르지 않는다)
// - 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이다(펼쳤어도 화면 밖이면 그대로)
// 화면에 들어오고 나가는 것은 가짜 IntersectionObserver 로 흉내 낸다 (jsdom 에는 없다)
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 2 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS]
const NOW = new Date(2026, 10, 9, 10).getTime()
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: 'Re: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다.',
receivedAt: at(2026, 10, 3, 9, 30),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
const FIRST = summary('c1', {
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
receivedAt: at(2026, 10, 3, 14, 10),
})
const SECOND = summary('c2', { isRead: false, receivedAt: at(2026, 10, 5, 9, 30) })
const THIRD = summary('c3', { isRead: false, receivedAt: at(2026, 10, 5, 13, 5) })
const DRAFT = summary('c5', { mailboxIds: ['drafts'], from: USER, receivedAt: at(2026, 10, 9, 9, 52) })
const CURRENT = summary('c4', { receivedAt: at(2026, 10, 9, 9, 30) })
const THREAD = [FIRST, SECOND, THIRD, CURRENT, DRAFT]
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
/** 가짜 IntersectionObserver — 테스트가 요소를 화면에 넣고 뺀다 */
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = []
readonly targets = new Set<Element>()
constructor(private readonly callback: IntersectionObserverCallback) {
FakeIntersectionObserver.instances.push(this)
}
observe(element: Element) {
this.targets.add(element)
}
unobserve(element: Element) {
this.targets.delete(element)
}
disconnect() {
this.targets.clear()
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
/** 그 요소들이 화면에 들어왔다 · 나갔다 — 보고 있는 것만 알린다 */
static set(elements: Element[], visible: boolean) {
for (const instance of FakeIntersectionObserver.instances) {
const entries = elements
.filter((element) => instance.targets.has(element))
.map((target) => ({ target, isIntersecting: visible }) as IntersectionObserverEntry)
if (entries.length > 0) instance.callback(entries, instance as unknown as IntersectionObserver)
}
}
}
describe('MessageConversation — [모두 펼치기] · 펼친 줄 머리의 [답장] (15단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let details: Map<string, ReturnType<typeof deferred>>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(NOW)
FakeIntersectionObserver.instances = []
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
details = new Map()
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.unstubAllGlobals()
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
/** 본문은 테스트가 풀어 줄 때 온다 (deferredDetails) — 아니면 바로 */
function serve(options: { conversation?: (request: MockRequest) => MockResult | Promise<MockResult>; hold?: boolean } = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return options.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...SECOND, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
const id = request.url.split('/').pop() ?? ''
const body = ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
if (!options.hold) return body
const wait = deferred()
details.set(id, wait)
return wait.promise.then(() => body)
}
return ok(null)
})
}
async function mountReader() {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [SECOND, THIRD, CURRENT]
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
store.listStatus = 'ready'
store.selected = detail(CURRENT)
store.selectedId = CURRENT.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/inbox/${CURRENT.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: 'split' },
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, store }
}
const detailGets = () =>
requests
.filter((request) => request.method === 'GET' && /^\/messages\/[^/]+$/.test(request.url))
.map((request) => request.url.split('/').pop())
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.url)
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
const toggleAll = (wrapper: VueWrapper) => wrapper.get('[data-conversation-toggle-all]')
const panelOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-panel="${id}"]`).element
async function show(wrapper: VueWrapper, ids: string[], visible = true) {
FakeIntersectionObserver.set(
ids.map((id) => panelOf(wrapper, id)),
visible,
)
await flushPromises()
}
it('칸 머리 오른쪽에 [모두 펼치기] — 누르면 불러온 줄(지금 메일 빼고)을 모두 펼치고 [모두 접기]가 된다', async () => {
serve()
const { wrapper } = await mountReader()
const button = toggleAll(wrapper)
expect(button.text()).toBe('모두 펼치기')
expect(button.element.tagName).toBe('BUTTON')
// 칸 제목과 같은 머리 줄
expect(button.element.parentElement?.querySelector('h3')?.textContent?.trim()).toBe('같은 대화 5통')
await button.trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 접기')
for (const id of ['c1', 'c2', 'c3', 'c5']) {
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
}
// 지금 보는 메일의 줄은 지금처럼 펼치지 않는다
expect(rowOf(wrapper, 'c4').find('[data-conversation-toggle]').exists()).toBe(false)
// 누른 것만으로는 본문을 받지 않는다 — 화면에 들어온 줄부터
expect(detailGets()).toEqual([])
expect(rowOf(wrapper, 'c3').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
await toggleAll(wrapper).trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
for (const id of ['c1', 'c2', 'c3', 'c5']) {
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
}
expect(wrapper.find('[data-conversation-panel]').exists()).toBe(false)
})
it('본문은 화면에 들어온 줄부터 하나씩 받는다 — 앞 줄을 받는 동안 다음 줄은 기다리고, 차례 전에 화면 밖으로 나간 줄은 건너뛴다', async () => {
serve({ hold: true })
const { wrapper } = await mountReader()
await toggleAll(wrapper).trigger('click')
await flushPromises()
await show(wrapper, ['c1', 'c2', 'c3'])
expect(detailGets()).toEqual(['c1'])
// c3 는 차례가 오기 전에 화면 밖으로 나갔다
await show(wrapper, ['c3'], false)
details.get('c1')?.resolve(ok(null))
await flushPromises()
expect(detailGets()).toEqual(['c1', 'c2'])
details.get('c2')?.resolve(ok(null))
await flushPromises()
expect(detailGets()).toEqual(['c1', 'c2'])
expect(rowOf(wrapper, 'c1').find('iframe').exists()).toBe(true)
expect(rowOf(wrapper, 'c3').find('iframe').exists()).toBe(false)
// 다시 들어오면 받는다 · 한 번 받은 줄은 다시 들어와도 다시 받지 않는다
await show(wrapper, ['c3', 'c1'])
expect(detailGets()).toEqual(['c1', 'c2', 'c3'])
})
it('읽음은 본문이 화면에 보인 메일만 — 받는 사이 화면 밖으로 나갔으면 들어올 때 읽음으로', async () => {
serve({ hold: true })
const { wrapper, store } = await mountReader()
await toggleAll(wrapper).trigger('click')
await flushPromises()
await show(wrapper, ['c2'])
expect(detailGets()).toEqual(['c2'])
// 받는 사이 내려서 화면 밖으로
await show(wrapper, ['c2'], false)
details.get('c2')?.resolve(ok(null))
await flushPromises()
expect(patches()).toEqual([])
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(false)
await show(wrapper, ['c2'])
expect(patches()).toEqual(['/messages/c2'])
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(true)
// 다시 드나들어도 다시 묻지 않는다
await show(wrapper, ['c2'], false)
await show(wrapper, ['c2'])
expect(patches()).toEqual(['/messages/c2'])
})
it('하나씩 다 펼쳐도 [모두 접기] — 하나라도 접으면 다시 [모두 펼치기]', async () => {
serve()
const { wrapper } = await mountReader()
for (const id of ['c1', 'c2', 'c3', 'c5']) await rowOf(wrapper, id).get('[data-conversation-toggle]').trigger('click')
await flushPromises()
expect(toggleAll(wrapper).text()).toBe('모두 접기')
await rowOf(wrapper, 'c1').get('[data-conversation-toggle]').trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('[더 보기]로 더 받은 줄은 접힌 채로 오고 단추는 다시 [모두 펼치기]', async () => {
const filler = Array.from({ length: 95 }, (_, index) =>
summary(`p${index}`, { receivedAt: at(2026, 9, 1, 9, index % 60) }),
)
const later = summary('c9', { receivedAt: at(2026, 10, 9, 9, 59) })
serve({
conversation: ({ params }) =>
params.cursor ? page([later], null, null) : page([...filler, ...THREAD], 'next', null),
})
const { wrapper } = await mountReader()
await toggleAll(wrapper).trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 접기')
await wrapper.get('[data-conversation-more]').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'c9').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
expect(rowOf(wrapper, 'c2').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('펼칠 줄이 없으면(지금 메일만 받았고 나머지를 받는 중) [모두 펼치기]가 없다', async () => {
const rest = deferred()
serve({ conversation: ({ params }) => (params.cursor ? rest.promise : page([CURRENT], 'next', 3)) })
const { wrapper } = await mountReader()
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
expect(wrapper.find('[data-conversation-toggle-all]').exists()).toBe(false)
rest.resolve(page([FIRST, SECOND], null, 3))
await flushPromises()
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('펼친 줄 머리 오른쪽에 [답장](펼침 단추와 따로 놓인 링크) — 접힌 줄 · 임시 저장본의 줄에는 없다', async () => {
serve()
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 'c2').find('[data-conversation-head-reply]').exists()).toBe(false)
await rowOf(wrapper, 'c2').get('[data-conversation-toggle]').trigger('click')
await rowOf(wrapper, 'c5').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const reply = rowOf(wrapper, 'c2').get('[data-conversation-head-reply]')
expect(reply.text()).toBe('답장')
expect(reply.attributes('href')).toBe('/mail/compose?replyTo=c2')
// 단추 안에 넣지 않고 머리 틀 안에 나란히 둔다
expect(reply.element.closest('button')).toBeNull()
expect(reply.element.parentElement?.querySelector('[data-conversation-toggle]')).not.toBeNull()
// 아래쪽 [답장] · [이 메일만 열기]는 그대로
expect(rowOf(wrapper, 'c2').find('[data-conversation-reply]').exists()).toBe(true)
expect(rowOf(wrapper, 'c2').find('[data-conversation-open]').exists()).toBe(true)
expect(rowOf(wrapper, 'c5').find('[data-conversation-head-reply]').exists()).toBe(false)
})
})
@@ -11,11 +11,15 @@
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
// - 칸 머리 오른쪽 [모두 펼치기](15단계 — 디자인 원본 Reader15 "6" · 36 글자 단추): 불러온 줄을 모두 펼치고 [모두 접기]가 된다(하나씩 다 펼쳐도).
// 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(useConversationPanelVisibility → 스토어의 noteConversationPanelVisible).
// 펼칠 줄이 없으면(지금 메일뿐 — 나머지를 받는 중) 단추가 없다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
import { useClock } from '@/composables/useClock'
import { useConversationPanelVisibility } from '@/composables/useConversationPanelVisibility'
import { useConversationSync } from '@/composables/useConversationSync'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount, formatTraceCode } from '@/shared/utils/format'
@@ -77,6 +81,15 @@ function loadMore() {
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
}
/** 펼칠 줄이 있다 — [모두 펼치기] · [모두 접기]를 둔다 */
const canExpandAll = computed(() => mailStore.conversationExpandableIds.length > 0)
/** [모두 펼치기] ↔ [모두 접기] — 하나라도 접혀 있으면 모두 펼친다 */
function toggleAll() {
if (mailStore.conversationAllExpanded) mailStore.collapseAllConversation()
else mailStore.expandAllConversation()
}
// ── 초점 지키기 ─────────────────────────────────────────
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
@@ -146,6 +159,13 @@ watch(
{ flush: 'pre' },
)
// 펼친 안이 화면에 들어오고 나가는 것을 스토어에 알린다 — [모두 펼치기]의 본문은 화면에 들어온 줄부터 하나씩 (15단계)
useConversationPanelVisibility({
root: sectionRef,
watchKey: () => renderKey.value,
onChange: (id, inView) => mailStore.noteConversationPanelVisible(id, inView),
})
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
watch(failed, async (isFailed) => {
announcement.value = ''
@@ -163,14 +183,25 @@ watch(failed, async (isFailed) => {
aria-labelledby="reader-conversation-title"
data-conversation
>
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<div class="conversation__head">
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<button
v-if="canExpandAll"
type="button"
class="conversation__toggle-all"
data-conversation-toggle-all
@click="toggleAll"
>
{{ mailStore.conversationAllExpanded ? MAIL_CONVERSATION_TEXT.collapseAll : MAIL_CONVERSATION_TEXT.expandAll }}
</button>
</div>
<ol class="conversation__list">
<ConversationMessageRow
v-for="row in rows"
@@ -245,6 +276,33 @@ watch(failed, async (isFailed) => {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 칸 머리 — 제목 · 오른쪽 [모두 펼치기] (디자인 원본 Reader15 "6" — 높이 36) */
.conversation__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
min-height: var(--size-notice-action);
}
/* [모두 펼치기] · [모두 접기] — 36 글자 단추, 펼친 줄 머리의 [답장]과 같은 크기(안내 줄 단추의 밀도 예외 재사용). 글자 끝을 상자 끝에 맞춘다 */
.conversation__toggle-all {
flex: 0 0 auto;
min-height: var(--size-notice-action);
margin-right: calc(var(--space-10) * -1);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation__toggle-all:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.conversation__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
@@ -9,6 +9,7 @@
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
// 그사이 다른 목록 · 다른 메일로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리 — 「영구 삭제」는 이름이 길어 더 넓은 폭에서 접는다)
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
@@ -95,6 +96,7 @@ watch([held, permanent, () => props.message.id], () => {
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
tone="danger"
:disabled="working"
collapsible
data-reader-delete
@click="onClick"
/>
@@ -10,12 +10,15 @@
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
// - 아직 보내지 않은 메일(9단계)은 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다
// (옮기거나 지워도 엔진은 그 시각에 보낸다 — 열어서 취소하거나 삭제한다. 스토어도 건너뛴다 — selection.ts)
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
@@ -60,6 +63,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -104,7 +109,10 @@ function onMouseDown(event: MouseEvent) {
},
]"
:data-row-id="message.id"
:draggable="!held"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
@dragend="drag.end"
>
<MailCheckbox
class="message-row__check"
@@ -44,6 +44,7 @@ import { isHeldMessage } from '@/shared/utils/pending-send'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
@@ -63,6 +64,8 @@ const mailStore = useMailStore()
const filterStore = useMailFilterStore()
const contactsStore = useContactsStore()
const blockStore = useMailBlockStore()
/** 차단 창의 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하거나 스팸메일함을 비우는 중 (15단계 — startBlock) */
const blockRunStore = useMailBlockRunStore()
const contactDialog = useMailContactDialog()
const print = useMailPrint()
const senderBlock = useSenderBlock()
@@ -93,6 +96,11 @@ const blockable = computed(
/** 이미 막고 있는 차단 (주소 또는 도메인) */
const senderBlockEntry = computed(() => (blockable.value ? blockStore.blockOf(sender.value) : null))
const inJunk = computed(() => removal.isInJunk(props.message))
/** 받은편지함의 메일이다 — 차단 창의 새 칸을 켜면 「이 메일도 …」를 켠 채 잠근다 (15단계) */
const inInbox = computed(() => {
const inboxId = mailStore.mailboxByRole('inbox')?.id
return inboxId !== undefined && props.message.mailboxIds.includes(inboxId)
})
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [
@@ -206,14 +214,15 @@ function focusButton() {
/**
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
* 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(cleanInbox — 15단계) 정리는 알림이 이어 보인다(기다리지 않는다).
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
*/
async function onBlock(target: string, moveToJunk: boolean) {
async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean) {
blockDialogOpen.value = false
const message = props.message
const source = mailStore.listSource
const nextId = mailStore.neighborAfterRemoval(message.id)
const outcome = await senderBlock.block(message, target, moveToJunk)
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)
@@ -269,6 +278,8 @@ watch(
:open="blockDialogOpen"
:sender-email="sender"
:in-junk="inJunk"
:in-inbox="inInbox"
:cleanup-block="blockRunStore.startBlock"
@confirm="onBlock"
@cancel="blockDialogOpen = false"
/>
@@ -0,0 +1,138 @@
<script setup lang="ts">
// 읽기 화면의 「이동 ▾」 — 열린 메일 한 통을 고른 메일함으로 (15단계 — 디자인 원본 Reader15 "1 · 2 · 3", README "15단계")
// - 자리: 답장 · 전체 답장 · 전달 | 이동 ▾ · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다
// - 메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품 · 같은 목록(mail-move-menu.ts): 메일함 패널처럼 들여 쓴 나무에서 그 메일이 든 메일함
// (검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통을 뺀다. 그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는
// 흐린 줄로 남기고 이름 아래에 "이 메일이 있는 메일함". 휴지통으로는 [삭제]로 옮긴다
// - 옮긴 뒤는 [삭제]와 같은 길: 분할 보기는 다음 메일, 목록 보기는 목록으로 + "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기]
// (같은 이름이 다른 곳에도 있을 때만 위 메일함 이름 — useMailMoveFeedback). 스팸메일함으로 옮기면 [스팸 신고], 스팸메일함에서 꺼내면 [스팸 아님]과 같다
// - 보내기를 기다리는 메일(보내는 중 · 예약)에는 없다 — 읽기 화면이 그리지 않는다(취소해야만 보낸편지함을 떠난다)
// - 키보드는 목록의 [이동 ▾]와 같다: Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). 메뉴가 화면 밖으로 넘치면 단추 오른쪽 끝에 맞춘다(EditorPopup 의 좌우 바꾸기)
// - 읽기 칸이 좁으면 이름을 숨기고 아이콘 + ▾ 만(MailToolButton 의 collapsible — MessageReader 의 컨테이너 쿼리)
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { moveMenuItems } from '@/components/mail/mail-move-menu'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { hasMoveTarget, mailMoveTargets } from '@/shared/utils/mail-move-targets'
import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageSummary
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
layout: 'split' | 'list'
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const buttonRef = ref<InstanceType<typeof MailToolButton> | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const working = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
/** 옮길 곳 — 그 메일이 든 메일함은 빼거나(하위가 있으면) 고를 수 없는 줄로 */
const targets = computed(() => mailMoveTargets(mailStore.mailboxes, new Set(props.message.mailboxIds)))
const items = computed<EditorMenuItem[]>(() => moveMenuItems(targets.value, MAIL_BULK_TEXT.moveHere))
/** 옮길 곳이 하나도 없으면(메일함 목록을 아직 못 받음) 그리지 않는다 */
const available = computed(() => hasMoveTarget(targets.value))
function openMenu(keyboard: boolean) {
if (working.value || !available.value) return
byKeyboard.value = keyboard
open.value = true
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
openMenu(true)
}
function onClose(restoreFocus: boolean) {
open.value = false
if (restoreFocus) trigger.value?.focus()
}
async function onSelect(id: string) {
const target = targets.value.find((row) => row.mailbox.id === id && !row.disabled)
if (!target || working.value) return
open.value = false
trigger.value?.focus()
const message = props.message
const source = mailStore.listSource
working.value = true
try {
const outcome = await removal.moveTo(message, target.mailbox)
await removal.leaveRemoved(outcome, source, props.layout, message.id)
} finally {
working.value = false
}
}
</script>
<template>
<div
v-if="available"
class="reader-move"
>
<MailToolButton
ref="buttonRef"
icon="folder-move"
:label="MAIL_BULK_TEXT.move"
collapsible
menu
aria-haspopup="menu"
:aria-expanded="open"
:disabled="working"
data-reader-move
@click="onClick"
@keydown="onKeydown"
/>
<EditorMenu
v-if="open"
class="reader-move__menu"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
.reader-move {
position: relative;
display: inline-flex;
}
/* 옮길 메일함 메뉴 — 목록의 [이동 ▾]와 같은 폭 (MailBulkMoveButton) */
.reader-move__menu {
width: max-content;
min-width: 12.5rem;
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
max-width: min(20rem, calc(100vw - 5rem));
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,606 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 읽기 화면의 15단계 — 예약 안내 줄 [지금 보내기] · 보낸 사람 차단 창의 「받은편지함에 있는 이 보낸 사람의 메일도 …」
// (디자인 원본 Reader15 "지금 보내기" · Mailbox15 "보낸 사람 차단 창" · 계약 2절 "보내기 취소 · 예약 발송" · "보낸 사람 차단")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 120, unreadCount: 0 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', totalCount: 2 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', totalCount: 0 }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 0 }
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS, JUNK, TRASH]
const NEWSLETTER: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' },
to: [USER],
subject: '[클라우드 브리프] 11월 둘째 주',
preview: '',
receivedAt: '2026-11-09T00:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '이번 주 소식입니다.',
hasBlockedImages: false,
imagesShown: false,
attachments: [],
}
const SEND_AT = '2026-11-10T00:00:00.000Z'
/** 내가 예약한 메일 — 보낸편지함 · 예약됨 */
const SCHEDULED: MessageDetail = {
...NEWSLETTER,
id: 's1',
mailboxIds: ['sent'],
from: USER,
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
subject: '11월 셋째 주 배포 일정 확인 부탁드립니다',
pendingSend: { kind: 'scheduled', sendAt: SEND_AT },
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
interface MountOptions {
message?: MessageDetail
source?: MailListSource
layout?: 'split' | 'list'
extra?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined
}
describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은 메일 옮기기', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(options: MountOptions = {}) {
const message = options.message ?? NEWSLETTER
const source = options.source ?? { kind: 'mailbox', mailboxId: message.mailboxIds[0] ?? 'inbox' }
requests = installMockApi((request) => {
const handled = options.extra?.(request)
if (handled) return handled
if (request.url === '/mail-filters') return ok({ rules: [], version: 'v1', isActive: true })
if (request.url === '/contacts') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
if (request.url === '/mail-blocks' && request.method === 'GET') return ok({ senders: [], isActive: true })
if (request.url === '/mail-blocks' && request.method === 'POST') {
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-11-09T05:00:00.000Z' }, 201)
}
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
if (request.url === '/scheduled-messages') {
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = source
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = message
store.selectedId = message.id
store.selectedStatus = 'ready'
const router = createTestRouter()
const listPath = source.kind === 'scheduled' ? '/mail/scheduled' : `/mail/${message.mailboxIds[0] ?? 'inbox'}`
await router.push(listPath)
await router.push(`${listPath}/${message.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, store }
}
const releases = () => requests.filter((request) => request.method === 'POST' && request.url.endsWith('/release'))
const toasts = () => useToastStore().toasts
/** 설정 › 보내기 취소 10초를 받아 두었다 (메일 화면에 들어올 때 받는다 — MailLayout) */
function knowUndoSeconds() {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
settings.status = 'ready'
}
describe('예약 안내 줄 [지금 보내기]', () => {
it('POST …/release(본문 없음) → "메일을 보냈습니다." + [보내기 취소](설정한 초로 센 마감) · 예약됨에서 빠지고 다음 예약 메일 · 예약됨 수를 다시 묻는다', async () => {
knowUndoSeconds()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
: undefined,
})
const countsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(releases().map((request) => [request.url, request.data])).toEqual([['/scheduled-messages/s1/release', undefined]])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(countsBefore)
const [toast] = toasts()
expect(toasts()).toHaveLength(1)
expect(toast).toMatchObject({ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } })
// 마감은 설정한 10초에서 1초를 뺀 때 — 이 PC 시계가 서버와 어긋나도 알림이 늘어나지 않게
const span = (toast?.expiresAt ?? 0) - (toast?.shownAt ?? 0)
expect(span).toBeGreaterThan(8_900)
expect(span).toBeLessThanOrEqual(9_000)
})
it('누르는 동안 세 단추를 함께 막는다(aria-disabled) — [지금 보내기]는 aria-busy, 다시 눌러도 한 번만 보낸다', async () => {
const pending = deferred()
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? pending.promise : undefined),
})
const notice = wrapper.get('[data-notice="scheduled"]')
await notice.get('[data-scheduled-send]').trigger('click')
expect(notice.findAll('button').map((button) => button.attributes('aria-disabled'))).toEqual(['true', 'true', 'true'])
expect(notice.get('[data-scheduled-send]').attributes('aria-busy')).toBe('true')
await notice.get('[data-scheduled-send]').trigger('click')
await notice.get('[data-scheduled-cancel]').trigger('click')
await flushPromises()
expect(releases()).toHaveLength(1)
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
pending.resolve(ok({ id: 's1', rejectedRecipients: [], pendingSend: null }))
await flushPromises()
})
it('바로 보냈거나 예약 시각이 막 지나 이미 보낸 메일(pendingSend: null) — "메일을 보냈습니다." + [보낸편지함 보기]', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release' ? ok({ id: 's1', rejectedRecipients: [], pendingSend: null }) : undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{
kind: 'success',
message: '메일을 보냈습니다.',
expiresAt: null,
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
},
])
})
it('받는 사람 일부가 받지 못하면 그 문장 — 보내기 취소를 기다리는 동안이면 [보내기 취소]와 함께', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({
id: 's1',
rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'not-found' }],
pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() },
})
: undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)', action: { label: '보내기 취소' } },
])
})
it('보낸편지함에서 연 예약 메일 — 그 자리에 남고 안내 줄만 사라진다(초점은 본문으로) · 붙잡힌 발송은 보내기 취소를 기다리는 것으로 바뀐다', async () => {
const sendAt = new Date(Date.now() + 10_000).toISOString()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt } })
: undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
expect(store.selected?.pendingSend).toEqual({ kind: 'undo', sendAt })
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
describe('보내지 못했거나 결과를 모를 때(503 MAIL_001 · SYS_001) — 그 메일을 다시 받아 붙잡은 발송 · 메일함으로 가른다', () => {
/** 예약은 풀렸는데 다시 내지 못한 메일 — 서버가 결과를 모를 때 남기는 꼴(보낸편지함 · 붙잡은 발송 없음) */
const UNSCHEDULED: MessageDetail = { ...SCHEDULED, pendingSend: null }
/** 보낸편지함 목록 — 다시 맞추면 그 메일은 남고(예약만 풀림) 다음 예약 메일도 있다 */
const sentList = (first: MessageDetail) =>
ok({ items: [first, { ...SCHEDULED, id: 'm2', subject: '다음 메일' }], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
it('임시보관함으로 되돌아갔으면 "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] · 예약됨을 떠났으니 안내 줄을 내리고 다음 예약 메일', async () => {
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
// 엔진이 거절해 서버가 그 메일을 임시보관함으로 되돌렸다
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
}
return undefined
},
})
const listsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
// 사전 문구(MAIL_001) 대신 화면 글 하나
expect(toasts()).toMatchObject([
{
kind: 'error',
message: '지금 보내지 못해 임시보관함으로 옮겼습니다.',
action: { label: '임시보관함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'drafts' } } },
},
])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(listsBefore)
})
it('예약이 그대로면(예약을 거두기 전에 멈춤) "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도] · 안내 줄은 그대로 — [다시 시도]는 같은 요청을 보낸다', async () => {
let attempt = 0
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
attempt += 1
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
}
// 예약 제출이 아직 기다린다 — 예약 표시 그대로
if (request.method === 'GET' && request.url === '/messages/s1') return ok(SCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [failed] = toasts()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '지금 보내지 못했습니다. 다시 시도해 주세요.', action: { label: '다시 시도' } },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
expect(wrapper.get('[data-scheduled-send]').attributes('aria-disabled')).toBeUndefined()
const action = failed ? useToastStore().takeAction(failed.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(releases()).toHaveLength(2)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
})
it('예약만 풀렸으면(보낸편지함 · 기다리는 발송 없음 — 이미 나갔을 수 있다) 예약됨에서 연 메일: "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 예약됨에서 빠지고 다음 예약 메일', async () => {
let attempt = 0
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
attempt += 1
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
}
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [failed] = toasts()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
// 분할 보기 화면이 다음 메일을 연다 (이 시험은 읽기 화면만 띄운다)
store.selectedId = 'm2'
// [다시 시도]는 같은 요청 — 서버가 이어 낸다. 다른 메일을 보고 있으니 그 자리에 둔다
const action = failed ? useToastStore().takeAction(failed.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(releases()).toHaveLength(2)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
})
it('예약만 풀렸으면 보낸편지함에서 연 메일도 안내 줄을 내린다 — 그 자리에 남고(목록에도 남는다) 초점은 본문으로', async () => {
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
expect(store.selected?.pendingSend).toBeNull()
expect(store.selected?.mailboxIds).toEqual(['sent'])
expect(store.messages.map((message) => message.id)).toEqual(['s1', 'm2'])
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
it('결과를 모르는 실패(504 SYS_001 — 앞단이 기다리다 끊음)도 같은 갈래 — 사전 문구 알림 없이 다시 받아 가른다', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(504, 'SYS_001')
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages/s1')).toHaveLength(1)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.'])
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
it('연결이 끊겼는데(SYS_001) 다시 받아 보니 다시 낸 발송이 보내기 취소를 기다리면 보낸 것과 같다 — "메일을 보냈습니다." + [보내기 취소]', async () => {
knowUndoSeconds()
const sendAt = new Date(Date.now() + 10_000).toISOString()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return 'network'
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, pendingSend: { kind: 'undo', sendAt } })
}
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([{ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } }])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
})
it('그 메일을 다시 받지 못하면 보냈다 · 못 보냈다 · 되돌렸다고 하지 않는다 — "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 안내 줄은 마지막으로 안 그대로', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
if (request.method === 'GET' && request.url === '/messages/s1') return 'network'
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } }])
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
})
it('받는 사람이 모두 받지 않으면(400 MAIL_010 — 임시보관함으로) 지금처럼 사전 문구 · 예약 표시를 서버 기준으로 다시 맞춘다', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(400, 'MAIL_010')
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
}
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts().map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_010])
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
})
it('혹시 409 MAIL_014(이미 보냄)로 와도 오류가 아니다 — "메일을 보냈습니다."', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? fail(409, 'MAIL_014') : undefined),
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
})
it('알림의 [보내기 취소]는 지금의 보내기 취소와 같다 — DELETE 로 취소하고 그 메일로 쓰기 화면', async () => {
knowUndoSeconds()
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
return ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
}
if (request.method === 'DELETE' && request.url === '/scheduled-messages/s1') return ok({ draftId: 's1' })
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [toast] = toasts()
const action = toast ? useToastStore().takeAction(toast.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/scheduled-messages/s1',
])
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=s1')
})
})
describe('차단 창 — 받은편지함에 있는 이 보낸 사람의 메일도 옮기기', () => {
async function openBlockDialog(wrapper: VueWrapper) {
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await wrapper.findAll('[role="menuitem"]').at(-1)?.trigger('click')
await flushPromises()
return wrapper.get('[data-block-dialog]')
}
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기]', async () => {
const { wrapper, router } = await mountWith({
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 3, remaining: 0, cursor: null }) : undefined,
})
const dialog = await openBlockDialog(wrapper)
await dialog.get('[data-block-clean]').setValue(true)
// 받은편지함의 메일이라 첫 칸은 켠 채 잠긴다
expect(dialog.get('[data-block-move]').attributes('aria-disabled')).toBe('true')
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
const order = requests
.filter((request) => request.method !== 'GET')
.map((request) => `${request.method} ${request.url}`)
expect(order).toEqual(['POST /mail-blocks', 'PATCH /messages', 'POST /mail-block-runs'])
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'junk', isJunk: true })
expect(requests.find((request) => request.url === '/mail-block-runs')?.data).toEqual({
sender: 'news@cloudbrief.example',
cursor: null,
})
expect(toasts()).toMatchObject([
{
kind: 'success',
message: '‘news@cloudbrief.example’을(를) 차단하고 받은편지함의 메일 4통을 스팸메일함으로 옮겼습니다.',
action: { label: '스팸메일함 보기' },
},
])
// 옮겼으니 삭제처럼 다음 메일을 연다
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
})
it('이 탭에서 필터를 적용하는 중이면 새 칸이 꺼져 있다 — 까닭 글', async () => {
const { wrapper } = await mountWith()
useMailFilterRunStore().running = true
const dialog = await openBlockDialog(wrapper)
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
expect(dialog.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
})
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸이 꺼져 있다 — 그 까닭 글, 비우기가 끝나면 다시 고를 수 있다', async () => {
const emptying = deferred()
const { wrapper, store } = await mountWith({
extra: (request) =>
request.method === 'DELETE' && request.url === '/mailboxes/junk/messages' ? emptying.promise : undefined,
})
const empty = store.runMailboxOperation('empty', JUNK, () => undefined)
const dialog = await openBlockDialog(wrapper)
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
expect(dialog.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
emptying.resolve(ok({ processed: 0, remaining: 0 }))
await empty
await flushPromises()
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBeUndefined()
expect(dialog.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
})
})
})
@@ -565,7 +565,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
it('도구 줄 — 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
const { wrapper } = await mountWith()
const toolbar = wrapper.get('.reader__toolbar')
@@ -573,6 +573,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
'답장',
'전체 답장',
'전달',
'이동',
'스팸 신고',
'삭제',
])
@@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import type { Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
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'
// 15단계 — 읽기 화면의 [이동 ▾] · 좁은 읽기 칸의 아이콘만 도구 줄 (디자인 원본 Reader15 "1 · 2 · 3")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('clients-2025', null, '2025', { parentId: 'clients' }),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-2025', null, '2025', { parentId: 'project' }),
]
function message(id: string, mailboxId: string, partial: Partial<MessageSummary> = {}): MessageDetail {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '(주)한빛솔루션 영업팀', email: 'sales@example.com' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
subject: `2025년 유지보수 정산 자료 ${id}`,
preview: '',
receivedAt: '2026-11-09T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
...partial,
}
}
describe('MessageReader — [이동 ▾] (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountReader(
options: { box?: string; layout?: 'split' | 'list'; open?: Partial<MessageSummary> } = {},
) {
const boxId = options.box ?? 'inbox'
requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 10 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: boxId }
const open = message('m1', boxId, options.open)
store.messages = [open, message('m2', boxId)] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}/m1`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
slots: options.layout === 'list' ? { 'toolbar-start': () => h('a', { href: `/mail/${boxId}` }, '목록으로') } : {},
global: { plugins: [pinia, router] },
attachTo: document.body,
})
mounted.push(wrapper)
return { wrapper, router, store }
}
const patches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
async function openMenu(wrapper: VueWrapper) {
await wrapper.get('[data-reader-move]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
return wrapper.get('[role="menu"]')
}
async function pick(wrapper: VueWrapper, label: string) {
const menu = await openMenu(wrapper)
const item = menu.findAll('[role="menuitem"]').find((entry) => (entry.attributes('aria-label') ?? entry.text()) === label)
if (!item) throw new Error(`${label} 항목이 없다`)
await item.trigger('click')
await flushPromises()
}
it('[전달] 뒤 구분선 다음, [스팸 신고] 앞 — 메뉴를 여는 단추(▾)이고 키보드로 열면 첫 항목에 초점', async () => {
const { wrapper } = await mountReader()
const toolbar = wrapper.get('.reader__toolbar')
const move = wrapper.get('[data-reader-move]')
const order = [...toolbar.element.children].map((child) =>
child.matches('.toolbar-divider') ? '|' : (child.textContent ?? '').trim() || (child.getAttribute('aria-label') ?? ''),
)
expect(order.slice(0, 7)).toEqual(['답장', '전체 답장', '전달', '|', '이동', '스팸 신고', '삭제'])
expect(move.attributes('aria-haspopup')).toBe('menu')
expect(move.attributes('aria-expanded')).toBe('false')
expect(move.find('.tool-button__chevron').exists()).toBe(true)
const menu = await openMenu(wrapper)
expect(menu.attributes('aria-label')).toBe('옮길 메일함')
expect(document.activeElement).toBe(menu.find('[role="menuitem"]').element)
expect(move.attributes('aria-expanded')).toBe('true')
})
it('옮길 곳은 메일함 패널처럼 들여 쓴다 — 그 메일이 든 받은편지함 · 임시보관함 · 휴지통은 없고, 기본 메일함과 내 메일함 사이에 선', async () => {
const { wrapper } = await mountReader()
const menu = await openMenu(wrapper)
const entries = [...menu.element.children].map((child) => {
if (child.tagName === 'HR') return '---'
const depth = [...child.classList].find((name) => name.startsWith('menu__item--depth-'))
return `${child.textContent?.trim()}${depth ? ` (${depth.slice(-1)})` : ''}`
})
expect(entries).toEqual(['보낸편지함', '스팸메일함', '---', '거래처', '2025 (1)', '견적 (1)', '프로젝트', '2025 (1)'])
// 들여 쓴 메일함은 위 메일함부터 읽는다 — 같은 이름이 둘이어도 가른다
const labels = menu.findAll('[role="menuitem"]').map((item) => item.attributes('aria-label') ?? null)
expect(labels).toEqual([null, null, null, '거래처 안의 2025', '거래처 안의 견적', null, '프로젝트 안의 2025'])
// 앞에 패널과 같은 아이콘
expect(menu.findAll('[role="menuitem"] .menu__icon')).toHaveLength(7)
})
it('그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄 "이 메일이 있는 메일함" — 눌러도 옮기지 않는다', async () => {
const { wrapper } = await mountReader({ box: 'clients' })
const menu = await openMenu(wrapper)
const here = menu.findAll('[role="menuitem"]').find((item) => item.text().startsWith('거래처'))
expect(here?.attributes('aria-disabled')).toBe('true')
expect(here?.get('.menu__note').text()).toBe('이 메일이 있는 메일함')
expect(here?.attributes('aria-describedby')).toBe(here?.get('.menu__note').attributes('id'))
// 받은편지함이 처음으로 든다 (그 메일은 거래처에 있다)
expect(menu.findAll('[role="menuitem"]')[0]?.text()).toBe('받은편지함')
await here?.trigger('click')
await flushPromises()
expect(patches()).toEqual([])
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
})
it('옮기면 [삭제]와 같은 길 — 다음 메일을 열고 "메일 1통을 거래처 › 2025로 옮겼습니다."(같은 이름이 또 있어서) + [되돌리기]', async () => {
const { wrapper, router, store } = await mountReader()
await pick(wrapper, '거래처 안의 2025')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients-2025' }])
expect(store.messages.map((item) => item.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 거래처 › 2025로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
expect(toastTexts().at(-1)).toBe('메일 1통을 받은편지함으로 되돌렸습니다.')
})
it('같은 이름이 하나뿐이면 이름만 — "메일 1통을 견적으로 옮겼습니다." · 목록 보기는 목록으로 돌아간다', async () => {
const { wrapper, router } = await mountReader({ layout: 'list' })
await pick(wrapper, '거래처 안의 견적')
expect(toastTexts()).toEqual(['메일 1통을 견적으로 옮겼습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 보내고 [되돌리기]는 표시도 되돌린다', async () => {
const { wrapper } = await mountReader()
await pick(wrapper, '스팸메일함')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같다 — "스팸이 아닌 메일로 표시해 …로 옮겼습니다."', async () => {
const { wrapper } = await mountReader({ box: 'junk' })
await pick(wrapper, '프로젝트')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project', isJunk: false }])
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 프로젝트로 옮겼습니다.'])
})
it('보내기를 기다리는 메일(보내는 중 · 예약)에는 [이동 ▾]가 없다 — [스팸 신고]처럼', async () => {
const sendAt = '2026-11-10T00:00:00Z'
const { wrapper } = await mountReader({ box: 'sent', open: { pendingSend: { kind: 'undo', sendAt } } })
expect(wrapper.find('[data-reader-move]').exists()).toBe(false)
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
})
})
describe('MessageReader — 좁은 읽기 칸의 아이콘만 도구 줄 (15단계 — 디자인 원본 Reader15 "2")', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(boxId: string, layout: 'split' | 'list' = 'split') {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: boxId }
const open = message('m1', boxId)
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}/m1`)
await router.isReady()
return mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
}
it('도구 줄의 이름 단추는 이름을 숨겨도 읽히게 aria-label · title 을 둔다 (숨기는 때는 읽기 칸 폭의 컨테이너 쿼리)', async () => {
const wrapper = await mountWith('inbox')
const buttons = wrapper.findAll('.reader__toolbar .tool-button--collapsible')
expect(buttons.map((button) => [button.attributes('aria-label'), button.attributes('title')])).toEqual([
['답장', '답장'],
['전체 답장', '전체 답장'],
['전달', '전달'],
['이동', '이동'],
['스팸 신고', '스팸 신고'],
['삭제', '삭제'],
])
// 이름은 따로 감싼 글 — 좁으면 이것만 숨긴다
expect(buttons.map((button) => button.get('.tool-button__label').text())).toEqual([
'답장',
'전체 답장',
'전달',
'이동',
'스팸 신고',
'삭제',
])
})
it('접는 폭을 가르는 모양 — 분할 보기 · 목록 보기, 「영구 삭제」가 있는 휴지통은 따로', async () => {
const inbox = await mountWith('inbox')
expect(inbox.get('.reader__toolbar').classes()).toEqual(['reader__toolbar', 'reader__toolbar--split'])
inbox.unmount()
const trash = await mountWith('trash')
expect(trash.get('.reader__toolbar').classes()).toContain('reader__toolbar--long')
expect(trash.get('[data-reader-delete]').text()).toBe('영구 삭제')
trash.unmount()
const list = await mountWith('inbox', 'list')
expect(list.get('.reader__toolbar').classes()).toContain('reader__toolbar--list')
list.unmount()
})
})
@@ -289,12 +289,12 @@ describe('MessageReader — 9단계', () => {
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [지금 보내기](15단계) [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
const notice = wrapper.get('[data-notice="scheduled"]')
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
expect(notice.findAll('button').map((button) => button.text())).toEqual(['지금 보내기', '고치기', '예약 취소'])
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
})
+107 -12
View File
@@ -3,10 +3,13 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 도구 줄: 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기 · 보낸 사람 차단(9단계))
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면" · "15단계").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 이동 · 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// 읽기 칸이 좁으면 도구 줄의 이름을 숨기고 아이콘만 둔다(15단계 — 디자인 원본 Reader15 "2"). 기준은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리 —
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함), 「영구 삭제」가 있으면 595. 목록 보기는 앞에 [목록으로] · 이전/다음이
// 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다. 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
@@ -29,10 +32,12 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageConversation from '@/components/mail/MessageConversation.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageMoveButton from '@/components/mail/MessageMoveButton.vue'
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailPrint } from '@/composables/useMailPrint'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
@@ -49,21 +54,30 @@ interface Props {
layout?: 'split' | 'list'
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const mailStore = useMailStore()
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
const print = useMailPrint()
const removal = useMessageRemoval()
const bodyFrameRef = ref<InstanceType<typeof MailBodyFrame> | null>(null)
const message = computed(() => mailStore.selected)
/** 예약한 메일 — 답장 · 전달이 없다 (9단계) */
const scheduled = computed(() => isScheduledMessage(message.value))
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 스팸 신고(옮기기)가 없다 */
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 이동 · 스팸 신고(옮기기)가 없다 */
const held = computed(() => isHeldMessage(message.value))
/** 도구 줄이 「영구 삭제」를 보인다 — 이름이 길어 더 넓은 폭에서 아이콘만으로 접는다 (MessageDeleteButton 과 같은 판정) */
const longDelete = computed(() => Boolean(message.value && !held.value && removal.deletesPermanently(message.value)))
/** 도구 줄의 모양 — 보기 방식(목록 보기는 앞에 [목록으로] · 이전/다음)과 「영구 삭제」에 따라 아이콘만으로 접는 폭이 다르다 */
const toolbarClass = computed(() => [
'reader__toolbar',
`reader__toolbar--${props.layout}`,
{ 'reader__toolbar--long': longDelete.value },
])
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
const searchMailboxName = computed(() => {
if (!mailStore.search || !message.value) return null
@@ -128,7 +142,7 @@ onBeforeUnmount(() => {
class="reader__toolbar-bar"
>
<div
class="reader__toolbar"
:class="toolbarClass"
role="group"
aria-label="이 메일로 할 일"
>
@@ -138,24 +152,32 @@ onBeforeUnmount(() => {
<MailToolButton
icon="reply"
label="답장"
collapsible
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
/>
<MailToolButton
icon="reply-all"
label="전체 답장"
collapsible
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
/>
<MailToolButton
icon="forward"
label="전달"
collapsible
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MessageSpamButton
v-if="!held"
:message="message"
:layout="layout"
/>
<template v-if="!held">
<MessageMoveButton
:message="message"
:layout="layout"
/>
<MessageSpamButton
:message="message"
:layout="layout"
/>
</template>
</template>
<MessageDeleteButton
:message="message"
@@ -325,11 +347,15 @@ onBeforeUnmount(() => {
flex-direction: column;
}
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
/*
* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다.
* 띠가 읽기 칸 폭의 컨테이너다(15단계) — 단추 이름을 숨길지 이 폭으로 정한다 (아래 @container). 크기 봉쇄는 가로뿐이라 메뉴는 띠 밖으로 나온다
*/
.reader__toolbar-bar {
position: sticky;
top: 0;
z-index: 1;
container: reader-tools / inline-size;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
@@ -345,6 +371,75 @@ onBeforeUnmount(() => {
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
/* 목록 보기 — 앞의 [목록으로] · 이전/다음까지 이름이 다 보이는 줄은 콘텐츠 열(800)보다 조금 넓다. 줄의 최대 폭을 풀어 넓은 칸에서는 한 줄에 둔다 */
.reader__toolbar--list {
max-width: none;
}
/*
* 읽기 칸이 단추 이름을 한 줄에 다 보일 폭보다 좁으면 — 이름을 숨기고 아이콘만 40×44 ([이동 ▾]는 ▾ 까지 남긴다).
* 폭(15단계 — 디자인 원본 Reader15 "2" 실측, 도구 줄 좌우 여백 포함): 분할 보기 570 · 「영구 삭제」 595, 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
*/
@container reader-tools (width < 35.625rem) {
.reader__toolbar :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 37.1875rem) {
.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 53.125rem) {
.reader__toolbar--list :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--list :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--list :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 54.6875rem) {
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
.reader__content {
display: flex;
@@ -1,11 +1,11 @@
<script setup lang="ts">
// 읽기 화면 머리 아래의 안내 줄 (디자인 원본 MailReadTools · MailSendLater · README "9단계" · "읽기 화면의 본문 안내")
// 차례: 예약한 메일(청록 — [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 차례: 예약한 메일(청록 — [지금 보내기](15단계) [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 본문이 다 보이는지(생략 → 잘림 — 계약 2절 "읽기 한도") → 외부 이미지 차단(노랑 — [이미지 보기]).
// - [이미지 보기](36 — 밀도 예외 --size-notice-action): 그 메일만 `images=show` 로 다시 받아 본문만 바꾼다(스토어의 showImages — 늦은 응답은 버린다).
// 보이면(imagesShown — 설정으로 처음부터 보인 것 포함) 이 줄은 없다. 다시 열면 설정대로다
// - 예약 안내 줄의 단추는 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 두 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [고치기] · [예약 취소])
// - 예약 안내 줄의 단추는 지금 보내기 · 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 세 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [지금 보내기] · [고치기] · [예약 취소])
// 줄 안의 단추는 disabled 대신 aria-disabled — 초점이 그대로 남게 (누르면 아무것도 하지 않는다)
import { computed, ref } from 'vue'
@@ -42,7 +42,7 @@ const scheduledActions = useScheduledMessageActions()
const senderBlock = useSenderBlock()
/** 예약 줄 · 차단 줄의 단추가 하는 중 */
const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
const working = ref<'send' | 'edit' | 'cancel' | 'unblock' | null>(null)
const pending = computed(() => messagePendingSend(props.message))
const scheduledTime = computed(() =>
@@ -63,16 +63,18 @@ async function showImages() {
if (await mailStore.showImages()) emit('settled')
}
/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
async function runScheduled(kind: 'edit' | 'cancel') {
/**
* [지금 보내기] · [고치기] · [예약 취소] — 했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다).
* 지금 보내기(15단계)는 예약을 취소하지 않고 보낸다 — 보내기 취소를 기다리는 동안은 보낸편지함 날짜 칸이 "보내는 중"이다
*/
async function runScheduled(kind: 'send' | 'edit' | 'cancel') {
if (working.value) return
working.value = kind
let done = false
try {
done =
kind === 'edit'
? await scheduledActions.editScheduled(props.message.id)
: await scheduledActions.cancelScheduled(props.message, props.layout)
if (kind === 'send') done = await scheduledActions.sendNow(props.message, props.layout)
else if (kind === 'edit') done = await scheduledActions.editScheduled(props.message.id)
else done = await scheduledActions.cancelScheduled(props.message, props.layout)
} finally {
working.value = null
}
@@ -103,26 +105,39 @@ async function unblock() {
class="reader__notice-icon"
/>
<span class="reader__notice-text reader__notice-text--strong">{{ SEND_LATER_TEXT.notice(scheduledTime) }}</span>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'edit' ? 'true' : undefined"
data-scheduled-edit
@click="runScheduled('edit')"
>
{{ SEND_LATER_TEXT.edit }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'cancel' ? 'true' : undefined"
data-scheduled-cancel
@click="runScheduled('cancel')"
>
{{ SEND_LATER_TEXT.cancelSchedule }}
</button>
<!-- 단추 셋은 한 묶음 — 줄이 좁으면 묶음째 다음 줄 오른쪽으로 (15단계 — 글이 한 글자 폭으로 눌리지 않게) -->
<span class="reader__notice-actions">
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'send' ? 'true' : undefined"
data-scheduled-send
@click="runScheduled('send')"
>
{{ SEND_LATER_TEXT.sendNow }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'edit' ? 'true' : undefined"
data-scheduled-edit
@click="runScheduled('edit')"
>
{{ SEND_LATER_TEXT.edit }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'cancel' ? 'true' : undefined"
data-scheduled-cancel
@click="runScheduled('cancel')"
>
{{ SEND_LATER_TEXT.cancelSchedule }}
</button>
</span>
</div>
<div
@@ -216,6 +231,7 @@ async function unblock() {
/* 예약한 메일 — 청록 (디자인 원본 MailSendLater: 바탕 #E3F2F3 · 글 #0A5F67 · 단추 테두리 #9ED6DB) */
.reader__notice--brand {
flex-wrap: wrap;
gap: var(--space-8);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
@@ -223,6 +239,19 @@ async function unblock() {
--reader-notice-button-hover: var(--color-brand-tint);
}
/* 단추 셋 — [지금 보내기] [고치기] [예약 취소] (15단계). 넓으면 글 오른쪽 한 줄, 좁으면 묶음째 다음 줄 오른쪽 */
.reader__notice-actions {
flex: 0 0 auto;
display: flex;
gap: var(--space-8);
margin-left: auto;
}
/* 글이 이 폭보다 좁아지면 단추 묶음이 다음 줄로 간다 */
.reader__notice--brand .reader__notice-text {
flex-basis: 12rem;
}
/* 차단한 보낸 사람 — 회색 (디자인 원본 MailReadTools: 바탕 #F2F4F7 · 글 #344054 · 단추 테두리 #D0D5DD) */
.reader__notice--muted {
background: var(--color-surface-muted);
@@ -4,6 +4,7 @@
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리)
import { computed, ref } from 'vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
@@ -49,6 +50,7 @@ async function onClick() {
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
:tone="inJunk ? 'accent' : 'default'"
:disabled="working"
collapsible
data-reader-spam
@click="onClick"
/>
@@ -7,12 +7,15 @@
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
@@ -45,6 +48,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const unread = computed(() => isRowUnread(props.message, props.role))
@@ -80,8 +85,11 @@ function onMouseDown(event: MouseEvent) {
<tr
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="message.id"
:draggable="!held"
@click="onRowClick"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
@dragend="drag.end"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
@@ -4,6 +4,7 @@
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
// 들여 쓴 항목(depth — 15단계 [이동 ▾]의 메일함 나무)은 메일함 패널과 같은 10 · 28 · 44 · 56 에서 시작하고, 화면 낭독 이름이 따로 있으면(ariaLabel) 그것으로 읽힌다
import { useId } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -43,6 +44,14 @@ function noteId(itemId: string): string {
return `${menuId}-${itemId}-note`
}
/** 들여쓰기는 3단계까지 — 그 아래는 같은 깊이로 (메일함 패널과 같다) */
const MAX_MENU_DEPTH = 3
function depthClass(depth: number | undefined): string | undefined {
if (!depth || depth <= 0) return undefined
return `menu__item--depth-${Math.min(depth, MAX_MENU_DEPTH)}`
}
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
function select(item: EditorMenuItem, event: MouseEvent) {
if (item.separator || item.disabled) return
@@ -70,8 +79,13 @@ function select(item: EditorMenuItem, event: MouseEvent) {
<button
v-else
type="button"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
:class="[
'menu__item',
depthClass(item.depth),
{ 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note },
]"
:role="item.role ?? 'menuitem'"
:aria-label="item.ariaLabel"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:aria-disabled="item.disabled ? 'true' : undefined"
@@ -154,6 +168,19 @@ function select(item: EditorMenuItem, event: MouseEvent) {
background: var(--color-surface-muted);
}
/* 들여 쓴 항목 — 메일함 패널과 같은 왼쪽 안쪽 (MailboxPanelRow) */
.menu__item--depth-1 {
padding-left: var(--space-28);
}
.menu__item--depth-2 {
padding-left: var(--space-44);
}
.menu__item--depth-3 {
padding-left: var(--space-56);
}
.menu__item[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import EditorMenu from './EditorMenu.vue'
// 15단계 — 메뉴 자리의 좌우 바꾸기 (디자인 원본 Reader15 2-4): 바라는 끝에 맞춰 그린 메뉴가 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다.
// EditorPopup 을 쓰는 메뉴 · 팝오버 모두에 걸린다 — 여기서는 EditorMenu 로 본다
function rect(left: number, width: number, top = 0, height = 40): DOMRect {
return { left, right: left + width, width, top, bottom: top + height, height, x: left, y: top, toJSON: () => ({}) } as DOMRect
}
/** 단추와 메뉴를 감싼 칸 — 실제 쓰는 곳(MailBulkMoveButton 등)과 같은 모양 */
function host(align: 'start' | 'end') {
return defineComponent({
setup() {
const trigger = ref<HTMLButtonElement | null>(null)
return () =>
h('div', { class: 'host' }, [
h('button', { ref: trigger, type: 'button', class: 'trigger' }, '이동'),
trigger.value
? h(EditorMenu, {
label: '옮길 메일함',
items: [
{ id: 'a', label: '보낸편지함' },
{ id: 'b', label: '스팸메일함' },
],
trigger: trigger.value,
align,
})
: null,
])
},
})
}
describe('EditorPopup — 좌우 자리', () => {
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
document.body.innerHTML = ''
})
/** 화면 폭 360 — 단추(40)는 triggerLeft, 메뉴 폭 200 */
function layout(triggerLeft: number) {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('.trigger')) return rect(triggerLeft, 40)
if (this.matches('[role="menu"]')) return rect(triggerLeft, 200, 44, 120)
return rect(0, 0)
})
}
async function open(align: 'start' | 'end') {
wrapper = mount(host(align), { attachTo: document.body })
// 첫 그리기에서 단추 ref 를 받고, 다음 그리기에서 메뉴를 그린다
await nextTick()
await nextTick()
return wrapper.get('[role="menu"]')
}
it('왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에 맞춘다', async () => {
layout(240)
const menu = await open('start')
expect(menu.classes()).toContain('editor-popup--end')
expect(menu.classes()).not.toContain('editor-popup--start')
})
it('다 들어가면 바라는 끝 그대로', async () => {
layout(60)
const menu = await open('start')
expect(menu.classes()).toContain('editor-popup--start')
})
it('오른쪽 끝에 맞춘 메뉴(⋯)가 화면 왼쪽 밖으로 넘치면 단추 왼쪽 끝에 맞춘다', async () => {
layout(80)
const menu = await open('end')
expect(menu.classes()).toContain('editor-popup--start')
})
})
@@ -5,8 +5,13 @@
// - 메뉴(role=menu): 위·아래 화살표 · Home · End 로 항목을 옮기고, Tab 은 메뉴를 닫는다
// - 마우스로 열었으면(focusContent) 항목이 아니라 팝오버 자체에 초점을 둔다 — 고른 항목에 키보드 고리가 뜨지 않게.
// 화살표를 누르면 그때 항목으로 들어간다 (키보드로 열면 처음부터 항목에 초점)
// - 맞출 끝(align)은 바라는 쪽이다 — 그린 직후(초점을 주기 전) 재어, 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다
// (15단계 — 좁은 폭의 읽기 화면 [이동 ▾]처럼 왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에. usePopupPlacement 의 measurePopupAlign)
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { measurePopupAlign } from '@/composables/usePopupPlacement'
import type { PopupAlign } from '@/shared/utils/popup-placement'
interface Props {
role: 'menu' | 'dialog'
label: string
@@ -32,6 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
/** 실제로 맞춘 끝 — 바라는 끝(align)으로 그린 뒤 넘치면 반대 끝 */
const placedAlign = ref<PopupAlign>(props.align)
/**
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
@@ -105,8 +112,16 @@ function onFocusOut(event: FocusEvent) {
if (isOutside(event.relatedTarget)) emit('close', false)
}
/** 바라는 끝으로 그린 팝오버를 재어 넘치면 반대 끝으로 — 초점을 주기 전에 (넘친 자리에 초점이 가 화면이 옆으로 밀리지 않게) */
function placeSide() {
const root = rootRef.value
if (!root || !props.trigger) return
placedAlign.value = measurePopupAlign(props.trigger, root, props.align)
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
placeSide()
void nextTick(focusInitial)
})
@@ -120,7 +135,7 @@ onBeforeUnmount(() => {
ref="rootRef"
:role="role"
:aria-label="label"
:class="['editor-popup', `editor-popup--${align}`, `editor-popup--${role}`]"
:class="['editor-popup', `editor-popup--${placedAlign}`, `editor-popup--${role}`]"
tabindex="-1"
data-editor-popup
@keydown="onKeydown"
@@ -18,6 +18,12 @@ export interface EditorMenuEntry {
disabled?: boolean
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
note?: string
/**
* 화면 낭독 이름 — 보이는 이름과 다르게 읽힐 때만 (15단계 [이동 ▾]의 들여 쓴 메일함 "거래처 안의 2025" — 같은 이름이 둘이어도 가르게)
*/
ariaLabel?: string
/** 들여쓰기 단계 — 메일함 패널처럼 왼쪽 안쪽 10 · 28 · 44 · 56 (3단계까지, 15단계 [이동 ▾]의 메일함 나무) */
depth?: number
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
@@ -0,0 +1,29 @@
// [이동 ▾] 메뉴의 항목 — 목록 도구 줄(MailBulkMoveButton)과 읽기 화면(MessageMoveButton)이 같은 모양을 쓴다
// (15단계 — 디자인 원본 Reader15 "1 · 이 메일이 든 메일함에 하위 메일함이 있을 때"). 옮길 곳을 고르는 규칙은 shared/utils/mail-move-targets.ts
// - 메일함 패널처럼 깊이만큼 들여 쓰고(10 · 28 · 44 · 56) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선
// - 들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025")
// - 메일이 든 메일함을 남긴 줄은 고를 수 없고(aria-disabled) 이름 아래에 까닭(hereNote)을 적는다
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import type { MailMoveTarget } from '@/shared/utils/mail-move-targets'
import { mailboxIcon } from '@/shared/utils/mailbox'
/** 기본 메일함과 내 메일함 사이의 선 — 메뉴 안에 하나뿐이다(두 묶음은 이어져 있다) */
const GROUP_SEPARATOR_ID = 'move-menu-group-separator'
export function moveMenuItems(rows: readonly MailMoveTarget[], hereNote: string): EditorMenuItem[] {
const items: EditorMenuItem[] = []
rows.forEach((row, index) => {
const previous = rows[index - 1]
if (previous && previous.group !== row.group) items.push(menuSeparator(GROUP_SEPARATOR_ID))
items.push({
id: row.mailbox.id,
label: row.mailbox.name,
ariaLabel: row.spokenName === row.mailbox.name ? undefined : row.spokenName,
icon: mailboxIcon(row.mailbox),
depth: row.depth,
disabled: row.disabled,
note: row.here ? hereNote : undefined,
})
})
return items
}
@@ -5,5 +5,7 @@ export type MailboxDialogRequest =
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
| { kind: 'create'; parent: Mailbox | null }
| { kind: 'rename'; mailbox: Mailbox }
/** 메일함 옮기기 (15단계) — 옮길 곳을 고르는 창 */
| { kind: 'move'; mailbox: Mailbox }
| { kind: 'delete'; mailbox: Mailbox }
| { kind: 'empty'; mailbox: Mailbox }
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AppTopBar from './AppTopBar.vue'
// 상단 바 — 15단계 좁은 폭(디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): 이름 · 주소 칸만 줄고 넘치면 "…", 전체는 마우스를 올리면 보인다.
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다
describe('AppTopBar — 계정 칸', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
restoreApiAdapter()
})
async function mountBar() {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(AppTopBar, { props: { title: '메일' }, global: { plugins: [pinia, router] } })
}
it('차례는 그대로 — 모듈 이름 · 검색 칸 · 계정(머리글자 · 이름과 주소 · [로그아웃])', async () => {
const wrapper = await mountBar()
const children = [...wrapper.get('.top-bar').element.children].map((child) => child.className)
expect(children).toEqual(['top-bar__title', 'top-bar__search', 'top-bar__account'])
const account = [...wrapper.get('.top-bar__account').element.children].map((child) => child.className)
expect(account).toEqual(['top-bar__avatar', 'top-bar__who', 'top-bar__logout'])
expect(wrapper.get('.top-bar__logout').text()).toBe('로그아웃')
})
it('이름 · 주소가 줄어 "…"가 되어도 마우스를 올리면 다 보인다 — "김아라 (ara.kim@arasystem.it.kr)"', async () => {
const wrapper = await mountBar()
const who = wrapper.get('.top-bar__who')
expect(who.attributes('title')).toBe('김아라 (ara.kim@arasystem.it.kr)')
expect(who.get('.top-bar__name').text()).toBe('김아라')
expect(who.get('.top-bar__email').text()).toBe('ara.kim@arasystem.it.kr')
})
})
+35 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
// 좁은 폭(15단계 — 디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): [로그아웃] · 머리글자는 줄지도 꺾이지도 않고 이름 · 주소 칸만 준다 —
// 넘치면 끝을 "…"로 줄이고 전체는 마우스를 올리면 보인다(title). 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다.
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -23,6 +26,11 @@ const { logout } = useAuth()
const loggingOut = ref<boolean>(false)
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
/** 줄어든 이름 · 주소의 전체 — "김아라 (ara.kim@arasystem.it.kr)" */
const accountTitle = computed(() => {
const user = authStore.user
return user ? `${user.name} (${user.email})` : ''
})
async function onLogout() {
if (loggingOut.value) return
@@ -68,7 +76,10 @@ async function onLogout() {
class="top-bar__avatar"
aria-hidden="true"
>{{ initial }}</span>
<div class="top-bar__who">
<div
class="top-bar__who"
:title="accountTitle"
>
<span class="top-bar__name">{{ authStore.user.name }}</span>
<span class="top-bar__email">{{ authStore.user.email }}</span>
</div>
@@ -135,12 +146,16 @@ async function onLogout() {
.top-bar__account {
margin-left: auto;
/* 줄이 모자라면 이름 · 주소 칸이 준다 (줄을 넘지 않는다) */
min-width: 0;
max-width: 100%;
display: flex;
align-items: center;
gap: var(--space-10);
}
.top-bar__avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -153,11 +168,20 @@ async function onLogout() {
}
.top-bar__who {
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
/* 넘치면 끝을 "…"로 — 전체는 칸의 title */
.top-bar__name,
.top-bar__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-bar__name {
font-weight: 600;
}
@@ -167,7 +191,10 @@ async function onLogout() {
color: var(--color-text-muted);
}
/* [로그아웃]은 줄지도 꺾이지도 않는다 */
.top-bar__logout {
flex: 0 0 auto;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: var(--space-6);
@@ -183,4 +210,11 @@ async function onLogout() {
.top-bar__logout:hover {
background: var(--color-surface-muted);
}
/* 화면 폭 480 이하 — 주소를 감추고 이름만 (tokens.css 화면 폭 기준) */
@media (max-width: 30rem) {
.top-bar__email {
display: none;
}
}
</style>