generated from ttipo/project-base
feat: 업데이트 내역 게시판 · 쓰기 화면 [보내기] 숨음과 목록 고르기 칸 고침 #26
@@ -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<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 스스로 처리하는 것 — 고르기 · 별 단추 (메일 표와 같다) */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -94,10 +94,15 @@ function onClick(event: MouseEvent) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* 상자 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다. 고르면 그림이 바뀌며(빈 상자 → 채운 상자) 누른 <rect> 가 문서에서 떨어져,
|
||||
* 줄 전체에 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 {
|
||||
|
||||
@@ -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 은 빈 상자 그림(<rect>)이고,
|
||||
// 단추의 처리기가 먼저 돌아 고르기를 바꾸면 브라우저는 처리기 사이에 microtask 를 돌려 Vue 가 다시 그린다 — 빈 상자 그림이 빠져 target 이 문서에서 떨어지고,
|
||||
// 줄의 처리기에서 closest('button') 이 비어 줄을 누른 것으로 보고 열었다. 고르기 칸 · 별 칸의 빈자리(단추 밖)를 눌러도 열었다.
|
||||
// 이제 줄은 사건이 지나간 길(event-path.ts — dispatch 때 정해진다)로 가르고, 상자 · 별 그림은 누름을 받지 않는다(pointer-events: none — 브라우저 시험).
|
||||
// 고르기 칸의 빈자리는 고르기 상자를 누른 것과 같고(고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다.
|
||||
// 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않는다 — 단추와 줄 사이(칸의 처리기)에서 그림을 떼어 내 브라우저의 다시 그리기를 흉내 낸다
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): 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<string, unknown>
|
||||
/** 고를 수 없는 줄로 바꾸는 props — 보내기를 기다리는 메일(9단계) */
|
||||
heldProps: Record<string, unknown>
|
||||
/** 고르기 · 별 사건에 실리는 줄 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<VueWrapper> {
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
|
||||
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
|
||||
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다 —
|
||||
// 고르기 · 별 칸은 어디를 눌러도(상자 · 별 그림 위 · 단추 밖의 빈자리) 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고,
|
||||
// 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 메일까지 열렸다)
|
||||
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
|
||||
@@ -19,6 +21,7 @@ import { useMailRowDrag } from '@/composables/useMailDrag'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
import { eventPathHas } from '@/shared/utils/event-path'
|
||||
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'
|
||||
@@ -48,6 +51,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 스스로 처리하는 것 — 고르기 · 별 단추 (Ctrl · Shift + 클릭으로도 고르지 않는다) */
|
||||
const ROW_CONTROLS = 'button, input'
|
||||
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
|
||||
const CHECK_CELL = '.mail-table__check'
|
||||
/** 눌러도 줄을 열지 않는 곳 — 제목 링크(스스로 연다) · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
|
||||
const ROW_NO_OPEN = 'a, .mail-table__star'
|
||||
|
||||
const drag = useMailRowDrag()
|
||||
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
|
||||
@@ -61,16 +71,24 @@ const held = computed(() => isHeldMessage(props.message))
|
||||
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
|
||||
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
/**
|
||||
* 행 어디를 눌러도 연다 — 단추(고르기 · 별)를 누른 경우 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다.
|
||||
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
|
||||
* 무엇을 눌렀는지는 사건이 지나간 길(eventPathHas)로 가른다 — 고르기 상자 가운데를 누르면 target 은 상자 그림인데 단추의 처리기가
|
||||
* 고르기를 바꾸며 다시 그려 문서에서 떨어진다(target.closest 로 보다가 메일까지 열렸다 — 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 (!held.value) emit('check', props.message.id, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (!held.value) emit('check', props.message.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)
|
||||
}
|
||||
@@ -216,6 +234,11 @@ function onMouseDown(event: MouseEvent) {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (고르기 상자 그림과 같다 — MailCheckbox) */
|
||||
.mail-table__star-button .app-icon {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { eventPathHas } from './event-path'
|
||||
|
||||
// 사건이 지나간 길 — 앞선 처리기가 target 을 문서에서 떼어 내도 dispatch 때의 길로 가른다 (2026-11-10 — 목록 보기의 고르기 상자)
|
||||
|
||||
describe('eventPathHas', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** <a-wrap> 안의 줄(tr 대신 div) — 줄 > 칸 > 단추 > 그림 */
|
||||
function build() {
|
||||
document.body.innerHTML =
|
||||
'<a id="outer"><div id="row"><div id="cell" class="cell"><button id="button"><svg><rect id="picture" /></svg></button></div><span id="text">글</span></div></a>'
|
||||
const get = (id: string) => document.getElementById(id) as Element
|
||||
return { outer: get('outer'), row: get('row'), cell: get('cell'), button: get('button'), picture: get('picture'), text: get('text') }
|
||||
}
|
||||
|
||||
/** 줄에 단 처리기에서 본 값 */
|
||||
function seenByRow(row: Element, target: Element, selector: string, before?: () => void): { path: boolean; closest: boolean } {
|
||||
let seen = { path: false, closest: false }
|
||||
const listener = (event: Event) => {
|
||||
seen = {
|
||||
path: eventPathHas(event, selector),
|
||||
closest: event.target instanceof Element && event.target.closest(selector) !== null,
|
||||
}
|
||||
}
|
||||
row.addEventListener('click', listener)
|
||||
if (before) target.parentElement?.closest('.cell')?.addEventListener('click', before)
|
||||
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
row.removeEventListener('click', listener)
|
||||
return seen
|
||||
}
|
||||
|
||||
it('그림을 누른 click — 단추의 처리기 뒤 그림이 문서에서 떨어져도 길에는 단추가 있다 (closest 는 비었다)', () => {
|
||||
const { row, picture } = build()
|
||||
|
||||
const seen = seenByRow(row, picture, 'button', () => picture.remove())
|
||||
|
||||
expect(picture.isConnected).toBe(false)
|
||||
expect(seen).toEqual({ path: true, closest: false })
|
||||
})
|
||||
|
||||
it('칸의 빈자리 · 칸 밖은 그 선택자가 길에 없다', () => {
|
||||
const { row, cell, text } = build()
|
||||
|
||||
expect(seenByRow(row, cell, 'button').path).toBe(false)
|
||||
expect(seenByRow(row, cell, '.cell').path).toBe(true)
|
||||
expect(seenByRow(row, text, 'button, .cell').path).toBe(false)
|
||||
})
|
||||
|
||||
it('사건을 받은 요소(currentTarget)까지만 본다 — 줄을 감싼 바깥 링크는 세지 않는다', () => {
|
||||
const { row, text } = build()
|
||||
|
||||
expect(seenByRow(row, text, 'a').path).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
// 사건이 지나간 길 — dispatch 할 때 정해져, 앞선 처리기가 화면을 다시 그려 target 이 문서에서 떨어져도 그대로다.
|
||||
// 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 가르는 곳(목록 보기의 줄 — MessageTableRow · ConversationTableRow)은
|
||||
// target.closest 대신 이것으로 본다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 누르면 target 은 상자 그림(<rect>)인데,
|
||||
// 단추의 처리기가 고르기를 바꾸자 처리기 사이(microtask)에 Vue 가 다시 그려 그 그림을 떼어 냈다. 줄의 처리기에서 closest('button') 이
|
||||
// 비어 줄을 누른 것으로 보고 메일까지 열었다)
|
||||
|
||||
/** 사건이 지나간 길에서 그 사건을 받은 요소(currentTarget)까지 — 선택자에 맞는 요소가 있었는가 */
|
||||
export function eventPathHas(event: Event, selector: string): boolean {
|
||||
for (const node of event.composedPath()) {
|
||||
if (node instanceof Element && node.matches(selector)) return true
|
||||
if (node === event.currentTarget) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user