feat: 업데이트 내역 게시판 · 쓰기 화면 [보내기] 숨음과 목록 고르기 칸 고침 #26

Merged
ttipo merged 12 commits from feat/release-notes into main 2026-11-11 06:24:46 +09:00
6 changed files with 355 additions and 9 deletions
Showing only changes of commit 0ff1ec46af - Show all commits
@@ -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)
})
})
+14
View File
@@ -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
}