feat(frontend): 목록을 대화로 묶어 보게 한다 (14단계 화면 3차)

메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의
한 줄을 더한다. 켜면 목록 출처가 GET /api/conversations 이고, 줄에 하는 일
(고르기 · 읽음 · 중요 · 이동 · 삭제)은 그 줄의 messageIds 에서 붙잡힌 메일을
뺀 메일을 지금의 여러 통 API 로 보낸다 — 붙잡힌 메일은 취소로만 보낸편지함을
떠난다(9단계).

- 대화 목록에서는 messages 가 비므로 읽기 화면 · 삭제 · 예약 취소가 줄 기준
  도우미(rowCount · rowIndexOfMessage · isListedMessage 등)로 같은 규칙을 탄다
- 첫 진입은 이 브라우저가 그 직원에게서 마지막으로 안 값으로 열고, 설정을
  받지 못해도 [대화로 묶기]를 쓸 수 있다
- 리뷰 M1: 로그아웃 · 세션 만료 직후 설정 reset 이 watcher 를 깨워 떠나는
  화면의 목록을 다시 부르던 회귀 — canUseApp 이 false 면 부르지 않고 reset 은
  보이는 값을 뒤집지 않는다
- 리뷰 L7: 버린 목록 요청은 AbortController 로 끊는다(엔진 방식은 한 요청이
  15초까지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:56:13 +09:00
parent 1a4f1d3b56
commit 726f9f854f
46 changed files with 3782 additions and 183 deletions
@@ -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,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>
+17 -13
View File
@@ -2,6 +2,8 @@
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수".
// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -32,16 +34,18 @@ 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))
/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.rowIndexOfMessage(props.messageId))
/**
* 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수.
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다
* 검색 결과의 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(() =>
@@ -51,18 +55,17 @@ const hasPrev = computed(() => index.value > 0)
/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */
const hasNext = computed(
() =>
index.value !== -1 &&
(index.value < mailStore.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore),
index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore),
)
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
const nextWaiting = computed(
() => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing,
() => 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)
}
/**
@@ -72,19 +75,19 @@ async function goPrev() {
async function goNext() {
if (loadingNext.value || nextWaiting.value || index.value === -1) return
const current = index.value
if (!mailStore.messages[current + 1] && (mailStore.hasMore || mailStore.isLoadingMore)) {
if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) {
loadingNext.value = true
try {
const waited = mailStore.isLoadingMore
await mailStore.loadMore()
if (waited && !mailStore.messages[current + 1] && mailStore.hasMore) 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)
}
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
@@ -94,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>
@@ -0,0 +1,80 @@
<script setup lang="ts">
// 목록 머리 오른쪽의 [대화로 묶기] (14단계 — 디자인 원본 ListThreads14): 눌림 단추(aria-pressed) · 높이 36(--size-notice-action — 안내 줄 단추의
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(groupByConversation)을 PATCH 한다 — 화면을 먼저 바꾸고(메일함 화면이 바로 묶거나 푼다)
// 끝내 실패하면 서버가 확인한 값으로 되돌린다(mail-settings.store — 칸마다 저장은 한 번에 하나, 알림은 인터셉터).
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(36×36 — 이름은 title · 화면 낭독, MailboxEmptyButton 과 같은 결), 목록 보기는 아이콘 + 이름.
// 켠 모양은 보기 전환의 고른 칸과 같다(청록 옅은 바탕 · 진한 청록 글자 — 테두리는 그대로, 보드의 #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>
@@ -8,6 +8,7 @@
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -75,7 +76,7 @@ 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(() =>
@@ -97,15 +98,17 @@ const singleChar = computed(
const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing)
/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */
const searchPending = computed(
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.messages.length === 0 && mailStore.hasMore,
() => 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
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)
}
@@ -240,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"
+53 -12
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](14단계) · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 개수(14단계 — 디자인 원본 ListThreads14): 묶지 않았으면 "메일 9통, 안 읽은 메일 2통"(메일함의 통 수), 묶었으면 "대화 6개, 안 읽은 메일 2통"
// (대화 수는 목록의 total — 모르면 그 부분을 뺀다). 안 읽은 메일이 없으면 뒤를 뺀다.
// [대화로 묶기]는 메일함 목록(묶었든 아니든)에서만 — 검색 결과 · 예약됨은 묶지 않는다. 설정을 받는 동안은 숨기고(그사이 고른 것은 받기가 덮는다),
// 받았거나 받지 못했으면 보인다 — 받지 못했으면 짐작 위에서 고르고 그 칸만 저장한다 (mail-settings.store — 리뷰 L4)
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
@@ -7,12 +11,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 } 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 {
@@ -26,6 +33,7 @@ withDefaults(defineProps<Props>(), {
})
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
@@ -36,9 +44,17 @@ const countText = computed(() => {
}
const box = mailStore.currentMailbox
if (!box) return ''
const total = `전체 ${formatCount(box.totalCount)}`
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
if (mailStore.isConversationList) {
return listHeadCountText({ grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount })
}
return listHeadCountText({ grouped: false, total: box.totalCount, unread: box.unreadCount })
})
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (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 +65,24 @@ 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>
<!-- 분할 보기 칸에서는 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다 (글은 그대로 있어 화면 낭독은 다 읽는다) -->
<span
class="list-header__count"
:title="countText || undefined"
>{{ countText }}</span>
</div>
<div class="list-header__end">
<MailConversationToggle
v-if="groupable"
:variant="variant"
/>
<MailboxEmptyButton
v-if="emptiable"
:mailbox="emptiable"
@@ -92,7 +118,22 @@ const emptiable = computed(() => {
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
}
/*
* 이름 · 개수 — 오른쪽 단추들이 남긴 자리를 쓴다. 좁으면 개수가 먼저 줄고(사라질 때까지) 그다음에 이름이 준다
* (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 +146,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);
@@ -8,6 +8,7 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
import { computed, nextTick, ref, watch } from 'vue'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
@@ -16,6 +17,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 +72,10 @@ 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)
})
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[]>(() => [
@@ -231,7 +236,7 @@ watch(disabledTools, (disabled) => {
</div>
<MailPurgeDialog
:open="purgeOpen"
:count="mailStore.checkedCount"
:count="mailStore.checkedActionIds.length"
id-prefix="bulk-purge"
@confirm="onPurgeConfirm"
@cancel="purgeOpen = false"
@@ -60,7 +60,7 @@ const title = computed(() => {
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.messages.map((message) => message.id)))
const listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.listedSummaries.map((message) => message.id)))
const rows = computed(() =>
conversationRowsOf(mailStore.conversationItems, {
currentId: props.messageId,
+38 -15
View File
@@ -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,21 +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"
:search="Boolean(mailStore.search)"
@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>
@@ -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)
}
@@ -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)
})
+61 -19
View File
@@ -3,23 +3,36 @@
// 칸: 고르기 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 { whoColumnLabel } from '@/shared/utils/mail-list-row'
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 ? whoColumnLabel(null, false) : rows.whoHeader.value))
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
@@ -40,7 +53,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 +67,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 +206,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"
>