generated from ttipo/project-base
fix(frontend): 같은 대화 칸의 안 읽은 줄을 굵게 · 패널 [조건 지우기]는 칸만 비운다
2026-11-07 사용자가 사용자 관점 점검의 보드 바꾸기 제안을 추천대로 정했다. - 같은 대화 칸: 안 읽은 줄을 목록 줄처럼 굵게 그리고 화면 낭독에 "안 읽음, "을 붙인다. 이 탭이 바꾼 읽음(펼침 · 읽기 화면 · 목록 · 여러 통 처리 · 모두 읽음)은 줄에 바로 적고, 받는 동안 바꾼 것은 받은 값보다 앞세운다. 읽음만 바뀐 소식으로는 다시 받지 않는다(메일을 열 때마다 다시 받게 되므로). - 여러 통 처리가 실패하면 바꾼 표시만 되돌린다(중요 표시 실패가 읽음을 덮지 않게). - 조건 패널의 [조건 지우기]는 칸만 비우고 패널을 열어 둔다(찾지 않는다). 조건만 걸린 검색에서 비운 채 [찾기]를 누르면 검색을 닫는다. - 검색 결과 미리 보기 줄의 강조는 글자를 --color-text-secondary 로 진하게 한다 (노랑 위 대비 4.05:1 → 6.3:1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,14 @@
|
||||
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
|
||||
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
|
||||
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
|
||||
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
|
||||
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
|
||||
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
|
||||
import { MAIL_LIST_ROW_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import type { ConversationRow } from '@/shared/utils/mail-conversation'
|
||||
import type { ConversationDetailState } from '@/stores/mail.store'
|
||||
@@ -40,7 +43,7 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="conversation-row"
|
||||
:class="['conversation-row', { 'is-unread': row.unread }]"
|
||||
:data-conversation-row="row.message.id"
|
||||
>
|
||||
<div
|
||||
@@ -52,7 +55,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
|
||||
aria-hidden="true"
|
||||
>{{ row.sender.initial }}</span>
|
||||
<span class="conversation-row__who">{{ row.sender.name }}</span>
|
||||
<span class="conversation-row__who">
|
||||
<span
|
||||
v-if="row.unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
|
||||
</span>
|
||||
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
|
||||
<time
|
||||
:class="['conversation-row__time', { 'is-pending': row.pending }]"
|
||||
@@ -78,7 +86,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
|
||||
aria-hidden="true"
|
||||
>{{ row.sender.initial }}</span>
|
||||
<span class="conversation-row__who">{{ row.sender.name }}</span>
|
||||
<span class="conversation-row__who">
|
||||
<span
|
||||
v-if="row.unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.sender.mailboxName"
|
||||
class="conversation-row__mailbox"
|
||||
@@ -200,8 +213,21 @@ button.conversation-row__line:focus-visible {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) */
|
||||
.conversation-row__time.is-pending {
|
||||
/* 안 읽음 — 뱃지 대신 굵기 · 색으로만, 목록 줄(MessageListItem)과 같은 값 (미리 보기가 목록 줄의 제목 자리다) */
|
||||
.conversation-row.is-unread .conversation-row__who {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.conversation-row.is-unread .conversation-row__preview,
|
||||
.conversation-row.is-unread .conversation-row__time {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) — 안 읽음이어도 */
|
||||
.conversation-row__time.is-pending,
|
||||
.conversation-row.is-unread .conversation-row__time.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ const segments = computed(() => highlightSegments(props.text, props.ranges))
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14) */
|
||||
/*
|
||||
* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14).
|
||||
* 흐린 글자 위면 부르는 쪽이 글자색을 진하게 한다 — 검색 결과의 미리 보기 줄(MessageListItem — 대비 4.5:1, 2026-11-07)
|
||||
*/
|
||||
.search-mark {
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: calc(var(--radius-xs) / 2);
|
||||
|
||||
@@ -465,22 +465,109 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
|
||||
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)
|
||||
describe('패널의 [조건 지우기] — 패널의 칸만 비운다 (2026-11-07 사용자 관점 점검)', () => {
|
||||
const textValues = (wrapper: VueWrapper) =>
|
||||
['from', 'to', 'subject', 'body'].map((name) => (field(wrapper, name).element as HTMLInputElement).value)
|
||||
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
it('칸 · 기간 · 날짜 · 첨부 체크를 비우고 패널은 열어 둔다 — 찾지 않고 초점은 그 단추에, 걸린 조건(주소 · [조건])은 그대로', async () => {
|
||||
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&subject=${encodeURIComponent('견적')}&since=2026-10-01&until=2026-11-05&attachment=1`
|
||||
const { wrapper, router, input } = await mountAt(path)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
await openPanel(wrapper)
|
||||
expect(textValues(wrapper)).toEqual(['이도윤', '', '견적', ''])
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('custom')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
const clear = wrapper.get<HTMLButtonElement>('[data-search-conditions-clear]')
|
||||
clear.element.focus()
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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')
|
||||
expect(textValues(wrapper)).toEqual(['', '', '', ''])
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
|
||||
expect(wrapper.find('[data-search-condition="since"]').exists()).toBe(false)
|
||||
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(false)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
expect(document.activeElement).toBe(clear.element)
|
||||
// 찾지 않는다 — 주소 · 검색 칸 · [조건]의 "조건이 걸려 있음"은 그대로
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(input.element.value).toBe('회의')
|
||||
expect(wrapper.get('[data-search-conditions-toggle]').text()).toContain('조건이 걸려 있음')
|
||||
// 다시 눌러도 그대로다 (비운 칸)
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
expect(textValues(wrapper)).toEqual(['', '', '', ''])
|
||||
expect(document.activeElement).toBe(clear.element)
|
||||
})
|
||||
|
||||
it('칸 아래 알림(날짜가 거꾸로)도 지운다', async () => {
|
||||
const { wrapper } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
await field(wrapper, 'since').setValue('2026-11-05')
|
||||
await field(wrapper, 'until').setValue('2026-10-01')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('[찾기] 없이 닫으면 아무것도 바뀌지 않는다 — 다시 열면 걸린 조건으로 다시 채운다', async () => {
|
||||
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&attachment=1`
|
||||
const { wrapper, router } = await mountAt(path)
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
|
||||
await openPanel(wrapper)
|
||||
expect(textValues(wrapper)).toEqual(['이도윤', '', '', ''])
|
||||
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('비운 뒤 [찾기] — 검색어가 있으면 조건만 빼고 다시 찾고, 조건만 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
|
||||
await router.replace('/mail/search?in=inbox&attachment=1')
|
||||
await flushPromises()
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('검색어도 조건도 없는 [찾기]는 조건이 걸리지 않았으면 아무것도 하지 않는다 — 패널은 그대로', async () => {
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
await input.setValue('')
|
||||
await openPanel(wrapper)
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
|
||||
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
|
||||
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
|
||||
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다
|
||||
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
|
||||
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -20,7 +21,7 @@ import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-
|
||||
|
||||
const PANEL_ID = 'mail-search-conditions'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, clearConditions, closeSearch } = useMailSearchNavigation()
|
||||
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const text = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
@@ -68,7 +69,10 @@ function onKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면 패널을 닫고 검색 칸으로 (낱말이 너무 많으면 알림만 — 패널은 그대로) */
|
||||
/**
|
||||
* [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면(조건을 다 비워 검색을 닫았어도) 패널을 닫고 검색 칸으로
|
||||
* (낱말이 너무 많으면 알림만 — 패널은 그대로)
|
||||
*/
|
||||
async function onApply(conditions: SearchConditions) {
|
||||
if (!(await startSearch(text.value, conditions))) return
|
||||
closeConditions({ restoreFocus: false })
|
||||
@@ -76,12 +80,6 @@ async function onApply(conditions: SearchConditions) {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/** [조건 지우기] — 걸린 조건이 있으면 빼고 다시 찾는다(검색어도 없으면 검색을 닫는다). 패널을 닫고 [조건]으로 */
|
||||
function onClear() {
|
||||
closeConditions()
|
||||
if (conditionsApplied.value) void clearConditions()
|
||||
}
|
||||
|
||||
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
|
||||
watch(
|
||||
() => (routeSearch.value ? routeSearch.value.q : null),
|
||||
@@ -145,7 +143,6 @@ watch(
|
||||
:panel-id="PANEL_ID"
|
||||
:applied="appliedConditions"
|
||||
@apply="onApply"
|
||||
@clear="onClear"
|
||||
@close="closeConditions()"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
|
||||
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
|
||||
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
|
||||
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점). 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
|
||||
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점), clear() 로 칸을 모두 비운다([조건 지우기]).
|
||||
// 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
|
||||
@@ -27,7 +28,7 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { draft, periodProblem, fill, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
|
||||
const { draft, periodProblem, fill, clear, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
|
||||
|
||||
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
|
||||
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
|
||||
@@ -58,6 +59,8 @@ async function takeConditions(): Promise<SearchConditions | null> {
|
||||
|
||||
defineExpose({
|
||||
take: takeConditions,
|
||||
/** 칸을 모두 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림도 (찾지 않는다) */
|
||||
clear,
|
||||
/** 첫 칸(보낸 사람)으로 */
|
||||
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
|
||||
})
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
|
||||
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
|
||||
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
|
||||
// - [조건 지우기]: clear 로 알린다 (부르는 쪽이 걸린 조건을 빼고 패널을 닫는다). Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
|
||||
// - [조건 지우기]: 패널의 칸만 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림까지. 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다.
|
||||
// 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
|
||||
// 걸린 조건을 빼는 일은 결과의 조건 줄 [조건 지우기](MailSearchConditionLine)가 한다 (2026-11-07 사용자 관점 점검)
|
||||
// - Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
|
||||
@@ -20,7 +23,6 @@ interface Props {
|
||||
|
||||
interface Emits {
|
||||
(e: 'apply', conditions: SearchConditions): void
|
||||
(e: 'clear'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
@@ -36,6 +38,11 @@ async function apply() {
|
||||
if (conditions) emit('apply', conditions)
|
||||
}
|
||||
|
||||
/** [조건 지우기] — 칸만 비운다 (머리말 — 찾지 않고, 패널과 초점은 그대로) */
|
||||
function clearFields() {
|
||||
fieldsRef.value?.clear()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
@@ -79,7 +86,7 @@ defineExpose({
|
||||
type="button"
|
||||
class="search-conditions__button"
|
||||
data-search-conditions-clear
|
||||
@click="emit('clear')"
|
||||
@click="clearFields"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.clear }}
|
||||
</button>
|
||||
|
||||
@@ -104,6 +104,8 @@ function deferred() {
|
||||
interface Server {
|
||||
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** 읽음 · 중요 표시 — 한 통(PATCH /messages/{id}) · 여러 통(PATCH /messages) */
|
||||
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
@@ -132,7 +134,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
function serve(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
|
||||
if (request.method === 'PATCH') return ok({ ...REPLY, isRead: true })
|
||||
if (request.method === 'PATCH') return server.patch?.(request) ?? ok({ ...REPLY, isRead: true })
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url.startsWith('/messages/')) {
|
||||
if (server.detail) return server.detail(request)
|
||||
@@ -218,8 +220,9 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
|
||||
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
|
||||
|
||||
// 안 읽은 줄이다 — 화면 낭독용 "안 읽음, "이 이름 앞에 든다 (아래 "안 읽은 줄")
|
||||
const other = rowOf(wrapper, 'm2')
|
||||
expect(other.get('.conversation-row__who').text()).toBe('이도윤')
|
||||
expect(other.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
|
||||
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
|
||||
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
|
||||
@@ -453,6 +456,102 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('안 읽은 줄 (2026-11-07 사용자 관점 점검)', () => {
|
||||
it('안 읽은 줄은 목록 줄처럼 굵게(is-unread) — 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ", 읽은 줄 · 임시 저장본은 아니다', async () => {
|
||||
const draft = summary('d1', {
|
||||
mailboxIds: ['drafts'],
|
||||
from: { name: '김아라', email: USER.email },
|
||||
isRead: false,
|
||||
receivedAt: at(2026, 10, 6, 11, 0),
|
||||
})
|
||||
serve({ conversation: () => page([ORIGINAL, REPLY, CURRENT, draft]) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
const unread = rowOf(wrapper, 'm2')
|
||||
expect(unread.classes()).toContain('is-unread')
|
||||
expect(unread.get('.conversation-row__who .visually-hidden').element.textContent).toBe('안 읽음, ')
|
||||
// 펼침 단추의 이름에서 머리글자는 읽지 않으니(aria-hidden) "안 읽음, 이도윤 …"이 된다
|
||||
expect(unread.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
expect(unread.get('.conversation-row__avatar').attributes('aria-hidden')).toBe('true')
|
||||
for (const id of ['m1', 'm3', 'd1']) {
|
||||
expect(rowOf(wrapper, id).classes()).not.toContain('is-unread')
|
||||
expect(rowOf(wrapper, id).find('.conversation-row__who .visually-hidden').exists()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('펼쳐 읽음이 되면 바로 굵기를 푼다 — 읽음으로 바꾸지 못하면 다시 굵게', async () => {
|
||||
const reply = deferred()
|
||||
serve({ patch: () => reply.promise })
|
||||
const { wrapper } = await mountReader()
|
||||
const row = () => rowOf(wrapper, 'm2')
|
||||
|
||||
await row().get('button').trigger('click')
|
||||
await flushPromises()
|
||||
// 본문을 받았고 읽음을 보내는 중 — 줄은 이미 읽은 모양
|
||||
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.url)).toEqual([
|
||||
'/messages/m2',
|
||||
])
|
||||
expect(row().classes()).not.toContain('is-unread')
|
||||
expect(row().find('.conversation-row__who .visually-hidden').exists()).toBe(false)
|
||||
|
||||
reply.resolve(fail(503, 'MAIL_001'))
|
||||
await flushPromises()
|
||||
expect(row().classes()).toContain('is-unread')
|
||||
expect(row().get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
})
|
||||
|
||||
it('목록 · 여러 통 처리에서 바꾼 읽음 · 안 읽음도 줄에 바로 — 바꾸지 못한 메일은 줄도 되돌린다, 지금 보는 메일도 같다', async () => {
|
||||
const first = deferred()
|
||||
let firstBulk: Promise<MockResult> | null = first.promise
|
||||
serve({
|
||||
patch: (request) => {
|
||||
if (request.url !== '/messages') return ok({ ...REPLY, isRead: true })
|
||||
const reply = firstBulk ?? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
|
||||
firstBulk = null
|
||||
return reply
|
||||
},
|
||||
})
|
||||
const { wrapper, store } = await mountReader()
|
||||
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
|
||||
|
||||
// 목록에서 골라 읽음 표시 — 보내는 동안 줄도 읽은 모양, 서버가 바꾸지 못했으면 다시 굵게
|
||||
store.toggleCheck('m2')
|
||||
const marking = store.patchChecked({ isRead: true })
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
|
||||
first.resolve(ok({ updatedIds: [], failedIds: ['m2'] }))
|
||||
await marking
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
|
||||
|
||||
// 지금 보는 메일을 안 읽음으로 — 그 줄도 굵게 ("지금 보고 있는 메일" 줄)
|
||||
store.clearChecked()
|
||||
store.toggleCheck('m3')
|
||||
await store.patchChecked({ isRead: false })
|
||||
await flushPromises()
|
||||
const current = rowOf(wrapper, 'm3')
|
||||
expect(current.classes()).toContain('is-unread')
|
||||
expect(current.get('[aria-current="true"] .conversation-row__who').text()).toBe('안 읽음, 이도윤')
|
||||
})
|
||||
|
||||
it('다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다 — 목록을 맞춰도 줄은 그대로', async () => {
|
||||
let thread = THREAD
|
||||
serve({ conversation: () => page(thread) })
|
||||
const { wrapper, store } = await mountReader()
|
||||
|
||||
// 다른 탭에서 m2 를 읽었다 — 목록 맞추기가 서버의 값을 가져왔다 (이 탭이 바꾼 것이 아니다)
|
||||
thread = [ORIGINAL, { ...REPLY, isRead: true }, CURRENT]
|
||||
store.messages = [{ ...REPLY, isRead: true }, CURRENT]
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
|
||||
|
||||
// 다시 받으면(새 메일 · 지움 · 옮김으로 통 수가 바뀜 · 쓰기 화면에서 돌아옴) 보인다
|
||||
await store.refreshConversation()
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
|
||||
})
|
||||
})
|
||||
|
||||
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
|
||||
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
|
||||
let failing = true
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
|
||||
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
|
||||
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
|
||||
// - 안 읽은 줄은 목록 줄처럼 굵게(2026-11-07). 이 탭에서 바꾼 읽음은 바로 바뀌고, 다른 탭 · 기기에서 바꾼 것은 다음에 받을 때 보인다(stores/mail/conversation.ts)
|
||||
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
|
||||
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
|
||||
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
|
||||
|
||||
@@ -337,6 +337,14 @@ function onMouseDown(event: MouseEvent) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
* 미리 보기 줄의 맞은 말만 글자색을 진하게 — 흐린 글자(#667085)는 노랑(#FCE7A4) 위에서 4.05:1 이라 4.5:1 에 못 미친다(#4A5363 은 6.3:1, 2026-11-07).
|
||||
* 제목 줄 · 읽기 화면 · 본문 틀의 강조는 진한 글자 위라 글자색 그대로다 (HighlightedText)
|
||||
*/
|
||||
.message-row__preview :deep(.search-mark) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.message-row__who.is-placeholder,
|
||||
.message-row__subject.is-placeholder {
|
||||
|
||||
Reference in New Issue
Block a user