From ab16379836909056120a319fea68d67771694d2a Mon Sep 17 00:00:00 2001
From: ttipo
Date: Fri, 6 Nov 2026 07:53:56 +0900
Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EA=B2=80=EC=83=89=EC=97=90?=
=?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20=EA=B0=95=EC=A1=B0=20=C2=B7=20?=
=?UTF-8?q?=EC=A4=80=EB=B9=84=20=EC=A4=91=20=EC=95=88=EB=82=B4=EB=A5=BC=20?=
=?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4=20(14=EB=8B=A8=EA=B3=84=20=ED=99=94?=
=?UTF-8?q?=EB=A9=B4=201=EC=B0=A8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- [조건] 패널(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부)과 결과 머리의 조건 줄 —
조건도 화면 주소에 싣는다(since · until 은 브라우저 시간대 날짜 · 양 끝 포함)
- 결과 줄 세 줄(분할 보기)과 맞은 말 노랑 강조 — HighlightedText 가 글 조각으로 그린다(v-html 없음),
읽기 화면은 서버가 넣은 · 제목 · 첨부 이름 강조. 강조가 든 응답은 보이기만 한다
(답장 · 전달 · 인쇄 · 예약은 강조 없이 받는다)
- "검색 준비 중" 줄 · 한 글자 안내 · 빈 쪽이면 이어 찾기(한 번에 5번까지) + [더 찾기],
total 이 null 이면 통 수를 숨긴다
- search-match.ts — backend search-query · search-text · subject-base 와 짝(같은 표본 표)
리뷰 반영: 이어 받는 동안 [다음] · [메일 더 보기] 기다림, 새로 고치는 동안 이어 받지 않음,
손으로 고친 주소도 패널과 같은 검사(8낱말 · 날짜 범위 · 거꾸로 된 기간), 패널 컴포넌트 나눔,
시험 정리(unmount · 보이지 않는 글자 이스케이프).
Co-Authored-By: Claude Opus 5.5
---
.../src/components/mail/HighlightedText.vue | 47 ++
.../src/components/mail/ListReadNav.spec.ts | 111 +++++
frontend/src/components/mail/ListReadNav.vue | 42 +-
.../src/components/mail/MailBodyFrame.vue | 6 +
.../src/components/mail/MailListFrame.vue | 70 ++-
.../src/components/mail/MailSearchBox.spec.ts | 318 +++++++++++-
.../src/components/mail/MailSearchBox.vue | 85 +++-
.../mail/MailSearchConditionFields.vue | 166 +++++++
.../mail/MailSearchConditionLine.vue | 76 +++
.../components/mail/MailSearchConditions.vue | 161 ++++++
.../mail/MailSearchConditionsToggle.vue | 83 ++++
.../src/components/mail/MailSearchEmpty.vue | 55 ++-
.../src/components/mail/MailSearchHeader.vue | 11 +-
.../src/components/mail/MailSearchMore.vue | 75 +++
.../src/components/mail/MailSearchNotices.vue | 73 +++
.../components/mail/MailSearchPeriodField.vue | 190 +++++++
.../components/mail/MessageAttachments.vue | 14 +-
frontend/src/components/mail/MessageList.vue | 1 +
.../src/components/mail/MessageListItem.vue | 57 ++-
.../src/components/mail/MessageReader.spec.ts | 6 +-
.../mail/MessageReader.stage9.spec.ts | 10 +-
.../src/components/mail/MessageReader.vue | 15 +-
.../src/components/mail/MessageTableRow.vue | 13 +-
frontend/src/composables/useMail.ts | 36 +-
.../composables/useMailSearchNavigation.ts | 63 ++-
.../composables/useSearchConditionsDraft.ts | 119 +++++
.../composables/useSearchConditionsPopup.ts | 74 +++
frontend/src/pages/mail/ComposePage.spec.ts | 18 +
frontend/src/pages/mail/MailPrintPage.spec.ts | 11 +
.../src/pages/mail/MailboxPage.search.spec.ts | 421 +++++++++++++++-
frontend/src/pages/mail/MailboxPage.vue | 33 +-
frontend/src/shared/constants/icons.ts | 14 +
frontend/src/shared/constants/mail-search.ts | 127 +++++
frontend/src/shared/constants/mail.ts | 15 +-
frontend/src/shared/types/mail.ts | 72 ++-
frontend/src/shared/utils/mail-html.spec.ts | 36 ++
frontend/src/shared/utils/mail-html.ts | 19 +-
.../src/shared/utils/mail-list-source.spec.ts | 70 +++
frontend/src/shared/utils/mail-list-source.ts | 89 ++--
frontend/src/shared/utils/mail-search.spec.ts | 262 ++++++++++
frontend/src/shared/utils/mail-search.ts | 306 ++++++++++++
.../src/shared/utils/search-highlight.spec.ts | 62 +++
frontend/src/shared/utils/search-highlight.ts | 60 +++
.../src/shared/utils/search-match.spec.ts | 149 ++++++
frontend/src/shared/utils/search-match.ts | 328 ++++++++++++
frontend/src/stores/mail.store.list.spec.ts | 26 +-
.../src/stores/mail.store.search14.spec.ts | 466 ++++++++++++++++++
frontend/src/stores/mail.store.ts | 2 +
frontend/src/stores/mail/list.ts | 199 ++++++--
frontend/src/stores/mail/message.ts | 29 +-
frontend/src/styles/tokens.css | 12 +-
.../src/test-utils/search-text-samples.ts | 137 +++++
52 files changed, 4746 insertions(+), 194 deletions(-)
create mode 100644 frontend/src/components/mail/HighlightedText.vue
create mode 100644 frontend/src/components/mail/MailSearchConditionFields.vue
create mode 100644 frontend/src/components/mail/MailSearchConditionLine.vue
create mode 100644 frontend/src/components/mail/MailSearchConditions.vue
create mode 100644 frontend/src/components/mail/MailSearchConditionsToggle.vue
create mode 100644 frontend/src/components/mail/MailSearchMore.vue
create mode 100644 frontend/src/components/mail/MailSearchNotices.vue
create mode 100644 frontend/src/components/mail/MailSearchPeriodField.vue
create mode 100644 frontend/src/composables/useSearchConditionsDraft.ts
create mode 100644 frontend/src/composables/useSearchConditionsPopup.ts
create mode 100644 frontend/src/shared/constants/mail-search.ts
create mode 100644 frontend/src/shared/utils/mail-search.spec.ts
create mode 100644 frontend/src/shared/utils/mail-search.ts
create mode 100644 frontend/src/shared/utils/search-highlight.spec.ts
create mode 100644 frontend/src/shared/utils/search-highlight.ts
create mode 100644 frontend/src/shared/utils/search-match.spec.ts
create mode 100644 frontend/src/shared/utils/search-match.ts
create mode 100644 frontend/src/stores/mail.store.search14.spec.ts
create mode 100644 frontend/src/test-utils/search-text-samples.ts
diff --git a/frontend/src/components/mail/HighlightedText.vue b/frontend/src/components/mail/HighlightedText.vue
new file mode 100644
index 0000000..5ffb4e1
--- /dev/null
+++ b/frontend/src/components/mail/HighlightedText.vue
@@ -0,0 +1,47 @@
+
+
+
+
+ {{ segment.text }}
+
+ {{ segment.text }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/ListReadNav.spec.ts b/frontend/src/components/mail/ListReadNav.spec.ts
index 1359f17..f5f992b 100644
--- a/frontend/src/components/mail/ListReadNav.spec.ts
+++ b/frontend/src/components/mail/ListReadNav.spec.ts
@@ -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) => void = () => undefined
+ const second = new Promise>((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')
+ })
+})
diff --git a/frontend/src/components/mail/ListReadNav.vue b/frontend/src/components/mail/ListReadNav.vue
index 4af15da..599abe3 100644
--- a/frontend/src/components/mail/ListReadNav.vue
+++ b/frontend/src/components/mail/ListReadNav.vue
@@ -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(() => {
+ 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"
>
/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
// 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다
+// 검색 결과에서 연 메일(14단계)은 맞은 말이 노랑으로 보인다 — 서식 본문은 서버가 넣은 , 글 본문은 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(), {
imagesShown: false,
title: '메일 본문',
foldLargeFiles: false,
+ textHighlights: null,
})
const emit = defineEmits()
@@ -63,6 +68,7 @@ const srcdoc = computed(() =>
textBody: props.textBody,
imagesShown: props.imagesShown,
foldLargeFiles: props.foldLargeFiles,
+ textHighlights: props.textHighlights,
}),
)
diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue
index 9f8595f..024affb 100644
--- a/frontend/src/components/mail/MailListFrame.vue
+++ b/frontend/src/components/mail/MailListFrame.vue
@@ -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(
/>
{{ retentionNotice }}
-
+
+
+
+
+
- {{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
+ {{ loadingMore ? '불러오는 중…' : '메일 더 보기' }}
+
+
diff --git a/frontend/src/components/mail/MailSearchBox.spec.ts b/frontend/src/components/mail/MailSearchBox.spec.ts
index e8d32ae..0ad4bb8 100644
--- a/frontend/src/components/mail/MailSearchBox.spec.ts
+++ b/frontend/src/components/mail/MailSearchBox.spec.ts
@@ -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('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')
+ })
+})
diff --git a/frontend/src/components/mail/MailSearchBox.vue b/frontend/src/components/mail/MailSearchBox.vue
index 9e19d69..ad1ead2 100644
--- a/frontend/src/components/mail/MailSearchBox.vue
+++ b/frontend/src/components/mail/MailSearchBox.vue
@@ -1,28 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ SEARCH_CONDITIONS_TEXT.attachment }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchConditionLine.vue b/frontend/src/components/mail/MailSearchConditionLine.vue
new file mode 100644
index 0000000..a29ab51
--- /dev/null
+++ b/frontend/src/components/mail/MailSearchConditionLine.vue
@@ -0,0 +1,76 @@
+
+
+
+
+ {{ summary }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchConditions.vue b/frontend/src/components/mail/MailSearchConditions.vue
new file mode 100644
index 0000000..5a39ba4
--- /dev/null
+++ b/frontend/src/components/mail/MailSearchConditions.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+ {{ SEARCH_CONDITIONS_TEXT.title }}
+
+
+
+ {{ SEARCH_CONDITIONS_TEXT.hint }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchConditionsToggle.vue b/frontend/src/components/mail/MailSearchConditionsToggle.vue
new file mode 100644
index 0000000..8e8dba1
--- /dev/null
+++ b/frontend/src/components/mail/MailSearchConditionsToggle.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailSearchEmpty.vue b/frontend/src/components/mail/MailSearchEmpty.vue
index 9049fb4..b70ee5c 100644
--- a/frontend/src/components/mail/MailSearchEmpty.vue
+++ b/frontend/src/components/mail/MailSearchEmpty.vue
@@ -1,30 +1,60 @@
+
+ {{ MAIL_SEARCH_TEXT.wordLimitHint }}
+