feat(frontend): 검색에 조건 · 강조 · 준비 중 안내를 더한다 (14단계 화면 1차)

- [조건] 패널(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부)과 결과 머리의 조건 줄 —
  조건도 화면 주소에 싣는다(since · until 은 브라우저 시간대 날짜 · 양 끝 포함)
- 결과 줄 세 줄(분할 보기)과 맞은 말 노랑 강조 — HighlightedText 가 글 조각으로 그린다(v-html 없음),
  읽기 화면은 서버가 넣은 <mark> · 제목 · 첨부 이름 강조. 강조가 든 응답은 보이기만 한다
  (답장 · 전달 · 인쇄 · 예약은 강조 없이 받는다)
- "검색 준비 중" 줄 · 한 글자 안내 · 빈 쪽이면 이어 찾기(한 번에 5번까지) + [더 찾기],
  total 이 null 이면 통 수를 숨긴다
- search-match.ts — backend search-query · search-text · subject-base 와 짝(같은 표본 표)

리뷰 반영: 이어 받는 동안 [다음] · [메일 더 보기] 기다림, 새로 고치는 동안 이어 받지 않음,
손으로 고친 주소도 패널과 같은 검사(8낱말 · 날짜 범위 · 거꾸로 된 기간), 패널 컴포넌트 나눔,
시험 정리(unmount · 보이지 않는 글자 이스케이프).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 07:53:56 +09:00
parent 59375f1347
commit ab16379836
52 changed files with 4746 additions and 194 deletions
@@ -0,0 +1,47 @@
<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) */
.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')
})
})
+28 -14
View File
@@ -34,21 +34,29 @@ const listHref = computed(() => {
})
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -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,
)
/**
* 전체 — 서버가 준 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
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.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore),
)
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
const nextWaiting = computed(
() => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing,
)
async function goPrev() {
@@ -57,13 +65,19 @@ async function goPrev() {
if (previous && source) await showSiblingIn(source, previous.id)
}
/**
* 다음 메일 — 불러온 범위의 끝이면 더 받는다. 검색 결과를 뒤에서 이어 받는 중(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.messages[current + 1] && (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()
} finally {
loadingNext.value = false
}
@@ -115,8 +129,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,
}),
)
+64 -6
View File
@@ -4,6 +4,8 @@
// 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다),
// 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
// - 검색 결과(14단계 — 디자인 원본 Search14): 찾을 곳 줄 아래 조건 줄(조건이 걸렸을 때) · 안내 줄(검색 준비 중 · 한 글자 안내).
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, nextTick, ref, watch } from 'vue'
@@ -14,8 +16,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 +31,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'
@@ -71,12 +77,34 @@ const retentionNotice = computed(() => {
if (text === null) return null
return mailStore.listStatus === 'ready' && mailStore.messages.length === 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.messages.length === 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 })
if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions })
else if (scheduled.value) void mailStore.openScheduled()
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
}
@@ -107,6 +135,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 +199,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"
@@ -199,18 +249,26 @@ 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"
:offer-all="search.scope !== SEARCH_SCOPE_ALL && !searchTooLong"
:word-limit="indexing && !searchTooLong"
:too-many-words="searchTooLong"
@search-all="setScope(SEARCH_SCOPE_ALL)"
/>
@@ -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,311 @@ 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(
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
)
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()
})
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 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 flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
})
+78 -7
View File
@@ -1,28 +1,64 @@
<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 는 걸려 있는 조건을 그대로 둔다
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 { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
const PANEL_ID = 'mail-search-conditions'
const { isSearching, routeSearch, startSearch, clearConditions, 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 +68,26 @@ function onKeydown(event: KeyboardEvent) {
}
}
/** [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면 패널을 닫고 검색 칸으로 (낱말이 너무 많으면 알림만 — 패널은 그대로) */
async function onApply(conditions: SearchConditions) {
if (!(await startSearch(text.value, conditions))) return
closeConditions({ restoreFocus: false })
await nextTick()
inputRef.value?.focus()
}
/** [조건 지우기] — 걸린 조건이 있으면 빼고 다시 찾는다(검색어도 없으면 검색을 닫는다). 패널을 닫고 [조건]으로 */
function onClear() {
closeConditions()
if (conditionsApplied.value) void clearConditions()
}
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
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 +95,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 +117,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 +139,21 @@ watch(
:stroke-width="1.9"
/>
</button>
<MailSearchConditions
v-if="conditionsOpen"
ref="panelRef"
:panel-id="PANEL_ID"
:applied="appliedConditions"
@apply="onApply"
@clear="onClear"
@close="closeConditions()"
/>
</form>
</template>
<style scoped>
.mail-search {
position: relative;
flex: 1 1 17.5rem;
max-width: 32.5rem;
display: flex;
@@ -90,7 +161,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,166 @@
<script setup lang="ts">
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점). 글 칸의 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, 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,
/** 첫 칸(보낸 사람)으로 */
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,76 @@
<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'
interface Props {
conditions: SearchConditions
}
const props = defineProps<Props>()
const clock = useClock()
const { clearConditions } = useMailSearchNavigation()
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now())))
</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,161 @@
<script setup lang="ts">
// 「조건으로 찾기」 패널 (디자인 원본 Search14 "조건 열림" · README 「14단계 › 검색 · 조건 검색 · 강조」) — 검색 칸 바로 아래, 검색 칸 폭.
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
// - [조건 지우기]: clear 로 알린다 (부르는 쪽이 걸린 조건을 빼고 패널을 닫는다). 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: 'clear'): 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 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="emit('clear')"
>
{{ 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,60 @@
<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
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
offerAll: boolean
/** 낱말 단위로 찾는 동안(지난 메일 색인을 준비하는 중)이다 — 그 한계를 알린다 */
wordLimit?: boolean
/** 검색어와 조건의 낱말이 한도를 넘어 찾지 않았다 */
tooManyWords?: boolean
}
interface Emits {
(e: 'search-all'): void
}
defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
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)
return props.query ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyConditionsHint
})
</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 +71,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>
@@ -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
@@ -35,6 +35,7 @@ function onCheck(id: string, range: boolean) {
: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"
/>
@@ -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,16 @@ 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);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {
@@ -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
@@ -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
@@ -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
@@ -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')
@@ -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 -2
View File
@@ -13,10 +13,13 @@
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
// 검색 결과에서 연 메일(14단계)은 제목 · 본문 · 첨부 이름의 맞은 말이 노랑이다 — 서버의 searchHighlight(제목 · 첨부 이름 · 글 본문의 위치)와
// 서식 본문 안의 <mark>. 강조가 든 응답은 여기서 보이기만 한다 — [답장] · [전달] · 인쇄는 그 메일을 검색 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계")
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'
@@ -68,14 +71,17 @@ const fromName = computed(() => displayName(message.value?.from) || '(보낸 사
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 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
@@ -194,7 +200,10 @@ onBeforeUnmount(() => {
id="reader-subject"
class="reader__subject"
>
{{ subject }}
<HighlightedText
:text="subject"
:ranges="subjectRanges"
/>
</h2>
<button
type="button"
@@ -265,6 +274,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 +284,7 @@ onBeforeUnmount(() => {
:message-id="message.id"
:attachments="attachments"
:large-files="largeFiles"
:name-highlights="message.searchHighlight?.attachments ?? null"
/>
</div>
</article>
@@ -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"