diff --git a/frontend/src/components/mail/ConversationTableRow.vue b/frontend/src/components/mail/ConversationTableRow.vue index 61d4706..2e0098c 100644 --- a/frontend/src/components/mail/ConversationTableRow.vue +++ b/frontend/src/components/mail/ConversationTableRow.vue @@ -3,6 +3,7 @@ // - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다 // - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다 // - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다) +// 고르기 · 별 칸은 어디를 눌러도 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고, 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고) // - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title) // - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5) // 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다. @@ -18,6 +19,7 @@ import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/co import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation' import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later' import type { ConversationSummary } from '@/shared/types/mail' +import { eventPathHas } from '@/shared/utils/event-path' import { formatMailTableDate } from '@/shared/utils/format' import { conversationCountText } from '@/shared/utils/mail-conversation-list' import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row' @@ -50,6 +52,13 @@ const props = withDefaults(defineProps(), { const emit = defineEmits() +/** 스스로 처리하는 것 — 고르기 · 별 단추 (메일 표와 같다) */ +const ROW_CONTROLS = 'button, input' +/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */ +const CHECK_CELL = '.mail-table__check' +/** 눌러도 줄을 열지 않는 곳 — 제목 링크 · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */ +const ROW_NO_OPEN = 'a, .mail-table__star' + const drag = useMailRowDrag() const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT) @@ -57,16 +66,23 @@ const countText = computed(() => conversationCountText(props.row)) /** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */ const pendingLabel = computed(() => pendingSendLabel(props.row.latest)) -/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */ +/** + * 행 어디를 눌러도 연다 — 단추 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다). + * 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다. + * 무엇을 눌렀는지는 사건이 지나간 길로 가른다 — 고르기 상자 그림은 고르면 다시 그려져 target 이 문서에서 떨어진다 (2026-11-10 사용자 보고) + */ function onRowClick(event: MouseEvent) { - const target = event.target instanceof Element ? event.target : null - if (target?.closest('button')) return + if (eventPathHas(event, ROW_CONTROLS)) return + if (eventPathHas(event, CHECK_CELL)) { + if (!props.held) emit('check', props.row.id, event.shiftKey) + return + } if (event.ctrlKey || event.metaKey || event.shiftKey) { event.preventDefault() if (!props.held) emit('check', props.row.id, event.shiftKey) return } - if (target?.closest('a')) return + if (eventPathHas(event, ROW_NO_OPEN)) return if (window.getSelection()?.toString()) return emit('open', props.to) } @@ -215,6 +231,11 @@ function onStar() { color: var(--color-star); } +/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (메일 표와 같다) */ +.thread-table-row__star .app-icon { + pointer-events: none; +} + .thread-table-row__star[aria-disabled='true'] { cursor: not-allowed; } diff --git a/frontend/src/components/mail/MailCheckbox.vue b/frontend/src/components/mail/MailCheckbox.vue index c17238a..1873420 100644 --- a/frontend/src/components/mail/MailCheckbox.vue +++ b/frontend/src/components/mail/MailCheckbox.vue @@ -94,10 +94,15 @@ function onClick(event: MouseEvent) { background: transparent; } +/* + * 상자 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다. 고르면 그림이 바뀌며(빈 상자 → 채운 상자) 누른 가 문서에서 떨어져, + * 줄 전체에 click 을 건 목록 보기가 단추를 누른 줄 모르고 메일까지 열었다 (2026-11-10 사용자 보고 — 줄은 사건의 길로도 가른다, event-path.ts) + */ .mail-checkbox__box { flex: 0 0 auto; width: var(--size-checkbox); height: var(--size-checkbox); + pointer-events: none; } .mail-checkbox__empty { diff --git a/frontend/src/components/mail/MailListRows.check.spec.ts b/frontend/src/components/mail/MailListRows.check.spec.ts new file mode 100644 index 0000000..02762f1 --- /dev/null +++ b/frontend/src/components/mail/MailListRows.check.spec.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' +import type { Component } from 'vue' + +import type { ConversationSummary, MessageSummary } from '@/shared/types/mail' +import { flushPromises } from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import ConversationTableRow from './ConversationTableRow.vue' +import MessageListItem from './MessageListItem.vue' +import MessageTableRow from './MessageTableRow.vue' + +// 고르기 칸을 누르면 메일까지 열렸다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 눌렀다). +// 목록 보기의 줄(tr)은 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 target.closest 로 갈랐다. 상자 가운데를 누르면 target 은 빈 상자 그림()이고, +// 단추의 처리기가 먼저 돌아 고르기를 바꾸면 브라우저는 처리기 사이에 microtask 를 돌려 Vue 가 다시 그린다 — 빈 상자 그림이 빠져 target 이 문서에서 떨어지고, +// 줄의 처리기에서 closest('button') 이 비어 줄을 누른 것으로 보고 열었다. 고르기 칸 · 별 칸의 빈자리(단추 밖)를 눌러도 열었다. +// 이제 줄은 사건이 지나간 길(event-path.ts — dispatch 때 정해진다)로 가르고, 상자 · 별 그림은 누름을 받지 않는다(pointer-events: none — 브라우저 시험). +// 고르기 칸의 빈자리는 고르기 상자를 누른 것과 같고(고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다. +// 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않는다 — 단추와 줄 사이(칸의 처리기)에서 그림을 떼어 내 브라우저의 다시 그리기를 흉내 낸다 + +function summary(id: string, partial: Partial = {}): MessageSummary { + return { + id, + threadId: `t-${id}`, + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }], + subject: `제목 ${id}`, + preview: '', + receivedAt: '2026-11-10T01:00:00Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 2048, + ...partial, + } +} + +function conversation(id: string): ConversationSummary { + const latest = summary(`${id}-latest`) + return { + id, + latest, + participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + messageCount: 3, + messageIds: [latest.id, `${id}-2`, `${id}-3`], + pendingSendIds: [], + isRead: true, + isFlagged: false, + hasAttachment: false, + } +} + +interface RowCase { + name: string + component: Component + props: Record + /** 고를 수 없는 줄로 바꾸는 props — 보내기를 기다리는 메일(9단계) */ + heldProps: Record + /** 고르기 · 별 사건에 실리는 줄 id */ + rowId: string +} + +const ROWS: RowCase[] = [ + { + name: '메일 줄 (MessageTableRow)', + component: MessageTableRow, + props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' }, + heldProps: { message: summary('m1', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-11T00:00:00Z' } }) }, + rowId: 'm1', + }, + { + name: '대화 줄 (ConversationTableRow — 대화로 묶기)', + component: ConversationTableRow, + props: { + row: conversation('c1'), + who: { text: '이도윤', placeholder: false }, + to: '/mail/inbox/c1-latest', + }, + heldProps: { held: true }, + rowId: 'c1', + }, +] + +describe('목록 보기의 줄 — 고르기 칸 · 별 칸을 눌러도 메일을 열지 않는다 (2026-11-10 사용자 보고)', () => { + let pinia: Pinia + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + async function mountRow(row: RowCase): Promise { + const router = createTestRouter() + await router.push('/mail/inbox') + await router.isReady() + return mount(row.component, { props: row.props, global: { plugins: [pinia, router] } }) + } + + /** 누른 그림을 단추와 줄 사이(칸의 처리기)에서 떼어 낸다 — 브라우저에서 단추의 처리기 뒤 Vue 가 다시 그린 것처럼 */ + function detachBeforeRow(wrapper: VueWrapper, cellSelector: string, picture: Element) { + wrapper.get(cellSelector).element.addEventListener('click', () => picture.remove()) + } + + function click(element: Element, init: MouseEventInit = {}) { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })) + } + + describe.each(ROWS)('$name', (row) => { + it('고르기 상자 그림(빈 상자)을 누르면 고르기만 한다 — 고르며 다시 그려 그림이 문서에서 떨어져도 열지 않는다', async () => { + const wrapper = await mountRow(row) + const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element + detachBeforeRow(wrapper, 'td.mail-table__check', picture) + + click(picture) + await flushPromises() + + expect(picture.isConnected).toBe(false) + expect(wrapper.emitted('check')).toEqual([[row.rowId, false]]) + expect(wrapper.emitted('open')).toBeUndefined() + }) + + it('Shift + 상자 그림은 범위로 고른다 — 열지 않는다', async () => { + const wrapper = await mountRow(row) + const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element + detachBeforeRow(wrapper, 'td.mail-table__check', picture) + + click(picture, { shiftKey: true }) + + expect(wrapper.emitted('check')).toEqual([[row.rowId, true]]) + expect(wrapper.emitted('open')).toBeUndefined() + }) + + it('고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고 · Shift 는 범위 · Ctrl(⌘)은 하나씩, 열지 않는다', async () => { + const wrapper = await mountRow(row) + const cell = wrapper.get('td.mail-table__check').element + + click(cell) + click(cell, { shiftKey: true }) + click(cell, { ctrlKey: true }) + click(cell, { metaKey: true }) + + expect(wrapper.emitted('check')).toEqual([ + [row.rowId, false], + [row.rowId, true], + [row.rowId, false], + [row.rowId, false], + ]) + expect(wrapper.emitted('open')).toBeUndefined() + }) + + it('고를 수 없는 줄(보내기를 기다리는 메일)이면 고르기 칸의 빈자리를 눌러도 아무 일도 없다', async () => { + const wrapper = await mountRow({ ...row, props: { ...row.props, ...row.heldProps } }) + + click(wrapper.get('td.mail-table__check').element) + click(wrapper.get('td.mail-table__check').element, { shiftKey: true }) + + expect(wrapper.emitted('check')).toBeUndefined() + expect(wrapper.emitted('open')).toBeUndefined() + }) + + it('별 칸의 빈자리(단추 밖)는 아무 일도 없다 — 열지도 · 별을 바꾸지도 · 고르지도 않는다', async () => { + const wrapper = await mountRow(row) + + click(wrapper.get('td.mail-table__star').element) + + expect(wrapper.emitted('open')).toBeUndefined() + expect(wrapper.emitted('check')).toBeUndefined() + expect(wrapper.emitted('toggle-flag')).toBeUndefined() + }) + + it('별 그림을 누르면 중요 표시만 한다 — 그림이 떨어져도 열지 않는다', async () => { + const wrapper = await mountRow(row) + const picture = wrapper.get('td.mail-table__star svg').element + detachBeforeRow(wrapper, 'td.mail-table__star', picture) + + click(picture) + + expect(wrapper.emitted('toggle-flag')).toEqual([[row.rowId]]) + expect(wrapper.emitted('open')).toBeUndefined() + }) + + it('줄의 다른 곳(사람 · 날짜 칸)을 누르면 지금처럼 연다 · Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다', async () => { + const wrapper = await mountRow(row) + const cells = wrapper.findAll('td') + + click(cells[2]!.element) + expect(wrapper.emitted('open')).toHaveLength(1) + + click(wrapper.get('td.mail-table__date').element, { ctrlKey: true }) + click(cells[2]!.element, { shiftKey: true }) + expect(wrapper.emitted('check')).toEqual([ + [row.rowId, false], + [row.rowId, true], + ]) + expect(wrapper.emitted('open')).toHaveLength(1) + }) + }) +}) + +describe('분할 보기의 줄 — 고르기 칸(36)은 링크 밖이라 열지 않는다 (지금처럼)', () => { + it('상자 그림을 눌러도 고르기만 하고 주소는 그대로다 · 줄(링크)을 누르면 연다', async () => { + setActivePinia(createPinia()) + const router = createTestRouter() + await router.push('/mail/inbox') + await router.isReady() + const wrapper = mount(MessageListItem, { + props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' }, + global: { plugins: [router] }, + }) + + click(wrapper.get('.message-row__check .mail-checkbox__empty').element) + await flushPromises() + expect(wrapper.emitted('check')).toEqual([['m1', false]]) + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + + await wrapper.get('.message-row__main').trigger('click') + await flushPromises() + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1') + }) + + function click(element: Element) { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + } +}) diff --git a/frontend/src/components/mail/MessageTableRow.vue b/frontend/src/components/mail/MessageTableRow.vue index 05579ec..860700e 100644 --- a/frontend/src/components/mail/MessageTableRow.vue +++ b/frontend/src/components/mail/MessageTableRow.vue @@ -1,6 +1,8 @@