generated from ttipo/project-base
chore: 14단계 검색 색인을 앱 로그인 브랜치에 합친다
앱 새 메일 알림(푸시)의 서버 일을 앱 세션과 14단계 위에서 하려고 합친다. 겹친 곳은 두 곳이다. auth.module.ts 는 import 둘을 함께 두고, AuthService.login() 은 enrollAfterLogin(14단계)을 남긴 뒤 recordSignIn(앱 로그인)으로 기록한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 대화 줄 (14단계 — 디자인 원본 ListThreads14) — 2줄: 참여자 · 통 수(회색 "4통" — 한 통이면 없다) · 마지막 시각 / 마지막 메일 제목(+ 첨부)
|
||||
// - 하나라도 안 읽었으면 굵게(뱃지 없이 — 메일 줄과 같다), 중요 표시한 메일이 있으면 별이 찬다. 누르면 그 메일함의 마지막 메일을 연다(아래에 같은 대화 칸)
|
||||
// - 고르기 칸 · Ctrl(⌘) / Shift + 클릭 · 별은 메일 줄(MessageListItem)과 같은 자리 · 크기다 — 하는 일은 그 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어)
|
||||
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
|
||||
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
|
||||
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
|
||||
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_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { ConversationSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
row: ConversationSummary
|
||||
/** 이름 자리 — 참여자 (useConversationRows) */
|
||||
who: RowWho
|
||||
/** 누르면 갈 곳 — 그 메일함의 마지막 메일(임시보관함은 쓰기 화면) */
|
||||
to: RouteLocationRaw
|
||||
/** 하나라도 안 읽었다 (임시보관함은 늘 false) */
|
||||
unread?: boolean
|
||||
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
|
||||
draft?: boolean
|
||||
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
|
||||
held?: boolean
|
||||
/** 지금 열려 있는 메일이 든 줄 */
|
||||
selected?: boolean
|
||||
/** 고른 줄 (여러 통 처리) */
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 줄에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', rowId: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', rowId: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
unread: false,
|
||||
draft: false,
|
||||
held: false,
|
||||
selected: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
|
||||
const countText = computed(() => conversationCountText(props.row))
|
||||
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
|
||||
const time = computed(() => pendingLabel.value ?? formatMailListTime(props.row.latest.receivedAt))
|
||||
|
||||
/** Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다 (메일 줄과 같다). 고를 수 없는 줄은 고르지 않는다 */
|
||||
function onMainClick(event: MouseEvent) {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
|
||||
event.preventDefault()
|
||||
if (!props.held) emit('check', props.row.id, event.shiftKey)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
|
||||
function onStar() {
|
||||
if (!props.held) emit('toggle-flag', props.row.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'thread-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
]"
|
||||
:data-row-id="row.id"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="thread-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
:unavailable="held"
|
||||
:note="SEND_LATER_TEXT.heldNotSelectable"
|
||||
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
|
||||
/>
|
||||
<RouterLink
|
||||
class="thread-row__main"
|
||||
:to="to"
|
||||
:data-message-id="row.latest.id"
|
||||
@click="onMainClick"
|
||||
>
|
||||
<span class="thread-row__top">
|
||||
<span :class="['thread-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="countText"
|
||||
class="thread-row__count"
|
||||
data-thread-count
|
||||
>{{ countText }}</span>
|
||||
<time
|
||||
:class="['thread-row__time', { 'is-pending': pendingLabel }]"
|
||||
:datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<span class="thread-row__bottom">
|
||||
<span :class="['thread-row__subject', { 'is-placeholder': draft && !row.latest.subject }]">{{ subject }}</span>
|
||||
<AppIcon
|
||||
v-if="row.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
:label="MAIL_LIST_ROW_TEXT.attachment"
|
||||
class="thread-row__clip"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
:class="['thread-row__star', { 'is-flagged': row.isFlagged }]"
|
||||
:aria-label="MAIL_LIST_ROW_TEXT.flag"
|
||||
:aria-pressed="row.isFlagged"
|
||||
:aria-disabled="held ? 'true' : undefined"
|
||||
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
|
||||
data-thread-star
|
||||
@click="onStar"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="15"
|
||||
:filled="row.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일 줄(MessageListItem)과 같은 자리 · 크기 — 2줄 약 50px (디자인 원본 ListThreads14 · Main) */
|
||||
.thread-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.thread-row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.thread-row.is-selected {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.thread-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.thread-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
/* 고르기 칸 — 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 (메일 줄과 같다) */
|
||||
.thread-row__check {
|
||||
position: absolute;
|
||||
width: var(--space-1);
|
||||
height: var(--space-1);
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외 --size-row-check)에 16px 상자 */
|
||||
.thread-row:hover .thread-row__check,
|
||||
.thread-row:focus-within .thread-row__check,
|
||||
.thread-row.is-checking .thread-row__check {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
width: var(--size-row-check);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
.thread-row__check:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
.thread-row__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--size-list-row);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
|
||||
line-height: var(--line-height-heading);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thread-row:hover .thread-row__main,
|
||||
.thread-row:focus-within .thread-row__main,
|
||||
.thread-row.is-checking .thread-row__main {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
.thread-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.thread-row__main:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* 첫 줄 — 참여자(넘치면 줄인다) · 통 수 · 시각(오른쪽 끝) (디자인 원본 ListThreads14) */
|
||||
.thread-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-row__who {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 통 수 — 회색 글, 뱃지 · 알약 없음 */
|
||||
.thread-row__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thread-row__time {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thread-row__time.is-pending,
|
||||
.thread-row.is-unread .thread-row__time.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.thread-row__bottom {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-row__subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-row__clip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 줄과 같다) */
|
||||
.thread-row__who.is-placeholder,
|
||||
.thread-row__subject.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 (메일 줄과 같다) */
|
||||
.thread-row.is-unread .thread-row__who {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-row.is-unread .thread-row__subject,
|
||||
.thread-row.is-unread .thread-row__time {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-row__star {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-touch);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.thread-row__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.thread-row__star[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.thread-row__star:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
|
||||
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
|
||||
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
|
||||
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
|
||||
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
|
||||
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
|
||||
// 스토어가 대화를 다시 받아 이 줄을 뺀다. 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다
|
||||
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
|
||||
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
|
||||
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import { useMailPrint } from '@/composables/useMailPrint'
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import type { ConversationRow } from '@/shared/utils/mail-conversation'
|
||||
import type { ConversationDetailState } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 줄을 펼친 단추가 가리키는 id (aria-controls) */
|
||||
panelId: string
|
||||
row: ConversationRow
|
||||
/** 읽기 화면의 보기 방식 — 목록 보기는 [이 메일만 열기]를 바꿔 끼운다 */
|
||||
layout?: 'split' | 'list'
|
||||
detail: ConversationDetailState | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const print = useMailPrint()
|
||||
|
||||
const recipients = computed(() => formatAddressList(props.row.message.to))
|
||||
const subject = computed(() => props.row.message.subject || MAIL_NO_SUBJECT)
|
||||
const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.detail : null))
|
||||
const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null))
|
||||
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
|
||||
const missing = computed(() => failure.value?.code === 'RES_001')
|
||||
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
|
||||
/** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */
|
||||
const attachmentCount = computed(() =>
|
||||
ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0,
|
||||
)
|
||||
const bodyTitle = computed(() => MAIL_CONVERSATION_TEXT.bodyTitle(props.row.sender.name, props.row.time))
|
||||
|
||||
/** 본문 안에서 누른 키 — Ctrl+P(맥 ⌘P)면 브라우저 인쇄 대신 이 메일의 인쇄 화면 */
|
||||
function onFrameKeydown(event: KeyboardEvent) {
|
||||
const current = ready.value
|
||||
if (!current || !print.isPrintShortcut(event)) return
|
||||
event.preventDefault()
|
||||
print.openPrint(current)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="panelId"
|
||||
class="conversation-panel"
|
||||
>
|
||||
<p
|
||||
v-if="recipients"
|
||||
class="conversation-panel__meta"
|
||||
>
|
||||
<span class="conversation-panel__label">{{ MAIL_CONVERSATION_TEXT.recipients }}</span>
|
||||
{{ recipients }}
|
||||
</p>
|
||||
<p class="conversation-panel__meta conversation-panel__meta--muted">
|
||||
<span class="conversation-panel__label">{{ MAIL_CONVERSATION_TEXT.subject }}</span>
|
||||
{{ subject }}
|
||||
</p>
|
||||
|
||||
<template v-if="ready">
|
||||
<p
|
||||
v-for="notice in bodyNotices"
|
||||
:key="notice.kind"
|
||||
class="conversation-panel__notice"
|
||||
:data-notice="notice.kind"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
class="conversation-panel__notice-icon"
|
||||
/>
|
||||
<span>{{ notice.text }}</span>
|
||||
</p>
|
||||
<MailBodyFrame
|
||||
class="conversation-panel__body"
|
||||
:html-body="ready.htmlBody"
|
||||
:text-body="ready.textBody"
|
||||
:images-shown="ready.imagesShown === true"
|
||||
:title="bodyTitle"
|
||||
@frame-keydown="onFrameKeydown"
|
||||
/>
|
||||
<p
|
||||
v-if="attachmentCount > 0"
|
||||
class="conversation-panel__attachments"
|
||||
data-conversation-attachments
|
||||
>
|
||||
<AppIcon
|
||||
name="paperclip"
|
||||
:size="14"
|
||||
class="conversation-panel__attachments-icon"
|
||||
/>
|
||||
<span>{{ MAIL_CONVERSATION_TEXT.attachmentsNote(formatCount(attachmentCount)) }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
v-else-if="failure"
|
||||
class="conversation-panel__error"
|
||||
data-conversation-detail-error
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="16"
|
||||
class="conversation-panel__error-icon"
|
||||
/>
|
||||
<span>{{ failure.message }}</span>
|
||||
<button
|
||||
v-if="!missing"
|
||||
type="button"
|
||||
class="conversation-panel__retry"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ MAIL_CONVERSATION_TEXT.retry }}
|
||||
</button>
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="conversation-panel__loading"
|
||||
aria-busy="true"
|
||||
:aria-label="MAIL_CONVERSATION_TEXT.bodyLoading"
|
||||
data-conversation-detail-loading
|
||||
>
|
||||
<span class="conversation-panel__bar" />
|
||||
<span class="conversation-panel__bar conversation-panel__bar--short" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!missing"
|
||||
class="conversation-panel__actions"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="row.replyLocation"
|
||||
class="conversation-panel__action"
|
||||
:to="row.replyLocation"
|
||||
data-conversation-reply
|
||||
>
|
||||
{{ MAIL_CONVERSATION_TEXT.reply }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="conversation-panel__action"
|
||||
:to="row.location"
|
||||
:replace="layout === 'list'"
|
||||
data-conversation-open
|
||||
>
|
||||
{{ MAIL_CONVERSATION_TEXT.openOnly }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="visually-hidden"
|
||||
role="status"
|
||||
data-conversation-detail-status
|
||||
>
|
||||
{{ failure?.message ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 줄 머리 아래 — 왼쪽 78px 은 이름 선에 맞춘다(줄 안쪽 14 + 화살표 16 + 사이 10 + 머리글자 28 + 사이 10, 디자인 원본 Thread14)
|
||||
*/
|
||||
.conversation-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-2) var(--space-16) var(--space-14)
|
||||
calc(var(--space-14) + var(--space-16) + var(--space-10) + var(--space-28) + var(--space-10));
|
||||
}
|
||||
|
||||
.conversation-panel__meta {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.conversation-panel__meta--muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.conversation-panel__label {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
/* 본문 안내 — 읽기 화면의 본문 안내 줄과 같은 모양 (노랑 · 위아래 10 · 좌우 12, 아이콘은 첫 줄에 — MessageReaderNotices) */
|
||||
.conversation-panel__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.conversation-panel__notice-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 펼친 본문은 이 칸 폭을 넘지 않는다 (본문 틀의 글줄 최대 폭은 그대로) */
|
||||
.conversation-panel__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 첨부 안내 — 머리 줄(받는 사람 · 제목)과 같은 12.5px 회색, 아이콘은 첫 줄에 */
|
||||
.conversation-panel__attachments {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.conversation-panel__attachments-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.conversation-panel__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.conversation-panel__bar {
|
||||
width: 80%;
|
||||
height: var(--space-10);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.conversation-panel__bar--short {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.conversation-panel__error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-8);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.conversation-panel__error-icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* [다시 시도] 44 — 글자 단추 */
|
||||
.conversation-panel__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-panel__retry:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* [답장] · [이 메일만 열기] — 글자 시작을 위 글과 맞춘다(단추 안쪽 10 만큼 당긴다) */
|
||||
.conversation-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-left: calc(var(--space-10) * -1);
|
||||
}
|
||||
|
||||
/* 높이 36 — 디자인 원본 Thread14 의 확정값, 안내 줄 단추의 밀도 예외(--size-notice-action)를 다시 쓴다 */
|
||||
.conversation-panel__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.conversation-panel__action:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
// "같은 대화" 칸의 줄 하나 (14단계 — 디자인 원본 Thread14) — 줄 48: ▸ · 머리글자 · 이름(내가 보냈으면 "나" + 회색 메일함 이름) · 미리 보기 한 줄 · 시각
|
||||
// - 지금 보는 메일: 옅은 청록 바탕 · "지금 보고 있는 메일" · 누를 수 없다(aria-current). 화살표 자리만큼 들여 머리글자 선을 맞춘다
|
||||
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
|
||||
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
|
||||
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
|
||||
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
|
||||
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
|
||||
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
|
||||
import { MAIL_LIST_ROW_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import type { ConversationRow } from '@/shared/utils/mail-conversation'
|
||||
import type { ConversationDetailState } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
row: ConversationRow
|
||||
/** 읽기 화면의 보기 방식 — 펼친 줄의 [이 메일만 열기]를 기록에 쌓을지 */
|
||||
layout?: 'split' | 'list'
|
||||
expanded?: boolean
|
||||
/** 펼친 줄의 본문 — 아직 받지 않았으면 null */
|
||||
detail?: ConversationDetailState | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle', id: string): void
|
||||
(e: 'retry-detail', id: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
expanded: false,
|
||||
detail: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
|
||||
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="['conversation-row', { 'is-unread': row.unread }]"
|
||||
:data-conversation-row="row.message.id"
|
||||
>
|
||||
<div
|
||||
v-if="row.current"
|
||||
class="conversation-row__line conversation-row__line--current"
|
||||
aria-current="true"
|
||||
>
|
||||
<span
|
||||
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
|
||||
aria-hidden="true"
|
||||
>{{ row.sender.initial }}</span>
|
||||
<span class="conversation-row__who">
|
||||
<span
|
||||
v-if="row.unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
|
||||
</span>
|
||||
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
|
||||
<time
|
||||
:class="['conversation-row__time', { 'is-pending': row.pending }]"
|
||||
:datetime="row.timeValue"
|
||||
>{{ row.time }}</time>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="conversation-row__line"
|
||||
:aria-expanded="expanded"
|
||||
:aria-controls="expanded ? panelId : undefined"
|
||||
data-conversation-toggle
|
||||
@click="emit('toggle', row.message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="expanded ? 'chevron-down' : 'chevron-right'"
|
||||
:stroke-width="2"
|
||||
class="conversation-row__chevron"
|
||||
/>
|
||||
<span
|
||||
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
|
||||
aria-hidden="true"
|
||||
>{{ row.sender.initial }}</span>
|
||||
<span class="conversation-row__who">
|
||||
<span
|
||||
v-if="row.unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.sender.mailboxName"
|
||||
class="conversation-row__mailbox"
|
||||
>{{ row.sender.mailboxName }}</span>
|
||||
<span class="conversation-row__preview">{{ row.preview }}</span>
|
||||
<time
|
||||
:class="['conversation-row__time', { 'is-pending': row.pending }]"
|
||||
:datetime="row.timeValue"
|
||||
>{{ row.time }}</time>
|
||||
</button>
|
||||
<ConversationMessagePanel
|
||||
v-if="expanded"
|
||||
:panel-id="panelId"
|
||||
:row="row"
|
||||
:layout="layout"
|
||||
:detail="detail"
|
||||
@retry="emit('retry-detail', row.message.id)"
|
||||
/>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conversation-row:not(:first-child) {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 줄 48 (디자인 원본 Thread14) — 안쪽 위아래 6 · 좌우 14 */
|
||||
.conversation-row__line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
width: 100%;
|
||||
min-height: var(--space-48);
|
||||
padding: var(--space-6) var(--space-14);
|
||||
border: 0;
|
||||
background: var(--color-surface);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.conversation-row__line:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
|
||||
button.conversation-row__line:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* 지금 보는 메일 — 옅은 청록 바탕, 화살표(16) + 사이(10) 만큼 들인다 */
|
||||
.conversation-row__line--current {
|
||||
padding-left: calc(var(--space-14) + var(--space-16) + var(--space-10));
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.conversation-row__chevron {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 머리글자 28 — 내가 보낸 메일은 회색 (디자인 원본 Thread14) */
|
||||
.conversation-row__avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-28);
|
||||
height: var(--space-28);
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-row__avatar--mine {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 이름은 미리 보기가 다 줄어든 뒤에야 줄인다 (미리 보기의 처음 폭이 0 이다) */
|
||||
.conversation-row__who {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-row__mailbox {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.conversation-row__preview,
|
||||
.conversation-row__current {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.conversation-row__preview {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.conversation-row__current {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.conversation-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기 · 색으로만, 목록 줄(MessageListItem)과 같은 값 (미리 보기가 목록 줄의 제목 자리다) */
|
||||
.conversation-row.is-unread .conversation-row__who {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.conversation-row.is-unread .conversation-row__preview,
|
||||
.conversation-row.is-unread .conversation-row__time {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) — 안 읽음이어도 */
|
||||
.conversation-row__time.is-pending,
|
||||
.conversation-row.is-unread .conversation-row__time.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 대화 줄(38) — 고르기 · 별 · 참여자(+ 통 수) · 제목(링크 + 첨부 클립) · 날짜 (14단계 — 보드 ListThreads14 에 표가 없어 정했다)
|
||||
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
|
||||
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
|
||||
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
|
||||
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
|
||||
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
|
||||
// 칸 폭 · 칸 공통 여백은 표(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_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { ConversationSummary } from '@/shared/types/mail'
|
||||
import { formatMailTableDate } from '@/shared/utils/format'
|
||||
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
row: ConversationSummary
|
||||
who: RowWho
|
||||
to: RouteLocationRaw
|
||||
unread?: boolean
|
||||
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
|
||||
draft?: boolean
|
||||
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
|
||||
held?: boolean
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'open', location: RouteLocationRaw): void
|
||||
(e: 'toggle-flag', rowId: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', rowId: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
unread: false,
|
||||
draft: false,
|
||||
held: false,
|
||||
checked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
|
||||
const countText = computed(() => conversationCountText(props.row))
|
||||
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
|
||||
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()
|
||||
if (!props.held) emit('check', props.row.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()
|
||||
}
|
||||
|
||||
function onStar() {
|
||||
if (!props.held) emit('toggle-flag', props.row.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="row.id"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
|
||||
<MailCheckbox
|
||||
class="thread-table-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
:unavailable="held"
|
||||
:note="SEND_LATER_TEXT.heldNotSelectable"
|
||||
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mail-table__star">
|
||||
<button
|
||||
type="button"
|
||||
:class="['thread-table-row__star', { 'is-flagged': row.isFlagged }]"
|
||||
:aria-label="MAIL_LIST_ROW_TEXT.flag"
|
||||
:aria-pressed="row.isFlagged"
|
||||
:aria-disabled="held ? 'true' : undefined"
|
||||
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
|
||||
data-thread-star
|
||||
@click="onStar"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="row.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="thread-table-row__who-cell">
|
||||
<div class="thread-table-row__who-line">
|
||||
<span :class="['thread-table-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="countText"
|
||||
class="thread-table-row__count"
|
||||
data-thread-count
|
||||
>{{ countText }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="thread-table-row__subject-line">
|
||||
<RouterLink
|
||||
:class="['thread-table-row__link', { 'is-placeholder': draft && !row.latest.subject }]"
|
||||
:to="to"
|
||||
:data-message-id="row.latest.id"
|
||||
>
|
||||
{{ subject }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="row.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
:label="MAIL_LIST_ROW_TEXT.attachment"
|
||||
class="thread-table-row__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
:class="['mail-table__date', { 'is-pending': pendingLabel }]"
|
||||
:title="pendingLabel ?? undefined"
|
||||
>
|
||||
<time :datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt">{{
|
||||
pendingLabel ?? formatMailTableDate(row.latest.receivedAt)
|
||||
}}</time>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일 표의 행(MessageTableRow)과 같은 높이 · 바탕 (디자인 원본 MainList · BulkSelect) */
|
||||
.thread-table-row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread-table-row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.thread-table-row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.thread-table-row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.thread-table-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.thread-table-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
.thread-table-row__check {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.thread-table-row__star {
|
||||
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);
|
||||
}
|
||||
|
||||
.thread-table-row__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.thread-table-row__star[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 참여자 칸 — 이름이 먼저 줄고 통 수는 남는다 */
|
||||
.thread-table-row__who-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-table-row__who {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.thread-table-row__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thread-table-row__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.thread-table-row__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thread-table-row__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.thread-table-row__clip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 표와 같다) */
|
||||
.thread-table-row__who.is-placeholder,
|
||||
.thread-table-row__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.thread-table-row.is-unread .thread-table-row__who,
|
||||
.thread-table-row.is-unread .thread-table-row__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-table-row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.thread-table-row .mail-table__date.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 강조가 든 글 (디자인 원본 Search14 · README 「14단계 › 검색 · 조건 검색 · 강조」) — 서버가 준 맞은 곳을 <mark>(노랑 · 글자색 그대로)로 그린다.
|
||||
// v-html 없이 조각(search-highlight.ts)으로 그린다. 맞은 곳이 없으면 글 그대로다(엔진 방식 검색 · 메일함 목록).
|
||||
// 뿌리 요소가 없다 — 부르는 쪽의 글 자리(span · h2 · a) 안에 넣는다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SearchRange } from '@/shared/types/mail'
|
||||
import { highlightSegments } from '@/shared/utils/search-highlight'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
/** 맞은 곳 [시작, 끝) — UTF-16, 서버가 NFC 로 맞춘 글 기준 */
|
||||
ranges?: readonly SearchRange[] | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
ranges: null,
|
||||
})
|
||||
|
||||
const segments = computed(() => highlightSegments(props.text, props.ranges))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template
|
||||
v-for="(segment, index) in segments"
|
||||
:key="index"
|
||||
>
|
||||
<mark
|
||||
v-if="segment.mark"
|
||||
class="search-mark"
|
||||
data-search-mark
|
||||
>{{ segment.text }}</mark>
|
||||
<template v-else>
|
||||
{{ segment.text }}
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14).
|
||||
* 흐린 글자 위면 부르는 쪽이 글자색을 진하게 한다 — 검색 결과의 미리 보기 줄(MessageListItem — 대비 4.5:1, 2026-11-07)
|
||||
*/
|
||||
.search-mark {
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: calc(var(--radius-xs) / 2);
|
||||
background: var(--color-search-mark);
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -158,3 +158,114 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ListReadNav — 14단계 검색의 빈 쪽', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('[다음]은 빈 쪽에 멈추지 않는다 — 다음 메일이 나올 때까지 커서로 이어 묻고 그 메일로 간다', async () => {
|
||||
const requests = installMockApi(({ params }) => {
|
||||
if (params.cursor === 'c2') return ok({ items: [], pageInfo: { hasNext: true, nextCursor: 'c3', total: null } })
|
||||
return ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
|
||||
store.messages = [summary('m1'), summary('m2'), summary('m3')]
|
||||
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: null }
|
||||
store.listStatus = 'ready'
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m3?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
|
||||
|
||||
// 전체 수를 모르면 "n / 전체" 를 보이지 않는다
|
||||
expect(wrapper.find('.nav-position').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([
|
||||
'c2',
|
||||
'c3',
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`)
|
||||
})
|
||||
|
||||
it('뒤에서 결과를 이어 받는 중에 누른 [다음]은 그 일을 기다렸다가 넘어간다 — 기다리는 동안 꺼지고 aria-busy, 새로 묻지 않는다', async () => {
|
||||
let resolveSecond: (value: ReturnType<typeof ok>) => void = () => undefined
|
||||
const second = new Promise<ReturnType<typeof ok>>((done) => {
|
||||
resolveSecond = done
|
||||
})
|
||||
const requests = installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
|
||||
if (!params.cursor) {
|
||||
return ok({ items: [summary('m1'), summary('m2'), summary('m3')], pageInfo: { hasNext: true, nextCursor: 'c2', total: null } })
|
||||
}
|
||||
return second
|
||||
})
|
||||
const store = useMailStore()
|
||||
const opening = store.openSearch('회의', 'all')
|
||||
await flushPromises()
|
||||
expect(store.isLoadingMore).toBe(true)
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m3?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
|
||||
const next = wrapper.get('button[aria-label="다음 메일"]')
|
||||
// 이어 받는 중이면 끝에 있어도 [다음]을 누를 수 있다
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
|
||||
await next.trigger('click')
|
||||
await flushPromises()
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
expect(next.attributes('aria-busy')).toBe('true')
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
|
||||
|
||||
resolveSecond(ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
|
||||
await opening
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`)
|
||||
expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([
|
||||
undefined,
|
||||
'c2',
|
||||
])
|
||||
})
|
||||
|
||||
it('목록을 보이는 채로 새로 고치는 동안 받아야 할 [다음]은 꺼지고 aria-busy — 끝나면 다시 누를 수 있다', async () => {
|
||||
const store = useMailStore()
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.messages = [summary('m1'), summary('m2'), summary('m3')]
|
||||
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 }
|
||||
store.listStatus = 'ready'
|
||||
store.isRefreshing = true
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.push('/mail/inbox/m3')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
|
||||
const next = wrapper.get('button[aria-label="다음 메일"]')
|
||||
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
expect(next.attributes('aria-busy')).toBe('true')
|
||||
|
||||
store.isRefreshing = false
|
||||
await flushPromises()
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
expect(next.attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
|
||||
// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수".
|
||||
// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -32,45 +34,60 @@ 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.search ? null : mailStore.currentMailbox?.totalCount) ??
|
||||
mailStore.messages.length,
|
||||
)
|
||||
/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.rowIndexOfMessage(props.messageId))
|
||||
/**
|
||||
* 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수.
|
||||
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다.
|
||||
* 대화 목록은 대화 수 — 모르면 다 받았을 때만 불러온 줄 수 (메일함의 통 수는 대화 수가 아니다)
|
||||
*/
|
||||
const total = computed<number | null>(() => {
|
||||
const known = mailStore.pageInfo?.total ?? null
|
||||
if (known !== null) return known
|
||||
if (mailStore.search) return null
|
||||
if (mailStore.isConversationList) return mailStore.hasMore ? null : mailStore.rowCount
|
||||
return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length
|
||||
})
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
index.value === -1 || total.value === null ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
)
|
||||
const hasPrev = computed(() => index.value > 0)
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */
|
||||
const hasNext = computed(
|
||||
() =>
|
||||
index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore),
|
||||
index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore),
|
||||
)
|
||||
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
|
||||
const nextWaiting = computed(
|
||||
() => index.value !== -1 && mailStore.rowMessageIdAt(index.value + 1) === null && mailStore.isRefreshing,
|
||||
)
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
const previous = mailStore.rowMessageIdAt(index.value - 1)
|
||||
const source = mailStore.listSource
|
||||
if (previous && source) await showSiblingIn(source, previous.id)
|
||||
if (previous && source) await showSiblingIn(source, previous)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 메일 — 불러온 범위의 끝이면 더 받는다. 검색 결과를 뒤에서 이어 받는 중(14단계)이면 그 일이 끝나기를 기다리고,
|
||||
* 그 안에 다음 메일이 없었으면 이번 누름의 차례로 한 번 더 받는다. 기다리는 동안 단추는 꺼지고 aria-busy
|
||||
*/
|
||||
async function goNext() {
|
||||
if (loadingNext.value || index.value === -1) return
|
||||
if (loadingNext.value || nextWaiting.value || index.value === -1) return
|
||||
const current = index.value
|
||||
if (!mailStore.messages[current + 1] && mailStore.hasMore) {
|
||||
if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) {
|
||||
loadingNext.value = true
|
||||
try {
|
||||
const waited = mailStore.isLoadingMore
|
||||
await mailStore.loadMore()
|
||||
if (waited && mailStore.rowMessageIdAt(current + 1) === null && mailStore.hasMore) await mailStore.loadMore()
|
||||
} finally {
|
||||
loadingNext.value = false
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
const next = mailStore.rowMessageIdAt(current + 1)
|
||||
const source = mailStore.listSource
|
||||
if (next && source) await showSiblingIn(source, next.id)
|
||||
if (next && source) await showSiblingIn(source, next)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
@@ -80,7 +97,8 @@ function onBackToList(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
const source = mailStore.listSource
|
||||
if (!source) return
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
// 대화 목록은 줄의 링크가 마지막 메일을 가리킨다 — 그 줄로 초점을 돌려준다
|
||||
viewStore.setReturnFocus(mailStore.rowMessageIdAt(index.value) ?? props.messageId)
|
||||
void backToSourceList(source)
|
||||
}
|
||||
</script>
|
||||
@@ -115,8 +133,8 @@ function onBackToList(event: MouseEvent) {
|
||||
type="button"
|
||||
class="nav-button"
|
||||
aria-label="다음 메일"
|
||||
:disabled="!hasNext || loadingNext"
|
||||
:aria-busy="loadingNext"
|
||||
:disabled="!hasNext || loadingNext || nextWaiting"
|
||||
:aria-busy="loadingNext || nextWaiting"
|
||||
@click="goNext"
|
||||
>
|
||||
<AppIcon
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
|
||||
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
|
||||
// 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다
|
||||
// 검색 결과에서 연 메일(14단계)은 맞은 말이 노랑으로 보인다 — 서식 본문은 서버가 넣은 <mark>, 글 본문은 textHighlights 의 위치로 (mail-html.ts)
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { SearchRange } from '@/shared/types/mail'
|
||||
import { buildMailDocument } from '@/shared/utils/mail-html'
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +31,8 @@ interface Props {
|
||||
title?: string
|
||||
/** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */
|
||||
foldLargeFiles?: boolean
|
||||
/** 글 본문의 검색에서 맞은 곳 — 서버의 searchHighlight.textBody (14단계 — 글 본문을 보일 때만 쓴다) */
|
||||
textHighlights?: readonly SearchRange[] | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -47,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
imagesShown: false,
|
||||
title: '메일 본문',
|
||||
foldLargeFiles: false,
|
||||
textHighlights: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -63,6 +68,7 @@ const srcdoc = computed(() =>
|
||||
textBody: props.textBody,
|
||||
imagesShown: props.imagesShown,
|
||||
foldLargeFiles: props.foldLargeFiles,
|
||||
textHighlights: props.textHighlights,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 오른쪽의 [대화로 묶기] (14단계 — 디자인 원본 ListThreads14): 눌림 단추(aria-pressed) · 높이 36(--size-notice-action — 안내 줄 단추의
|
||||
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(groupByConversation)을 PATCH 한다 — 화면을 먼저 바꾸고(메일함 화면이 바로 묶거나 푼다)
|
||||
// 끝내 실패하면 서버가 확인한 값으로 되돌린다(mail-settings.store — 칸마다 저장은 한 번에 하나, 알림은 인터셉터).
|
||||
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(36×36 — 이름은 title · 화면 낭독, MailboxEmptyButton 과 같은 결), 목록 보기는 아이콘 + 이름.
|
||||
// 이름까지 보이면 99px 이라 1440 분할 보기에서도 "받은편지함" 뒤 개수 글 자리가 11px 만 남는다(2026-11-06 쟀다) — 그래서 아이콘만 두고
|
||||
// 설정 › 보내기 · 읽기의 설명이 "말풍선 모양 [대화로 묶기] 단추"라고 모양을 말한다(MAIL_SETTINGS_TEXT.groupHint)
|
||||
// 켠 모양은 보기 전환의 고른 칸과 같다(청록 옅은 바탕 · 진한 청록 글자 — 테두리는 그대로, 보드의 #9FD3D7 테두리는 새 색이라 두지 않았다)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
interface Props {
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
const pressed = computed(() => settingsStore.groupByConversation)
|
||||
const iconOnly = computed(() => props.variant === 'split')
|
||||
|
||||
function toggle() {
|
||||
void settingsStore.update('groupByConversation', !pressed.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['group-toggle', { 'is-on': pressed, 'group-toggle--icon': iconOnly }]"
|
||||
:aria-pressed="pressed"
|
||||
:title="iconOnly ? MAIL_CONVERSATION_LIST_TEXT.toggle : undefined"
|
||||
data-group-toggle
|
||||
@click="toggle"
|
||||
>
|
||||
<AppIcon
|
||||
name="message-square"
|
||||
:size="14"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span :class="{ 'visually-hidden': iconOnly }">{{ MAIL_CONVERSATION_LIST_TEXT.toggle }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 높이 36(밀도 예외 --size-notice-action) · 테두리 · 12.5px 굵게 (디자인 원본 ListThreads14) */
|
||||
.group-toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-toggle:hover:not(.is-on) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.group-toggle.is-on {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 분할 보기 — 아이콘만, 정사각형 36 */
|
||||
.group-toggle--icon {
|
||||
width: var(--size-notice-action);
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,8 +4,11 @@
|
||||
// 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다),
|
||||
// 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
|
||||
// - 검색 결과(14단계 — 디자인 원본 Search14): 찾을 곳 줄 아래 조건 줄(조건이 걸렸을 때) · 안내 줄(검색 준비 중 · 한 글자 안내).
|
||||
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐)
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -14,8 +17,11 @@ import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
|
||||
import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue'
|
||||
import MailSearchConditionLine from '@/components/mail/MailSearchConditionLine.vue'
|
||||
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
|
||||
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
|
||||
import MailSearchMore from '@/components/mail/MailSearchMore.vue'
|
||||
import MailSearchNotices from '@/components/mail/MailSearchNotices.vue'
|
||||
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
|
||||
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
@@ -26,7 +32,8 @@ import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { isScheduledSource } from '@/shared/utils/mail-list-source'
|
||||
import { isScheduledSource, isSearchTooLong } from '@/shared/utils/mail-list-source'
|
||||
import { hasSearchConditions, skipsBodySearch } from '@/shared/utils/mail-search'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -69,15 +76,39 @@ const retentionNotice = computed(() => {
|
||||
const role = mailbox.value?.role ?? null
|
||||
const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null
|
||||
if (text === null) return null
|
||||
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
|
||||
return mailStore.listStatus === 'ready' && mailStore.rowCount === 0 ? null : text
|
||||
})
|
||||
/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */
|
||||
const searchConditions = computed(() =>
|
||||
search.value && hasSearchConditions(search.value.conditions) ? (search.value.conditions ?? null) : null,
|
||||
)
|
||||
/** 검색어와 조건의 낱말이 한도를 넘는 주소 — 찾지 않았다, 결과 없음 자리에 그 까닭을 보인다 (14단계) */
|
||||
const searchTooLong = computed(() => isSearchTooLong(search.value))
|
||||
/** 지난 메일의 색인을 만드는 중 — 결과 머리 아래 "검색 준비 중" 줄 · 결과 없음의 한계 안내 (14단계) */
|
||||
const indexing = computed(() => mailStore.searchIndexStatus === 'building')
|
||||
/** 한 글자 낱말뿐이라 본문을 찾지 않았다 — 색인 방식(준비가 끝남)일 때만 알린다 (14단계) */
|
||||
const singleChar = computed(
|
||||
() =>
|
||||
Boolean(search.value) &&
|
||||
!searchTooLong.value &&
|
||||
mailStore.searchIndexStatus === 'ready' &&
|
||||
skipsBodySearch(search.value?.q ?? '', search.value?.conditions),
|
||||
)
|
||||
/** 더 받는 중 · 보이는 채로 새로 고치는 중 — [메일 더 보기] · [더 찾기]를 막는다 (새로 고치는 동안 옛 커서로 붙이지 않게) */
|
||||
const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing)
|
||||
/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */
|
||||
const searchPending = computed(
|
||||
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.rowCount === 0 && mailStore.hasMore,
|
||||
)
|
||||
/** 없는 주소(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 })
|
||||
const source = mailStore.listSource
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions })
|
||||
else if (scheduled.value) void mailStore.openScheduled()
|
||||
else if (source?.kind === 'conversations') void mailStore.openConversations(source.mailboxId)
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
|
||||
@@ -107,6 +138,18 @@ function onKeydown(event: KeyboardEvent) {
|
||||
mailStore.clearChecked()
|
||||
}
|
||||
|
||||
// 조건 줄의 [조건 지우기] · 결과를 기다리던 [더 찾기]는 누르면 그 자리가 사라진다 — 초점을 잃었으면 목록 칸으로 (14단계)
|
||||
watch(
|
||||
[() => searchConditions.value !== null, searchPending],
|
||||
([hasConditions, pending], [hadConditions, wasPending]) => {
|
||||
if ((hadConditions && !hasConditions) || (wasPending && !pending)) {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) focusSection()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
|
||||
watch(
|
||||
() => mailStore.checkedCount,
|
||||
@@ -159,7 +202,17 @@ watch(
|
||||
/>
|
||||
<span>{{ retentionNotice }}</span>
|
||||
</p>
|
||||
<MailSearchScope v-if="search" />
|
||||
<template v-if="search">
|
||||
<MailSearchScope />
|
||||
<MailSearchConditionLine
|
||||
v-if="searchConditions"
|
||||
:conditions="searchConditions"
|
||||
/>
|
||||
<MailSearchNotices
|
||||
:indexing="indexing"
|
||||
:single-char="singleChar"
|
||||
/>
|
||||
</template>
|
||||
<MailNotifyOffer
|
||||
v-else-if="!scheduled"
|
||||
:variant="variant"
|
||||
@@ -190,7 +243,7 @@ watch(
|
||||
@leave="leave"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<template v-else-if="mailStore.rowCount > 0">
|
||||
<slot />
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
@@ -199,18 +252,27 @@ watch(
|
||||
<button
|
||||
type="button"
|
||||
class="mail-list-frame__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
:disabled="loadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
{{ loadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MailSearchMore
|
||||
v-else-if="searchPending"
|
||||
:busy="loadingMore"
|
||||
@more="mailStore.loadMore()"
|
||||
/>
|
||||
|
||||
<MailSearchEmpty
|
||||
v-else-if="mailStore.listStatus === 'ready' && search"
|
||||
:query="search.q"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
|
||||
:has-conditions="searchConditions !== null"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL && !searchTooLong"
|
||||
:word-limit="indexing && !searchTooLong"
|
||||
:too-many-words="searchTooLong"
|
||||
@search-all="setScope(SEARCH_SCOPE_ALL)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](14단계) · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 개수(14단계 — 디자인 원본 ListThreads14): 묶지 않았으면 "메일 9통, 안 읽은 메일 2통"(메일함의 통 수), 묶었으면 "대화 6개, 안 읽은 메일 2통"
|
||||
// (대화 수는 목록의 total — 모르면 그 부분을 뺀다). 안 읽은 메일이 없으면 뒤를 뺀다.
|
||||
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통"(묶었으면 "대화 6개")을 보이고 전체 글은 title · 화면 낭독에 둔다.
|
||||
// 칸 사이도 조금 좁힌다(이름↔개수 6 · 머리↔단추 4 · 단추 사이 4) — 1440 의 목록 칸(384)에서 "받은편지함" 뒤 "안 읽은 메일 99통"(86px)까지 다 보이게
|
||||
// (2026-11-06 실사용 시험 — 원래 자리 74px 로 "메일 6통, 안 …"처럼 안 읽은 수가 늘 잘렸다)
|
||||
// [대화로 묶기]는 메일함 목록(묶었든 아니든)에서만 — 검색 결과 · 예약됨은 묶지 않는다. 설정을 받는 동안은 숨기고(그사이 고른 것은 받기가 덮는다),
|
||||
// 받았거나 받지 못했으면 보인다 — 받지 못했으면 짐작 위에서 고르고 그 칸만 저장한다 (mail-settings.store — 리뷰 L4)
|
||||
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
|
||||
@@ -7,12 +14,15 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailConversationToggle from '@/components/mail/MailConversationToggle.vue'
|
||||
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isScheduledSource } from '@/shared/utils/mail-list-source'
|
||||
import { listHeadCountText, listHeadShortCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { isScheduledSource, mailboxListIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -21,24 +31,40 @@ interface Props {
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
|
||||
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
|
||||
/** 메일함 목록의 개수 칸 — 묶었으면 대화 수(목록의 total), 아니면 메일함의 통 수. 예약됨 · 메일함을 모르면 null */
|
||||
const countInput = computed(() => {
|
||||
const box = scheduled.value ? null : mailStore.currentMailbox
|
||||
if (!box) return null
|
||||
return mailStore.isConversationList
|
||||
? { grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount }
|
||||
: { grouped: false, total: box.totalCount, unread: box.unreadCount }
|
||||
})
|
||||
const countText = computed(() => {
|
||||
if (scheduled.value) {
|
||||
if (mailStore.listStatus !== 'ready') return ''
|
||||
return SEND_LATER_TEXT.listCount(formatCount(mailStore.pageInfo?.total ?? mailStore.messages.length))
|
||||
}
|
||||
const box = mailStore.currentMailbox
|
||||
if (!box) return ''
|
||||
const total = `전체 ${formatCount(box.totalCount)}`
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
return countInput.value ? listHeadCountText(countInput.value) : ''
|
||||
})
|
||||
/** 보이는 개수 — 분할 보기(좁은 머리)의 메일함 목록은 줄인 글(안 읽은 메일이 있으면 그 수만), 그 밖은 전체 글 */
|
||||
const shownCountText = computed(() =>
|
||||
props.variant === 'split' && countInput.value ? listHeadShortCountText(countInput.value) : countText.value,
|
||||
)
|
||||
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (14단계 — 받는 중 · 받기 전에는 숨긴다) */
|
||||
const groupable = computed(
|
||||
() =>
|
||||
mailboxListIdOf(mailStore.listSource) !== null &&
|
||||
(settingsStore.status === 'ready' || settingsStore.status === 'error'),
|
||||
)
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
|
||||
const emptiable = computed(() => {
|
||||
@@ -49,14 +75,32 @@ const emptiable = computed(() => {
|
||||
|
||||
<template>
|
||||
<div :class="['list-header', `list-header--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="list-header__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<div class="list-header__heading">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="list-header__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<!--
|
||||
분할 보기는 줄인 글을 보이고 전체 글은 title · 화면 낭독에 둔다. 그래도 좁으면 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다
|
||||
-->
|
||||
<span
|
||||
class="list-header__count"
|
||||
:title="countText || undefined"
|
||||
>
|
||||
<template v-if="shownCountText !== countText">
|
||||
<span aria-hidden="true">{{ shownCountText }}</span>
|
||||
<span class="visually-hidden">{{ countText }}</span>
|
||||
</template>
|
||||
<template v-else>{{ countText }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-header__end">
|
||||
<MailConversationToggle
|
||||
v-if="groupable"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailboxEmptyButton
|
||||
v-if="emptiable"
|
||||
:mailbox="emptiable"
|
||||
@@ -92,7 +136,35 @@ const emptiable = computed(() => {
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸(384) — 개수 글이 들 자리를 내려고 칸 사이를 조금 좁힌다 (머리말) */
|
||||
.list-header--split {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.list-header--split .list-header__heading {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.list-header--split .list-header__end {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/*
|
||||
* 이름 · 개수 — 오른쪽 단추들이 남긴 자리를 쓴다. 좁으면 개수가 먼저 줄고(사라질 때까지) 그다음에 이름이 준다
|
||||
* (14단계 — [대화로 묶기]가 더해져 분할 보기 칸에서 이름이 1px 모자라 "색인 …"으로 잘렸다)
|
||||
*/
|
||||
.list-header__heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
@@ -105,7 +177,7 @@ const emptiable = computed(() => {
|
||||
|
||||
/* 좁으면 이름보다 먼저 줄어든다 */
|
||||
.list-header__count {
|
||||
flex: 0 100 auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
|
||||
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 대화로 묶은 휴지통에서 줄을 골랐으면(from) 제목은 "휴지통에 있는 메일 N통을 영구 삭제할까요?" — 줄의 통 수는 대화 전체라서 (14단계, 2026-11-07)
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통 — from 없이 지금 글) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { purgeTitleText } from '@/shared/utils/mail-bulk'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
count: number
|
||||
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
/** 대화로 묶은 목록에서 고른 줄이면 그 메일함 이름 ("휴지통") — 없으면 "메일 N통을 …" */
|
||||
from?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -19,15 +24,19 @@ interface Emits {
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
from: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() => purgeTitleText(props.count, { from: props.from }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
|
||||
:title="title"
|
||||
:message="MAIL_BULK_TEXT.purgeMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
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 { createManualClock } from '@/test-utils/manual-clock'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailSearchBox from './MailSearchBox.vue'
|
||||
@@ -51,8 +53,10 @@ describe('MailSearchBox (메일 검색)', () => {
|
||||
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.find('[data-search-clear]').exists()).toBe(false)
|
||||
expect(wrapper.get('form').classes()).not.toContain('is-active')
|
||||
// [조건]은 늘 있다 (14단계)
|
||||
expect(wrapper.get('[data-search-conditions-toggle]').text()).toBe('조건')
|
||||
})
|
||||
|
||||
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
|
||||
@@ -123,7 +127,7 @@ describe('MailSearchBox (메일 검색)', () => {
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('회의')
|
||||
expect(wrapper.get('form').classes()).toContain('is-active')
|
||||
const clear = wrapper.get('button')
|
||||
const clear = wrapper.get('[data-search-clear]')
|
||||
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
|
||||
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
|
||||
|
||||
@@ -165,3 +169,405 @@ describe('MailSearchBox (메일 검색)', () => {
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디자인 원본 Search14)', () => {
|
||||
let pinia: Pinia
|
||||
/** 2026년 11월 6일 오전 9시(브라우저 시간대) — 최근 1달은 10월 6일부터 */
|
||||
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
|
||||
|
||||
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 } }))
|
||||
})
|
||||
|
||||
/** 문서에 붙여 그린 칸 — 테스트마다 떼어 낸다 (열린 패널의 문서 pointerdown 처리가 다음 테스트에 남지 않게) */
|
||||
let mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailSearchBox, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return { wrapper, router, input: wrapper.get<HTMLInputElement>('input[type="search"]') }
|
||||
}
|
||||
|
||||
async function openPanel(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-search-conditions-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.get('[data-search-conditions]')
|
||||
}
|
||||
|
||||
const field = (wrapper: VueWrapper, name: string) => wrapper.get(`[data-search-condition="${name}"]`)
|
||||
|
||||
it('[조건]은 칸 안(✕ 앞)에 있고 누르면 칸 아래에 패널이 열린다 — aria-expanded · 청록 바탕, 첫 칸(보낸 사람)으로 초점', async () => {
|
||||
const { wrapper } = await mountAt('/mail/inbox')
|
||||
const toggle = wrapper.get('[data-search-conditions-toggle]')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
expect(toggle.classes()).not.toContain('is-on')
|
||||
|
||||
const panel = await openPanel(wrapper)
|
||||
|
||||
expect(toggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(toggle.attributes('aria-controls')).toBe(panel.attributes('id'))
|
||||
expect(toggle.classes()).toContain('is-on')
|
||||
expect(panel.attributes('role')).toBe('group')
|
||||
expect(panel.get('h2').text()).toBe('조건으로 찾기')
|
||||
expect(panel.findAll('label').map((label) => label.text())).toEqual([
|
||||
'보낸 사람',
|
||||
'받는 사람',
|
||||
'제목',
|
||||
'내용',
|
||||
'기간',
|
||||
'첨부 파일이 있는 메일만',
|
||||
])
|
||||
expect(panel.findAll('option').map((option) => option.text())).toEqual([
|
||||
'전체 기간',
|
||||
'최근 1주',
|
||||
'최근 1달',
|
||||
'최근 3달',
|
||||
'최근 1년',
|
||||
'날짜 고르기',
|
||||
])
|
||||
expect(panel.text()).toContain(
|
||||
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
|
||||
)
|
||||
// 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험)
|
||||
expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([
|
||||
'이름이나 메일 주소',
|
||||
'이름이나 메일 주소',
|
||||
'제목에 든 말',
|
||||
'내용에 든 말',
|
||||
])
|
||||
expect(document.activeElement).toBe(field(wrapper, 'from').element)
|
||||
})
|
||||
|
||||
it('검색 칸이 비어도 조건만으로 찾는다 — 최근 1달은 그 날짜로 바꿔 주소에 싣는다, 패널은 닫히고 검색 칸으로', async () => {
|
||||
const { wrapper, router, input } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'from').setValue(' 이도윤 ')
|
||||
await field(wrapper, 'period').setValue('month')
|
||||
await field(wrapper, 'attachment').setValue(true)
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
`/mail/search?in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
|
||||
)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
})
|
||||
|
||||
it('패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같다 — 검색어와 조건을 함께', async () => {
|
||||
const { wrapper, router, input } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'subject').setValue('견적')
|
||||
await input.setValue('계약')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
`/mail/search?q=${encodeURIComponent('계약')}&in=inbox&subject=${encodeURIComponent('견적')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('검색어와 조건을 합친 낱말이 8개를 넘으면 찾지 않고 VAL_001 사전 문구 — 패널 · 친 글자는 그대로', async () => {
|
||||
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 알림과 떨어뜨린다
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(Date.now() + 60_000)
|
||||
const { wrapper, router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('하나 둘 셋 넷 다섯')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'body').setValue('여섯 일곱 여덟 아홉')
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
expect((field(wrapper, 'body').element as HTMLInputElement).value).toBe('여섯 일곱 여덟 아홉')
|
||||
})
|
||||
|
||||
it('날짜 고르기의 시작이 끝보다 늦으면 찾지 않고 칸 아래에 알린다 — 끝 날짜 칸으로 초점', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
await field(wrapper, 'since').setValue('2026-11-05')
|
||||
await field(wrapper, 'until').setValue('2026-10-01')
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
const error = wrapper.get('[role="alert"]')
|
||||
expect(error.text()).toBe('끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.')
|
||||
expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('true')
|
||||
expect(field(wrapper, 'until').attributes('aria-describedby')).toBe(error.attributes('id'))
|
||||
expect(document.activeElement).toBe(field(wrapper, 'until').element)
|
||||
|
||||
// 고쳐서 찾으면 그 날짜로
|
||||
await field(wrapper, 'until').setValue('2026-11-05')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-05', until: '2026-11-05' })
|
||||
})
|
||||
|
||||
it('날짜는 1970년 1월 1일 ~ 9999년 12월 30일만 고른다 — 그 밖의 날짜는 찾지 않고 칸 아래에 알린다 (그 칸으로 초점)', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
expect(field(wrapper, 'since').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
|
||||
expect(field(wrapper, 'until').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
|
||||
|
||||
await field(wrapper, 'since').setValue('1969-12-31')
|
||||
await field(wrapper, 'until').setValue('2026-11-05')
|
||||
// 고른 끝 날짜가 시작 날짜 칸의 끝이 된다
|
||||
expect(field(wrapper, 'since').attributes('max')).toBe('2026-11-05')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
const error = wrapper.get('[role="alert"]')
|
||||
expect(error.text()).toBe('날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.')
|
||||
expect(field(wrapper, 'since').attributes('aria-invalid')).toBe('true')
|
||||
expect(field(wrapper, 'since').attributes('aria-describedby')).toBe(error.attributes('id'))
|
||||
expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('false')
|
||||
expect(document.activeElement).toBe(field(wrapper, 'since').element)
|
||||
|
||||
// 날짜를 고치면 알림을 지우고, 고른 시작 날짜가 끝 날짜 칸의 처음이 된다
|
||||
await field(wrapper, 'since').setValue('2026-11-01')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(field(wrapper, 'until').attributes('min')).toBe('2026-11-01')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-01', until: '2026-11-05' })
|
||||
})
|
||||
|
||||
it('미리 정한 기간에서 날짜 고르기로 바꾸면 그 기간의 날짜로 칸을 채워 둔다', async () => {
|
||||
const { wrapper } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
|
||||
await field(wrapper, 'period').setValue('week')
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
|
||||
expect((field(wrapper, 'since').element as HTMLInputElement).value).toBe('2026-10-30')
|
||||
expect((field(wrapper, 'until').element as HTMLInputElement).value).toBe('2026-11-06')
|
||||
})
|
||||
|
||||
it('Esc 는 패널만 닫고 [조건]으로 초점을 돌린다 — 바깥을 누르면 닫는다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
await openPanel(wrapper)
|
||||
|
||||
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-search-conditions-toggle]').element)
|
||||
// 검색은 그대로
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
await openPanel(wrapper)
|
||||
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('패널이 열린 채 검색 칸에서 Esc 를 누르면 패널만 닫고 초점은 검색 칸에 남는다 — 검색은 그대로', async () => {
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
await openPanel(wrapper)
|
||||
input.element.focus()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
})
|
||||
|
||||
it('키보드로 검색 칸 밖으로 나가면(focusout) 패널을 닫는다 — 검색 칸 안으로 옮기는 초점은 그대로', async () => {
|
||||
const { wrapper, input } = await mountAt('/mail/inbox')
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(outside)
|
||||
await openPanel(wrapper)
|
||||
const from = field(wrapper, 'from').element
|
||||
|
||||
from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: input.element }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
|
||||
from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
// 나간 곳의 초점을 [조건]으로 끌어오지 않는다
|
||||
expect(document.activeElement).not.toBe(wrapper.get('[data-search-conditions-toggle]').element)
|
||||
})
|
||||
|
||||
it('다른 화면으로 가면(뒤로 가기 등) 열어 둔 패널을 닫는다', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
|
||||
await router.push('/mail/trash')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-search-conditions-toggle]').attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('조건이 걸린 검색 — [조건]이 청록이고, 열면 걸린 조건으로 칸을 채운다(오늘 기준 최근 1달)', async () => {
|
||||
const { wrapper } = await mountAt(
|
||||
`/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
|
||||
)
|
||||
const toggle = wrapper.get('[data-search-conditions-toggle]')
|
||||
expect(toggle.classes()).toContain('is-on')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
expect(toggle.text()).toContain('조건이 걸려 있음')
|
||||
|
||||
await openPanel(wrapper)
|
||||
|
||||
expect((field(wrapper, 'from').element as HTMLInputElement).value).toBe('이도윤')
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('month')
|
||||
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('패널을 닫은 채 누른 Enter 는 걸린 조건을 그대로 두고 검색어만 바꾼다 (기록을 쌓지 않는다)', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all&from=${encodeURIComponent('이도윤')}`)
|
||||
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&from=${encodeURIComponent('이도윤')}`,
|
||||
)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('패널의 [조건 지우기] — 패널의 칸만 비운다 (2026-11-07 사용자 관점 점검)', () => {
|
||||
const textValues = (wrapper: VueWrapper) =>
|
||||
['from', 'to', 'subject', 'body'].map((name) => (field(wrapper, name).element as HTMLInputElement).value)
|
||||
|
||||
it('칸 · 기간 · 날짜 · 첨부 체크를 비우고 패널은 열어 둔다 — 찾지 않고 초점은 그 단추에, 걸린 조건(주소 · [조건])은 그대로', async () => {
|
||||
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&subject=${encodeURIComponent('견적')}&since=2026-10-01&until=2026-11-05&attachment=1`
|
||||
const { wrapper, router, input } = await mountAt(path)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
await openPanel(wrapper)
|
||||
expect(textValues(wrapper)).toEqual(['이도윤', '', '견적', ''])
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('custom')
|
||||
|
||||
const clear = wrapper.get<HTMLButtonElement>('[data-search-conditions-clear]')
|
||||
clear.element.focus()
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(textValues(wrapper)).toEqual(['', '', '', ''])
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
|
||||
expect(wrapper.find('[data-search-condition="since"]').exists()).toBe(false)
|
||||
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(false)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
expect(document.activeElement).toBe(clear.element)
|
||||
// 찾지 않는다 — 주소 · 검색 칸 · [조건]의 "조건이 걸려 있음"은 그대로
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(input.element.value).toBe('회의')
|
||||
expect(wrapper.get('[data-search-conditions-toggle]').text()).toContain('조건이 걸려 있음')
|
||||
// 다시 눌러도 그대로다 (비운 칸)
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
expect(textValues(wrapper)).toEqual(['', '', '', ''])
|
||||
expect(document.activeElement).toBe(clear.element)
|
||||
})
|
||||
|
||||
it('칸 아래 알림(날짜가 거꾸로)도 지운다', async () => {
|
||||
const { wrapper } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
await field(wrapper, 'since').setValue('2026-11-05')
|
||||
await field(wrapper, 'until').setValue('2026-10-01')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('[찾기] 없이 닫으면 아무것도 바뀌지 않는다 — 다시 열면 걸린 조건으로 다시 채운다', async () => {
|
||||
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&attachment=1`
|
||||
const { wrapper, router } = await mountAt(path)
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
|
||||
await openPanel(wrapper)
|
||||
expect(textValues(wrapper)).toEqual(['이도윤', '', '', ''])
|
||||
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('비운 뒤 [찾기] — 검색어가 있으면 조건만 빼고 다시 찾고, 조건만 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
|
||||
await router.replace('/mail/search?in=inbox&attachment=1')
|
||||
await flushPromises()
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('검색어도 조건도 없는 [찾기]는 조건이 걸리지 않았으면 아무것도 하지 않는다 — 패널은 그대로', async () => {
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
await input.setValue('')
|
||||
await openPanel(wrapper)
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · Search14 · BulkSelect 머리 · README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
|
||||
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
|
||||
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
|
||||
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
|
||||
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
|
||||
import { ref, watch } from 'vue'
|
||||
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
|
||||
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
|
||||
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
|
||||
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailSearchConditions from '@/components/mail/MailSearchConditions.vue'
|
||||
import MailSearchConditionsToggle from '@/components/mail/MailSearchConditionsToggle.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { useSearchConditionsPopup } from '@/composables/useSearchConditionsPopup'
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-search'
|
||||
|
||||
const PANEL_ID = 'mail-search-conditions'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const text = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
const toggleRef = ref<InstanceType<typeof MailSearchConditionsToggle> | null>(null)
|
||||
const panelRef = ref<InstanceType<typeof MailSearchConditions> | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: conditionsOpen,
|
||||
close: closeConditions,
|
||||
toggle: toggleConditions,
|
||||
onFocusOut,
|
||||
} = useSearchConditionsPopup({
|
||||
container: formRef,
|
||||
toggle: toggleRef,
|
||||
onOpened: () => panelRef.value?.focusFirst(),
|
||||
})
|
||||
|
||||
/** 지금 걸린 조건 — 검색 화면의 주소에서 */
|
||||
const appliedConditions = computed<SearchConditions | null>(() => routeSearch.value?.conditions ?? null)
|
||||
const conditionsApplied = computed(() => hasSearchConditions(appliedConditions.value))
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void startSearch(text.value)
|
||||
// 패널이 열린 동안은 [찾기]와 같다 — 패널의 조건과 함께
|
||||
if (conditionsOpen.value) void panelRef.value?.apply()
|
||||
else void startSearch(text.value)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
if (conditionsOpen.value) {
|
||||
// 패널만 닫는다 — 치던 검색 칸에 초점을 둔다
|
||||
event.preventDefault()
|
||||
closeConditions({ restoreFocus: false })
|
||||
return
|
||||
}
|
||||
if (isSearching.value) {
|
||||
event.preventDefault()
|
||||
void closeSearch()
|
||||
@@ -32,12 +69,23 @@ function onKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면(조건을 다 비워 검색을 닫았어도) 패널을 닫고 검색 칸으로
|
||||
* (낱말이 너무 많으면 알림만 — 패널은 그대로)
|
||||
*/
|
||||
async function onApply(conditions: SearchConditions) {
|
||||
if (!(await startSearch(text.value, conditions))) return
|
||||
closeConditions({ restoreFocus: false })
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
|
||||
watch(
|
||||
() => routeSearch.value?.q ?? null,
|
||||
() => (routeSearch.value ? routeSearch.value.q : null),
|
||||
(query, previous) => {
|
||||
if (query !== null) text.value = query
|
||||
else if (previous) text.value = ''
|
||||
else if (previous !== null && previous !== undefined) text.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -45,15 +93,18 @@ watch(
|
||||
|
||||
<template>
|
||||
<form
|
||||
ref="formRef"
|
||||
role="search"
|
||||
:class="['mail-search', { 'is-active': isSearching }]"
|
||||
@submit.prevent
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="mail-search__icon"
|
||||
/>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="text"
|
||||
type="search"
|
||||
class="mail-search__input"
|
||||
@@ -64,12 +115,20 @@ watch(
|
||||
enterkeyhint="search"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<MailSearchConditionsToggle
|
||||
ref="toggleRef"
|
||||
:expanded="conditionsOpen"
|
||||
:applied="conditionsApplied"
|
||||
:panel-id="PANEL_ID"
|
||||
@toggle="toggleConditions"
|
||||
/>
|
||||
<button
|
||||
v-if="isSearching"
|
||||
type="button"
|
||||
class="mail-search__clear"
|
||||
:aria-label="MAIL_SEARCH_TEXT.clear"
|
||||
:title="MAIL_SEARCH_TEXT.clearTitle"
|
||||
data-search-clear
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -78,11 +137,20 @@ watch(
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<MailSearchConditions
|
||||
v-if="conditionsOpen"
|
||||
ref="panelRef"
|
||||
:panel-id="PANEL_ID"
|
||||
:applied="appliedConditions"
|
||||
@apply="onApply"
|
||||
@close="closeConditions()"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-search {
|
||||
position: relative;
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
@@ -90,7 +158,7 @@ watch(
|
||||
gap: var(--space-8);
|
||||
height: var(--size-search-box);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-12);
|
||||
padding: 0 var(--space-4) 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
|
||||
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
|
||||
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
|
||||
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점), clear() 로 칸을 모두 비운다([조건 지우기]).
|
||||
// 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useSearchConditionsDraft } from '@/composables/useSearchConditionsDraft'
|
||||
import { MAIL_SEARCH_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { SearchConditions, SearchConditionTextField } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 칸 id 의 앞 — 패널의 id */
|
||||
idPrefix: string
|
||||
/** 지금 걸린 조건 — 처음 그릴 때 칸을 채운다 (없으면 빈 칸) */
|
||||
applied: SearchConditions | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { draft, periodProblem, fill, clear, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
|
||||
|
||||
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
|
||||
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
|
||||
{ field: 'to', label: SEARCH_CONDITIONS_TEXT.to, placeholder: SEARCH_CONDITIONS_TEXT.toPlaceholder },
|
||||
{ field: 'subject', label: SEARCH_CONDITIONS_TEXT.subject, placeholder: SEARCH_CONDITIONS_TEXT.subjectPlaceholder },
|
||||
{ field: 'body', label: SEARCH_CONDITIONS_TEXT.body, placeholder: SEARCH_CONDITIONS_TEXT.bodyPlaceholder },
|
||||
]
|
||||
|
||||
const fieldId = (name: string) => `${props.idPrefix}-${name}`
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const periodRef = ref<InstanceType<typeof MailSearchPeriodField> | null>(null)
|
||||
|
||||
function onTextKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
/** 넘길 조건 — 기간이 잘못됐으면 null 이고, 칸 아래에 알린 뒤 그 날짜 칸으로 */
|
||||
async function takeConditions(): Promise<SearchConditions | null> {
|
||||
const conditions = take()
|
||||
if (conditions) return conditions
|
||||
await nextTick()
|
||||
periodRef.value?.focusInvalid()
|
||||
return null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
take: takeConditions,
|
||||
/** 칸을 모두 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림도 (찾지 않는다) */
|
||||
clear,
|
||||
/** 첫 칸(보낸 사람)으로 */
|
||||
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
|
||||
})
|
||||
|
||||
onMounted(() => fill(props.applied))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="condition-fields"
|
||||
>
|
||||
<template
|
||||
v-for="item in TEXT_FIELDS"
|
||||
:key="item.field"
|
||||
>
|
||||
<label
|
||||
:for="fieldId(item.field)"
|
||||
class="condition-fields__label"
|
||||
>{{ item.label }}</label>
|
||||
<input
|
||||
:id="fieldId(item.field)"
|
||||
v-model="draft[item.field]"
|
||||
class="form-input condition-fields__control"
|
||||
type="text"
|
||||
:maxlength="MAIL_SEARCH_MAX_LENGTH"
|
||||
:placeholder="item.placeholder"
|
||||
autocomplete="off"
|
||||
:data-search-condition="item.field"
|
||||
@keydown="onTextKeydown"
|
||||
>
|
||||
</template>
|
||||
|
||||
<label
|
||||
:for="fieldId('period')"
|
||||
class="condition-fields__label condition-fields__label--top"
|
||||
>{{ SEARCH_CONDITIONS_TEXT.period }}</label>
|
||||
<MailSearchPeriodField
|
||||
ref="periodRef"
|
||||
:select-id="fieldId('period')"
|
||||
:period="draft.period"
|
||||
:since="draft.since"
|
||||
:until="draft.until"
|
||||
:problem="periodProblem"
|
||||
@period="changePeriod"
|
||||
@day="changeDay"
|
||||
/>
|
||||
|
||||
<span class="condition-fields__label">{{ SEARCH_CONDITIONS_TEXT.attachment }}</span>
|
||||
<label class="condition-fields__check">
|
||||
<input
|
||||
v-model="draft.hasAttachment"
|
||||
type="checkbox"
|
||||
data-search-condition="attachment"
|
||||
>
|
||||
<span>{{ SEARCH_CONDITIONS_TEXT.attachmentOnly }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 이름 76 · 칸 (디자인 원본 Search14) */
|
||||
.condition-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 4.75rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-10) var(--space-12);
|
||||
}
|
||||
|
||||
.condition-fields__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 기간 — 칸 아래에 날짜 · 안내가 더해져도 이름은 첫 줄(기간 고르기)에 맞춘다 */
|
||||
.condition-fields__label--top {
|
||||
align-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-filter-control);
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 (필터 조건 칸과 같다) */
|
||||
.condition-fields__control {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.condition-fields__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-filter-control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.condition-fields__check input {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
// 조건이 걸린 검색 결과의 조건 줄 (디자인 원본 Search14 "조건으로 찾음") — 찾을 곳 줄 아래 한 줄(최소 40)
|
||||
// "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기](36 — 안내 줄 단추의 밀도 예외 --size-notice-action).
|
||||
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(하루면 "… 하루"),
|
||||
// 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (mail-search.ts)
|
||||
// [조건 지우기]는 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { SEARCH_CONDITION_LINE_TEXT } from '@/shared/constants/mail-search'
|
||||
import { searchConditionsSummary, type SearchConditions } from '@/shared/utils/mail-search'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
interface Props {
|
||||
conditions: SearchConditions
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const clock = useClock()
|
||||
const authStore = useAuthStore()
|
||||
const { clearConditions } = useMailSearchNavigation()
|
||||
|
||||
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now()), authStore.user))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="search-condition-line"
|
||||
data-search-condition-line
|
||||
>
|
||||
<span class="search-condition-line__text">{{ summary }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="search-condition-line__clear"
|
||||
data-search-condition-line-clear
|
||||
@click="clearConditions"
|
||||
>
|
||||
{{ SEARCH_CONDITION_LINE_TEXT.clear }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-condition-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
min-height: var(--size-row-min);
|
||||
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.search-condition-line__text {
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 36 — 안내 줄 단추의 밀도 예외 (tokens.css) */
|
||||
.search-condition-line__clear {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-condition-line__clear:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
// 「조건으로 찾기」 패널 (디자인 원본 Search14 "조건 열림" · README 「14단계 › 검색 · 조건 검색 · 강조」) — 검색 칸 바로 아래, 검색 칸 폭.
|
||||
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
|
||||
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
|
||||
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
|
||||
// - [조건 지우기]: 패널의 칸만 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림까지. 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다.
|
||||
// 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
|
||||
// 걸린 조건을 빼는 일은 결과의 조건 줄 [조건 지우기](MailSearchConditionLine)가 한다 (2026-11-07 사용자 관점 점검)
|
||||
// - Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { SearchConditions } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 패널의 id — [조건] 단추가 aria-controls 로 가리킨다 */
|
||||
panelId: string
|
||||
/** 지금 걸린 조건 — 열 때 칸을 채운다 (없으면 빈 칸) */
|
||||
applied: SearchConditions | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'apply', conditions: SearchConditions): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const titleId = computed(() => `${props.panelId}-title`)
|
||||
const fieldsRef = ref<InstanceType<typeof MailSearchConditionFields> | null>(null)
|
||||
|
||||
/** [찾기] — 기간이 잘못됐으면 넘기지 않는다 (칸이 알린다) */
|
||||
async function apply() {
|
||||
const conditions = await fieldsRef.value?.take()
|
||||
if (conditions) emit('apply', conditions)
|
||||
}
|
||||
|
||||
/** [조건 지우기] — 칸만 비운다 (머리말 — 찾지 않고, 패널과 초점은 그대로) */
|
||||
function clearFields() {
|
||||
fieldsRef.value?.clear()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
apply,
|
||||
/** 연 뒤 첫 칸(보낸 사람)으로 */
|
||||
focusFirst: () => fieldsRef.value?.focusFirst(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="panelId"
|
||||
class="search-conditions"
|
||||
role="group"
|
||||
:aria-labelledby="titleId"
|
||||
data-search-conditions
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<h2
|
||||
:id="titleId"
|
||||
class="search-conditions__title"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.title }}
|
||||
</h2>
|
||||
<MailSearchConditionFields
|
||||
ref="fieldsRef"
|
||||
:id-prefix="panelId"
|
||||
:applied="applied"
|
||||
@submit="apply"
|
||||
/>
|
||||
<p class="search-conditions__hint">
|
||||
{{ SEARCH_CONDITIONS_TEXT.hint }}
|
||||
</p>
|
||||
<div class="search-conditions__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="search-conditions__button"
|
||||
data-search-conditions-clear
|
||||
@click="clearFields"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.clear }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-conditions__button search-conditions__button--primary"
|
||||
data-search-conditions-apply
|
||||
@click="apply"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.apply }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 검색 칸 바로 아래 · 검색 칸 폭 — 위 16 · 좌우 18 · 아래 14, 칸 사이 14 (디자인 원본 Search14) */
|
||||
.search-conditions {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-6));
|
||||
left: calc(var(--border-width) * -2);
|
||||
right: calc(var(--border-width) * -2);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-16) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-conditions__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-conditions__hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-conditions__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 단추 높이 40 — 칸과 같은 밀도 예외 */
|
||||
.search-conditions__button {
|
||||
min-height: var(--size-filter-control);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-conditions__button:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.search-conditions__button--primary {
|
||||
padding: 0 var(--space-18);
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.search-conditions__button--primary:hover {
|
||||
border-color: var(--color-brand-strong);
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 칸 안 오른쪽(✕ 앞)의 [조건] (14단계 — 디자인 원본 Search14): 높이 32 · 밀도 예외(--size-compact-control — 새 토큰 없이 다시 쓴다).
|
||||
// 「조건으로 찾기」 패널을 열고 닫는다(aria-expanded · 열린 동안만 aria-controls). 패널이 열렸거나 조건이 걸려 있으면 청록 바탕이고,
|
||||
// 걸려 있으면 화면 낭독기에 ", 조건이 걸려 있음"을 더한다
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 패널이 열렸다 */
|
||||
expanded: boolean
|
||||
/** 조건이 걸려 있다 */
|
||||
applied: boolean
|
||||
/** 패널의 id */
|
||||
panelId: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
defineExpose({
|
||||
focus: () => buttonRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:class="['conditions-toggle', { 'is-on': expanded || applied }]"
|
||||
:title="SEARCH_CONDITIONS_TEXT.toggleTitle"
|
||||
:aria-expanded="expanded"
|
||||
:aria-controls="expanded ? panelId : undefined"
|
||||
data-search-conditions-toggle
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<AppIcon
|
||||
name="sliders"
|
||||
:size="14"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ SEARCH_CONDITIONS_TEXT.toggle }}</span>
|
||||
<span
|
||||
v-if="applied"
|
||||
class="visually-hidden"
|
||||
>, {{ SEARCH_CONDITIONS_TEXT.toggleApplied }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 높이 32 · 밀도 예외(--size-compact-control), 패널이 열렸거나 조건이 걸리면 청록 바탕 (디자인 원본 Search14) */
|
||||
.conditions-toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
height: var(--size-compact-control);
|
||||
padding: 0 var(--space-9);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conditions-toggle:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.conditions-toggle.is-on {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
|
||||
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
|
||||
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
|
||||
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
|
||||
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
// 검색어와 조건의 낱말이 한도를 넘는 주소(손으로 고친 주소 — 14단계)면 찾지 않았다 — "찾을 말이 너무 많습니다"와 줄이는 방법 (tooManyWords)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_SEARCH_MAX_WORDS, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 검색어 — 조건만으로 찾았으면 빈 글 */
|
||||
query: string
|
||||
/** 걸린 조건이 있다 — 없으면 조건을 줄이라고 하지 않는다 */
|
||||
hasConditions?: boolean
|
||||
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
|
||||
offerAll: boolean
|
||||
/** 낱말 단위로 찾는 동안(지난 메일 색인을 준비하는 중)이다 — 그 한계를 알린다 */
|
||||
wordLimit?: boolean
|
||||
/** 검색어와 조건의 낱말이 한도를 넘어 찾지 않았다 */
|
||||
tooManyWords?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'search-all'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hasConditions: false,
|
||||
wordLimit: false,
|
||||
tooManyWords: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() =>
|
||||
props.tooManyWords ? SEARCH_NOTICE_TEXT.tooManyWordsTitle : MAIL_SEARCH_TEXT.emptyTitle(props.query),
|
||||
)
|
||||
const hint = computed(() => {
|
||||
if (props.tooManyWords) return SEARCH_NOTICE_TEXT.tooManyWordsHint(MAIL_SEARCH_MAX_WORDS)
|
||||
if (!props.query) return MAIL_SEARCH_TEXT.emptyConditionsHint
|
||||
return props.hasConditions ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyWordsHint
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-empty"
|
||||
icon="search"
|
||||
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
|
||||
:hint="MAIL_SEARCH_TEXT.emptyHint"
|
||||
:icon="tooManyWords ? 'alert-circle' : 'search'"
|
||||
:title="title"
|
||||
:hint="hint"
|
||||
:data-search-too-many-words="tooManyWords ? '' : undefined"
|
||||
>
|
||||
<p
|
||||
v-if="wordLimit"
|
||||
class="search-empty__limit"
|
||||
data-search-word-limit
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.wordLimitHint }}
|
||||
</p>
|
||||
<button
|
||||
v-if="offerAll"
|
||||
type="button"
|
||||
@@ -41,9 +76,18 @@ const emit = defineEmits<Emits>()
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
|
||||
.search-empty :deep(.state-message__hint) {
|
||||
/* 도움말은 좁게 두 줄로 (디자인 원본 300px) — 한국어는 낱말 가운데에서 줄을 바꾸지 않는다(keep-all · 긴 영문은 anywhere) */
|
||||
.search-empty :deep(.state-message__hint),
|
||||
.search-empty__limit {
|
||||
max-width: 18.75rem;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search-empty__limit {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-empty__all {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
|
||||
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
|
||||
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
|
||||
// 통 수는 서버의 total 이다 — null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 수를 보이지 않는다. 조건만으로 찾았으면 검색어 없이 수만
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -26,12 +27,12 @@ const mailStore = useMailStore()
|
||||
const { originMailbox, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const countText = computed(() => {
|
||||
const query = mailStore.search?.q
|
||||
if (!query) return ''
|
||||
const search = mailStore.search
|
||||
if (!search) return ''
|
||||
const query = search.q
|
||||
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 total = mailStore.listStatus === 'ready' ? (mailStore.pageInfo?.total ?? null) : null
|
||||
return total === null ? MAIL_SEARCH_TEXT.quoted(query) : MAIL_SEARCH_TEXT.count(query, formatCount(total))
|
||||
})
|
||||
|
||||
const closeTitle = computed(() => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
// 이어 찾을 것이 남았는데 아직 하나도 찾지 못한 검색 (14단계 — 계약 2절 "검색 — 14단계" › hasNext 인데 빈 쪽)
|
||||
// 서버는 한 요청에 확인하는 메일 수에 상한이 있다 — 목록 스토어가 정한 횟수(MAIL_SEARCH_FILL_MAX_REQUESTS)까지 이어 묻고도 못 찾았으면
|
||||
// 결과 없음 대신 이 안내와 [더 찾기]를 보인다. 누르면 같은 횟수만큼 더 이어 묻는다(목록 스토어의 loadMore). 찾는 동안 단추는 aria-busy
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'more'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onMore() {
|
||||
if (!props.busy) emit('more')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-more"
|
||||
icon="search"
|
||||
:title="SEARCH_NOTICE_TEXT.partialTitle"
|
||||
:hint="SEARCH_NOTICE_TEXT.partialHint"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="search-more__button"
|
||||
:aria-busy="busy"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
data-search-more
|
||||
@click="onMore"
|
||||
>
|
||||
{{ busy ? SEARCH_NOTICE_TEXT.moreBusy : SEARCH_NOTICE_TEXT.more }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-more {
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한국어는 낱말 가운데에서 줄을 바꾸지 않는다(keep-all · 긴 영문은 anywhere) */
|
||||
.search-more :deep(.state-message__hint) {
|
||||
max-width: 18.75rem;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search-more__button {
|
||||
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-more__button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.search-more__button[aria-disabled='true'] {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 머리 아래 안내 줄 (디자인 원본 Search14 "검색 준비 중" — 옅은 청록 바탕 · 12.5px · info 아이콘) — 14단계
|
||||
// - 검색 준비 중: 지난 메일의 색인을 만드는 동안(GET /search-index 의 building) — 그동안의 검색은 낱말이 통째로 같을 때만 맞는다
|
||||
// - 한 글자 안내: 낱말이 모두 한 글자라 본문은 찾지 않았을 때(색인 방식 — 서버와 같은 낱말 나누기, search-match.ts 의 isSingleCharSearch)
|
||||
// 둘을 감싼 칸이 role="status" 라 줄이 생기면 화면 낭독기가 읽는다 (감싼 칸은 검색 화면이면 늘 있다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
indexing: boolean
|
||||
singleChar: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="search-notices"
|
||||
role="status"
|
||||
>
|
||||
<p
|
||||
v-if="indexing"
|
||||
class="search-notices__line"
|
||||
data-search-notice="indexing"
|
||||
>
|
||||
<AppIcon
|
||||
name="info"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
class="search-notices__icon"
|
||||
/>
|
||||
<span>{{ SEARCH_NOTICE_TEXT.indexing }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="singleChar"
|
||||
class="search-notices__line"
|
||||
data-search-notice="single-char"
|
||||
>
|
||||
<AppIcon
|
||||
name="info"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
class="search-notices__icon"
|
||||
/>
|
||||
<span>{{ SEARCH_NOTICE_TEXT.singleChar }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 옅은 청록 바탕 · 아래 선 #E3EEEF · 위아래 10 · 좌우 16 (디자인 원본 Search14) */
|
||||
.search-notices__line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-brand-tint-border);
|
||||
background: var(--color-brand-tint);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
/* 한국어는 낱말 가운데에서 줄을 바꾸지 않는다 */
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search-notices__icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
// 「조건으로 찾기」 패널의 기간 칸 (14단계 — 디자인 원본 Search14): 기간 고르기(전체 기간 · 최근 1주 · 1달 · 3달 · 1년 · 날짜 고르기)와
|
||||
// 날짜 고르기의 시작 ~ 끝. 날짜 칸은 고를 수 있는 날짜(SEARCH_DAY_MIN ~ SEARCH_DAY_MAX)와 서로의 날짜로 범위를 둔다 —
|
||||
// 손으로 친 날짜는 [찾기] 때 다시 본다(useSearchConditionsDraft 의 take).
|
||||
// 잘못(problem)은 칸 아래에 알리고(role=alert) 그 날짜 칸을 aria-invalid 로 — 넘기지 못했으면 부르는 쪽이 focusInvalid() 로 그 칸에 초점을 둔다.
|
||||
// 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { SearchPeriodProblem } from '@/composables/useSearchConditionsDraft'
|
||||
import {
|
||||
SEARCH_CONDITIONS_TEXT,
|
||||
SEARCH_DAY_MAX,
|
||||
SEARCH_DAY_MIN,
|
||||
SEARCH_PERIOD_LABELS,
|
||||
SEARCH_PERIOD_ORDER,
|
||||
type SearchPeriod,
|
||||
} from '@/shared/constants/mail-search'
|
||||
import { isSearchDay } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 기간 고르기의 id — 부르는 쪽의 이름(label for)이 가리킨다 */
|
||||
selectId: string
|
||||
period: SearchPeriod
|
||||
/** 날짜 고르기의 시작 · 끝 (YYYY-MM-DD, 비면 '') */
|
||||
since: string
|
||||
until: string
|
||||
/** 알릴 잘못 — 없으면 null */
|
||||
problem: SearchPeriodProblem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'period', period: SearchPeriod): void
|
||||
(e: 'day', edge: 'since' | 'until', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const sinceRef = ref<HTMLInputElement | null>(null)
|
||||
const untilRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const periodModel = computed<SearchPeriod>({
|
||||
get: () => props.period,
|
||||
set: (value) => emit('period', value),
|
||||
})
|
||||
const sinceModel = computed<string>({
|
||||
get: () => props.since,
|
||||
set: (value) => emit('day', 'since', value),
|
||||
})
|
||||
const untilModel = computed<string>({
|
||||
get: () => props.until,
|
||||
set: (value) => emit('day', 'until', value),
|
||||
})
|
||||
|
||||
const errorId = computed(() => `${props.selectId}-error`)
|
||||
const errorText = computed(() =>
|
||||
props.problem === 'range' ? SEARCH_CONDITIONS_TEXT.periodRange : SEARCH_CONDITIONS_TEXT.periodOrder,
|
||||
)
|
||||
|
||||
/** 고를 수 없는 날짜 — 비었으면 아니다 */
|
||||
const outOfRange = (value: string) => value !== '' && !isSearchDay(value)
|
||||
/** 순서가 거꾸로면 두 칸 모두, 고를 수 없는 날짜면 그 칸만 */
|
||||
const sinceInvalid = computed(
|
||||
() => props.problem === 'order' || (props.problem === 'range' && outOfRange(props.since)),
|
||||
)
|
||||
const untilInvalid = computed(
|
||||
() => props.problem === 'order' || (props.problem === 'range' && outOfRange(props.until)),
|
||||
)
|
||||
/** 날짜 칸의 범위 — 고를 수 있는 날짜 안에서 시작은 끝을, 끝은 시작을 넘지 않게 */
|
||||
const sinceMax = computed(() => (isSearchDay(props.until) ? props.until : SEARCH_DAY_MAX))
|
||||
const untilMin = computed(() => (isSearchDay(props.since) ? props.since : SEARCH_DAY_MIN))
|
||||
|
||||
defineExpose({
|
||||
/** 넘기지 못한 까닭의 칸으로 — 고를 수 없는 시작 날짜면 시작, 그 밖은 끝 날짜 */
|
||||
focusInvalid: () => {
|
||||
if (props.problem === 'range' && outOfRange(props.since)) sinceRef.value?.focus()
|
||||
else untilRef.value?.focus()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="period-field">
|
||||
<div class="period-field__row">
|
||||
<select
|
||||
:id="selectId"
|
||||
v-model="periodModel"
|
||||
class="form-input period-field__control period-field__select"
|
||||
data-search-condition="period"
|
||||
>
|
||||
<option
|
||||
v-for="option in SEARCH_PERIOD_ORDER"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ SEARCH_PERIOD_LABELS[option] }}
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
v-if="period === 'custom'"
|
||||
class="period-field__dates"
|
||||
>
|
||||
<input
|
||||
ref="sinceRef"
|
||||
v-model="sinceModel"
|
||||
class="form-input period-field__control"
|
||||
type="date"
|
||||
:min="SEARCH_DAY_MIN"
|
||||
:max="sinceMax"
|
||||
:aria-label="SEARCH_CONDITIONS_TEXT.since"
|
||||
:aria-invalid="sinceInvalid ? 'true' : 'false'"
|
||||
:aria-describedby="sinceInvalid ? errorId : undefined"
|
||||
data-search-condition="since"
|
||||
>
|
||||
<span
|
||||
class="period-field__tilde"
|
||||
aria-hidden="true"
|
||||
>~</span>
|
||||
<input
|
||||
ref="untilRef"
|
||||
v-model="untilModel"
|
||||
class="form-input period-field__control"
|
||||
type="date"
|
||||
:min="untilMin"
|
||||
:max="SEARCH_DAY_MAX"
|
||||
:aria-label="SEARCH_CONDITIONS_TEXT.until"
|
||||
:aria-invalid="untilInvalid ? 'true' : 'false'"
|
||||
:aria-describedby="untilInvalid ? errorId : undefined"
|
||||
data-search-condition="until"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="problem"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
role="alert"
|
||||
:data-search-period-problem="problem"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ errorText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.period-field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.period-field__row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 (필터 조건 칸과 같다) */
|
||||
.period-field__control {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.period-field__select {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
|
||||
.period-field__dates {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.period-field__tilde {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -164,7 +164,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
expect(store.checkedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 그 메일함 이름으로 알린다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m3')
|
||||
@@ -186,6 +186,31 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
|
||||
// 한 메일함으로 되돌렸다 — 어디로 되돌렸는지 말한다 (2026-11-06 실사용 시험)
|
||||
expect(toastTexts()).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('여러 메일함으로 되돌렸으면 메일함 이름 없이 "메일 N통을 되돌렸습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
messages: [summary('m1'), summary('m2', { mailboxIds: ['sent'] }), summary('m3')],
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
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()
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches().slice(1)).toEqual([
|
||||
{ ids: ['m1'], mailboxId: 'inbox' },
|
||||
{ ids: ['m2'], mailboxId: 'sent' },
|
||||
])
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
@@ -398,7 +423,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
expect(toastTexts()).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
|
||||
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
|
||||
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
|
||||
// ("휴지통에 있는 메일 N통을 영구 삭제할까요?" — 줄의 통 수는 대화 전체라서, useBulkMailActions 의 conversationFrom).
|
||||
// 분할 보기는 보이는 글만 "N개 선택됨"으로 줄이고 화면 낭독은 "대화 N개 선택됨"을 읽는다 — 목록 칸(384)에서 단추 일곱(280)과 한 줄에 들게
|
||||
// (2026-11-06 실사용 시험: "대화 1개 선택됨" 88px 이면 도구가 두 줄 · 97px 로 넘쳤다. "N개 선택됨"은 세 자리까지 79px — 1440 의 자리 83px)
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
@@ -16,6 +20,7 @@ import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -70,7 +75,16 @@ const rovingTool = computed<ToolId>(() => {
|
||||
|
||||
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const countText = computed(() => {
|
||||
const count = formatCount(mailStore.checkedCount)
|
||||
return mailStore.isConversationList ? MAIL_CONVERSATION_LIST_TEXT.selected(count) : MAIL_BULK_TEXT.selected(count)
|
||||
})
|
||||
/** 분할 보기에서 보이는 고른 수 — 대화 목록만 줄인다("1개 선택됨"). 줄이지 않으면 null (화면 낭독은 늘 countText) */
|
||||
const shortCountText = computed(() =>
|
||||
props.variant === 'split' && mailStore.isConversationList
|
||||
? MAIL_CONVERSATION_LIST_TEXT.selectedShort(formatCount(mailStore.checkedCount))
|
||||
: null,
|
||||
)
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
@@ -162,7 +176,13 @@ watch(disabledTools, (disabled) => {
|
||||
:id="titleId"
|
||||
class="bulk-bar__count"
|
||||
>
|
||||
{{ countText }}
|
||||
<template v-if="shortCountText">
|
||||
<span aria-hidden="true">{{ shortCountText }}</span>
|
||||
<span class="visually-hidden">{{ countText }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ countText }}
|
||||
</template>
|
||||
</h1>
|
||||
<div
|
||||
ref="toolsRef"
|
||||
@@ -231,7 +251,8 @@ watch(disabledTools, (disabled) => {
|
||||
</div>
|
||||
<MailPurgeDialog
|
||||
:open="purgeOpen"
|
||||
:count="mailStore.checkedCount"
|
||||
:count="mailStore.checkedActionIds.length"
|
||||
:from="bulk.conversationFrom.value"
|
||||
id-prefix="bulk-purge"
|
||||
@confirm="onPurgeConfirm"
|
||||
@cancel="purgeOpen = false"
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
// 대용량 첨부(11단계 — MessageDetail.largeFiles)도 같은 칸에 첨부처럼 — 「첨부 파일 N개」는 둘을 합한 수 (MessageLargeFileItem).
|
||||
// 보낸 사람 자신(링크 주인)에게는 칸 아래에 링크 줄 — 받은 수 · [링크 복사] · [링크 끊기] (MessageLargeFileLinkBar — 끝난 링크는 없다)
|
||||
// 보안 검사 중인 대용량 첨부가 있는 동안은 상태를 가끔 다시 묻고, 막힌 [다운로드]를 누르면 한 번 바로 묻는다 (useLargeFileScanRefresh)
|
||||
// 검색 결과에서 연 메일(14단계)은 첨부 이름의 맞은 말을 <mark> 로 (nameHighlights — 첨부 id → 맞은 곳, 맞은 첨부만)
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MessageLargeFileItem from '@/components/mail/MessageLargeFileItem.vue'
|
||||
import MessageLargeFileLinkBar from '@/components/mail/MessageLargeFileLinkBar.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useLargeFileScanRefresh } from '@/composables/useLargeFileScanRefresh'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { MessageLargeFile } from '@/shared/types/large-file'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import type { Attachment, SearchRange } from '@/shared/types/mail'
|
||||
import { formatFileSize } from '@/shared/utils/format'
|
||||
import { isEndedLinkStatus } from '@/shared/utils/large-file-status'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -25,10 +27,13 @@ interface Props {
|
||||
attachments: Attachment[]
|
||||
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 온다 */
|
||||
largeFiles?: MessageLargeFile[]
|
||||
/** 첨부 이름의 검색에서 맞은 곳 — 서버의 searchHighlight.attachments (14단계) */
|
||||
nameHighlights?: Readonly<Record<string, readonly SearchRange[]>> | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
largeFiles: () => [],
|
||||
nameHighlights: null,
|
||||
})
|
||||
|
||||
const { attachmentDownloadUrl, downloadAttachment } = useMail()
|
||||
@@ -126,7 +131,12 @@ function onDownload(event: MouseEvent, attachmentId: string) {
|
||||
<AppIcon name="file" />
|
||||
</span>
|
||||
<div class="attachments__meta">
|
||||
<span class="attachments__name">{{ attachment.name }}</span>
|
||||
<span class="attachments__name">
|
||||
<HighlightedText
|
||||
:text="attachment.name"
|
||||
:ranges="nameHighlights?.[attachment.id] ?? null"
|
||||
/>
|
||||
</span>
|
||||
<span class="attachments__size">{{ formatFileSize(attachment.size) }}</span>
|
||||
</div>
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK, type Clock } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } 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 { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화" · 계약 2절 "대화 — 14단계")
|
||||
// 시각은 지역 시각으로 만든다 — 시간대(KST · UTC)와 상관없이 같은 글이 된다. 시간은 손으로 돌리는 시계로 넘긴다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
const MAILBOXES = [INBOX, SENT, DRAFTS]
|
||||
/** 2026년 11월 6일(금) 오후 3시 — 브라우저 시간대 */
|
||||
const NOW = new Date(2026, 10, 6, 15).getTime()
|
||||
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '회신: 유지보수 계약 갱신 건',
|
||||
preview: '네, 확인했습니다. 단가와 기간을 정리해서 보내 드리겠습니다.',
|
||||
receivedAt: at(2026, 9, 29, 9, 30),
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(base: MessageSummary, partial: Partial<MessageDetail> = {}): MessageDetail {
|
||||
return {
|
||||
...base,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: `${base.id} 본문`,
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
/** 내가 보낸 처음 메일 · 이도윤의 회신(안 읽음) · 지금 보는 메일 */
|
||||
const ORIGINAL = summary('m1', {
|
||||
mailboxIds: ['sent'],
|
||||
from: { name: '김아라', email: USER.email },
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
subject: '유지보수 계약 갱신 건',
|
||||
preview: '다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.',
|
||||
receivedAt: at(2026, 9, 27, 14, 10),
|
||||
})
|
||||
const REPLY = summary('m2', { isRead: false })
|
||||
const CURRENT = summary('m3', {
|
||||
subject: 'Re: 유지보수 계약 갱신 건',
|
||||
receivedAt: at(2026, 10, 6, 10, 42),
|
||||
hasAttachment: true,
|
||||
})
|
||||
const THREAD = [ORIGINAL, REPLY, CURRENT]
|
||||
|
||||
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
|
||||
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
interface Server {
|
||||
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** 읽음 · 중요 표시 — 한 통(PATCH /messages/{id}) · 여러 통(PATCH /messages) */
|
||||
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
clock = createManualClock(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function serve(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
|
||||
if (request.method === 'PATCH') return server.patch?.(request) ?? ok({ ...REPLY, isRead: true })
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url.startsWith('/messages/')) {
|
||||
if (server.detail) return server.detail(request)
|
||||
const id = request.url.split('/').pop() ?? ''
|
||||
return ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
|
||||
}
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
|
||||
async function mountReader(
|
||||
options: {
|
||||
path?: string
|
||||
source?: MailListSource
|
||||
current?: MessageSummary
|
||||
layout?: 'split' | 'list'
|
||||
/** 손으로 돌리는 시계 대신 줄 시계 (시계가 뒤로 가는 경우) */
|
||||
clock?: Clock
|
||||
} = {},
|
||||
) {
|
||||
const store = useMailStore()
|
||||
const current = options.current ?? CURRENT
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
store.messages = [REPLY, CURRENT]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = detail(current, {
|
||||
attachments: [{ id: 'a1', name: '유지보수_계약서_수정본.docx', type: 'application/msword', size: 52_000 }],
|
||||
})
|
||||
store.selectedId = current.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(options.path ?? `/mail/inbox/${current.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: options.clock ?? clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
|
||||
const section = (wrapper: VueWrapper) => wrapper.get('[data-conversation]')
|
||||
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
|
||||
|
||||
describe('칸 모양 (디자인 원본 Thread14)', () => {
|
||||
it('본문 · 첨부 아래 "같은 대화 3통" — 테두리 상자 안의 줄, 오래된 차례', async () => {
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(conversationRequests().map((request) => [request.url, request.params])).toEqual([
|
||||
['/messages/m3/conversation', { limit: MAIL_CONVERSATION_PAGE_SIZE }],
|
||||
])
|
||||
// 본문 · 첨부 다음이 칸 — 화면 낭독용 상태 영역은 늘 그 뒤에 있다(보이지 않는다)
|
||||
const content = wrapper.get('.reader__content')
|
||||
const order = [...content.element.children].map((child) => child.className.split(' ')[0])
|
||||
expect(order.slice(-4)).toEqual(['mail-body-frame', 'attachments', 'conversation', 'visually-hidden'])
|
||||
expect(content.element.lastElementChild?.getAttribute('role')).toBe('status')
|
||||
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
|
||||
expect(section(wrapper).get('h3').attributes('tabindex')).toBe('-1')
|
||||
expect(section(wrapper).attributes('aria-labelledby')).toBe(section(wrapper).get('h3').attributes('id'))
|
||||
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
'm3',
|
||||
])
|
||||
})
|
||||
|
||||
it('내가 보낸 메일은 "나" + 회색 "보낸편지함"(머리글자도 회색) · 다른 줄은 이름 · 미리 보기 한 줄 · 시각', async () => {
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
const mine = rowOf(wrapper, 'm1')
|
||||
expect(mine.get('.conversation-row__who').text()).toBe('나')
|
||||
expect(mine.get('.conversation-row__mailbox').text()).toBe('보낸편지함')
|
||||
expect(mine.get('.conversation-row__avatar').text()).toBe('김')
|
||||
expect(mine.get('.conversation-row__avatar').classes()).toContain('conversation-row__avatar--mine')
|
||||
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
|
||||
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
|
||||
|
||||
// 안 읽은 줄이다 — 화면 낭독용 "안 읽음, "이 이름 앞에 든다 (아래 "안 읽은 줄")
|
||||
const other = rowOf(wrapper, 'm2')
|
||||
expect(other.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
|
||||
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
|
||||
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
|
||||
// 강조 · 뱃지 없이 글로만
|
||||
expect(section(wrapper).findAll('mark')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('지금 보는 메일의 줄은 "지금 보고 있는 메일"(aria-current)이고 누를 수 없다 — 다른 줄은 접힌 단추(aria-expanded="false")', async () => {
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
const current = rowOf(wrapper, 'm3')
|
||||
expect(current.find('button').exists()).toBe(false)
|
||||
expect(current.get('[aria-current="true"]').text()).toContain('지금 보고 있는 메일')
|
||||
expect(current.get('time').text()).toBe('오늘 오전 10:42')
|
||||
for (const id of ['m1', 'm2']) {
|
||||
const button = rowOf(wrapper, id).get('button')
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
expect(button.attributes('aria-controls')).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('한 통뿐이면 칸이 없다', async () => {
|
||||
serve({ conversation: () => page([CURRENT]) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
expect(wrapper.find('[data-conversation]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('통 수를 모르면(남은 쪽이 있고 total null) 수 없이 "같은 대화" — 상자 아래 [더 보기]', async () => {
|
||||
const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`))
|
||||
serve({
|
||||
conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화')
|
||||
const more = wrapper.get('[data-conversation-more]')
|
||||
expect(more.text()).toBe('더 보기')
|
||||
|
||||
await more.trigger('click')
|
||||
expect(more.attributes('aria-busy')).toBe('true')
|
||||
expect(more.text()).toBe('불러오는 중…')
|
||||
await flushPromises()
|
||||
|
||||
expect(conversationRequests().at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE, cursor: 'c2' })
|
||||
expect(wrapper.find('[data-conversation-more]').exists()).toBe(false)
|
||||
expect(section(wrapper).get('h3').text()).toBe(`같은 대화 ${MAIL_CONVERSATION_PAGE_SIZE + 1}통`)
|
||||
})
|
||||
|
||||
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중"', async () => {
|
||||
const sendAt = new Date(2026, 10, 7, 9, 0).toISOString()
|
||||
serve({
|
||||
conversation: () =>
|
||||
page([
|
||||
...THREAD,
|
||||
summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } }),
|
||||
summary('s2', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'undo', sendAt } }),
|
||||
]),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(rowOf(wrapper, 's1').get('time').text()).toBe('예약 · 11월 7일 오전 9:00')
|
||||
expect(rowOf(wrapper, 's1').get('time').classes()).toContain('is-pending')
|
||||
expect(rowOf(wrapper, 's2').get('time').text()).toBe('보내는 중')
|
||||
expect(rowOf(wrapper, 's2').get('time').attributes('datetime')).toBe(sendAt)
|
||||
})
|
||||
})
|
||||
|
||||
describe('줄의 차례 · 미리 보기 (2026-11-06 실사용 시험)', () => {
|
||||
it('보내기를 기다리는 메일은 받은 줄의 맨 끝 — 서버가 저장한 시각 차례로 주어도 그 뒤에 온 답장보다 앞에 서지 않는다', async () => {
|
||||
const sendAt = new Date(2026, 10, 6, 23, 38).toISOString()
|
||||
const scheduled = summary('s1', {
|
||||
mailboxIds: ['sent'],
|
||||
from: USER,
|
||||
receivedAt: at(2026, 9, 29, 10, 0),
|
||||
pendingSend: { kind: 'scheduled', sendAt },
|
||||
})
|
||||
serve({ conversation: () => page([ORIGINAL, scheduled, REPLY, CURRENT]) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
'm3',
|
||||
's1',
|
||||
])
|
||||
})
|
||||
|
||||
it('미리 보기에서 우리 원문 인용("> 원본 메일")부터 뒤는 뺀다 — 남는 글이 없으면 제목', async () => {
|
||||
serve({
|
||||
conversation: () =>
|
||||
page([
|
||||
{ ...ORIGINAL, preview: '도윤님, 금요일까지 정하겠습니다. > 원본 메일 > 보낸 사람: 이도윤' },
|
||||
{ ...REPLY, preview: '> 원본 메일 > 보낸 사람: 김아라 > 날짜: 2026. 10. 27.' },
|
||||
CURRENT,
|
||||
]),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(rowOf(wrapper, 'm1').get('.conversation-row__preview').text()).toBe('도윤님, 금요일까지 정하겠습니다.')
|
||||
expect(rowOf(wrapper, 'm2').get('.conversation-row__preview').text()).toBe(REPLY.subject)
|
||||
})
|
||||
})
|
||||
|
||||
describe('펼치기 · 접기', () => {
|
||||
it('다른 줄을 누르면 그 자리에서 펼친다 — 받는 사람 · 제목 · 본문(강조 쿼리 없이) · [답장] · [이 메일만 열기], 안 읽은 메일이면 읽음으로', async () => {
|
||||
serve()
|
||||
const { wrapper, store } = await mountReader()
|
||||
const button = rowOf(wrapper, 'm2').get('button')
|
||||
|
||||
await button.trigger('click')
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
const panelId = button.attributes('aria-controls')
|
||||
expect(panelId).toBe('conversation-panel-m2')
|
||||
expect(rowOf(wrapper, 'm2').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
|
||||
await flushPromises()
|
||||
|
||||
const panel = wrapper.get(`#${panelId}`)
|
||||
expect(panel.findAll('.conversation-panel__meta').map((line) => line.text())).toEqual([
|
||||
'받는 사람 김아라 <ara.kim@arasystem.test>',
|
||||
'제목 회신: 유지보수 계약 갱신 건',
|
||||
])
|
||||
const frame = panel.get('iframe')
|
||||
expect(frame.attributes('srcdoc')).toContain('m2 본문')
|
||||
expect(frame.attributes('title')).toBe('메일 본문: 이도윤, 10월 29일 (목) 오전 9:30')
|
||||
expect(panel.get('[data-conversation-reply]').attributes('href')).toBe('/mail/compose?replyTo=m2')
|
||||
expect(panel.get('[data-conversation-reply]').text()).toBe('답장')
|
||||
expect(panel.get('[data-conversation-open]').attributes('href')).toBe('/mail/inbox/m2')
|
||||
expect(panel.get('[data-conversation-open]').text()).toBe('이 메일만 열기')
|
||||
|
||||
expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({})
|
||||
expect(requests.filter((request) => request.method === 'PATCH').map((request) => [request.url, request.data])).toEqual([
|
||||
['/messages/m2', { isRead: true }],
|
||||
])
|
||||
// 목록의 그 줄도 읽음으로 — 읽기 화면에서 연 것과 같은 길
|
||||
expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.find(`#${panelId}`).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('내가 보낸 메일의 [이 메일만 열기]는 그 메일이 든 보낸편지함으로 — 누르면 기록에 쌓아 옮긴다', async () => {
|
||||
serve()
|
||||
const { wrapper, router } = await mountReader()
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await rowOf(wrapper, 'm1').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
const open = rowOf(wrapper, 'm1').get('[data-conversation-open]')
|
||||
expect(open.attributes('href')).toBe('/mail/sent/m1')
|
||||
|
||||
await open.trigger('click')
|
||||
await flushPromises()
|
||||
expect(push).toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m1')
|
||||
})
|
||||
|
||||
it('임시 저장본 · 예약한 메일에는 [답장]이 없다 — 임시 저장본의 [이 메일만 열기]는 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const sendAt = new Date(2026, 10, 7, 9, 0).toISOString()
|
||||
const draft = summary('d1', { mailboxIds: ['drafts'], from: USER })
|
||||
const scheduled = summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } })
|
||||
serve({ conversation: () => page([...THREAD, draft, scheduled]) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(rowOf(wrapper, 'd1').get('.conversation-row__mailbox').text()).toBe('임시보관함')
|
||||
await rowOf(wrapper, 'd1').get('button').trigger('click')
|
||||
await rowOf(wrapper, 's1').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(rowOf(wrapper, 'd1').find('[data-conversation-reply]').exists()).toBe(false)
|
||||
expect(rowOf(wrapper, 'd1').get('[data-conversation-open]').attributes('href')).toBe('/mail/compose?draft=d1')
|
||||
expect(rowOf(wrapper, 's1').find('[data-conversation-reply]').exists()).toBe(false)
|
||||
expect(rowOf(wrapper, 's1').get('[data-conversation-open]').attributes('href')).toBe('/mail/sent/s1')
|
||||
})
|
||||
|
||||
it('첨부가 있으면 본문 아래 · [답장] 위에 "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." — 본문 그림은 세지 않는다', async () => {
|
||||
serve({
|
||||
detail: (request) => {
|
||||
const id = request.url.split('/').pop() ?? ''
|
||||
const base = THREAD.find((item) => item.id === id) ?? summary(id)
|
||||
if (id !== 'm2') return ok(detail(base))
|
||||
return ok(
|
||||
detail(base, {
|
||||
attachments: [
|
||||
{ id: 'a1', name: '견적_수정안.csv', type: 'text/csv', size: 44 },
|
||||
{ id: 'a2', name: '단가표.xlsx', type: 'application/vnd.ms-excel', size: 9_000 },
|
||||
{ id: 'i1', name: 'logo.png', type: 'image/png', size: 1_200, isInline: true },
|
||||
],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await rowOf(wrapper, 'm2').get('button').trigger('click')
|
||||
await rowOf(wrapper, 'm1').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const panel = rowOf(wrapper, 'm2').get('.conversation-panel')
|
||||
const note = panel.get('[data-conversation-attachments]')
|
||||
expect(note.text()).toBe('첨부 파일 2개는 [이 메일만 열기]를 누르면 볼 수 있습니다.')
|
||||
// 본문 아래 · [답장] 위
|
||||
const order = [...panel.element.children].map((child) => child.className.split(' ')[0])
|
||||
expect(order.indexOf('conversation-panel__attachments')).toBe(order.indexOf('mail-body-frame') + 1)
|
||||
expect(order.indexOf('conversation-panel__actions')).toBe(order.indexOf('conversation-panel__attachments') + 1)
|
||||
// 첨부가 없는 메일에는 없다
|
||||
expect(rowOf(wrapper, 'm1').find('[data-conversation-attachments]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('본문을 못 받으면 줄 안에 사전 문구 + [다시 시도] — 알리지 않는다', async () => {
|
||||
let failing = true
|
||||
serve({ detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await rowOf(wrapper, 'm2').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]')
|
||||
expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
// 읽지 못했으니 읽음으로 바꾸지 않는다
|
||||
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
|
||||
|
||||
failing = false
|
||||
await error.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('안 읽은 줄 (2026-11-07 사용자 관점 점검)', () => {
|
||||
it('안 읽은 줄은 목록 줄처럼 굵게(is-unread) — 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ", 읽은 줄 · 임시 저장본은 아니다', async () => {
|
||||
const draft = summary('d1', {
|
||||
mailboxIds: ['drafts'],
|
||||
from: { name: '김아라', email: USER.email },
|
||||
isRead: false,
|
||||
receivedAt: at(2026, 10, 6, 11, 0),
|
||||
})
|
||||
serve({ conversation: () => page([ORIGINAL, REPLY, CURRENT, draft]) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
const unread = rowOf(wrapper, 'm2')
|
||||
expect(unread.classes()).toContain('is-unread')
|
||||
expect(unread.get('.conversation-row__who .visually-hidden').element.textContent).toBe('안 읽음, ')
|
||||
// 펼침 단추의 이름에서 머리글자는 읽지 않으니(aria-hidden) "안 읽음, 이도윤 …"이 된다
|
||||
expect(unread.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
expect(unread.get('.conversation-row__avatar').attributes('aria-hidden')).toBe('true')
|
||||
for (const id of ['m1', 'm3', 'd1']) {
|
||||
expect(rowOf(wrapper, id).classes()).not.toContain('is-unread')
|
||||
expect(rowOf(wrapper, id).find('.conversation-row__who .visually-hidden').exists()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('펼쳐 읽음이 되면 바로 굵기를 푼다 — 읽음으로 바꾸지 못하면 다시 굵게', async () => {
|
||||
const reply = deferred()
|
||||
serve({ patch: () => reply.promise })
|
||||
const { wrapper } = await mountReader()
|
||||
const row = () => rowOf(wrapper, 'm2')
|
||||
|
||||
await row().get('button').trigger('click')
|
||||
await flushPromises()
|
||||
// 본문을 받았고 읽음을 보내는 중 — 줄은 이미 읽은 모양
|
||||
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.url)).toEqual([
|
||||
'/messages/m2',
|
||||
])
|
||||
expect(row().classes()).not.toContain('is-unread')
|
||||
expect(row().find('.conversation-row__who .visually-hidden').exists()).toBe(false)
|
||||
|
||||
reply.resolve(fail(503, 'MAIL_001'))
|
||||
await flushPromises()
|
||||
expect(row().classes()).toContain('is-unread')
|
||||
expect(row().get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
})
|
||||
|
||||
it('목록 · 여러 통 처리에서 바꾼 읽음 · 안 읽음도 줄에 바로 — 바꾸지 못한 메일은 줄도 되돌린다, 지금 보는 메일도 같다', async () => {
|
||||
const first = deferred()
|
||||
let firstBulk: Promise<MockResult> | null = first.promise
|
||||
serve({
|
||||
patch: (request) => {
|
||||
if (request.url !== '/messages') return ok({ ...REPLY, isRead: true })
|
||||
const reply = firstBulk ?? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
|
||||
firstBulk = null
|
||||
return reply
|
||||
},
|
||||
})
|
||||
const { wrapper, store } = await mountReader()
|
||||
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
|
||||
|
||||
// 목록에서 골라 읽음 표시 — 보내는 동안 줄도 읽은 모양, 서버가 바꾸지 못했으면 다시 굵게
|
||||
store.toggleCheck('m2')
|
||||
const marking = store.patchChecked({ isRead: true })
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
|
||||
first.resolve(ok({ updatedIds: [], failedIds: ['m2'] }))
|
||||
await marking
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
|
||||
|
||||
// 지금 보는 메일을 안 읽음으로 — 그 줄도 굵게 ("지금 보고 있는 메일" 줄)
|
||||
store.clearChecked()
|
||||
store.toggleCheck('m3')
|
||||
await store.patchChecked({ isRead: false })
|
||||
await flushPromises()
|
||||
const current = rowOf(wrapper, 'm3')
|
||||
expect(current.classes()).toContain('is-unread')
|
||||
expect(current.get('[aria-current="true"] .conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
})
|
||||
|
||||
it('다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다 — 목록을 맞춰도 줄은 그대로', async () => {
|
||||
let thread = THREAD
|
||||
serve({ conversation: () => page(thread) })
|
||||
const { wrapper, store } = await mountReader()
|
||||
|
||||
// 다른 탭에서 m2 를 읽었다 — 목록 맞추기가 서버의 값을 가져왔다 (이 탭이 바꾼 것이 아니다)
|
||||
thread = [ORIGINAL, { ...REPLY, isRead: true }, CURRENT]
|
||||
store.messages = [{ ...REPLY, isRead: true }, CURRENT]
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
|
||||
|
||||
// 다시 받으면(새 메일 · 지움 · 옮김으로 통 수가 바뀜 · 쓰기 화면에서 돌아옴) 보인다
|
||||
await store.refreshConversation()
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
|
||||
})
|
||||
})
|
||||
|
||||
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
|
||||
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
|
||||
let failing = true
|
||||
serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : page(THREAD)) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(wrapper.find('#reader-subject').exists()).toBe(true)
|
||||
const notice = wrapper.get('[data-conversation-failed]')
|
||||
expect(notice.text()).toContain('같은 대화를 불러오지 못했습니다.')
|
||||
expect(notice.text()).toContain('문의 코드 3f1c0a2e')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
// 화면 낭독용 상태 영역은 늘 있고 글만 바뀐다 — 안내 줄 자체에는 role 이 없다(두 번 읽지 않게)
|
||||
expect(notice.attributes('role')).toBeUndefined()
|
||||
const status = wrapper.get('[data-conversation-status]')
|
||||
expect(status.attributes('role')).toBe('status')
|
||||
expect(status.text()).toBe('같은 대화를 불러오지 못했습니다.')
|
||||
|
||||
failing = false
|
||||
await notice.get('[data-conversation-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false)
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
|
||||
expect(wrapper.get('[data-conversation-status]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('없는 메일(404 RES_001 — 이 경로가 없는 옛 서버 포함) · 모양이 틀린 응답이면 칸 없이', async () => {
|
||||
serve({ conversation: () => fail(404, 'RES_001') })
|
||||
const missing = await mountReader()
|
||||
expect(missing.wrapper.find('[data-conversation]').exists()).toBe(false)
|
||||
expect(missing.wrapper.find('[data-conversation-failed]').exists()).toBe(false)
|
||||
missing.wrapper.unmount()
|
||||
|
||||
setActivePinia((pinia = createPinia()))
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
serve({ conversation: () => ok({ usedBytes: 0 }) })
|
||||
const odd = await mountReader()
|
||||
expect(odd.wrapper.find('[data-conversation]').exists()).toBe(false)
|
||||
expect(odd.wrapper.find('[data-conversation-failed]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('보호 화면을 쓸 수 없는 세션(로그인 전 · 제한된 세션)에는 묻지 않는다', async () => {
|
||||
useAuthStore().clearAuth()
|
||||
serve()
|
||||
await mountReader()
|
||||
expect(conversationRequests()).toEqual([])
|
||||
})
|
||||
|
||||
it('제한된 세션(새 비밀번호를 정하기 전 — 13단계)이면 묻지 않고, 제한이 풀리면 그때 받는다', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
expect(auth.canUseApp).toBe(false)
|
||||
expect(conversationRequests()).toEqual([])
|
||||
|
||||
auth.markAuthenticated(USER)
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
|
||||
})
|
||||
})
|
||||
|
||||
describe('다시 받기 — 바뀜 소식은 모아서 · 돌아오면 바로', () => {
|
||||
it(`다시 받아 달라는 소식이 오면 다시 받는다 — 마지막으로 받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안이면 그때까지 모아 한 번`, async () => {
|
||||
let thread = THREAD
|
||||
serve({ conversation: () => page(thread) })
|
||||
const { wrapper, store } = await mountReader()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(1000)
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
thread = [...THREAD, summary('m4', { mailboxIds: ['sent'], from: USER, receivedAt: at(2026, 10, 6, 14, 0) })]
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1000)
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
expect(conversationRequests()[1]?.config.silent).toBe(true)
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화 4통')
|
||||
|
||||
// 간격이 지난 뒤의 소식은 바로
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('받아 둔 대화로 돌아오면(쓰기 화면에서 답장을 보내고) 보이는 채로 바로 다시 받는다', async () => {
|
||||
serve()
|
||||
const first = await mountReader()
|
||||
first.wrapper.unmount()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
const back = mount(MessageReader, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, first.router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(back)
|
||||
expect(back.find('[data-conversation]').exists()).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
expect(conversationRequests()[1]?.config.silent).toBe(true)
|
||||
})
|
||||
|
||||
it('메일을 바꾸면 그 메일로 새로 받고, 앞 메일의 늦은 응답은 버린다', async () => {
|
||||
const late = deferred()
|
||||
serve({
|
||||
conversation: ({ url }) =>
|
||||
url === '/messages/m3/conversation' ? late.promise : page([summary('x1'), summary('x2', { subject: '다른 대화' })]),
|
||||
})
|
||||
const { wrapper, store } = await mountReader()
|
||||
|
||||
store.selected = detail(summary('x2', { subject: '다른 대화' }))
|
||||
store.selectedId = 'x2'
|
||||
await flushPromises()
|
||||
late.resolve(page(THREAD))
|
||||
await flushPromises()
|
||||
|
||||
expect(conversationRequests().map((request) => request.url)).toEqual([
|
||||
'/messages/m3/conversation',
|
||||
'/messages/x2/conversation',
|
||||
])
|
||||
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual(['x1', 'x2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('검색 결과에서 연 메일 (/mail/search/:id)', () => {
|
||||
it('칸이 보이지만 강조가 없다 — 대화 · 펼친 본문 모두 검색 쿼리 없이 받는다', async () => {
|
||||
serve()
|
||||
const { wrapper, store } = await mountReader({
|
||||
path: `/mail/search/m3?q=${encodeURIComponent('계약')}&in=all`,
|
||||
source: { kind: 'search', q: '계약', scope: 'all' },
|
||||
})
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
|
||||
expect(conversationRequests()[0]?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
|
||||
|
||||
await rowOf(wrapper, 'm2').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({})
|
||||
expect(section(wrapper).findAll('mark')).toHaveLength(0)
|
||||
// 받아 둔 결과에 있는 메일은 검색 결과 안에서 연다
|
||||
expect(rowOf(wrapper, 'm2').get('[data-conversation-open]').attributes('href')).toBe(
|
||||
`/mail/search/m2?q=${encodeURIComponent('계약')}&in=all`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('초점 — 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면', () => {
|
||||
const focusOn = (element: Element) => (element as HTMLElement).focus()
|
||||
|
||||
it('칸의 [다시 시도] — 또 못 받으면 새로 그린 [다시 시도]로, 받으면(받는 동안은 기다렸다가) 칸 제목(h3)으로', async () => {
|
||||
let failing = true
|
||||
const retried = deferred()
|
||||
serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : retried.promise) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
focusOn(wrapper.get('[data-conversation-retry]').element)
|
||||
await wrapper.get('[data-conversation-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-conversation-retry]').element)
|
||||
|
||||
failing = false
|
||||
focusOn(wrapper.get('[data-conversation-retry]').element)
|
||||
await wrapper.get('[data-conversation-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
|
||||
retried.resolve(page(THREAD))
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
|
||||
})
|
||||
|
||||
it('[더 보기] — 받는 동안은 그 단추에 남고(aria-busy), 마지막 쪽을 받아 단추가 사라지면 칸 제목으로', async () => {
|
||||
const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`))
|
||||
serve({
|
||||
conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
const more = wrapper.get('[data-conversation-more]')
|
||||
|
||||
focusOn(more.element)
|
||||
await more.trigger('click')
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-conversation-more]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
|
||||
})
|
||||
|
||||
it('펼친 줄의 [다시 시도] — 본문을 받으면 그 줄의 펼침 단추로', async () => {
|
||||
let failing = true
|
||||
serve({
|
||||
detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
failing = false
|
||||
const retry = rowOf(wrapper, 'm2').get('[data-conversation-detail-error] button')
|
||||
focusOn(retry.element)
|
||||
await retry.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true)
|
||||
expect(document.activeElement).toBe(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element)
|
||||
})
|
||||
|
||||
it('다시 받아 초점이 있던 줄이 빠지면 칸 제목으로, 칸이 없어지면(한 통만 남음) 읽기 화면의 본문으로', async () => {
|
||||
let thread = THREAD
|
||||
serve({ conversation: () => page(thread) })
|
||||
const { wrapper, store } = await mountReader()
|
||||
async function refreshWith(next: MessageSummary[]) {
|
||||
thread = next
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
focusOn(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element)
|
||||
await refreshWith([ORIGINAL, CURRENT])
|
||||
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
|
||||
|
||||
focusOn(rowOf(wrapper, 'm1').get('[data-conversation-toggle]').element)
|
||||
await refreshWith([CURRENT])
|
||||
expect(wrapper.find('[data-conversation]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('.reader__content iframe').element)
|
||||
})
|
||||
|
||||
it('초점이 칸 밖에 있으면 다시 받아 줄이 빠져도 건드리지 않는다', async () => {
|
||||
let thread = THREAD
|
||||
serve({ conversation: () => page(thread) })
|
||||
const { wrapper, store } = await mountReader()
|
||||
const star = wrapper.get('.reader__star')
|
||||
focusOn(star.element)
|
||||
|
||||
thread = [ORIGINAL, CURRENT]
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(star.element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('다시 받기의 때 — 받는 중에 온 소식 · 시계가 뒤로 감', () => {
|
||||
it('처음 받는 중에 온 소식은 버리지 않는다 — 받기가 끝난 때부터 간격을 세어 한 번 더 받는다', async () => {
|
||||
const first = deferred()
|
||||
let calls = 0
|
||||
serve({
|
||||
conversation: () => {
|
||||
calls += 1
|
||||
return calls === 1 ? first.promise : page(THREAD)
|
||||
},
|
||||
})
|
||||
const { store } = await mountReader()
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 받는 동안 흐른 시간은 세지 않는다
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
first.resolve(page(THREAD))
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1)
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('이 PC 시계가 뒤로 가도 간격보다 오래 기다리지 않는다', async () => {
|
||||
let offset = 0
|
||||
const skewed: Clock = {
|
||||
now: () => clock.now() + offset,
|
||||
setTimeout: (callback, ms) => clock.setTimeout(callback, ms),
|
||||
clearTimeout: (timer) => clock.clearTimeout(timer),
|
||||
}
|
||||
serve()
|
||||
const { store } = await mountReader({ clock: skewed })
|
||||
|
||||
offset = -60 * 60 * 1000
|
||||
store.requestConversationRefresh()
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
await flushPromises()
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
|
||||
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
|
||||
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const row = rowOf(wrapper, 'm2')
|
||||
expect(row.findAll('[data-notice]').map((notice) => [notice.attributes('data-notice'), notice.text()])).toEqual([
|
||||
['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted],
|
||||
['truncated', MAIL_BODY_NOTICES.truncated],
|
||||
])
|
||||
expect(row.find('iframe').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('본문이 404 RES_001(그사이 지워짐)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 알리고, 대화를 다시 받아 그 줄을 뺀다', async () => {
|
||||
const refreshed = deferred()
|
||||
let calls = 0
|
||||
serve({
|
||||
conversation: () => {
|
||||
calls += 1
|
||||
return calls === 1 ? page(THREAD) : refreshed.promise
|
||||
},
|
||||
detail: () => fail(404, 'RES_001'),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
const row = rowOf(wrapper, 'm2')
|
||||
expect(row.get('[data-conversation-detail-error]').text()).toContain(ERROR_CODE_MESSAGES.RES_001)
|
||||
expect(row.find('[data-conversation-detail-error] button').exists()).toBe(false)
|
||||
expect(row.find('[data-conversation-reply]').exists()).toBe(false)
|
||||
expect(row.find('[data-conversation-open]').exists()).toBe(false)
|
||||
expect(row.get('[data-conversation-detail-status]').text()).toBe(ERROR_CODE_MESSAGES.RES_001)
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
|
||||
refreshed.resolve(page([ORIGINAL, CURRENT]))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
|
||||
expect(section(wrapper).get('h3').text()).toBe('같은 대화 2통')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('펼친 본문 안의 Ctrl+P — 그 펼친 메일의 인쇄 화면', () => {
|
||||
it('펼친 본문 안에서 누른 Ctrl+P 는 읽기 화면의 메일이 아니라 펼친 메일의 인쇄 화면을 연다 — 다른 글쇠와 함께면 브라우저에 맡긴다', async () => {
|
||||
const opened: unknown[][] = []
|
||||
vi.spyOn(window, 'open').mockImplementation((...args: unknown[]) => {
|
||||
opened.push(args)
|
||||
return null
|
||||
})
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const frame = rowOf(wrapper, 'm2').get('iframe').element as HTMLIFrameElement
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true, cancelable: true })
|
||||
frame.contentDocument?.dispatchEvent(press)
|
||||
expect(press.defaultPrevented).toBe(true)
|
||||
expect(opened).toEqual([['/mail/print/m2', '_blank', 'noopener']])
|
||||
|
||||
const withShift = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true })
|
||||
frame.contentDocument?.dispatchEvent(withShift)
|
||||
expect(withShift.defaultPrevented).toBe(false)
|
||||
expect(opened).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('[이 메일만 열기] — 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다', () => {
|
||||
it.each([
|
||||
['split', 'push'],
|
||||
['list', 'replace'],
|
||||
] as const)('%s 보기 → router.%s', async (layout, method) => {
|
||||
serve()
|
||||
const { wrapper, router } = await mountReader({ layout })
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await rowOf(wrapper, 'm2').get('[data-conversation-open]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
expect(method === 'push' ? push : replace).toHaveBeenCalled()
|
||||
expect(method === 'push' ? replace : push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
|
||||
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
|
||||
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
|
||||
// - 안 읽은 줄은 목록 줄처럼 굵게(2026-11-07). 이 탭에서 바꾼 읽음은 바로 바뀌고, 다른 탭 · 기기에서 바꾼 것은 다음에 받을 때 보인다(stores/mail/conversation.ts)
|
||||
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
|
||||
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
|
||||
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
|
||||
// - 검색 결과에서 연 메일에도 보이지만 칸 안의 줄 · 본문에는 검색 강조를 넣지 않는다
|
||||
// - 초점: 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면(받은 [다시 시도] · 마지막 쪽을 받은 [더 보기] · 펼친 줄의 [다시 시도] ·
|
||||
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
|
||||
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
|
||||
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useConversationSync } from '@/composables/useConversationSync'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { formatCount, formatTraceCode } from '@/shared/utils/format'
|
||||
import { conversationRowsOf } from '@/shared/utils/mail-conversation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 읽기 화면에 연 메일 */
|
||||
messageId: string
|
||||
/** split: 분할 보기 · list: 목록 보기 — [이 메일만 열기]를 기록에 쌓을지(목록 보기는 바꿔 끼운다) */
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 초점이 있던 칸이 없어졌다 — 읽기 화면이 초점을 옮긴다 (본문으로) */
|
||||
(e: 'settled'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const clock = useClock()
|
||||
useConversationSync({ messageId: () => props.messageId, clock })
|
||||
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
const titleRef = ref<HTMLHeadingElement | null>(null)
|
||||
const failureRef = ref<HTMLElement | null>(null)
|
||||
/** 화면 낭독용 상태 — 받지 못했을 때의 안내 (영역은 늘 있다) */
|
||||
const announcement = ref<string>('')
|
||||
|
||||
/** 칸이 이 메일의 대화를 가리킬 때만 그린다 — 메일을 바꾼 직후 앞 메일의 대화가 잠깐 보이지 않게 */
|
||||
const forThisMessage = computed(() => mailStore.conversationMessageId === props.messageId)
|
||||
const visible = computed(() => forThisMessage.value && mailStore.conversationVisible)
|
||||
const failed = computed(() => forThisMessage.value && mailStore.conversationFailed)
|
||||
const showMore = computed(() => mailStore.hasMoreConversation || mailStore.conversationLoadingMore)
|
||||
const title = computed(() => {
|
||||
const count = mailStore.conversationCount
|
||||
return count === null ? MAIL_CONVERSATION_TEXT.title : MAIL_CONVERSATION_TEXT.titleCount(formatCount(count))
|
||||
})
|
||||
const traceCode = computed(() => formatTraceCode(mailStore.conversationError?.traceId))
|
||||
const listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.listedSummaries.map((message) => message.id)))
|
||||
const rows = computed(() =>
|
||||
conversationRowsOf(mailStore.conversationItems, {
|
||||
currentId: props.messageId,
|
||||
me: authStore.user,
|
||||
mailboxes: mailStore.mailboxes,
|
||||
source: mailStore.listSource,
|
||||
listedIds: listedIds.value,
|
||||
now: new Date(clock.now()),
|
||||
}),
|
||||
)
|
||||
|
||||
function loadMore() {
|
||||
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
|
||||
}
|
||||
|
||||
// ── 초점 지키기 ─────────────────────────────────────────
|
||||
|
||||
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
|
||||
const renderKey = computed(() => {
|
||||
const details = mailStore.conversationDetails
|
||||
const expanded = [...mailStore.conversationExpandedIds].map((id) => `${id}=${details.get(id)?.status ?? ''}`)
|
||||
return [
|
||||
visible.value,
|
||||
failed.value,
|
||||
showMore.value,
|
||||
mailStore.conversationStatus,
|
||||
mailStore.conversationItems.map((item) => item.id).join(','),
|
||||
expanded.join(','),
|
||||
].join('|')
|
||||
})
|
||||
|
||||
/** 칸 안에서 잃은 초점 — 초점이 있던 줄(없으면 null). 받는 중이면 다 받을 때까지 남는다 */
|
||||
let lostFocus: { rowId: string | null } | null = null
|
||||
|
||||
function isInside(element: Element | null): boolean {
|
||||
if (!element) return false
|
||||
return Boolean(sectionRef.value?.contains(element) || failureRef.value?.contains(element))
|
||||
}
|
||||
|
||||
function rowToggle(rowId: string): HTMLElement | null {
|
||||
const rowElement = [...(sectionRef.value?.querySelectorAll<HTMLElement>('[data-conversation-row]') ?? [])].find(
|
||||
(element) => element.dataset.conversationRow === rowId,
|
||||
)
|
||||
return rowElement?.querySelector<HTMLElement>('[data-conversation-toggle]') ?? null
|
||||
}
|
||||
|
||||
/** 그린 뒤 — 초점을 잃었으면(body) 갈 곳으로 옮긴다 */
|
||||
function settleFocus() {
|
||||
if (!lostFocus) return
|
||||
const active = document.activeElement
|
||||
if (active !== null && active !== document.body) {
|
||||
// 초점이 남아 있거나 사용자가 다른 곳으로 옮겼다
|
||||
lostFocus = null
|
||||
return
|
||||
}
|
||||
const target =
|
||||
(lostFocus.rowId ? rowToggle(lostFocus.rowId) : null) ??
|
||||
failureRef.value?.querySelector<HTMLElement>('[data-conversation-retry]') ??
|
||||
titleRef.value
|
||||
if (target) {
|
||||
lostFocus = null
|
||||
target.focus()
|
||||
return
|
||||
}
|
||||
// 받는 중 — 칸이 다시 생길 때까지 기다린다
|
||||
if (mailStore.conversationStatus === 'loading' && forThisMessage.value) return
|
||||
lostFocus = null
|
||||
emit('settled')
|
||||
}
|
||||
|
||||
// 그리기 전에 초점이 칸 안에 있었는지 적어 둔다 (pre — 아직 옛 화면이다)
|
||||
watch(
|
||||
renderKey,
|
||||
() => {
|
||||
const active = document.activeElement
|
||||
if (isInside(active)) {
|
||||
const row = active?.closest<HTMLElement>('[data-conversation-row]')
|
||||
lostFocus = { rowId: row?.dataset.conversationRow ?? null }
|
||||
}
|
||||
void nextTick(settleFocus)
|
||||
},
|
||||
{ flush: 'pre' },
|
||||
)
|
||||
|
||||
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
|
||||
watch(failed, async (isFailed) => {
|
||||
announcement.value = ''
|
||||
if (!isFailed) return
|
||||
await nextTick()
|
||||
announcement.value = MAIL_CONVERSATION_TEXT.loadFailed
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="visible"
|
||||
ref="sectionRef"
|
||||
class="conversation"
|
||||
aria-labelledby="reader-conversation-title"
|
||||
data-conversation
|
||||
>
|
||||
<h3
|
||||
id="reader-conversation-title"
|
||||
ref="titleRef"
|
||||
class="conversation__title"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<ol class="conversation__list">
|
||||
<ConversationMessageRow
|
||||
v-for="row in rows"
|
||||
:key="row.message.id"
|
||||
:row="row"
|
||||
:layout="layout"
|
||||
:expanded="mailStore.conversationExpandedIds.has(row.message.id)"
|
||||
:detail="mailStore.conversationDetails.get(row.message.id) ?? null"
|
||||
@toggle="mailStore.toggleConversationMessage"
|
||||
@retry-detail="mailStore.retryConversationDetail"
|
||||
/>
|
||||
</ol>
|
||||
<div
|
||||
v-if="showMore"
|
||||
class="conversation__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="conversation__text-button"
|
||||
:aria-busy="mailStore.conversationLoadingMore"
|
||||
data-conversation-more
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ mailStore.conversationLoadingMore ? MAIL_CONVERSATION_TEXT.moreBusy : MAIL_CONVERSATION_TEXT.more }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-else-if="failed"
|
||||
ref="failureRef"
|
||||
class="conversation conversation--failed"
|
||||
data-conversation-failed
|
||||
>
|
||||
<p class="conversation__failure">
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="conversation__failure-icon"
|
||||
/>
|
||||
<span>{{ MAIL_CONVERSATION_TEXT.loadFailed }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="conversation__text-button"
|
||||
data-conversation-retry
|
||||
@click="mailStore.retryConversation()"
|
||||
>
|
||||
{{ MAIL_CONVERSATION_TEXT.retry }}
|
||||
</button>
|
||||
<span
|
||||
v-if="traceCode"
|
||||
class="conversation__trace"
|
||||
>{{ MAIL_CONVERSATION_TEXT.traceCode(traceCode) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="visually-hidden"
|
||||
role="status"
|
||||
data-conversation-status
|
||||
>
|
||||
{{ announcement }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 첨부 칸과 같은 칸 머리 — 위 선 · 12.5px 회색 제목 (디자인 원본 Thread14) */
|
||||
.conversation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding-top: var(--space-16);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.conversation__title {
|
||||
margin: 0;
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 테두리 상자 — 줄 사이는 옅은 선 (ConversationMessageRow) */
|
||||
.conversation__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conversation__more {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* [더 보기] · [다시 시도] 44 — 글자 단추 */
|
||||
.conversation__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation__text-button:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.conversation__text-button[aria-busy='true'] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.conversation__failure {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-8);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.conversation__failure-icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.conversation__trace {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,17 @@
|
||||
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
|
||||
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
|
||||
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
|
||||
// 대화로 묶은 메일함(14단계 — 디자인 원본 ListThreads14)은 한 줄이 대화 하나다 — 줄 규칙은 useConversationRows, 줄은 ConversationListItem
|
||||
import ConversationListItem from '@/components/mail/ConversationListItem.vue'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import { useConversationRows } from '@/composables/useConversationRows'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const rows = useMailListRows()
|
||||
const conversationRows = useConversationRows()
|
||||
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
@@ -24,20 +28,40 @@ function onCheck(id: string, range: boolean) {
|
||||
title-id="message-list-title"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<template v-if="mailStore.isConversationList">
|
||||
<ConversationListItem
|
||||
v-for="row in mailStore.conversations"
|
||||
:key="row.id"
|
||||
:row="row"
|
||||
:who="conversationRows.whoOf(row, 'split')"
|
||||
:to="conversationRows.locationOf(row)"
|
||||
:unread="conversationRows.isUnread(row)"
|
||||
:draft="conversationRows.isDraft.value"
|
||||
:held="conversationRows.isHeld(row)"
|
||||
:selected="conversationRows.isSelected(row)"
|
||||
:checked="mailStore.checkedIds.has(row.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
@toggle-flag="conversationRows.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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"
|
||||
:search="Boolean(mailStore.search)"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
|
||||
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
|
||||
// 아직 보내지 않은 내 메일(9단계)은 시각 자리에 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록 — 디자인 원본 MailSendLater)
|
||||
// 검색 결과(14단계 — 디자인 원본 Search14)는 세 줄(최소 64): 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기 한 줄.
|
||||
// 서버가 준 searchHighlight 의 위치로 제목 · 미리 보기의 맞은 말을 <mark>(HighlightedText — v-html 없음)로 그린다. 강조가 없으면(엔진 방식) 글 그대로
|
||||
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
|
||||
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
@@ -12,16 +14,17 @@ import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
message: MessageListItem
|
||||
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
to: RouteLocationRaw
|
||||
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
|
||||
@@ -36,6 +39,8 @@ interface Props {
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
/** 검색 결과의 줄 — 미리 보기 줄을 더하고 맞은 말을 강조한다 (14단계) */
|
||||
search?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -50,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
search: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -63,6 +69,11 @@ const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
/** 아직 보내지 않은 메일 — 고를 수 없다 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
/** 검색 강조 (14단계) — 제목이 비었으면 "(제목 없음)"에는 강조가 없다 */
|
||||
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
|
||||
/** 검색 결과의 미리 보기 — 본문에서 맞았으면 맞은 말 둘레, 아니면 미리 보기 */
|
||||
const preview = computed(() => props.message.searchHighlight?.preview.text ?? props.message.preview)
|
||||
const previewRanges = computed(() => props.message.searchHighlight?.preview.ranges ?? null)
|
||||
|
||||
/**
|
||||
* Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다).
|
||||
@@ -84,7 +95,13 @@ function onMouseDown(event: MouseEvent) {
|
||||
<li
|
||||
:class="[
|
||||
'message-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
{
|
||||
'is-selected': selected,
|
||||
'is-unread': unread,
|
||||
'is-checked': checked,
|
||||
'is-checking': checking,
|
||||
'message-row--search': search,
|
||||
},
|
||||
]"
|
||||
:data-row-id="message.id"
|
||||
@mousedown="onMouseDown"
|
||||
@@ -119,9 +136,14 @@ function onMouseDown(event: MouseEvent) {
|
||||
:datetime="message.pendingSend?.sendAt ?? message.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만. 검색 결과만 미리 보기 한 줄 (14단계) -->
|
||||
<span class="message-row__bottom">
|
||||
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">{{ subject }}</span>
|
||||
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">
|
||||
<HighlightedText
|
||||
:text="subject"
|
||||
:ranges="search ? subjectRanges : null"
|
||||
/>
|
||||
</span>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
@@ -131,6 +153,16 @@ function onMouseDown(event: MouseEvent) {
|
||||
class="message-row__clip"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="search"
|
||||
class="message-row__preview"
|
||||
data-row-preview
|
||||
>
|
||||
<HighlightedText
|
||||
:text="preview"
|
||||
:ranges="previewRanges"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
@@ -226,6 +258,11 @@ function onMouseDown(event: MouseEvent) {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
/* 검색 결과 — 세 줄 최소 64 (디자인 원본 Search14 — 분할 보기 2줄 행 --size-list-row 의 검색판) */
|
||||
.message-row--search .message-row__main {
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -290,6 +327,24 @@ function onMouseDown(event: MouseEvent) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 검색 결과의 미리 보기 한 줄 — 12.5px 흐린 글자 (디자인 원본 Search14) */
|
||||
.message-row__preview {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
* 미리 보기 줄의 맞은 말만 글자색을 진하게 — 흐린 글자(#667085)는 노랑(#FCE7A4) 위에서 4.05:1 이라 4.5:1 에 못 미친다(#4A5363 은 6.3:1, 2026-11-07).
|
||||
* 제목 줄 · 읽기 화면 · 본문 틀의 강조는 진한 글자 위라 글자색 그대로다 (HighlightedText)
|
||||
*/
|
||||
.message-row__preview :deep(.search-mark) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.message-row__who.is-placeholder,
|
||||
.message-row__subject.is-placeholder {
|
||||
|
||||
@@ -215,7 +215,7 @@ async function onBlock(target: string, moveToJunk: boolean) {
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const outcome = await senderBlock.block(message, target, moveToJunk)
|
||||
if (!outcome.moved || outcome.stale) return
|
||||
const removed = !mailStore.messages.some((item) => item.id === message.id)
|
||||
const removed = !mailStore.isListedMessage(message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useM
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -338,7 +338,7 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
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.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
@@ -380,7 +380,7 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
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통을 되돌렸습니다.')
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
|
||||
@@ -537,7 +537,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
store.mailboxes = mailboxes
|
||||
store.listSource = { kind: 'mailbox', mailboxId: box }
|
||||
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
@@ -636,8 +636,9 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await items[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
|
||||
// 읽기 화면은 열릴 때 같은 대화를 받는다(14단계). 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /messages/m1/conversation',
|
||||
'GET /mail-filters',
|
||||
'GET /contacts',
|
||||
'GET /contacts',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -144,7 +144,7 @@ describe('MessageReader — 9단계', () => {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = source
|
||||
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }]
|
||||
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = message
|
||||
@@ -288,11 +288,11 @@ describe('MessageReader — 9단계', () => {
|
||||
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
|
||||
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
|
||||
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
|
||||
const notice = wrapper.get('[data-notice="scheduled"]')
|
||||
|
||||
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)} 에 보낼 메일입니다.`)
|
||||
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
|
||||
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
|
||||
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
|
||||
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
|
||||
@@ -426,7 +426,7 @@ describe('MessageReader — 9단계', () => {
|
||||
await wrapper.get('[data-scheduled-cancel]').trigger('click')
|
||||
// 기다리는 사이 받은편지함의 다른 메일을 연다
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
store.messages = [{ ...NEWSLETTER, id: 'i1' }]
|
||||
store.messages = [{ ...NEWSLETTER, id: 'i1' }] as MessageSummary[]
|
||||
store.selectedId = 'i1'
|
||||
await router.push('/mail/inbox/i1')
|
||||
cancel.resolve(ok({ draftId: 's1' }))
|
||||
@@ -445,7 +445,7 @@ describe('MessageReader — 9단계', () => {
|
||||
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
|
||||
})
|
||||
|
||||
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }]
|
||||
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }] as MessageSummary[]
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
|
||||
@@ -587,7 +587,7 @@ describe('MessageReader — 9단계', () => {
|
||||
expect(wrapper.get('#block-dialog-title').text()).toBe('보낸 사람 차단')
|
||||
expect((dialog.get('[data-block-scope="address"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(dialog.text()).toContain('news@cloudbrief.example')
|
||||
expect(dialog.text()).toContain('@cloudbrief.example 에서 오는 메일 모두')
|
||||
expect(dialog.text()).toContain('@cloudbrief.example에서 오는 메일 모두')
|
||||
expect((dialog.get('[data-block-move]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-block-confirm]').element)
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('MessageReader — 9단계', () => {
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
// 기다리는 사이 보낸편지함의 메일을 연다
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
|
||||
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }]
|
||||
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }] as MessageSummary[]
|
||||
store.selectedId = 'x1'
|
||||
await router.push('/mail/sent/x1')
|
||||
blocking.resolve(ok({ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T06:00:00.000Z' }, 201))
|
||||
|
||||
@@ -13,22 +13,27 @@
|
||||
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
|
||||
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
|
||||
// 검색 결과에서 연 메일(14단계)은 제목 · 본문 · 첨부 이름의 맞은 말이 노랑이다 — 서버의 searchHighlight(제목 · 첨부 이름 · 글 본문의 위치)와
|
||||
// 서식 본문 안의 <mark>. 강조가 든 응답은 여기서 보이기만 한다 — [답장] · [전달] · 인쇄는 그 메일을 검색 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계")
|
||||
// 본문 · 첨부 아래에 "같은 대화 N통"(14단계 — MessageConversation, 한 통뿐이면 없다). 칸 안에는 검색 강조가 없다
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
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 MessageConversation from '@/components/mail/MessageConversation.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
|
||||
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
|
||||
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailPrint } from '@/composables/useMailPrint'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
@@ -64,18 +69,21 @@ 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 fromName = computed(() => displayName(message.value?.from) || MAIL_NO_SENDER)
|
||||
const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
const subject = computed(() => message.value?.subject || '(제목 없음)')
|
||||
/** 검색 강조 (14단계) — 검색 결과에서 연 메일만 온다. "(제목 없음)"에는 없다 */
|
||||
const subjectRanges = computed(() => (message.value?.subject ? (message.value.searchHighlight?.subject ?? null) : null))
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 서버가 준다 (필드가 없는 서버면 없다) */
|
||||
const largeFiles = computed(() => message.value?.largeFiles ?? [])
|
||||
|
||||
/** 다시 시도 — 검색 결과에서 연 메일이면 같은 강조 쿼리로 */
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId, mailStore.selectedHighlight)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
@@ -92,7 +100,7 @@ function onPrintKey(event: KeyboardEvent) {
|
||||
print.openPrint(current)
|
||||
}
|
||||
|
||||
/** 안내 줄의 단추가 줄과 함께 사라졌다 — 초점을 잃었으면 본문으로 */
|
||||
/** 안내 줄의 단추가 줄과 함께 사라졌다 · 초점이 있던 같은 대화 칸이 없어졌다(14단계) — 초점을 잃었으면 본문으로 */
|
||||
function onNoticeSettled() {
|
||||
const active = document.activeElement
|
||||
if (active !== null && active !== document.body) return
|
||||
@@ -194,7 +202,10 @@ onBeforeUnmount(() => {
|
||||
id="reader-subject"
|
||||
class="reader__subject"
|
||||
>
|
||||
{{ subject }}
|
||||
<HighlightedText
|
||||
:text="subject"
|
||||
:ranges="subjectRanges"
|
||||
/>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -265,6 +276,7 @@ onBeforeUnmount(() => {
|
||||
:text-body="message.textBody"
|
||||
:images-shown="message.imagesShown === true"
|
||||
:fold-large-files="largeFiles.length > 0"
|
||||
:text-highlights="message.searchHighlight?.textBody ?? null"
|
||||
:title="`메일 본문: ${subject}`"
|
||||
@frame-keydown="onPrintKey"
|
||||
/>
|
||||
@@ -274,6 +286,14 @@ onBeforeUnmount(() => {
|
||||
:message-id="message.id"
|
||||
:attachments="attachments"
|
||||
:large-files="largeFiles"
|
||||
:name-highlights="message.searchHighlight?.attachments ?? null"
|
||||
/>
|
||||
|
||||
<!-- 같은 대화 (14단계) — 한 통뿐이거나 아직 받지 못했으면 아무것도 그리지 않는다. 초점이 있던 칸이 없어지면 본문으로 -->
|
||||
<MessageConversation
|
||||
:message-id="message.id"
|
||||
:layout="layout"
|
||||
@settled="onNoticeSettled"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -93,10 +93,10 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
|
||||
it('머리에 "메일 N통, 안 읽은 메일 M통"(14단계 — 디자인 원본 ListThreads14) 과 보기 전환이 있다', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('메일 15통, 안 읽은 메일 2통')
|
||||
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,23 +3,35 @@
|
||||
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
|
||||
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
|
||||
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
|
||||
// 대화로 묶은 메일함(14단계 — 보드 ListThreads14 에 표가 없어 정했다)은 한 줄이 대화 하나다(ConversationTableRow):
|
||||
// 고르기 · 별 · 참여자(+ 통 수 — 칸 머리도 "참여자") · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import ConversationTableRow from '@/components/mail/ConversationTableRow.vue'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
|
||||
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
|
||||
import { useConversationRows } from '@/composables/useConversationRows'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const rows = useMailListRows()
|
||||
const conversationRows = useConversationRows()
|
||||
|
||||
const caption = computed(() =>
|
||||
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
|
||||
)
|
||||
/** 대화로 묶은 메일함 — 크기 칸이 없고 사람 칸에 참여자를 보인다 */
|
||||
const threads = computed(() => mailStore.isConversationList)
|
||||
const caption = computed(() => {
|
||||
if (mailStore.search) return MAIL_SEARCH_TEXT.title
|
||||
const name = mailStore.currentMailbox?.name ?? '메일함'
|
||||
return threads.value ? MAIL_CONVERSATION_LIST_TEXT.tableCaption(name) : `${name} 메일 목록`
|
||||
})
|
||||
/** 사람 칸 머리 — 대화는 줄이 참여자라 어느 메일함이든 "참여자", 메일은 "보낸 사람"(보낸편지함 · 임시보관함은 "받는 사람") */
|
||||
const whoHeader = computed(() => (threads.value ? MAIL_CONVERSATION_LIST_TEXT.participantsColumn : rows.whoHeader.value))
|
||||
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
@@ -40,7 +52,7 @@ function open(location: RouteLocationRaw) {
|
||||
>
|
||||
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value, 'mail-table--threads': threads }]">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
@@ -54,26 +66,48 @@ function open(location: RouteLocationRaw) {
|
||||
>
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
<col
|
||||
v-if="!threads"
|
||||
class="mail-table__col-size"
|
||||
>
|
||||
</colgroup>
|
||||
<MessageTableHead
|
||||
:who-header="rows.whoHeader.value"
|
||||
:who-header="whoHeader"
|
||||
:mixed="rows.mixed.value"
|
||||
:threads="threads"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<template v-if="threads">
|
||||
<ConversationTableRow
|
||||
v-for="row in mailStore.conversations"
|
||||
:key="row.id"
|
||||
:row="row"
|
||||
:who="conversationRows.whoOf(row, 'table')"
|
||||
:to="conversationRows.locationOf(row)"
|
||||
:unread="conversationRows.isUnread(row)"
|
||||
:draft="conversationRows.isDraft.value"
|
||||
:held="conversationRows.isHeld(row)"
|
||||
:checked="mailStore.checkedIds.has(row.id)"
|
||||
@open="open"
|
||||
@toggle-flag="conversationRows.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -171,6 +205,13 @@ function open(location: RouteLocationRaw) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 대화로 묶은 메일함 — 크기 칸이 없어 날짜 칸이 마지막 칸이다 (14단계 — 1100px 미만은 아래 규칙이 같은 폭을 준다) */
|
||||
@media (min-width: 68.75rem) {
|
||||
.mail-table--threads .mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
|
||||
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
|
||||
// 대화로 묶은 메일함(14단계)은 불러온 대화 모두 고르기이고 크기 칸이 없다
|
||||
// 칸 폭 · 칸 사이 여백은 표(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 { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -12,10 +14,13 @@ interface Props {
|
||||
whoHeader: string
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
|
||||
mixed?: boolean
|
||||
/** 대화로 묶은 메일함 — 크기 칸이 없다 */
|
||||
threads?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
threads: false,
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
@@ -33,7 +38,7 @@ const mailStore = useMailStore()
|
||||
class="mail-table-head__check"
|
||||
:checked="mailStore.checkState === 'all'"
|
||||
:mixed="mailStore.checkState === 'some'"
|
||||
:label="MAIL_BULK_TEXT.selectAll"
|
||||
:label="threads ? MAIL_CONVERSATION_LIST_TEXT.selectAll : MAIL_BULK_TEXT.selectAll"
|
||||
@toggle="mailStore.toggleCheckAll()"
|
||||
/>
|
||||
</th>
|
||||
@@ -66,6 +71,7 @@ const mailStore = useMailStore()
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
v-if="!threads"
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
|
||||
@@ -5,21 +5,23 @@
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
|
||||
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
|
||||
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
message: MessageListItem
|
||||
to: RouteLocationRaw
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
@@ -51,6 +53,8 @@ const draft = computed(() => isDraftRole(props.role))
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.message))
|
||||
/** 아직 보내지 않은 메일 — 고를 수 없다 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
|
||||
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
@@ -125,7 +129,10 @@ function onMouseDown(event: MouseEvent) {
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ subject }}
|
||||
<HighlightedText
|
||||
:text="subject"
|
||||
:ranges="subjectRanges"
|
||||
/>
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
|
||||
Reference in New Issue
Block a user