generated from ttipo/project-base
feat(frontend): 새 메일 확인·메일 검색·여러 통 선택과 처리 화면
- 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다 - 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계) - 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제 - 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게 - 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다 - 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
@@ -168,6 +168,15 @@ onMounted(() => {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
|
||||
:slotted(.modal-dialog__action--danger) {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
|
||||
:slotted(.modal-dialog__action--secondary) {
|
||||
background: transparent;
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
await router.push(`/mail/inbox/${messageId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId },
|
||||
props: { messageId },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
return { wrapper, router }
|
||||
@@ -114,7 +114,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
await router.push('/mail/inbox/m2')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId: 'm2' },
|
||||
props: { messageId: 'm2' },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
@@ -134,4 +134,27 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
|
||||
})
|
||||
|
||||
it('검색 결과에서 연 메일 — 이전/다음은 결과 안의 주소로, "목록으로"는 검색 결과로 돌아간다', async () => {
|
||||
const store = useMailStore()
|
||||
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m2?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm2' }, global: { plugins: [pinia, router] } })
|
||||
|
||||
expect(wrapper.get('a.tool-button').attributes('href')).toBe(`/mail/search?${query}`)
|
||||
expect(wrapper.get('.nav-position').text()).toBe('2 / 3')
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -9,11 +10,11 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
@@ -22,17 +23,23 @@ const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
|
||||
const { backToSourceList, showSiblingIn } = useMailNavigation()
|
||||
|
||||
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
|
||||
const loadingNext = ref<boolean>(false)
|
||||
|
||||
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
|
||||
const listHref = computed(() => {
|
||||
const source = mailStore.listSource
|
||||
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
|
||||
})
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */
|
||||
const total = computed(
|
||||
() =>
|
||||
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
|
||||
mailStore.pageInfo?.total ??
|
||||
(mailStore.search ? null : mailStore.currentMailbox?.totalCount) ??
|
||||
mailStore.messages.length,
|
||||
)
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
@@ -46,7 +53,8 @@ const hasNext = computed(
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
if (previous) await showSibling(props.mailboxId, previous.id)
|
||||
const source = mailStore.listSource
|
||||
if (previous && source) await showSiblingIn(source, previous.id)
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
@@ -61,7 +69,8 @@ async function goNext() {
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
if (next) await showSibling(props.mailboxId, next.id)
|
||||
const source = mailStore.listSource
|
||||
if (next && source) await showSiblingIn(source, next.id)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
@@ -69,8 +78,10 @@ function onBackToList(event: MouseEvent) {
|
||||
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
const source = mailStore.listSource
|
||||
if (!source) return
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
void backToList(props.mailboxId)
|
||||
void backToSourceList(source)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄의 단추 — 높이 44 · 아이콘 + 이름 (디자인 원본 BulkSelect · ListStates)
|
||||
// 이름은 labelMode 로 정한다: always(늘 보인다) · wide(폭 1100 이상에서만 — 목록 보기) · never(아이콘만 40×44 — 분할 보기의 목록 칸)
|
||||
// 이름을 숨겨도 aria-label · 툴팁(title)으로 읽힌다. data-tool · tabindex(로빙) · aria-haspopup 같은 속성은 단추에 그대로 붙는다.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
label: string
|
||||
/** 툴팁 — 주지 않으면 이름 */
|
||||
title?: string
|
||||
labelMode?: 'always' | 'wide' | 'never'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) */
|
||||
tone?: 'default' | 'danger'
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
|
||||
menu?: boolean
|
||||
disabled?: boolean
|
||||
iconStrokeWidth?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: undefined,
|
||||
labelMode: 'wide',
|
||||
tone: 'default',
|
||||
menu: false,
|
||||
disabled: false,
|
||||
iconStrokeWidth: 1.75,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
defineExpose({
|
||||
/** 단추 요소 — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
|
||||
element: buttonRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:class="['bulk-button', `bulk-button--${tone}`, `bulk-button--label-${labelMode}`, { 'bulk-button--menu': menu }]"
|
||||
:aria-label="label"
|
||||
:title="title ?? label"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span class="bulk-button__label">{{ label }}</span>
|
||||
<AppIcon
|
||||
v-if="menu"
|
||||
name="chevron-down"
|
||||
:size="12"
|
||||
:stroke-width="2"
|
||||
class="bulk-button__chevron"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-width: var(--size-icon-button);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--toolbar-button-inline);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bulk-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bulk-button--menu {
|
||||
padding: 0 var(--space-8) 0 var(--toolbar-button-inline);
|
||||
}
|
||||
|
||||
.bulk-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bulk-button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
|
||||
.bulk-button--label-never {
|
||||
width: var(--size-icon-button);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bulk-button--label-never .bulk-button__label,
|
||||
.bulk-button--label-never .bulk-button__chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 아이콘만 (메뉴 단추는 ▾ 를 둔다 · tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.bulk-button--label-wide .bulk-button__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
// 이동 ▾ — 옮길 메일함 메뉴 (디자인 원본 BulkSelect "이동 메뉴" · README "3단계 › 여러 통 처리")
|
||||
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
|
||||
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
|
||||
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
|
||||
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 { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
|
||||
interface Props {
|
||||
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
|
||||
mailboxes: readonly Mailbox[]
|
||||
labelMode: 'always' | 'wide' | 'never'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'move', mailbox: Mailbox): void
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<InstanceType<typeof MailBulkButton> | null>(null)
|
||||
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 })))
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
if (props.disabled || items.value.length === 0) 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()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
|
||||
if (target) emit('move', target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulk-move">
|
||||
<MailBulkButton
|
||||
v-bind="$attrs"
|
||||
ref="buttonRef"
|
||||
icon="folder-move"
|
||||
:label="MAIL_BULK_TEXT.move"
|
||||
:label-mode="labelMode"
|
||||
menu
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
:label="MAIL_BULK_TEXT.moveMenu"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.bulk-move {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 고르기 상자 — 여러 통 처리 (디자인 원본 BulkSelect · ListStates)
|
||||
// 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크, 일부만 골랐으면(표 머리) – 표시.
|
||||
// 누르는 영역(폭 · 높이)은 쓰는 쪽이 class 로 정한다 — 표의 고르기 칸 32 · 분할 보기 행의 고르기 칸 36 (밀도 예외, tokens.css).
|
||||
// 수정 글쇠(Shift — 범위)는 쓰는 쪽이 판단하도록 click 이벤트를 그대로 넘긴다.
|
||||
interface Props {
|
||||
checked: boolean
|
||||
/** 일부만 골랐다 — 표 머리의 상자 (aria-checked="mixed") */
|
||||
mixed?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
class="mail-checkbox"
|
||||
:aria-checked="mixed ? 'mixed' : checked"
|
||||
:aria-label="label"
|
||||
@click="emit('toggle', $event)"
|
||||
>
|
||||
<svg
|
||||
class="mail-checkbox__box"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
v-if="!checked && !mixed"
|
||||
class="mail-checkbox__empty"
|
||||
x="1"
|
||||
y="1"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="3.5"
|
||||
/>
|
||||
<template v-else>
|
||||
<rect
|
||||
class="mail-checkbox__fill"
|
||||
x="0.25"
|
||||
y="0.25"
|
||||
width="15.5"
|
||||
height="15.5"
|
||||
rx="4"
|
||||
/>
|
||||
<line
|
||||
v-if="mixed"
|
||||
class="mail-checkbox__mark"
|
||||
x1="4.5"
|
||||
y1="8"
|
||||
x2="11.5"
|
||||
y2="8"
|
||||
/>
|
||||
<polyline
|
||||
v-else
|
||||
class="mail-checkbox__mark"
|
||||
points="4.2 8.3 6.9 10.8 11.8 5.4"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mail-checkbox__box {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-checkbox);
|
||||
height: var(--size-checkbox);
|
||||
}
|
||||
|
||||
.mail-checkbox__empty {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-border-input);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.mail-checkbox__fill {
|
||||
fill: var(--color-brand);
|
||||
}
|
||||
|
||||
.mail-checkbox__mark {
|
||||
fill: none;
|
||||
stroke: var(--color-on-brand);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 0,
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailListFrame (목록 칸의 틀)', () => {
|
||||
let pinia: Pinia
|
||||
let fresh: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
fresh = []
|
||||
installMockApi(() => ok({ items: fresh, pageInfo: { hasNext: false, nextCursor: null, total: fresh.length } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountList(messages: MessageSummary[]) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [INBOX]
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
return { wrapper, store, section: wrapper.get('section.message-list').element as HTMLElement }
|
||||
}
|
||||
|
||||
it('목록을 내려 본 채 새 메일이 위에 생기면 머리 아래에 "새 메일 N통 · 맨 위로" — 누르면 맨 위로 가고 사라진다', async () => {
|
||||
const old = [summary('m1'), summary('m2'), summary('m3')]
|
||||
const { wrapper, store, section } = await mountList(old)
|
||||
section.scrollTop = 240
|
||||
fresh = [summary('new1', { isRead: false }), summary('new2', { isRead: false }), ...old]
|
||||
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.get('.new-mail-notice')
|
||||
expect(notice.text()).toBe('새 메일 2통 · 맨 위로')
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(5)
|
||||
expect(section.scrollTop).toBe(240)
|
||||
|
||||
await notice.trigger('click')
|
||||
expect(section.scrollTop).toBe(0)
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.findAll('.message-row__main')[0]?.element)
|
||||
})
|
||||
|
||||
it('맨 위를 보고 있으면 알림 줄 없이 새 메일이 그냥 위에 생긴다', async () => {
|
||||
const old = [summary('m1')]
|
||||
const { wrapper, store } = await mountList(old)
|
||||
fresh = [summary('new1', { isRead: false }), ...old]
|
||||
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')[0]?.classes()).toContain('is-unread')
|
||||
})
|
||||
|
||||
it('알림 줄은 스스로 맨 위로 올라가도 사라지고, 다른 메일함으로 바꿔도 사라진다', async () => {
|
||||
const old = [summary('m1')]
|
||||
const { wrapper, store, section } = await mountList(old)
|
||||
section.scrollTop = 300
|
||||
fresh = [summary('new1'), ...old]
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(true)
|
||||
|
||||
section.scrollTop = 0
|
||||
section.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
|
||||
section.scrollTop = 300
|
||||
fresh = [summary('new2'), summary('new1'), ...old]
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.new-mail-notice').text()).toBe('새 메일 1통 · 맨 위로')
|
||||
|
||||
await store.openMailbox('sent')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
|
||||
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const clear = wrapper.get('[data-tool="markRead"]')
|
||||
;(clear.element as HTMLElement).focus()
|
||||
|
||||
await clear.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(document.activeElement).toBe(section)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
|
||||
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
|
||||
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
|
||||
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
|
||||
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
|
||||
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
|
||||
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useListArrivalNotice } from '@/composables/useListArrivalNotice'
|
||||
import { useListPositionMemory } from '@/composables/useListPositionMemory'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
variant: 'split' | 'list'
|
||||
/** 목록 칸 제목의 id — 머리 · 검색 결과 머리 · 도구 줄 가운데 보이는 것이 이 id 를 단다 */
|
||||
titleId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
const { setScope } = useMailSearchNavigation()
|
||||
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
const topRef = ref<HTMLElement | null>(null)
|
||||
const { noticeCount, goTop } = useListArrivalNotice(sectionRef, topRef)
|
||||
// 분할 보기의 목록 칸은 메일을 열어도 사라지지 않는다 — 목록 보기의 표만 위치를 기억한다
|
||||
useListPositionMemory(sectionRef, () => props.variant === 'list')
|
||||
|
||||
const search = computed(() => mailStore.search)
|
||||
const selecting = computed(() => mailStore.checkedCount > 0)
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
|
||||
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
|
||||
|
||||
function retry() {
|
||||
const current = search.value
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (search.value) void setScope(SEARCH_SCOPE_ALL)
|
||||
else void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
/** 초점이 사라진 도구 줄 대신 목록 칸에 둔다 — 키보드 사용자가 자리를 잃지 않게 */
|
||||
function focusSection() {
|
||||
sectionRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('dialog, [data-editor-popup]')) return
|
||||
event.preventDefault()
|
||||
mailStore.clearChecked()
|
||||
}
|
||||
|
||||
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
|
||||
watch(
|
||||
() => mailStore.checkedCount,
|
||||
(count, previous) => {
|
||||
if (count > 0 || previous === 0) return
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) focusSection()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
:class="['mail-list-frame', `mail-list-frame--${variant}`]"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-busy="busy"
|
||||
tabindex="-1"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div
|
||||
ref="topRef"
|
||||
class="mail-list-frame__top"
|
||||
>
|
||||
<MailSelectionToolbar
|
||||
v-if="selecting"
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchHeader
|
||||
v-else-if="search"
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailListHeader
|
||||
v-else
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchScope v-if="search" />
|
||||
<div
|
||||
class="mail-list-frame__notice"
|
||||
role="status"
|
||||
>
|
||||
<MailNewNotice
|
||||
v-if="noticeCount > 0"
|
||||
:count="noticeCount"
|
||||
@go-top="goTop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageListSkeleton
|
||||
v-if="mailStore.listStatus === 'loading'"
|
||||
:label="search ? MAIL_SEARCH_TEXT.loading : undefined"
|
||||
/>
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
:leave-label="leaveLabel"
|
||||
@retry="retry"
|
||||
@leave="leave"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<slot />
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
:class="['mail-list-frame__more', `mail-list-frame__more--${variant}`]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mail-list-frame__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MailSearchEmpty
|
||||
v-else-if="mailStore.listStatus === 'ready' && search"
|
||||
:query="search.q"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
|
||||
@search-all="setScope(SEARCH_SCOPE_ALL)"
|
||||
/>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:class="{ 'mail-list-frame__empty': variant === 'list' }"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-list-frame {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 프로그램이 준 초점(도구 줄이 사라진 뒤)에는 고리를 그리지 않는다 — 칸 전체에 고리가 생기면 어지럽다 */
|
||||
.mail-list-frame:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 머리 묶음 — 스크롤해도 위에 남는다 (넓은 화면은 칸, 좁은 화면은 창 기준) */
|
||||
.mail-list-frame__top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mail-list-frame__more--split {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.mail-list-frame__more--list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-list-frame__more--split .mail-list-frame__more-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-frame__more--list .mail-list-frame__more-button {
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-list-frame__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mail-list-frame {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -54,9 +55,6 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
|
||||
<style scoped>
|
||||
.list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// "새 메일 N통 · 맨 위로" — 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래에 (디자인 원본 ListStates · README "3단계 › 새 메일")
|
||||
// 높이 40(밀도 예외) · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표. 누르면 맨 위로 가고 줄은 사라진다 (목록 틀이 처리한다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { NEW_MAIL_NOTICE_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'go-top'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="new-mail-notice"
|
||||
@click="emit('go-top')"
|
||||
>
|
||||
<AppIcon
|
||||
name="arrow-up"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ NEW_MAIL_NOTICE_TEXT(formatCount(count)) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.new-mail-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
width: 100%;
|
||||
min-height: var(--size-notice-bar);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-mail-notice:hover {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.new-mail-notice:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
|
||||
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
count: number
|
||||
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
|
||||
:message="MAIL_BULK_TEXT.purgeMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
data-purge-confirm
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAIL_BULK_TEXT.purgeConfirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-purge-cancel
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAIL_BULK_TEXT.purgeCancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } 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 { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailSearchBox from './MailSearchBox.vue'
|
||||
|
||||
// 상단 검색 칸 (디자인 README "3단계 › 검색" · 계약 2절 "검색 › 클라이언트")
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('trash', 'trash', '휴지통')]
|
||||
const Q = encodeURIComponent('회의')
|
||||
|
||||
describe('MailSearchBox (메일 검색)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailSearchBox, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router, input: wrapper.get('input') }
|
||||
}
|
||||
|
||||
it('이름 · 안내는 "메일 검색" — 검색 중이 아니면 ✕ 가 없다', async () => {
|
||||
const { wrapper, input } = await mountAt('/mail/inbox')
|
||||
|
||||
expect(input.attributes('aria-label')).toBe('메일 검색')
|
||||
expect(input.attributes('placeholder')).toBe('메일 검색')
|
||||
expect(wrapper.get('form').attributes('role')).toBe('search')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.get('form').classes()).not.toContain('is-active')
|
||||
})
|
||||
|
||||
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox/m7')
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue(' 회의 ')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(push).toHaveBeenCalled()
|
||||
expect(useMailStore().searchOrigin).toEqual({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
|
||||
})
|
||||
|
||||
it('휴지통에서 시작하면 그 안에서 찾는다', async () => {
|
||||
const { router, input } = await mountAt('/mail/trash')
|
||||
|
||||
await input.setValue('회의')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=trash`)
|
||||
})
|
||||
|
||||
it('한글 조합 중의 Enter 는 글자 확정이다 — 찾지 않는다 (isComposing · Safari 의 keyCode 229)', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('회의')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
const safari = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
|
||||
input.element.dispatchEvent(safari)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(safari.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('낱말이 8개를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
const words = '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉'
|
||||
await input.setValue(words)
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect((input.element as HTMLInputElement).value).toBe(words)
|
||||
})
|
||||
|
||||
it('빈 검색어의 Enter 는 아무것도 하지 않는다', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue(' ')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('검색 화면 — 칸에 검색어를 보이고 테두리가 강조색, 끝에 ✕("검색어 지우기") — ✕ 는 검색 전 메일함으로', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('회의')
|
||||
expect(wrapper.get('form').classes()).toContain('is-active')
|
||||
const clear = wrapper.get('button')
|
||||
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
|
||||
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
|
||||
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('검색 화면의 Esc 는 ✕ 와 같다 — 시작한 곳을 모르면(주소로 바로 들어옴) 그 범위의 메일함으로', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=trash`)
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/trash')
|
||||
})
|
||||
|
||||
it('검색 화면이 아니면 Esc 는 친 글자만 지운다', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('회의')
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('검색 화면에서 새로 찾으면 지금 범위 그대로 바꿔 끼운다 (기록을 쌓지 않는다)', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue('일정')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${encodeURIComponent('일정')}&in=all`)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
|
||||
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
|
||||
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
|
||||
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
|
||||
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const text = ref<string>('')
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void startSearch(text.value)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
if (isSearching.value) {
|
||||
event.preventDefault()
|
||||
void closeSearch()
|
||||
} else if (text.value !== '') {
|
||||
event.preventDefault()
|
||||
text.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
|
||||
watch(
|
||||
() => routeSearch.value?.q ?? null,
|
||||
(query, previous) => {
|
||||
if (query !== null) text.value = query
|
||||
else if (previous) text.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
role="search"
|
||||
:class="['mail-search', { 'is-active': isSearching }]"
|
||||
@submit.prevent
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="mail-search__icon"
|
||||
/>
|
||||
<input
|
||||
v-model="text"
|
||||
type="search"
|
||||
class="mail-search__input"
|
||||
:aria-label="MAIL_SEARCH_TEXT.box"
|
||||
:placeholder="MAIL_SEARCH_TEXT.box"
|
||||
:maxlength="MAIL_SEARCH_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
enterkeyhint="search"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-if="isSearching"
|
||||
type="button"
|
||||
class="mail-search__clear"
|
||||
:aria-label="MAIL_SEARCH_TEXT.clear"
|
||||
:title="MAIL_SEARCH_TEXT.clearTitle"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-search {
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: var(--size-search-box);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 검색 중 · 칸에 초점 — 강조색 1px 테두리 + 1px 그림자 (입력칸 자체의 고리 대신 칸 테두리로 초점을 보인다) */
|
||||
.mail-search.is-active,
|
||||
.mail-search:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
|
||||
}
|
||||
|
||||
/* 끝의 ✕ 가 칸 오른쪽 끝까지 닿는다 */
|
||||
.mail-search.is-active {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.mail-search__icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-search__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-search__input:focus,
|
||||
.mail-search__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 브라우저의 지우기 단추 대신 ✕ 하나만 */
|
||||
.mail-search__input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 40×38 — 칸(40) 안쪽 높이를 채운다 · 밀도 예외 (tokens.css) */
|
||||
.mail-search__clear {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-search-clear-width);
|
||||
height: var(--size-search-clear-height);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0 calc(var(--radius-md) - var(--border-width)) calc(var(--radius-md) - var(--border-width)) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-search__clear:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-search__clear:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
query: string
|
||||
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
|
||||
offerAll: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'search-all'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-empty"
|
||||
icon="search"
|
||||
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
|
||||
:hint="MAIL_SEARCH_TEXT.emptyHint"
|
||||
>
|
||||
<button
|
||||
v-if="offerAll"
|
||||
type="button"
|
||||
class="search-empty__all"
|
||||
@click="emit('search-all')"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.searchAll }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-empty {
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
|
||||
.search-empty :deep(.state-message__hint) {
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
.search-empty__all {
|
||||
margin-top: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-empty__all:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
|
||||
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
|
||||
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { originMailbox, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const countText = computed(() => {
|
||||
const query = mailStore.search?.q
|
||||
if (!query) return ''
|
||||
if (mailStore.listStatus === 'loading') return MAIL_SEARCH_TEXT.searching(query)
|
||||
if (mailStore.listStatus !== 'ready') return MAIL_SEARCH_TEXT.quoted(query)
|
||||
const total = mailStore.pageInfo?.total ?? mailStore.messages.length
|
||||
return MAIL_SEARCH_TEXT.count(query, formatCount(total))
|
||||
})
|
||||
|
||||
const closeTitle = computed(() => {
|
||||
const name = originMailbox.value?.name
|
||||
return name ? MAIL_SEARCH_TEXT.closeTitle(`${name}${directionParticle(name)}`) : MAIL_SEARCH_TEXT.close
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['search-header', `search-header--${variant}`]">
|
||||
<button
|
||||
type="button"
|
||||
class="search-header__close"
|
||||
:aria-label="MAIL_SEARCH_TEXT.close"
|
||||
:title="closeTitle"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
name="arrow-left"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="search-header__title"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.title }}
|
||||
</h1>
|
||||
<span class="search-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="search-header__switch" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 오른쪽 여백을 표 머리와 맞춘다 (디자인 원본 ListStates) */
|
||||
.search-header--list {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
.search-header__close {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-header__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-header__title {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.search-header__count {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.search-header__switch {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 범위 줄(48 · 바탕 #FBFBFC) — "찾을 곳" + [그 메일함 | 전체 메일함] + 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"
|
||||
// 디자인 원본 Search · README "3단계 › 검색". 전환 단추는 설정 화면과 같은 .segmented(36 — 밀도 예외, base.css)
|
||||
// "그 메일함"은 검색을 시작한 메일함이다 (휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다) — 바꾸면 기록을 쌓지 않고 다시 찾는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { originMailbox, setScope } = useMailSearchNavigation()
|
||||
|
||||
const isAll = computed(() => mailStore.search?.scope === SEARCH_SCOPE_ALL)
|
||||
/** 그 메일함 — 한 메일함에서 찾는 중이면 그 메일함, 전체에서 찾는 중이면 검색을 시작한 메일함 */
|
||||
const boxMailbox = computed<Mailbox | null>(() => (isAll.value ? originMailbox.value : mailStore.currentMailbox))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-scope">
|
||||
<span
|
||||
id="search-scope-label"
|
||||
class="search-scope__label"
|
||||
>{{ MAIL_SEARCH_TEXT.scopeLabel }}</span>
|
||||
<div
|
||||
class="segmented"
|
||||
role="group"
|
||||
aria-labelledby="search-scope-label"
|
||||
>
|
||||
<button
|
||||
v-if="boxMailbox"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="!isAll"
|
||||
@click="setScope(boxMailbox.id)"
|
||||
>
|
||||
{{ boxMailbox.name }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="isAll"
|
||||
@click="setScope(SEARCH_SCOPE_ALL)"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.scopeAll }}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-if="isAll"
|
||||
class="search-scope__hint"
|
||||
>{{ MAIL_SEARCH_TEXT.scopeAllHint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-scope {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6) var(--space-10);
|
||||
min-height: var(--space-48);
|
||||
padding: var(--space-4) var(--space-12) var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.search-scope__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-scope__hint {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary, MessagesPatch } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.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 MessageList from './MessageList.vue'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
// 여러 통 처리 — 도구 줄 · 이동 메뉴 · 알림 · 되돌리기 · 영구 삭제 확인 (디자인 원본 BulkSelect · ListStates)
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [
|
||||
mailbox('inbox', 'inbox', '받은편지함'),
|
||||
mailbox('sent', 'sent', '보낸편지함'),
|
||||
mailbox('drafts', 'drafts', '임시보관함'),
|
||||
mailbox('junk', 'junk', '스팸메일함'),
|
||||
mailbox('trash', 'trash', '휴지통'),
|
||||
mailbox('archive', 'archive', '보관함'),
|
||||
]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (request: MockRequest) => MockResult | Promise<MockResult> | undefined
|
||||
|
||||
describe('여러 통 처리 도구 줄', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountView(
|
||||
options: { view?: 'split' | 'list'; boxId?: string; messages?: MessageSummary[]; path?: string; handler?: Handler } = {},
|
||||
) {
|
||||
const boxId = options.boxId ?? 'inbox'
|
||||
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
|
||||
requests = installMockApi((request) => {
|
||||
const custom = options.handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 6 } })
|
||||
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
|
||||
}
|
||||
if (request.method === 'PATCH') {
|
||||
const patch = request.data as MessagesPatch
|
||||
return ok({ updatedIds: patch.ids, failedIds: [] })
|
||||
}
|
||||
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.currentMailboxId = boxId
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(options.path ?? `/mail/${boxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(options.view === 'split' ? MessageList : MessageTable, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const bulkPatches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
|
||||
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
|
||||
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
|
||||
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
|
||||
})
|
||||
|
||||
it('읽음 · 안 읽음 · 중요 표시 — PATCH /messages 한 번, 목록이 바로 바뀌고 알리지 않는다 · 모두 중요면 "중요 해제"', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
messages: [summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')],
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="markRead"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], isRead: true }])
|
||||
expect(wrapper.findAll('tbody tr.is-unread')).toHaveLength(0)
|
||||
|
||||
await wrapper.get('[data-tool="flag"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], isFlagged: true })
|
||||
expect(wrapper.get('[data-tool="flag"]').attributes('aria-label')).toBe('중요 해제')
|
||||
expect(toastTexts()).toEqual([])
|
||||
})
|
||||
|
||||
it('이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함, 아래 화살표로 열면 첫 항목에 초점 · Esc 는 메뉴만 닫는다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const move = wrapper.get('[data-tool="move"]')
|
||||
|
||||
await move.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(wrapper.get('[role="menu"]').attributes('aria-label')).toBe('옮길 메일함')
|
||||
expect(items.map((item) => item.text())).toEqual(['보낸편지함', '스팸메일함', '보관함'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
expect(move.attributes('aria-expanded')).toBe('true')
|
||||
|
||||
await items[0]?.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="move"]').element)
|
||||
expect(store.checkedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m3')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
const archive = wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함')
|
||||
await archive?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'archive' }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(store.checkedCount).toBe(0)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('일부만 옮겼으면 알림 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm2'], failedIds: ['m3'] }) : undefined,
|
||||
})
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보낸편지함')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual([
|
||||
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
])
|
||||
})
|
||||
|
||||
it('휴지통 밖의 삭제는 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 }', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m2'], mailboxId: 'trash' }])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(toastTexts()).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('요청이 실패하면 인터셉터 알림만 — 고른 것을 그대로 둔다', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) => (request.method === 'PATCH' ? fail(503, 'MAIL_001') : undefined),
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages).toHaveLength(3)
|
||||
expect(store.checkedCount).toBe(1)
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
})
|
||||
|
||||
describe('휴지통 — 영구 삭제 확인', () => {
|
||||
const trashed = () => [summary('t1', { mailboxIds: ['trash'] }), summary('t2', { mailboxIds: ['trash'] })]
|
||||
|
||||
it('"영구 삭제"를 누르면 먼저 묻는다 — 제목 · 글, 처음 초점은 「취소」 · 취소하면 지우지 않는다', async () => {
|
||||
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
const purge = wrapper.get('[data-tool="delete"]')
|
||||
expect(purge.attributes('aria-label')).toBe('영구 삭제')
|
||||
|
||||
await purge.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.get('#bulk-purge-title').text()).toBe('메일 2통을 영구 삭제할까요?')
|
||||
expect(dialog.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
|
||||
|
||||
// 대화상자 안의 Esc 는 고른 것을 풀지 않는다 (목록 칸의 Esc 와 겹치지 않게)
|
||||
await dialog.get('[data-purge-cancel]').trigger('keydown', { key: 'Escape' })
|
||||
await dialog.get('[data-purge-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
|
||||
expect(store.checkedCount).toBe(2)
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
})
|
||||
|
||||
it('「영구 삭제」를 누르면 POST /message-deletions — 지운 메일을 빼고 "메일 N통을 영구 삭제했습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const deletion = requests.find((request) => request.url === '/message-deletions')
|
||||
expect(deletion).toMatchObject({ method: 'POST', data: { ids: ['t1', 't2'] } })
|
||||
expect(store.messages).toEqual([])
|
||||
expect(toastTexts()).toEqual(['메일 2통을 영구 삭제했습니다.'])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('분할 보기 — 열어 둔 메일을 옮기면 목록 주소로 바꿔 끼운다', async () => {
|
||||
const { wrapper, router, store } = await mountView({ view: 'split', path: '/mail/inbox/m2' })
|
||||
store.selectedId = 'm2'
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('도구 줄 안에서는 화살표로 단추를 옮긴다 (로빙 tabindex) · 선택 해제는 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const first = wrapper.get('[data-tool="markRead"]')
|
||||
expect(first.attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('[data-tool="markUnread"]').attributes('tabindex')).toBe('-1')
|
||||
|
||||
await first.trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="markUnread"]').element)
|
||||
await wrapper.get('[data-tool="markUnread"]').trigger('keydown', { key: 'End' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="clear"]').element)
|
||||
|
||||
await wrapper.get('[data-tool="clear"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('옮기는 동안 눌리지 않는 단추(이동 · 삭제)는 Tab 자리 · 화살표에서 건너뛰고, 초점이 그 단추에 있었으면 다음 단추로 옮긴다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
|
||||
;(tool('delete').element as HTMLElement).focus()
|
||||
await flushPromises()
|
||||
expect(tool('delete').attributes('tabindex')).toBe('0')
|
||||
|
||||
await tool('delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(tool('delete').attributes('disabled')).toBeDefined()
|
||||
expect(tool('move').attributes('disabled')).toBeDefined()
|
||||
expect(tool('delete').attributes('tabindex')).toBe('-1')
|
||||
expect(tool('clear').attributes('tabindex')).toBe('0')
|
||||
expect(document.activeElement).toBe(tool('clear').element)
|
||||
|
||||
await tool('flag').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(tool('clear').element)
|
||||
await tool('clear').trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(document.activeElement).toBe(tool('flag').element)
|
||||
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('초점이 도구 줄 밖에 있어도 Tab 자리는 누를 수 있는 단추로 옮긴다 — 초점은 빼앗지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
|
||||
;(tool('delete').element as HTMLElement).focus()
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(outside)
|
||||
outside.focus()
|
||||
await flushPromises()
|
||||
|
||||
await tool('delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(tool('delete').attributes('tabindex')).toBe('-1')
|
||||
expect(tool('clear').attributes('tabindex')).toBe('0')
|
||||
expect(document.activeElement).toBe(outside)
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
|
||||
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
|
||||
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
|
||||
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** "N통 선택됨"(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const bulk = useBulkMailActions()
|
||||
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
|
||||
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
|
||||
|
||||
interface LeadingTool {
|
||||
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
|
||||
icon: IconName
|
||||
label: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const toolsRef = ref<HTMLElement | null>(null)
|
||||
/** 로빙 tabindex — 마지막으로 초점이 간 단추 (Tab 으로 들어올 단추는 rovingTool) */
|
||||
const activeTool = ref<ToolId>('markRead')
|
||||
const purgeOpen = ref<boolean>(false)
|
||||
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
|
||||
let lastFocusedTool: ToolId | null = null
|
||||
|
||||
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
|
||||
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
|
||||
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const rovingTool = computed<ToolId>(() => {
|
||||
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
|
||||
const start = TOOL_ORDER.indexOf(activeTool.value)
|
||||
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
|
||||
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
|
||||
if (candidate && !disabledTools.value.has(candidate)) return candidate
|
||||
}
|
||||
return 'clear'
|
||||
})
|
||||
|
||||
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
|
||||
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
|
||||
{
|
||||
id: 'flag',
|
||||
icon: 'star',
|
||||
label: bulk.allFlagged.value ? MAIL_BULK_TEXT.unflag : MAIL_BULK_TEXT.flag,
|
||||
run: () => void bulk.toggleFlagged(),
|
||||
},
|
||||
])
|
||||
|
||||
function tabindexOf(id: ToolId): number {
|
||||
return rovingTool.value === id ? 0 : -1
|
||||
}
|
||||
|
||||
function focusTool(id: ToolId) {
|
||||
activeTool.value = id
|
||||
toolsRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`)?.focus()
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (bulk.inTrash.value) {
|
||||
purgeOpen.value = true
|
||||
return
|
||||
}
|
||||
void bulk.moveToTrash()
|
||||
}
|
||||
|
||||
function onPurgeConfirm() {
|
||||
purgeOpen.value = false
|
||||
void bulk.purge()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null
|
||||
// 메뉴 · 대화상자 안의 키는 그쪽이 처리한다
|
||||
if (!target || target.closest('[data-editor-popup], dialog')) return
|
||||
const order = enabledTools.value
|
||||
const current = order.findIndex((id) => id === target.dataset.tool)
|
||||
if (current === -1) return
|
||||
const last = order.length - 1
|
||||
const moves: Record<string, number> = {
|
||||
ArrowRight: current === last ? 0 : current + 1,
|
||||
ArrowLeft: current === 0 ? last : current - 1,
|
||||
Home: 0,
|
||||
End: last,
|
||||
}
|
||||
const next = order[moves[event.key] ?? -1]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
focusTool(next)
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const element = event.target instanceof HTMLElement ? event.target : null
|
||||
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
|
||||
if (!tool) return
|
||||
activeTool.value = tool
|
||||
lastFocusedTool = tool
|
||||
}
|
||||
|
||||
/** 초점이 도구 줄 밖의 다른 곳으로 갔다 — 갈 곳 없이 사라진 것(단추가 눌리지 않게 됨)은 기억해 둔다 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && toolsRef.value?.contains(next)) return
|
||||
if (next !== null) lastFocusedTool = null
|
||||
}
|
||||
|
||||
// 초점이 있던 단추를 누를 수 없게 되면(옮기기 · 지우기 시작) 다음 누를 수 있는 단추로 초점을 옮긴다 — 키보드 사용자가 자리를 잃지 않게
|
||||
watch(disabledTools, (disabled) => {
|
||||
const focused = lastFocusedTool
|
||||
if (!focused || !disabled.has(focused)) return
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement
|
||||
const lost =
|
||||
active === null ||
|
||||
active === document.body ||
|
||||
(active instanceof HTMLElement && active.dataset.tool === focused)
|
||||
if (lost) focusTool(rovingTool.value)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['bulk-bar', `bulk-bar--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="bulk-bar__count"
|
||||
>
|
||||
{{ countText }}
|
||||
</h1>
|
||||
<div
|
||||
ref="toolsRef"
|
||||
class="bulk-bar__tools"
|
||||
role="toolbar"
|
||||
:aria-label="MAIL_BULK_TEXT.toolbar"
|
||||
@keydown="onKeydown"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<MailBulkButton
|
||||
v-for="tool in leadingTools"
|
||||
:key="tool.id"
|
||||
:icon="tool.icon"
|
||||
:label="tool.label"
|
||||
:label-mode="labelMode"
|
||||
:data-tool="tool.id"
|
||||
:tabindex="tabindexOf(tool.id)"
|
||||
@click="tool.run"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'list'"
|
||||
class="bulk-bar__divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MailBulkMoveButton
|
||||
:mailboxes="bulk.moveTargets.value"
|
||||
:label-mode="labelMode"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="move"
|
||||
:tabindex="tabindexOf('move')"
|
||||
@move="bulk.moveTo"
|
||||
/>
|
||||
<MailBulkButton
|
||||
icon="trash"
|
||||
:label="deleteLabel"
|
||||
:label-mode="labelMode"
|
||||
tone="danger"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="delete"
|
||||
:tabindex="tabindexOf('delete')"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<MailBulkButton
|
||||
class="bulk-bar__clear"
|
||||
icon="x"
|
||||
:label="MAIL_BULK_TEXT.clear"
|
||||
:title="MAIL_BULK_TEXT.clearTitle"
|
||||
:label-mode="variant === 'split' ? 'never' : 'always'"
|
||||
:icon-stroke-width="1.9"
|
||||
data-tool="clear"
|
||||
:tabindex="tabindexOf('clear')"
|
||||
@click="mailStore.clearChecked()"
|
||||
/>
|
||||
</div>
|
||||
<MailPurgeDialog
|
||||
:open="purgeOpen"
|
||||
:count="mailStore.checkedCount"
|
||||
id-prefix="bulk-purge"
|
||||
@confirm="onPurgeConfirm"
|
||||
@cancel="purgeOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸(384) — 촘촘하게 (디자인 원본 ListStates) */
|
||||
.bulk-bar--split {
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-12);
|
||||
}
|
||||
|
||||
.bulk-bar__count {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 var(--space-10) 0 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bulk-bar--split .bulk-bar__count {
|
||||
margin-right: var(--space-4);
|
||||
}
|
||||
|
||||
.bulk-bar__tools {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.bulk-bar--split .bulk-bar__tools {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bulk-bar__divider {
|
||||
width: var(--space-1);
|
||||
height: var(--space-20);
|
||||
margin: 0 var(--space-6);
|
||||
background: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.bulk-bar__clear {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · useMessageRemoval)
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
/** split: 지우면 다음 메일을 연다 · list: 지우면 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
|
||||
/** 「영구 삭제」할 메일인가 — 휴지통 목록 밖에서는 휴지통에만 든 메일만 (useMessageRemoval) */
|
||||
const permanent = computed(() => removal.deletesPermanently(props.message))
|
||||
|
||||
async function run(kind: 'trash' | 'purge') {
|
||||
const target = props.message
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
|
||||
if (!outcome.removed || !source) return
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (working.value) return
|
||||
if (permanent.value) confirmOpen.value = true
|
||||
else void run('trash')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
confirmOpen.value = false
|
||||
void run('purge')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
|
||||
tone="danger"
|
||||
:disabled="working"
|
||||
data-reader-delete
|
||||
@click="onClick"
|
||||
/>
|
||||
<MailPurgeDialog
|
||||
:open="confirmOpen"
|
||||
:count="1"
|
||||
id-prefix="reader-purge"
|
||||
@confirm="onConfirm"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,22 +2,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { flushPromises, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
const DRAFTS: Mailbox = {
|
||||
id: 'drafts',
|
||||
name: '임시보관함',
|
||||
role: 'drafts',
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
totalCount: 2,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 0,
|
||||
}
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', sortOrder: 3, totalCount: 2 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
@@ -50,21 +53,22 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[], path = `/mail/${mailbox.id}`) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [mailbox]
|
||||
store.mailboxes = [INBOX, SENT, DRAFTS]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
|
||||
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const wrapper = await mountList(DRAFTS, [
|
||||
const { wrapper } = await mountList(DRAFTS, [
|
||||
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
])
|
||||
@@ -80,11 +84,76 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
})
|
||||
|
||||
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
|
||||
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const { wrapper } = await mountList(SENT, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const row = wrapper.get('.message-row')
|
||||
|
||||
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
|
||||
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
|
||||
})
|
||||
|
||||
describe('여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기")', () => {
|
||||
const inboxRows = () => [
|
||||
summary('m1', { mailboxIds: ['inbox'] }),
|
||||
summary('m2', { mailboxIds: ['inbox'] }),
|
||||
summary('m3', { mailboxIds: ['inbox'] }),
|
||||
]
|
||||
|
||||
it('행마다 고르기 상자("고르기: 제목")가 있고, 하나라도 고르면 모든 행이 고르기 칸을 보인다', async () => {
|
||||
const { wrapper, store } = await mountList(INBOX, inboxRows())
|
||||
|
||||
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(0)
|
||||
const box = wrapper.findAll('.message-row [role="checkbox"]')[1]
|
||||
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
|
||||
|
||||
await box?.trigger('click')
|
||||
|
||||
expect(store.checkedIds.has('m2')).toBe(true)
|
||||
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-checked')
|
||||
// 머리 자리가 도구 줄로 — 분할 보기의 목록 칸에서는 아이콘만(이름은 aria-label · 툴팁)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('1통 선택됨')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
|
||||
expect(wrapper.get('[data-tool="markRead"]').attributes('aria-label')).toBe('읽음 표시')
|
||||
})
|
||||
|
||||
it('행을 누르면 지금처럼 메일을 열고, Ctrl(⌘)+클릭 · Shift+클릭은 열지 않고 고른다', async () => {
|
||||
const { wrapper, router, store } = await mountList(INBOX, inboxRows())
|
||||
const main = (index: number) => wrapper.findAll('.message-row__main')[index]
|
||||
|
||||
await main(0)?.trigger('click', { metaKey: true })
|
||||
await main(2)?.trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
|
||||
await main(1)?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
expect(store.checkedCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => {
|
||||
const q = encodeURIComponent('회의')
|
||||
const { wrapper, store } = await mountList(
|
||||
INBOX,
|
||||
[
|
||||
summary('r1', { mailboxIds: ['inbox'], from: { name: '이도윤', email: 'doyun.lee@arasystem.test' } }),
|
||||
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
|
||||
],
|
||||
`/mail/search?q=${q}&in=all`,
|
||||
)
|
||||
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
await flushPromises()
|
||||
const rows = wrapper.findAll('.message-row')
|
||||
|
||||
expect(rows.map((row) => row.get('.message-row__who').text())).toEqual(['이도윤', '받는 사람: 정하늘'])
|
||||
expect(rows.map((row) => row.get('.message-row__mailbox').text())).toEqual(['받은편지함 ·', '보낸편지함 ·'])
|
||||
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe(`/mail/search/r1?q=${q}&in=all`)
|
||||
// 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 목록 영역 — 머리(메일함 이름·개수·보기 전환·새로고침) + 2줄 행 + 메일 더 보기 (디자인 원본 Main 가운데)
|
||||
// 상태: 불러오는 중 · 메일 서버 장애(다시 시도) · 없는 메일함(받은편지함으로) · 빈 메일함 (디자인 원본 States)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
|
||||
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
|
||||
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
const rows = useMailListRows()
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const showRecipients = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
if (range) mailStore.checkRangeTo(id)
|
||||
else mailStore.toggleCheck(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
<MailListFrame
|
||||
class="message-list"
|
||||
aria-labelledby="message-list-title"
|
||||
:aria-busy="busy"
|
||||
variant="split"
|
||||
title-id="message-list-title"
|
||||
>
|
||||
<MailListHeader title-id="message-list-title" />
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:mailbox-id="mailboxId"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:show-recipients="showRecipients"
|
||||
:draft="isDrafts"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-list__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-list__text-button message-list__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</ul>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -114,38 +53,4 @@ function goToInbox() {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.message-list__more {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.message-list__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-list__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-list__more-button {
|
||||
width: 100%;
|
||||
min-height: var(--size-row-min);
|
||||
}
|
||||
|
||||
.message-list__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
|
||||
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
|
||||
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
|
||||
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
|
||||
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
mailboxId: string
|
||||
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
to: RouteLocationRaw
|
||||
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
mixed?: boolean
|
||||
/** 시각 앞에 보일 메일함 이름 (전체 메일함 검색 결과) */
|
||||
mailboxName?: string | null
|
||||
/** 지금 열려 있는 메일 */
|
||||
selected?: boolean
|
||||
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
|
||||
showRecipients?: boolean
|
||||
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
draft?: boolean
|
||||
/** 고른 메일 (여러 통 처리) */
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', id: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', id: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
mailboxName: null,
|
||||
selected: false,
|
||||
showRecipients: false,
|
||||
draft: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
|
||||
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
|
||||
const who = computed(() => {
|
||||
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
|
||||
if (props.showRecipients) {
|
||||
const recipients = summarizeRecipients(props.message.to)
|
||||
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
|
||||
}
|
||||
return displayName(props.message.from) || '(보낸 사람 없음)'
|
||||
})
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
const unread = computed(() => !props.draft && !props.message.isRead)
|
||||
const location = computed<RouteLocationRaw>(() =>
|
||||
props.draft
|
||||
? { name: 'mail-compose', query: { draft: props.message.id } }
|
||||
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
|
||||
)
|
||||
const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
|
||||
/** Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다) */
|
||||
function onMainClick(event: MouseEvent) {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
|
||||
<li
|
||||
:class="[
|
||||
'message-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
]"
|
||||
:data-row-id="message.id"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="message-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
<RouterLink
|
||||
class="message-row__main"
|
||||
:to="location"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
@click="onMainClick"
|
||||
>
|
||||
<span class="message-row__top">
|
||||
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
|
||||
<span :class="['message-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who }}
|
||||
>안 읽음, </span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="mixed && mailboxName"
|
||||
class="message-row__mailbox"
|
||||
>{{ mailboxName }} ·</span>
|
||||
<time
|
||||
class="message-row__time"
|
||||
:datetime="message.receivedAt"
|
||||
@@ -104,6 +136,7 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
|
||||
<style scoped>
|
||||
.message-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
@@ -118,6 +151,45 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 고른 행 (디자인 원본 ListStates · BulkSelect) */
|
||||
.message-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.message-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
/*
|
||||
* 고르기 칸 — 고르지 않았고 마우스 · 초점도 없으면 칸 없이 지금 행 그대로다 (디자인 원본 ListStates).
|
||||
* 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 — 초점이 오면 행에 초점이 있으므로 나타난다
|
||||
*/
|
||||
.message-row__check {
|
||||
position: absolute;
|
||||
width: var(--space-1);
|
||||
height: var(--space-1);
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외)에 16px 상자, 상자는 칸 오른쪽에 붙는다 */
|
||||
.message-row:hover .message-row__check,
|
||||
.message-row:focus-within .message-row__check,
|
||||
.message-row.is-checking .message-row__check {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
width: var(--size-row-check);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
.message-row__check:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
|
||||
.message-row__main {
|
||||
flex: 1 1 auto;
|
||||
@@ -133,6 +205,13 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 고르기 칸이 보이면 칸 뒤 10px 에서 글이 시작한다 */
|
||||
.message-row:hover .message-row__main,
|
||||
.message-row:focus-within .message-row__main,
|
||||
.message-row.is-checking .message-row__main {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -161,6 +240,13 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과 — 시각 앞의 메일함 이름 (디자인 원본 Search) */
|
||||
.message-row__mailbox {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중" · Search "찾는 중")
|
||||
interface Props {
|
||||
rows?: number
|
||||
/** 스크린리더에 읽힐 이름 — 검색 중이면 "찾는 중" */
|
||||
label?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
rows: 7,
|
||||
label: '메일 목록 불러오는 중',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="list-skeleton"
|
||||
aria-label="메일 목록 불러오는 중"
|
||||
:aria-label="label"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
|
||||
@@ -5,10 +5,19 @@ import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
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'
|
||||
|
||||
@@ -270,3 +279,181 @@ describe('MessageReader 불러오기 실패', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를 쓰지 않는다', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: {
|
||||
mailboxIds?: string[]
|
||||
layout?: 'split' | 'list'
|
||||
handler?: (request: MockRequest) => MockResult | undefined
|
||||
source?: MailListSource
|
||||
} = {},
|
||||
) {
|
||||
requests = installMockApi((request) => {
|
||||
const custom = options.handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
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 === '/message-deletions') {
|
||||
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
}
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [INBOX, TRASH]
|
||||
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
const open = { ...DETAIL, mailboxIds: options.mailboxIds ?? ['inbox'] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }]
|
||||
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/inbox')
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const deleteButton = (wrapper: Awaited<ReturnType<typeof mountWith>>['wrapper']) => wrapper.get('[data-reader-delete]')
|
||||
const usedDelete = () => requests.some((request) => request.method === 'DELETE')
|
||||
|
||||
it('휴지통 밖 — 휴지통으로 옮기고(PATCH) 다음 메일을 연다, "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
expect(deleteButton(wrapper).text()).toBe('삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.method === 'PATCH')).toMatchObject({
|
||||
url: '/messages',
|
||||
data: { ids: ['m1'], mailboxId: 'trash' },
|
||||
})
|
||||
expect(usedDelete()).toBe(false)
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 휴지통으로 옮겼습니다.')
|
||||
|
||||
// 되돌리기 — 원래 메일함으로 (여러 통 처리와 같은 규칙)
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
|
||||
const { wrapper, router } = await mountWith({ layout: 'list' })
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통인지는 목록이 아니라 그 메일의 메일함으로 정한다 — 「영구 삭제」는 확인(처음 초점 「취소」)을 거쳐 POST /message-deletions', async () => {
|
||||
const { wrapper, router, store } = await mountWith({ mailboxIds: ['trash'] })
|
||||
expect(deleteButton(wrapper).text()).toBe('영구 삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
|
||||
|
||||
await dialog.get('[data-purge-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.url === '/message-deletions')).toMatchObject({
|
||||
method: 'POST',
|
||||
data: { ids: ['m1'] },
|
||||
})
|
||||
expect(usedDelete()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 영구 삭제했습니다.'])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('그사이 휴지통을 떠난 메일이면(skippedIds) 지우지 않았다고 알리고 목록을 다시 맞춘다 — 넘어가지 않는다', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
mailboxIds: ['trash'],
|
||||
handler: (request) =>
|
||||
request.url === '/message-deletions' ? ok({ deletedIds: [], skippedIds: ['m1'] }) : undefined,
|
||||
})
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
])
|
||||
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages')).toHaveLength(1)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 목록 밖에서 휴지통과 다른 메일함에 함께 든 메일은 「삭제」 — 휴지통으로 옮긴다(영구 삭제를 묻지 않는다)', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
mailboxIds: ['inbox', 'trash'],
|
||||
source: { kind: 'search', q: '회의', scope: 'all' },
|
||||
})
|
||||
expect(deleteButton(wrapper).text()).toBe('삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'trash' })
|
||||
// 전체 메일함 검색에는 휴지통이 빠진다 — 결과에서 빠지고 알림은 "옮겼습니다"
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 목록 밖에서 휴지통에만 든 메일은 「영구 삭제」, 휴지통 목록에서는 다른 메일함에 함께 들어도 「영구 삭제」 (지금처럼)', async () => {
|
||||
const onlyTrash = await mountWith({ mailboxIds: ['trash'], source: { kind: 'search', q: '회의', scope: 'all' } })
|
||||
expect(deleteButton(onlyTrash.wrapper).text()).toBe('영구 삭제')
|
||||
onlyTrash.wrapper.unmount()
|
||||
|
||||
const inTrashList = await mountWith({ mailboxIds: ['trash', 'inbox'], source: { kind: 'mailbox', mailboxId: 'trash' } })
|
||||
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead · Search 오른쪽)
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
@@ -12,11 +14,14 @@ import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { mailboxOfMessage } from '@/shared/utils/mail-list-row'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -27,18 +32,19 @@ interface Props {
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const deleting = ref<boolean>(false)
|
||||
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
|
||||
const searchMailboxName = computed(() => {
|
||||
if (!mailStore.search || !message.value) return null
|
||||
return mailboxOfMessage(message.value, mailStore.mailboxes, mailStore.currentMailboxId)?.name ?? null
|
||||
})
|
||||
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
|
||||
const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
@@ -49,35 +55,14 @@ const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.valu
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
if (!current || !mailboxId || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
const nextId = await mailStore.removeMessage(current.id)
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 삭제하면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToList(mailboxId)
|
||||
return
|
||||
}
|
||||
await router.replace(nextId ? messageLocation(mailboxId, nextId) : mailboxLocation(mailboxId))
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 메일은 그대로 둔다
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
function leaveToList() {
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
void (mailboxId ? backToList(mailboxId) : replaceWithMailbox(null))
|
||||
const source = mailStore.listSource
|
||||
void (source ? backToSourceList(source) : replaceWithMailbox(null))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -114,12 +99,9 @@ function leaveToList() {
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="isTrash ? '영구 삭제' : '삭제'"
|
||||
tone="danger"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -206,6 +188,13 @@ function leaveToList() {
|
||||
<span class="reader__recipients-label">참조</span>
|
||||
{{ formatAddressList(message.cc) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="searchMailboxName"
|
||||
class="reader__mailbox"
|
||||
>
|
||||
<span class="reader__recipients-label">{{ MAIL_SEARCH_TEXT.mailboxLine }}</span>
|
||||
{{ searchMailboxName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +244,11 @@ function leaveToList() {
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택하세요"
|
||||
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
|
||||
:hint="
|
||||
mailStore.search
|
||||
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
@@ -400,6 +393,13 @@ function leaveToList() {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
/* 검색 결과에서 연 메일 — "메일함 받은편지함" (12.5px #667085, 디자인 원본 Search) */
|
||||
.reader__mailbox {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -20,6 +21,8 @@ const INBOX: Mailbox = {
|
||||
totalCount: 15,
|
||||
unreadCount: 2,
|
||||
}
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
@@ -55,37 +58,38 @@ describe('MessageTable (목록 보기)', () => {
|
||||
})
|
||||
|
||||
async function mountTable(
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean; path?: string } = {},
|
||||
) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [options.mailbox ?? INBOX]
|
||||
store.mailboxes = [INBOX, SENT, DRAFTS]
|
||||
store.currentMailboxId = (options.mailbox ?? INBOX).id
|
||||
store.messages = options.messages ?? [summary('m1', { isRead: false, hasAttachment: true }), summary('m2')]
|
||||
store.pageInfo = { hasNext: options.hasNext ?? false, nextCursor: options.hasNext ? 'c2' : null, total: 15 }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${store.currentMailboxId}`)
|
||||
await router.push(options.path ?? `/mail/${store.currentMailboxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageTable, {
|
||||
props: { mailboxId: store.currentMailboxId ?? 'inbox' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('진짜 <table> 이다 — 캡션 · 다섯 칸 colgroup · 머리 칸', async () => {
|
||||
it('진짜 <table> 이다 — 캡션 · 여섯 칸 colgroup(고르기 칸 포함) · 머리 칸', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(6)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'',
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 메일 모두 고르기')
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -144,26 +148,25 @@ describe('MessageTable (목록 보기)', () => {
|
||||
})
|
||||
|
||||
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
|
||||
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
|
||||
const { wrapper } = await mountTable({ mailbox: SENT, messages: [summary('s1', { mailboxIds: ['sent'] })] })
|
||||
|
||||
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('thead th')[2]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
|
||||
})
|
||||
|
||||
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
const { wrapper, router } = await mountTable({
|
||||
mailbox: drafts,
|
||||
mailbox: DRAFTS,
|
||||
messages: [
|
||||
summary('d1', {
|
||||
mailboxIds: ['drafts'],
|
||||
isRead: false,
|
||||
to: [
|
||||
{ name: '박준호', email: 'junho.park@arasystem.test' },
|
||||
{ name: null, email: 'infra@arasystem.test' },
|
||||
],
|
||||
}),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
summary('d2', { mailboxIds: ['drafts'], to: [], subject: '' }),
|
||||
],
|
||||
})
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
@@ -188,7 +191,7 @@ describe('MessageTable (목록 보기)', () => {
|
||||
)
|
||||
const { wrapper } = await mountTable({ hasNext: true })
|
||||
|
||||
await wrapper.get('.message-table__more-button').trigger('click')
|
||||
await wrapper.get('.mail-list-frame__more-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
@@ -204,4 +207,103 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
|
||||
scrollTo.mockRestore()
|
||||
})
|
||||
|
||||
describe('여러 통 고르기 (디자인 원본 BulkSelect)', () => {
|
||||
const three = () => [summary('m1'), summary('m2'), summary('m3'), summary('m4')]
|
||||
|
||||
it('행의 고르기 상자 — 이름 "고르기: 제목", 고르면 행이 고른 바탕이 되고 머리가 도구 줄 "N통 선택됨"으로 바뀐다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
const box = wrapper.findAll('tbody [role="checkbox"]')[1]
|
||||
|
||||
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
|
||||
expect(box?.attributes('aria-checked')).toBe('false')
|
||||
await box?.trigger('click')
|
||||
|
||||
expect(store.checkedIds.has('m2')).toBe(true)
|
||||
expect(wrapper.findAll('tbody tr')[1]?.classes()).toContain('is-checked')
|
||||
expect(wrapper.findAll('tbody [role="checkbox"]')[1]?.attributes('aria-checked')).toBe('true')
|
||||
expect(wrapper.find('.list-header').exists()).toBe(false)
|
||||
expect(wrapper.get('#message-table-title').text()).toBe('1통 선택됨')
|
||||
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('고른 메일')
|
||||
})
|
||||
|
||||
it('머리의 상자 — 하나도 · 일부만(mixed) · 모두, 누르면 불러온 메일을 모두 고르고 모두 골랐으면 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
const head = () => wrapper.get('thead [role="checkbox"]')
|
||||
|
||||
expect(head().attributes('aria-checked')).toBe('false')
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
expect(head().attributes('aria-checked')).toBe('mixed')
|
||||
|
||||
await head().trigger('click')
|
||||
expect([...store.checkedIds]).toEqual(['m1', 'm2', 'm3', 'm4'])
|
||||
expect(head().attributes('aria-checked')).toBe('true')
|
||||
|
||||
await head().trigger('click')
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('Ctrl(⌘)+클릭은 메일을 열지 않고 하나씩, Shift+클릭은 범위로 고른다', async () => {
|
||||
const { wrapper, router, store } = await mountTable({ messages: three() })
|
||||
const rows = () => wrapper.findAll('tbody tr')
|
||||
|
||||
await rows()[0]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
|
||||
await rows()[2]?.get('.mail-table__link').trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
|
||||
await rows()[1]?.get('.mail-table__date').trigger('click', { metaKey: true })
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
|
||||
})
|
||||
|
||||
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
|
||||
await wrapper.get('section.message-table').trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(wrapper.find('.list-header').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 보낸 사람 뒤에 메일함 칸(96), 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름"', async () => {
|
||||
const store = useMailStore()
|
||||
const { wrapper } = await mountTable({
|
||||
path: `/mail/search?q=${encodeURIComponent('회의')}&in=all`,
|
||||
messages: [
|
||||
summary('r1', { mailboxIds: ['inbox'] }),
|
||||
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
|
||||
summary('r3', { mailboxIds: ['drafts'], to: [{ name: '운영팀', email: 'ops@arasystem.test' }] }),
|
||||
],
|
||||
})
|
||||
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(7)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'',
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'메일함',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.map((row) => row.get('.mail-table__who').text())).toEqual([
|
||||
'이도윤',
|
||||
'받는 사람: 정하늘',
|
||||
'받는 사람: 운영팀',
|
||||
])
|
||||
expect(rows.map((row) => row.get('.mail-table__mailbox').text())).toEqual(['받은편지함', '보낸편지함', '임시보관함'])
|
||||
// 검색 결과에서 연 메일의 주소 · 임시 저장본은 지금처럼 쓰기 화면
|
||||
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe(
|
||||
`/mail/search/r1?q=${encodeURIComponent('회의')}&in=all`,
|
||||
)
|
||||
expect(rows[2]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=r3')
|
||||
expect(wrapper.get('table caption').text()).toBe('검색 결과')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,279 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
|
||||
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
|
||||
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList · BulkSelect · ListStates). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
|
||||
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
|
||||
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
|
||||
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { messageLocation, replaceWithMailbox } = useMailNavigation()
|
||||
const rows = useMailListRows()
|
||||
|
||||
/** 넓은 화면에서 표 칸 자체가 스크롤된다 — 메일을 열었다 돌아올 때 위치를 되살린다 */
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const isOutgoing = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
|
||||
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
const caption = computed(() =>
|
||||
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function whoOf(message: MessageSummary): string {
|
||||
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
|
||||
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
|
||||
return displayName(message.from) || '(보낸 사람 없음)'
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
if (range) mailStore.checkRangeTo(id)
|
||||
else mailStore.toggleCheck(id)
|
||||
}
|
||||
|
||||
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
|
||||
function hasNoDraftRecipients(message: MessageSummary): boolean {
|
||||
return isDrafts.value && draftRecipientNames(message.to) === ''
|
||||
function open(location: RouteLocationRaw) {
|
||||
void router.push(location)
|
||||
}
|
||||
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
function isUnread(message: MessageSummary): boolean {
|
||||
return !isDrafts.value && !message.isRead
|
||||
}
|
||||
|
||||
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
function rowLocation(message: MessageSummary): RouteLocationRaw {
|
||||
return isDrafts.value
|
||||
? { name: 'mail-compose', query: { draft: message.id } }
|
||||
: messageLocation(props.mailboxId, message.id)
|
||||
}
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent, message: MessageSummary) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('a, button')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
void router.push(rowLocation(message))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
// 메일을 열면 이 표가 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살린다 (불러온 페이지는 스토어에 그대로 있다)
|
||||
onBeforeUnmount(() => {
|
||||
viewStore.rememberListPosition({
|
||||
mailboxId: props.mailboxId,
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = viewStore.takeListPosition(props.mailboxId)
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
// 보던 메일의 제목 링크로 초점을 돌려준다 (키보드 사용자가 자리를 잃지 않게)
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
<MailListFrame
|
||||
class="message-table"
|
||||
aria-labelledby="message-table-title"
|
||||
:aria-busy="busy"
|
||||
variant="list"
|
||||
title-id="message-table-title"
|
||||
>
|
||||
<MailListHeader
|
||||
title-id="message-table-title"
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<!-- 표 최소 폭(400px)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table class="mail-table">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
|
||||
@click="onRowClick($event, message)"
|
||||
>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
|
||||
<span
|
||||
v-if="isUnread(message)"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ whoOf(message) }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
|
||||
:to="rowLocation(message)"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ message.subject || MAIL_NO_SUBJECT }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-table__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-table__text-button message-table__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
class="message-table__empty"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-check">
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col
|
||||
v-if="rows.mixed.value"
|
||||
class="mail-table__col-mailbox"
|
||||
>
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<MessageTableHead
|
||||
:who-header="rows.whoHeader.value"
|
||||
:mixed="rows.mixed.value"
|
||||
/>
|
||||
<tbody>
|
||||
<MessageTableRow
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
@open="open"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-table {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -293,18 +102,31 @@ onMounted(async () => {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과 — 메일함 칸만큼 더 넓어야 칸이 찌그러지지 않는다 */
|
||||
.mail-table--mixed {
|
||||
min-width: calc(var(--table-min-width) + var(--table-col-mailbox) + var(--table-gap));
|
||||
}
|
||||
|
||||
/*
|
||||
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
|
||||
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
|
||||
*/
|
||||
.mail-table__col-check {
|
||||
width: calc(var(--space-20) + var(--table-col-check) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-star {
|
||||
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
|
||||
width: calc(var(--table-col-star) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-mailbox {
|
||||
width: calc(var(--table-col-mailbox) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--table-gap));
|
||||
}
|
||||
@@ -313,8 +135,9 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-size) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th,
|
||||
.mail-table td {
|
||||
/* 칸 공통 — 머리 줄(MessageTableHead)과 행(MessageTableRow)의 칸에 함께 쓴다 */
|
||||
.mail-table :deep(th),
|
||||
.mail-table :deep(td) {
|
||||
padding: 0 var(--table-gap) 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -323,164 +146,35 @@ onMounted(async () => {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mail-table th:first-child,
|
||||
.mail-table td:first-child {
|
||||
.mail-table :deep(th:first-child),
|
||||
.mail-table :deep(td:first-child) {
|
||||
padding-left: var(--space-20);
|
||||
}
|
||||
|
||||
.mail-table th:last-child,
|
||||
.mail-table td:last-child {
|
||||
.mail-table :deep(th:last-child),
|
||||
.mail-table :deep(td:last-child) {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.mail-table thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table thead .mail-table__star {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 `.mail-table th/td { text-align: left }` 보다
|
||||
우선하도록 th·td 를 함께 적는다 (클래스만 쓰면 기본 규칙에 져서 행만 왼쪽으로 어긋난다) */
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table th.mail-table__size,
|
||||
.mail-table td.mail-table__date,
|
||||
.mail-table td.mail-table__size {
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 칸 공통 규칙보다 우선하도록 th·td 를 함께 적는다 */
|
||||
.mail-table :deep(th.mail-table__date),
|
||||
.mail-table :deep(th.mail-table__size),
|
||||
.mail-table :deep(td.mail-table__date),
|
||||
.mail-table :deep(td.mail-table__size) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mail-table__date,
|
||||
.mail-table__size {
|
||||
.mail-table :deep(.mail-table__date),
|
||||
.mail-table :deep(.mail-table__size) {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-table__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.message-table__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-table__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-table__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.message-table__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-table__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
.mail-table__size {
|
||||
.mail-table :deep(.mail-table__size) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -488,13 +182,13 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table td.mail-table__date {
|
||||
.mail-table :deep(th.mail-table__date),
|
||||
.mail-table :deep(td.mail-table__date) {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭보다 좁으면 가로 스크롤 */
|
||||
@media (max-width: 37.4375rem) {
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who-narrow) + var(--table-gap));
|
||||
@@ -504,11 +198,4 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-date-narrow) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 표 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-table {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
|
||||
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
|
||||
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함은 "받는 사람" */
|
||||
whoHeader: string
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
|
||||
mixed?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead class="mail-table-head">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__check"
|
||||
>
|
||||
<!-- 폭 32 · 머리 줄 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
<MailCheckbox
|
||||
class="mail-table-head__check"
|
||||
:checked="mailStore.checkState === 'all'"
|
||||
:mixed="mailStore.checkState === 'some'"
|
||||
:label="MAIL_BULK_TEXT.selectAll"
|
||||
@toggle="mailStore.toggleCheckAll()"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th
|
||||
v-if="mixed"
|
||||
scope="col"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.mailboxColumn }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-table-head th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table-head__check {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-head) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
|
||||
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
|
||||
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
to: RouteLocationRaw
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
mixed?: boolean
|
||||
mailboxName?: string | null
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'open', location: RouteLocationRaw): void
|
||||
(e: 'toggle-flag', id: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', id: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
mailboxName: null,
|
||||
checked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
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))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('button')) return
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (target?.closest('a')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
emit('open', props.to)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="message.id"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
<MailCheckbox
|
||||
class="mail-table__check-button"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="emit('toggle-flag', message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td :class="['mail-table__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who.text }}
|
||||
</td>
|
||||
<td
|
||||
v-if="mixed"
|
||||
class="mail-table__mailbox"
|
||||
>
|
||||
{{ mailboxName ?? '' }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
:class="['mail-table__link', { 'is-placeholder': draft && !message.subject }]"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ subject }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 고른 행 (디자인 원본 BulkSelect) */
|
||||
.mail-table__row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.mail-table__row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
.mail-table__check-button {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과의 메일함 칸 (디자인 원본 ListStates) */
|
||||
.mail-table__mailbox {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
|
||||
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
|
||||
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRail from './AppRail.vue'
|
||||
@@ -20,7 +21,14 @@ const moduleLabel = computed(() => findAppModule(props.moduleId).label)
|
||||
<div class="app-shell">
|
||||
<AppRail :active-module="moduleId" />
|
||||
<div class="app-shell__main">
|
||||
<AppTopBar :title="moduleLabel" />
|
||||
<AppTopBar :title="moduleLabel">
|
||||
<template
|
||||
v-if="$slots.search"
|
||||
#search
|
||||
>
|
||||
<slot name="search" />
|
||||
</template>
|
||||
</AppTopBar>
|
||||
<div class="app-shell__module">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
|
||||
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
|
||||
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -39,23 +40,25 @@ async function onLogout() {
|
||||
<header class="top-bar">
|
||||
<span class="top-bar__title">{{ title }}</span>
|
||||
|
||||
<!-- 통합 검색은 1단계 범위 밖이다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
<slot name="search">
|
||||
<!-- 모듈을 가로지르는 통합 검색은 아직 없다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
</div>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
v-if="authStore.user"
|
||||
@@ -109,7 +112,7 @@ async function onLogout() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: 2.5rem;
|
||||
height: var(--size-search-box);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
Reference in New Issue
Block a user