feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기

확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) ·
고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다.

- 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 —
  연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다
- 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다
- 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:59:53 +09:00
parent 7ce234750d
commit 894550732e
45 changed files with 5410 additions and 80 deletions
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
@@ -21,6 +22,8 @@ interface Props {
describedBy?: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
/** 칸이 많은 창 — 폭 620 · 좌우 여백 20 (디자인 원본 SettingsFilter 의 고치기 창). 주지 않으면 공용 폭 360 */
wide?: boolean
}
interface Emits {
@@ -33,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
messageId: undefined,
describedBy: undefined,
role: undefined,
wide: false,
})
const emit = defineEmits<Emits>()
@@ -97,7 +101,7 @@ onMounted(() => {
<template>
<dialog
ref="dialogRef"
class="modal-dialog"
:class="['modal-dialog', { 'modal-dialog--wide': wide }]"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
@@ -135,6 +139,12 @@ onMounted(() => {
box-shadow: var(--shadow-dialog);
}
/* 넓은 창 — 칸이 많은 고치기 창 (디자인 원본 SettingsFilter: 폭 620 · 안쪽 여백 18 20 14) */
.modal-dialog--wide {
width: min(38.75rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-20) var(--space-14);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
@@ -178,13 +188,14 @@ onMounted(() => {
font-weight: 600;
}
:slotted(.modal-dialog__action:not(:disabled):hover) {
:slotted(.modal-dialog__action:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-brand-strong);
}
/*
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
* 마우스를 올린 모양은 모두 :not(:disabled):not([aria-disabled='true']) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
* (초점을 지키려고 disabled 대신 aria-disabled 로 막은 단추 — 필터 고치기 창의 [저장] · [이 필터 지우기] — 도 같다)
*/
:slotted(.modal-dialog__action:disabled) {
opacity: 0.5;
@@ -196,7 +207,7 @@ onMounted(() => {
background: var(--color-danger);
}
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
:slotted(.modal-dialog__action--danger:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-danger-strong);
}
@@ -207,7 +218,7 @@ onMounted(() => {
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
:slotted(.modal-dialog__action--secondary:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
@@ -341,9 +341,9 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
])
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다 (꺼진 항목은 aria-disabled — 초점은 받는다)
const items = await openMenu(wrapper, '거래처')
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
await flushPromises()
@@ -388,7 +388,7 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
const items = await openMenu(wrapper, '받은편지함')
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
expect(items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])).toEqual([
['모두 읽음으로 표시', true],
['하위 메일함 만들기', false],
])
@@ -587,7 +587,7 @@ describe('MailboxPanel — 메일함 관리', () => {
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
const itemState = (items: DOMWrapper<Element>[]) =>
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
const second = deferred()
@@ -1,49 +1,79 @@
<script setup lang="ts">
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
// 과 그 아래 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
// 보낸 사람 주소가 없는 메일에는 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useMail } from '@/composables/useMail'
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useMailFilterStore } from '@/stores/mail-filter.store'
interface Props {
messageId: string
/** 보낸 사람 주소 — 없으면 「이런 메일로 필터 만들기」를 두지 않는다 */
senderEmail?: string | null
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
senderEmail: null,
})
const { downloadMessageSource } = useMail()
const filterStore = useMailFilterStore()
const router = useRouter()
const buttonRef = ref<HTMLButtonElement | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const ITEMS: readonly EditorMenuItem[] = [
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
]
const sender = computed(() => props.senderEmail?.trim() ?? '')
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
if (sender.value) {
list.push({
id: 'filter',
label: MAIL_READER_MORE_TEXT.createFilter,
icon: 'filter',
disabled: filterStore.isFull,
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
})
}
return list
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
function openMenu(keyboard: boolean) {
byKeyboard.value = keyboard
open.value = true
// 필터를 50개 다 만들었는지 — 받아 둔 것이 없을 때만 받아 본다
if (sender.value) filterStore.ensureLoaded()
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
byKeyboard.value = event.detail === 0
open.value = true
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
byKeyboard.value = true
open.value = true
openMenu(true)
}
function onClose(restoreFocus: boolean) {
@@ -55,6 +85,9 @@ function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'source') void downloadMessageSource(props.messageId)
if (id === 'filter' && sender.value && !filterStore.isFull) {
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
}
}
</script>
@@ -78,7 +111,7 @@ function onSelect(id: string) {
v-if="open"
class="reader-more__menu"
:label="MAIL_READER_MORE_TEXT.button"
:items="ITEMS"
:items="items"
:trigger="trigger"
align="end"
:focus-content="!byKeyboard"
@@ -113,8 +146,9 @@ function onSelect(id: string) {
color: var(--color-text);
}
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */
.reader-more__menu {
min-width: 12.5rem;
max-width: 17.5rem;
}
</style>
@@ -5,7 +5,7 @@ import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -47,6 +47,9 @@ const DETAIL: MessageDetail = {
attachments: [],
}
/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */
const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true }
describe('MessageReader 도구 모음', () => {
let pinia: Pinia
@@ -487,12 +490,20 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
})
async function mountWith(
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
options: {
box?: string
mailboxes?: Mailbox[]
me?: MockResult
refresh?: MockResult
filters?: MailFilterSet
from?: MessageDetail['from']
} = {},
) {
const box = options.box ?? 'inbox'
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
requests = installMockApi((request) => {
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS)
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url === '/messages' && request.method === 'GET') {
@@ -504,7 +515,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const store = useMailStore()
store.mailboxes = mailboxes
store.listSource = { kind: 'mailbox', mailboxId: box }
const open = { ...DETAIL, mailboxIds: [box] }
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
@@ -580,13 +591,14 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기'])
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
// 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me'])
expect(downloads).toEqual(['/api/messages/m1/source'])
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement).toBe(more.element)
@@ -603,4 +615,94 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
it('⋯ → 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터로 보낸 사람 주소를 싣고 간다', async () => {
const { wrapper, router } = await mountWith()
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]')[1]
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
expect(item?.attributes('disabled')).toBeUndefined()
await item?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
expect(router.currentRoute.value.query).toEqual({ from: 'doyun.lee@arasystem.it.kr' })
})
const fiftyFilters = (): MailFilterRule[] =>
Array.from({ length: 50 }, (_, index) => ({
id: `f${index}`,
name: '',
isEnabled: true,
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
}))
it('필터를 50개 다 만들었으면 그 항목을 끄고 까닭을 아래에 — 키보드로 가서 까닭은 듣되 고를 수 없다 (WAI-ARIA 메뉴)', async () => {
const { wrapper, router } = await mountWith({ filters: { rules: fiftyFilters(), version: 'v1', isActive: true } })
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menuitem"]')
const item = items[1]
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.attributes('disabled')).toBeUndefined()
const note = item?.get('.menu__note')
expect(note?.text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id'))
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement).toBe(item?.element)
await item?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
// 꺼진 항목을 골라도 메뉴는 그대로다
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
})
it('50개로 받아 둔 뒤 다른 곳에서 지웠으면 — ⋯ 를 다시 열 때 알림 없이 다시 받아 항목을 켠다', async () => {
const server: { filters: MailFilterSet } = { filters: { rules: fiftyFilters(), version: 'v1', isActive: true } }
const { wrapper } = await mountWith({
get filters() {
return server.filters
},
})
const more = wrapper.get('[data-reader-more]')
const filterItem = () => wrapper.findAll('[role="menuitem"]')[1]
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
await more.trigger('click')
await flushPromises()
expect(filterItem()?.attributes('aria-disabled')).toBe('true')
await more.trigger('click')
server.filters = { rules: fiftyFilters().slice(1), version: 'v2', isActive: true }
await more.trigger('click')
await flushPromises()
expect(filterItem()?.attributes('aria-disabled')).toBeUndefined()
expect(filterItem()?.find('.menu__note').exists()).toBe(false)
expect(filterGets()).toHaveLength(2)
// 49개면 다시 받지 않는다
await more.trigger('click')
await more.trigger('click')
await flushPromises()
expect(filterGets()).toHaveLength(2)
expect(useToastStore().toasts).toEqual([])
})
it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => {
const { wrapper } = await mountWith({ from: null })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
})
})
@@ -3,7 +3,8 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기)
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
import { computed } from 'vue'
@@ -110,7 +111,10 @@ function leaveToList() {
:message="message"
:layout="layout"
/>
<MessageMoreMenu :message-id="message.id" />
<MessageMoreMenu
:message-id="message.id"
:sender-email="message.from?.email ?? null"
/>
</template>
</div>
</div>
@@ -2,6 +2,10 @@
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
import { useId } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
@@ -28,9 +32,22 @@ withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
/** 이 메뉴의 고유 접두 — 까닭(note)의 id */
const menuId = useId()
function isToggle(role: string | undefined): boolean {
return role === 'menuitemcheckbox' || role === 'menuitemradio'
}
function noteId(itemId: string): string {
return `${menuId}-${itemId}-note`
}
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
function select(item: EditorMenuItem, event: MouseEvent) {
if (item.separator || item.disabled) return
emit('select', item.id, event.detail === 0)
}
</script>
<template>
@@ -53,12 +70,13 @@ function isToggle(role: string | undefined): boolean {
<button
v-else
type="button"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
:role="item.role ?? 'menuitem'"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:disabled="item.disabled"
@click="(event: MouseEvent) => emit('select', item.id, event.detail === 0)"
:aria-disabled="item.disabled ? 'true' : undefined"
:aria-describedby="item.note ? noteId(item.id) : undefined"
@click="(event: MouseEvent) => select(item, event)"
>
<AppIcon
v-if="item.icon"
@@ -71,7 +89,22 @@ function isToggle(role: string | undefined): boolean {
:color="item.colorGlyph.color"
class="menu__icon"
/>
<span :class="['menu__label', item.preview ? `preview--${item.preview}` : '']">{{ item.label }}</span>
<span
v-if="item.note"
class="menu__text"
>
<span class="menu__label">{{ item.label }}</span>
<!-- 이름에 섞이지 않게 숨기고 aria-describedby 로만 읽힌다 -->
<span
:id="noteId(item.id)"
class="menu__note"
aria-hidden="true"
>{{ item.note }}</span>
</span>
<span
v-else
:class="['menu__label', item.preview ? `preview--${item.preview}` : '']"
>{{ item.label }}</span>
<span
v-if="item.badge"
class="menu__badge"
@@ -117,11 +150,11 @@ function isToggle(role: string | undefined): boolean {
text-align: left;
}
.menu__item:hover:not(:disabled) {
.menu__item:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.menu__item:disabled {
.menu__item[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
@@ -131,7 +164,7 @@ function isToggle(role: string | undefined): boolean {
color: var(--color-danger);
}
.menu__item--danger:hover:not(:disabled) {
.menu__item--danger:hover:not([aria-disabled='true']) {
background: var(--color-danger-surface);
}
@@ -148,6 +181,32 @@ function isToggle(role: string | undefined): boolean {
min-width: 0;
}
/* 이름 + 아래 작은 글 (note) — 글은 메뉴 폭 안에서 줄을 바꾼다 */
.menu__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) 0;
}
.menu__note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
white-space: normal;
}
/* 까닭이 붙은 꺼진 항목 — 이름 · 아이콘만 흐리게, 까닭은 그대로 읽힌다 */
.menu__item--noted[aria-disabled='true'] {
opacity: 1;
}
.menu__item--noted[aria-disabled='true'] .menu__label,
.menu__item--noted[aria-disabled='true'] .menu__icon {
opacity: 0.5;
}
.menu__badge,
.menu__hint {
flex: 0 0 auto;
@@ -33,6 +33,10 @@ const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
/**
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
* disabled 단추는 초점을 받을 수 없어 건너뛴다
*/
const MENU_ITEM = '[role^="menuitem"]:not(:disabled)'
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex="0"]'
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
hint?: string
checked?: boolean
disabled?: boolean
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
note?: string
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
@@ -0,0 +1,278 @@
<script setup lang="ts">
// 고치기 창의 "할 일" (디자인 원본 SettingsFilter) — 줄마다 44:
// [x] 메일함으로 옮기기 + 옮길 메일함(40) · "옮기면 이 아래 필터는 적용하지 않습니다." / [x] 읽음으로 표시 / [x] 중요 표시 /
// [x] 동료에게 전달 + 사내 주소(40) · 안내(잘못이면 그 자리가 빨간 글)
// 끈 할 일의 칸은 흐리게 — 고른 메일함 · 쓴 주소는 남긴다. 할 일을 하나도 고르지 않았으면 묶음 아래 빨간 글(첫 칸이 그 글을 가리킨다).
// 옮길 메일함은 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(filterMoveTargets). 골라 둔 메일함이 지워졌으면
// "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로 보인다(다시 고를 수는 없다). 고를 수 없는 곳(보관함 등)을 가리키면 그 이름을 그대로 보인다
// 고정 id — #mail-filter-move · #mail-filter-move-target · #mail-filter-move-hint · #mail-filter-read · #mail-filter-flag ·
// #mail-filter-forward · #mail-filter-forward-to · #mail-filter-forward-help · #mail-filter-actions-error
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_FORWARD_MAX_LENGTH } from '@/shared/constants/mail-filter'
import type { Mailbox } from '@/shared/types/mail'
import type { MailFilterActionDraft } from '@/shared/utils/mail-filter'
interface Props {
value: MailFilterActionDraft
/** 고를 수 있는 메일함 — 이 차례로 보인다 */
targets: readonly Mailbox[]
/** 모든 메일함 — 골라 둔 메일함이 있는지 본다 */
mailboxes: readonly Mailbox[]
/** 로그인한 직원 주소의 도메인 — 주소 칸의 보기 */
domain: string
/** 할 일을 하나도 고르지 않았다 — 칸 문구 */
actionsError: string | null
/** 전달 주소의 잘못 — 칸 문구 (안내 자리에 빨간 글로) */
forwardError: string | null
/** 처음 초점을 첫 칸(메일함으로 옮기기)에 — 읽기 화면에서 만든 새 필터 */
autofocusFirst: boolean
}
interface Emits {
(e: 'update', patch: Partial<MailFilterActionDraft>): void
}
interface MoveOption {
id: string
label: string
disabled: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const moveRef = ref<HTMLInputElement | null>(null)
const forwardRef = ref<HTMLInputElement | null>(null)
const options = computed<MoveOption[]>(() => {
const list = props.targets.map((mailbox) => ({ id: mailbox.id, label: mailbox.name, disabled: false }))
const target = props.value.moveTarget
if (target === '' || list.some((option) => option.id === target)) return list
const known = props.mailboxes.find((mailbox) => mailbox.id === target)
const current = known
? { id: known.id, label: known.name, disabled: false }
: { id: target, label: MAIL_FILTER_DIALOG_TEXT.missingMailboxOption, disabled: true }
return [current, ...list]
})
function checked(event: Event): boolean {
return (event.target as HTMLInputElement).checked
}
function valueOf(event: Event): string {
return (event.target as HTMLInputElement | HTMLSelectElement).value
}
defineExpose({
/** 할 일이 없을 때 · 읽기 화면에서 만든 새 필터 — 첫 칸(메일함으로 옮기기)으로 */
focusFirst: () => moveRef.value?.focus(),
/** 전달 주소가 잘못됐을 때 */
focusForward: () => forwardRef.value?.focus(),
})
</script>
<template>
<fieldset class="filter-actions">
<legend class="filter-actions__legend">
{{ MAIL_FILTER_DIALOG_TEXT.actionsLegend }}
</legend>
<div class="filter-actions__line">
<label class="filter-actions__check filter-actions__check--lead">
<input
id="mail-filter-move"
ref="moveRef"
type="checkbox"
class="filter-actions__box"
:checked="value.move"
:autofocus="autofocusFirst || undefined"
:aria-describedby="actionsError ? 'mail-filter-actions-error' : undefined"
@change="emit('update', { move: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.move }}</span>
</label>
<select
id="mail-filter-move-target"
class="form-input filter-actions__select"
:aria-label="MAIL_FILTER_DIALOG_TEXT.moveTarget"
aria-describedby="mail-filter-move-hint"
:value="value.moveTarget"
:disabled="!value.move"
@change="emit('update', { moveTarget: valueOf($event) })"
>
<option
v-for="option in options"
:key="option.id"
:value="option.id"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</div>
<p
id="mail-filter-move-hint"
class="form-message filter-actions__hint filter-actions__hint--spaced"
>
{{ MAIL_FILTER_DIALOG_TEXT.moveHint }}
</p>
<label class="filter-actions__check">
<input
id="mail-filter-read"
type="checkbox"
class="filter-actions__box"
:checked="value.markAsRead"
@change="emit('update', { markAsRead: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.markAsRead }}</span>
</label>
<label class="filter-actions__check">
<input
id="mail-filter-flag"
type="checkbox"
class="filter-actions__box"
:checked="value.flag"
@change="emit('update', { flag: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.flag }}</span>
</label>
<div class="filter-actions__line">
<label class="filter-actions__check filter-actions__check--lead">
<input
id="mail-filter-forward"
type="checkbox"
class="filter-actions__box"
:checked="value.forward"
@change="emit('update', { forward: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.forward }}</span>
</label>
<input
id="mail-filter-forward-to"
ref="forwardRef"
class="form-input filter-actions__forward"
type="email"
:value="value.forwardTo"
:maxlength="MAIL_FILTER_FORWARD_MAX_LENGTH"
:placeholder="MAIL_FILTER_DIALOG_TEXT.forwardPlaceholder(domain)"
:aria-label="MAIL_FILTER_DIALOG_TEXT.forwardTarget"
:aria-invalid="forwardError ? 'true' : 'false'"
aria-describedby="mail-filter-forward-help"
:disabled="!value.forward"
autocomplete="off"
spellcheck="false"
@input="emit('update', { forwardTo: valueOf($event) })"
>
</div>
<p
id="mail-filter-forward-help"
:class="['form-message', 'filter-actions__hint', { 'form-message--error': forwardError }]"
>
<AppIcon
v-if="forwardError"
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ forwardError ?? MAIL_FILTER_DIALOG_TEXT.forwardHint }}</span>
</p>
<p
v-if="actionsError"
id="mail-filter-actions-error"
class="form-message form-message--error filter-actions__error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ actionsError }}</span>
</p>
</fieldset>
</template>
<style scoped>
.filter-actions {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.filter-actions__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 고르기 + 칸 한 줄 — 좁으면 칸이 아래로 내려간다 */
.filter-actions__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-12);
min-height: var(--size-touch);
}
.filter-actions__check {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
/* 칸 앞의 이름 — 두 줄의 칸이 같은 선에서 시작한다 (176) */
.filter-actions__check--lead {
min-width: 11rem;
}
.filter-actions__box {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
/* 칸 높이 40 — 밀도 예외 (tokens.css "6단계") */
.filter-actions__select,
.filter-actions__forward {
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-10);
}
.filter-actions__select {
min-width: 12.5rem;
}
.filter-actions__forward {
flex: 1 1 15rem;
min-width: 0;
max-width: 20rem;
}
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 */
.filter-actions__hint {
margin-left: calc(var(--size-option-input) + var(--space-10));
}
.filter-actions__hint--spaced {
margin-bottom: var(--space-4);
}
.filter-actions__error {
margin-top: var(--space-4);
}
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
// 고치기 창의 "어떤 메일에" (디자인 원본 SettingsFilter) — 고르기(모두 · 하나라도 · 모든 메일 — 44, 최대 320) + 조건 줄(MailFilterConditionRow) +
// [조건 더하기](40 — 5개까지, 새 줄은 제목) · "5개까지 · 글자는 대소문자를 가리지 않습니다". 모든 메일이면 조건 줄을 숨긴다(쓴 조건은 부모가 남겨 둔다).
// 줄을 더하면 새 줄의 "어디에서"로, 지우면 같은 자리(마지막이었으면 앞) 줄의 찾을 글로 초점을 옮긴다 — 누른 단추가 사라지거나 꺼지므로
// 고정 id — #mail-filter-match · #mail-filter-add-condition (줄의 오류 id 는 MailFilterConditionRow)
import { nextTick, type ComponentPublicInstance } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailFilterConditionRow from '@/components/settings/MailFilterConditionRow.vue'
import {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_MATCH_LABELS,
MAIL_FILTER_MAX_CONDITIONS,
} from '@/shared/constants/mail-filter'
import type { MailFilterCondition, MailFilterMatch } from '@/shared/types/mail'
interface Props {
match: MailFilterMatch
conditions: readonly MailFilterCondition[]
/** 줄마다의 잘못 — 칸 문구 (차례는 conditions 와 같다) */
errors: readonly (string | null)[]
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기 */
domain: string
}
interface Emits {
(e: 'update:match', match: MailFilterMatch): void
(e: 'update-condition', index: number, patch: Partial<MailFilterCondition>): void
(e: 'add'): void
(e: 'remove', index: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const MATCHES = Object.keys(MAIL_FILTER_MATCH_LABELS) as MailFilterMatch[]
/** 조건 줄 — 차례대로 (잘못된 줄 · 새 줄에 초점을 준다) */
const rows: (InstanceType<typeof MailFilterConditionRow> | null)[] = []
function setRow(index: number, row: Element | ComponentPublicInstance | null) {
rows[index] = row as InstanceType<typeof MailFilterConditionRow> | null
}
function setMatch(event: Event) {
const match = MATCHES.find((candidate) => candidate === (event.target as HTMLSelectElement).value)
if (match) emit('update:match', match)
}
function add() {
if (props.conditions.length >= MAIL_FILTER_MAX_CONDITIONS) return
const added = props.conditions.length
emit('add')
void nextTick(() => rows[added]?.focusField())
}
function remove(index: number) {
if (props.conditions.length <= 1) return
const next = Math.min(index, props.conditions.length - 2)
emit('remove', index)
void nextTick(() => rows[next]?.focusValue())
}
defineExpose({
/** 잘못된 줄의 찾을 글 칸으로 */
focusValue: (index: number) => rows[index]?.focusValue(),
})
</script>
<template>
<fieldset class="filter-conditions">
<legend class="filter-conditions__legend">
{{ MAIL_FILTER_DIALOG_TEXT.matchLegend }}
</legend>
<select
id="mail-filter-match"
class="form-input filter-conditions__match"
:aria-label="MAIL_FILTER_DIALOG_TEXT.matchLegend"
:value="match"
@change="setMatch"
>
<option
v-for="option in MATCHES"
:key="option"
:value="option"
>
{{ MAIL_FILTER_MATCH_LABELS[option] }}
</option>
</select>
<template v-if="match !== 'always'">
<ul class="filter-conditions__list">
<MailFilterConditionRow
v-for="(condition, index) in conditions"
:key="index"
:ref="(row) => setRow(index, row)"
:condition="condition"
:index="index"
:can-remove="conditions.length > 1"
:error="errors[index] ?? null"
:domain="domain"
@update="(patch) => emit('update-condition', index, patch)"
@remove="remove(index)"
/>
</ul>
<div class="filter-conditions__add">
<button
id="mail-filter-add-condition"
type="button"
class="filter-conditions__add-button"
:disabled="conditions.length >= MAIL_FILTER_MAX_CONDITIONS"
@click="add"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="1.9"
/>
<span>{{ MAIL_FILTER_DIALOG_TEXT.addCondition }}</span>
</button>
<span class="filter-conditions__hint">{{ MAIL_FILTER_DIALOG_TEXT.conditionHint }}</span>
</div>
</template>
</fieldset>
</template>
<style scoped>
.filter-conditions {
display: flex;
flex-direction: column;
gap: var(--space-8);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.filter-conditions__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.filter-conditions__match {
max-width: 20rem;
}
.filter-conditions__list {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.filter-conditions__add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
/* [조건 더하기] 높이 40 — 밀도 예외 (tokens.css "6단계") */
.filter-conditions__add-button {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-filter-control);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-secondary);
font-weight: 500;
}
.filter-conditions__add-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.filter-conditions__add-button:disabled {
color: var(--color-icon-muted);
cursor: not-allowed;
}
.filter-conditions__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,197 @@
<script setup lang="ts">
// 고치기 창의 조건 한 줄 (디자인 원본 SettingsFilter) — 어디에서(116) · 포함/포함하지 않음(132) · 찾을 글 · ✕(36×40). 칸 높이 40 — 밀도 예외(tokens.css "6단계").
// 잘못은 [저장]을 누를 때 그 줄 아래 빨간 글로 (디자인 원본 MailFilterStates) — 찾을 글 칸이 aria-invalid · aria-describedby 로 가리킨다.
// 조건이 하나뿐이면 ✕ 를 끈다 (모두 · 하나라도는 조건이 1개 이상)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_FIELD_LABELS,
MAIL_FILTER_OPERATOR_LABELS,
MAIL_FILTER_VALUE_MAX_LENGTH,
MAIL_FILTER_VALUE_PLACEHOLDERS,
} from '@/shared/constants/mail-filter'
import type { MailFilterCondition, MailFilterField, MailFilterOperator } from '@/shared/types/mail'
interface Props {
condition: MailFilterCondition
/** 0부터 — 칸 이름은 "조건 1 — …" */
index: number
canRemove: boolean
/** 이 줄의 잘못 (칸 · 사전 문구) — 없으면 null */
error: string | null
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기("예: all@<도메인>") */
domain: string
}
interface Emits {
(e: 'update', patch: Partial<MailFilterCondition>): void
(e: 'remove'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const FIELDS = Object.keys(MAIL_FILTER_FIELD_LABELS) as MailFilterField[]
const OPERATORS = Object.keys(MAIL_FILTER_OPERATOR_LABELS) as MailFilterOperator[]
const fieldRef = ref<HTMLSelectElement | null>(null)
const valueRef = ref<HTMLInputElement | null>(null)
const no = computed(() => props.index + 1)
const errorId = computed(() => `mail-filter-condition-${props.index}-error`)
function selected(event: Event): string {
return (event.target as HTMLSelectElement).value
}
function setField(event: Event) {
const field = FIELDS.find((candidate) => candidate === selected(event))
if (field) emit('update', { field })
}
function setOperator(event: Event) {
const operator = OPERATORS.find((candidate) => candidate === selected(event))
if (operator) emit('update', { operator })
}
function setValue(event: Event) {
emit('update', { value: (event.target as HTMLInputElement).value })
}
defineExpose({
/** 잘못된 줄 — 찾을 글 칸으로 */
focusValue: () => valueRef.value?.focus(),
/** 방금 더한 줄 — 어디에서 칸으로 */
focusField: () => fieldRef.value?.focus(),
})
</script>
<template>
<li class="filter-condition">
<div class="filter-condition__row">
<select
ref="fieldRef"
class="form-input filter-condition__control"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionField(no)"
:value="condition.field"
data-filter-condition-field
@change="setField"
>
<option
v-for="field in FIELDS"
:key="field"
:value="field"
>
{{ MAIL_FILTER_FIELD_LABELS[field] }}
</option>
</select>
<select
class="form-input filter-condition__control"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionOperator(no)"
:value="condition.operator"
data-filter-condition-operator
@change="setOperator"
>
<option
v-for="operator in OPERATORS"
:key="operator"
:value="operator"
>
{{ MAIL_FILTER_OPERATOR_LABELS[operator] }}
</option>
</select>
<input
ref="valueRef"
class="form-input filter-condition__control filter-condition__value"
type="text"
:value="condition.value"
:maxlength="MAIL_FILTER_VALUE_MAX_LENGTH"
:placeholder="MAIL_FILTER_VALUE_PLACEHOLDERS[condition.field](domain)"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionValue(no)"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? errorId : undefined"
autocomplete="off"
data-filter-condition-value
@input="setValue"
>
<button
type="button"
class="filter-condition__remove"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionRemove(no)"
:disabled="!canRemove"
data-filter-condition-remove
@click="emit('remove')"
>
<AppIcon
name="x"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
<p
v-if="error"
:id="errorId"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</li>
</template>
<style scoped>
.filter-condition {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 어디에서 116 · 포함/포함하지 않음 132 · 찾을 글 · ✕ 36 (디자인 원본 SettingsFilter) */
.filter-condition__row {
display: grid;
grid-template-columns: 7.25rem 8.25rem minmax(0, 1fr) var(--size-filter-remove);
align-items: center;
gap: var(--space-8);
}
/* 칸 높이 40 — 밀도 예외 */
.filter-condition__control {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-8);
}
.filter-condition__value {
padding: 0 var(--space-10);
}
.filter-condition__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-filter-remove);
height: var(--size-filter-control);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.filter-condition__remove:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.filter-condition__remove:disabled {
color: var(--color-border-strong);
}
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
// [새 필터] — 설정 › 필터 머리 오른쪽 · 필터가 없을 때의 안내 안 (디자인 원본 SettingsFilter · MailFilterStates — 브랜드색 44, + 아이콘 15)
// 누를 수 없을 때(50개를 다 만들었다 · 아직 받는 중)는 disabled 가 아니라 aria-disabled — 고치기 창을 닫으면 초점이 이 단추로 돌아오기 때문이다.
// 까닭은 옆의 글을 describedBy 로 가리킨다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
interface Props {
disabled?: boolean
/** 누를 수 없는 까닭의 id */
describedBy?: string
}
interface Emits {
(e: 'create'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
describedBy: undefined,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
function onClick() {
if (!props.disabled) emit('create')
}
defineExpose({
focus: () => buttonRef.value?.focus(),
})
</script>
<template>
<button
ref="buttonRef"
type="button"
class="filter-create"
:aria-disabled="disabled ? 'true' : undefined"
:aria-describedby="describedBy"
data-filter-create
@click="onClick"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="2"
/>
<span>{{ MAIL_FILTER_TEXT.create }}</span>
</button>
</template>
<style scoped>
.filter-create {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.filter-create:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 꺼진 모양 (디자인 원본 MailFilterStates "50개를 다 만들었을 때") */
.filter-create[aria-disabled='true'] {
background: var(--color-skeleton);
color: var(--color-icon-muted);
cursor: not-allowed;
}
</style>
@@ -0,0 +1,466 @@
<script setup lang="ts">
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
// 서버의 거절(VAL_001 · MAIL_013 · 그사이 바뀜 MAIL_012)과 50개 넘음은 단추 줄 위에 — 부모가 error 로 준다(무엇이든 고치면 clear-error).
// 그사이 바뀜(conflict)이면 그 옆에 [다시 불러오기] — 누르면 부모가 목록을 다시 받고, 받았으면 [저장]으로 초점을 옮긴다(focusSave)
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
// 고정 id — #mail-filter-dialog · #mail-filter-dialog-title · #mail-filter-name · #mail-filter-name-error · #mail-filter-error · #mail-filter-reload ·
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel (조건 · 할 일 칸의 id 는 각 칸 컴포넌트에)
import { computed, reactive, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
import { getErrorMessage } from '@/composables/useApi'
import type { MailFilterDialogPending } from '@/composables/useMailFilterDialog'
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_NAME_MAX_LENGTH, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
import {
defaultMoveTarget,
emptyCondition,
filterDraftOf,
filterDraftProblems,
filterMoveTargets,
filterRuleOf,
hasDraftAction,
hasFilterProblems,
mailDomainOf,
newFilterDraft,
newFilterRuleBase,
noFilterProblems,
type MailFilterActionDraft,
type MailFilterConditionProblem,
type MailFilterDraft,
type MailFilterForwardProblem,
type MailFilterProblems,
} from '@/shared/utils/mail-filter'
interface Props {
open: boolean
/** 고칠 규칙 — null 이면 새 필터 */
rule: MailFilterRule | null
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
prefillFrom?: string | null
/** 모든 메일함 — 옮길 메일함 고르기 · 지워진 메일함 판정 */
mailboxes: readonly Mailbox[]
/** 로그인한 직원 주소 — 사내 도메인 · 자기 주소 판정 */
userEmail: string
pending?: MailFilterDialogPending
/** 다른 저장(켜기 · 순서)을 기다리는 중 */
busy?: boolean
/** 서버가 거절한 까닭 — 사전 문구 (50개 넘음은 fullNote) */
error?: string | null
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
conflict?: boolean
}
interface Emits {
(e: 'save', rule: MailFilterRule): void
(e: 'remove'): void
(e: 'cancel'): void
(e: 'clear-error'): void
/** 그사이 바뀜 — 목록을 다시 받아 달라 (창 · 쓴 내용은 그대로) */
(e: 'reload'): void
}
const props = withDefaults(defineProps<Props>(), {
prefillFrom: null,
pending: null,
busy: false,
error: null,
conflict: false,
})
const emit = defineEmits<Emits>()
const nameRef = ref<HTMLInputElement | null>(null)
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
const saveRef = ref<HTMLButtonElement | null>(null)
const targets = computed(() => filterMoveTargets(props.mailboxes))
const domain = computed(() => mailDomainOf(props.userEmail))
/** 읽기 화면에서 만든 새 필터 — 처음 초점이 할 일의 첫 칸이다 */
const prefilled = computed(() => props.rule === null && Boolean(props.prefillFrom))
const locked = computed(() => props.pending !== null || props.busy)
const title = computed(() =>
props.rule ? MAIL_FILTER_DIALOG_TEXT.editTitle : MAIL_FILTER_DIALOG_TEXT.createTitle,
)
const draft = reactive<MailFilterDraft>(initialDraft())
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
let base = baseOf()
const actionsValue = computed<MailFilterActionDraft>(() => ({
move: draft.move,
moveTarget: draft.moveTarget,
markAsRead: draft.markAsRead,
flag: draft.flag,
forward: draft.forward,
forwardTo: draft.forwardTo,
}))
const nameError = computed(() => (problems.value.name ? getErrorMessage('VAL_001') : null))
const conditionErrors = computed(() => problems.value.conditions.map(conditionMessage))
const actionsError = computed(() => (problems.value.actions ? MAIL_FILTER_DIALOG_TEXT.actionsEmpty : null))
const forwardError = computed(() => forwardMessage(problems.value.forward))
function initialDraft(): MailFilterDraft {
const target = defaultMoveTarget(targets.value)
return props.rule
? filterDraftOf(props.rule, target)
: newFilterDraft({ moveTarget: target, from: props.prefillFrom })
}
function baseOf(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
return props.rule ? { id: props.rule.id, isEnabled: props.rule.isEnabled } : newFilterRuleBase()
}
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다 */
function resetDraft() {
Object.assign(draft, initialDraft())
problems.value = noFilterProblems(draft.conditions.length)
base = baseOf()
}
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.conditionEmpty
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
if (problem === 'invalid') return getErrorMessage('VAL_001')
return null
}
function forwardMessage(problem: MailFilterForwardProblem | null): string | null {
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.forwardEmpty
if (problem === 'invalid') return MAIL_FILTER_DIALOG_TEXT.forwardInvalid
if (problem === 'external') return MAIL_FILTER_DIALOG_TEXT.forwardExternal(domain.value)
if (problem === 'self') return MAIL_FILTER_DIALOG_TEXT.forwardSelf
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
return null
}
/** 칸을 고쳤다 — 그 칸의 잘못(patch)과 서버의 거절을 내린다 */
function edited(patch: Partial<MailFilterProblems>) {
problems.value = { ...problems.value, ...patch }
if (props.error) emit('clear-error')
}
function setName(event: Event) {
draft.name = (event.target as HTMLInputElement).value
edited({ name: false })
}
function setMatch(match: MailFilterMatch) {
draft.match = match
if (match !== 'always' && draft.conditions.length === 0) draft.conditions = [emptyCondition()]
edited({ conditions: draft.conditions.map(() => null) })
}
function updateCondition(index: number, patch: Partial<MailFilterCondition>) {
draft.conditions = draft.conditions.map((condition, at) => (at === index ? { ...condition, ...patch } : condition))
edited({ conditions: problems.value.conditions.map((problem, at) => (at === index ? null : problem)) })
}
function addCondition() {
draft.conditions = [...draft.conditions, emptyCondition('subject')]
edited({ conditions: [...problems.value.conditions, null] })
}
function removeCondition(index: number) {
draft.conditions = draft.conditions.filter((_, at) => at !== index)
edited({ conditions: problems.value.conditions.filter((_, at) => at !== index) })
}
function updateActions(patch: Partial<MailFilterActionDraft>) {
Object.assign(draft, patch)
const touchesForward = 'forward' in patch || 'forwardTo' in patch
edited({
actions: problems.value.actions && !hasDraftAction(draft),
forward: touchesForward ? null : problems.value.forward,
})
}
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
function focusFirstProblem(found: MailFilterProblems) {
const condition = found.conditions.findIndex((problem) => problem !== null)
if (found.name) nameRef.value?.focus()
else if (condition >= 0) conditionsRef.value?.focusValue(condition)
else if (found.actions) actionsRef.value?.focusFirst()
else if (found.forward) actionsRef.value?.focusForward()
}
function save() {
if (locked.value) return
const found = filterDraftProblems(draft, props.userEmail)
problems.value = found
if (hasFilterProblems(found)) {
focusFirstProblem(found)
return
}
emit('save', filterRuleOf(draft, base))
}
function remove() {
if (!locked.value) emit('remove')
}
function reload() {
if (props.pending === null) emit('reload')
}
defineExpose({
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
focusSave: () => saveRef.value?.focus(),
})
// 열 때마다(연 채로 다른 규칙을 받아도) 칸을 새로 채운다 — 창을 그리기 전에(처음 초점이 새 칸에 간다)
watch(
() => [props.open, props.rule, props.prefillFrom] as const,
([open]) => {
if (open) resetDraft()
},
)
</script>
<template>
<ModalDialog
id="mail-filter-dialog"
:open="open"
:title="title"
title-id="mail-filter-dialog-title"
wide
@cancel="emit('cancel')"
>
<div class="filter-dialog">
<div class="filter-dialog__field">
<label
for="mail-filter-name"
class="filter-dialog__label"
>
{{ MAIL_FILTER_DIALOG_TEXT.nameLabel }}
<span class="filter-dialog__optional">{{ MAIL_FILTER_DIALOG_TEXT.nameOptional }}</span>
</label>
<input
id="mail-filter-name"
ref="nameRef"
class="form-input"
type="text"
:value="draft.name"
:maxlength="MAIL_FILTER_NAME_MAX_LENGTH"
:placeholder="MAIL_FILTER_DIALOG_TEXT.namePlaceholder"
:aria-invalid="nameError ? 'true' : 'false'"
:aria-describedby="nameError ? 'mail-filter-name-error' : undefined"
:autofocus="!prefilled || undefined"
autocomplete="off"
@input="setName"
>
<p
v-if="nameError"
id="mail-filter-name-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ nameError }}</span>
</p>
</div>
<MailFilterConditionFields
ref="conditionsRef"
:match="draft.match"
:conditions="draft.conditions"
:errors="conditionErrors"
:domain="domain"
@update:match="setMatch"
@update-condition="updateCondition"
@add="addCondition"
@remove="removeCondition"
/>
<MailFilterActionFields
ref="actionsRef"
:value="actionsValue"
:targets="targets"
:mailboxes="mailboxes"
:domain="domain"
:actions-error="actionsError"
:forward-error="forwardError"
:autofocus-first="prefilled"
@update="updateActions"
/>
<p class="filter-dialog__notice">
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
</p>
<div
v-if="error"
class="filter-dialog__error"
>
<p
id="mail-filter-error"
class="form-message form-message--error filter-dialog__error-text"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
<button
v-if="conflict"
id="mail-filter-reload"
type="button"
class="filter-dialog__reload"
aria-describedby="mail-filter-error"
:aria-disabled="pending !== null ? 'true' : undefined"
:aria-busy="pending === 'reload' ? 'true' : undefined"
@click="reload"
>
{{ MAIL_FILTER_TEXT.reload }}
</button>
</div>
</div>
<template #actions>
<button
v-if="rule"
id="mail-filter-remove"
type="button"
class="modal-dialog__action filter-dialog__remove"
:aria-disabled="locked ? 'true' : undefined"
:aria-busy="pending === 'remove' ? 'true' : undefined"
@click="remove"
>
<AppIcon
name="trash"
:size="15"
/>
<span>{{ MAIL_FILTER_DIALOG_TEXT.remove }}</span>
</button>
<button
id="mail-filter-save"
ref="saveRef"
type="button"
class="modal-dialog__action filter-dialog__save"
:aria-disabled="locked ? 'true' : undefined"
:aria-busy="pending === 'save' ? 'true' : undefined"
@click="save"
>
{{ MAIL_FILTER_DIALOG_TEXT.save }}
</button>
<button
id="mail-filter-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 묶음 사이 16 — 제목과의 사이도 16(창의 8 + 8), 단추 줄 앞도 18 (디자인 원본 SettingsFilter) */
.filter-dialog {
display: flex;
flex-direction: column;
gap: var(--space-16);
margin: var(--space-8) 0 var(--space-6);
}
.filter-dialog__field {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.filter-dialog__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.filter-dialog__optional {
font-weight: 400;
color: var(--color-text-muted);
}
/* 단추 줄 위의 거절 — 그사이 바뀜이면 옆에 [다시 불러오기] (36 — 목록 위 줄의 단추와 같은 밀도 예외) */
.filter-dialog__error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
.filter-dialog__error-text {
flex: 1 1 18rem;
}
.filter-dialog__reload {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-brand-strong);
font-weight: 600;
}
.filter-dialog__reload:hover:not([aria-disabled='true']) {
background: var(--color-brand-subtle);
}
.filter-dialog__reload[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.filter-dialog__notice {
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* [이 필터 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 SettingsFilter) */
.modal-dialog__action.filter-dialog__remove {
gap: var(--space-6);
margin-right: auto;
padding: 0 var(--space-12);
background: transparent;
color: var(--color-danger);
}
.modal-dialog__action.filter-dialog__remove:not([aria-disabled='true']):hover {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
.modal-dialog__action.filter-dialog__save {
padding: 0 var(--space-18);
}
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.modal-dialog__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
// 필터가 아직 없을 때 — 목록 자리의 안내 (디자인 원본 MailFilterStates): 점선 상자 · 깔때기 36 · "아직 필터가 없습니다" · 설명 · [새 필터]
// [새 필터]는 머리의 단추와 같은 일을 한다
import AppIcon from '@/components/AppIcon.vue'
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
interface Emits {
(e: 'create'): void
}
const emit = defineEmits<Emits>()
</script>
<template>
<div
class="filter-empty"
data-filter-empty
>
<AppIcon
name="filter"
:size="36"
:stroke-width="1.5"
class="filter-empty__icon"
/>
<p class="filter-empty__title">
{{ MAIL_FILTER_TEXT.emptyTitle }}
</p>
<p class="filter-empty__text">
{{ MAIL_FILTER_TEXT.emptyText }}
</p>
<MailFilterCreateButton
class="filter-empty__create"
@create="emit('create')"
/>
</div>
</template>
<style scoped>
.filter-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-8);
padding: var(--space-40) var(--space-24);
border: var(--border-width) dashed var(--color-border-strong);
border-radius: var(--radius-lg);
text-align: center;
}
.filter-empty__icon {
color: var(--color-icon-muted);
}
.filter-empty__title {
margin: var(--space-4) 0 0;
font-size: var(--font-size-body);
font-weight: 600;
}
.filter-empty__text {
max-width: 25rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.filter-empty__create {
margin-top: var(--space-6);
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
// 필터 목록 — 테두리 상자 하나에 행(MailFilterRow) (디자인 원본 SettingsFilter). 위에서부터 차례로 적용하므로 순서 있는 목록(ol)이다.
// 켜기 · ▲▼ · 행 누름(고치기)을 부모에 알리고, 부모가 행 안의 단추에 초점을 줄 수 있게 한다(focusRule — 순서를 바꾸거나 지운 뒤)
import type { ComponentPublicInstance } from 'vue'
import MailFilterRow from '@/components/settings/MailFilterRow.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
interface Props {
rules: readonly MailFilterRule[]
mailboxes: readonly Mailbox[]
/** 저장을 기다리는 중 — 행마다 켜기 · 순서를 막는다(aria-disabled — 목록 전체에 aria-busy 는 걸지 않는다) */
busy: boolean
}
interface Emits {
(e: 'toggle', rule: MailFilterRule): void
(e: 'move', rule: MailFilterRule, offset: -1 | 1): void
(e: 'edit', rule: MailFilterRule): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
/** 규칙 id → 행 — 행이 옮겨 다녀도 같은 행을 찾는다 */
const rows = new Map<string, InstanceType<typeof MailFilterRow>>()
function setRow(id: string, row: Element | ComponentPublicInstance | null) {
if (row) rows.set(id, row as InstanceType<typeof MailFilterRow>)
else rows.delete(id)
}
defineExpose({
/** 그 규칙 행의 단추에 초점 — 행이 없으면 false */
focusRule(id: string, part: MailFilterRowPart): boolean {
const row = rows.get(id)
row?.focus(part)
return row !== undefined
},
})
</script>
<template>
<ol
class="filter-list"
:aria-label="MAIL_FILTER_TEXT.listLabel"
>
<MailFilterRow
v-for="(rule, index) in rules"
:key="rule.id"
:ref="(row) => setRow(rule.id, row)"
:rule="rule"
:index="index"
:count="rules.length"
:mailboxes="mailboxes"
:busy="busy"
@toggle="emit('toggle', rule)"
@move="(offset) => emit('move', rule, offset)"
@edit="emit('edit', rule)"
/>
</ol>
</template>
<style scoped>
.filter-list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FILTER_TEXT.noteVacation] as const
</script>
<template>
<div
class="filter-notes"
data-filter-notes
>
<p class="filter-notes__note">
<span aria-hidden="true">·</span>
<span><strong class="filter-notes__strong">{{ MAIL_FILTER_TEXT.noteOrderStrong }}</strong>{{
MAIL_FILTER_TEXT.noteOrderAfter
}}</span>
</p>
<p
v-for="note in NOTES"
:key="note"
class="filter-notes__note"
>
<span aria-hidden="true">·</span>
<span>{{ note }}</span>
</p>
</div>
</template>
<style scoped>
.filter-notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 43.75rem;
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.filter-notes__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
.filter-notes__strong {
font-weight: 600;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
interface Props {
icon: IconName
message: string
actionLabel: string
busy?: boolean
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
live?: 'alert' | 'status'
}
interface Emits {
(e: 'action'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
live: 'status',
})
const emit = defineEmits<Emits>()
function onAction() {
if (!props.busy) emit('action')
}
</script>
<template>
<div
class="filter-notice"
:role="live"
>
<AppIcon
:name="icon"
:size="16"
:stroke-width="1.9"
class="filter-notice__icon"
/>
<span class="filter-notice__text">{{ message }}</span>
<button
type="button"
class="filter-notice__action"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
@click="onAction"
>
{{ actionLabel }}
</button>
</div>
</template>
<style scoped>
.filter-notice {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-10) var(--space-8) var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
.filter-notice__icon {
margin-top: var(--space-2);
}
.filter-notice__text {
flex: 1 1 auto;
min-width: 0;
align-self: center;
}
/* 줄 안의 단추 36 — 밀도 예외 (알림 권하기 줄의 단추와 같다) */
.filter-notice__action {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-warning-text);
font-weight: 600;
}
.filter-notice__action:hover:not([aria-disabled='true']) {
background: var(--color-warning-hover);
}
.filter-notice__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,306 @@
<script setup lang="ts">
// 필터 목록의 한 행 (디자인 원본 SettingsFilter — 행 60, 칸 22 · 40 · 나머지 · ▲▼)
// 순서 번호 · 켜기 스위치(role="switch" — 그림은 36×20, 누르는 곳은 칸 전체 40×44 — 밀도 예외) · 이름(굵게)과 요약 한 줄(누르면 고치기 창) ·
// ▲ ▼(32×40 — 밀도 예외, 메일함 ⋯ 와 같다). 단추 이름에 넣는 이름은 quotedRuleTitle(길면 자르고 따옴표가 겹치지 않게).
// 꺼 둔 규칙은 글자를 흐리게, 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄.
// 저장하는 동안(busy)과 맨 위의 ▲ · 맨 아래의 ▼ 는 disabled 가 아니라 aria-disabled — 누른 단추가 초점을 잃지 않게 (5단계의 규칙과 같다)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
import { isMoveTargetMissing, quotedRuleTitle, ruleSummary, ruleTitle } from '@/shared/utils/mail-filter'
interface Props {
rule: MailFilterRule
/** 0부터 */
index: number
count: number
mailboxes: readonly Mailbox[]
/** 다른 저장을 기다리는 중 — 켜기 · 순서를 막는다 */
busy: boolean
}
interface Emits {
(e: 'toggle'): void
(e: 'move', offset: -1 | 1): void
(e: 'edit'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editRef = ref<HTMLButtonElement | null>(null)
const upRef = ref<HTMLButtonElement | null>(null)
const downRef = ref<HTMLButtonElement | null>(null)
const title = computed(() => ruleTitle(props.rule))
/** 단추 이름에 넣는 이름 — 따옴표로 묶고, 조건 글이 길면 자른다 */
const quoted = computed(() => quotedRuleTitle(props.rule))
const summary = computed(() => ruleSummary(props.rule, mailboxName))
const missing = computed(() => isMoveTargetMissing(props.rule, props.mailboxes))
const isFirst = computed(() => props.index === 0)
const isLast = computed(() => props.index === props.count - 1)
const summaryId = computed(() => `mail-filter-${props.index}-summary`)
const missingId = computed(() => `mail-filter-${props.index}-missing`)
function mailboxName(id: string): string | null {
return props.mailboxes.find((mailbox) => mailbox.id === id)?.name ?? null
}
function toggle() {
if (!props.busy) emit('toggle')
}
function move(offset: -1 | 1) {
const atEdge = offset < 0 ? isFirst.value : isLast.value
if (!props.busy && !atEdge) emit('move', offset)
}
defineExpose({
focus: (part: MailFilterRowPart) => ({ edit: editRef, up: upRef, down: downRef })[part].value?.focus(),
})
</script>
<template>
<li
:class="['filter-row', { 'is-off': !rule.isEnabled, 'is-busy': busy }]"
:data-filter-rule="rule.id"
>
<span class="filter-row__no">{{ index + 1 }}</span>
<button
type="button"
class="filter-row__switch"
role="switch"
:aria-checked="rule.isEnabled ? 'true' : 'false'"
:aria-label="MAIL_FILTER_TEXT.switchLabel(quoted)"
:aria-disabled="busy ? 'true' : undefined"
data-filter-switch
@click="toggle"
>
<span
class="filter-row__track"
aria-hidden="true"
>
<span class="filter-row__knob" />
</span>
</button>
<button
ref="editRef"
type="button"
class="filter-row__main"
:aria-label="MAIL_FILTER_TEXT.editLabel(quoted)"
:aria-describedby="missing ? `${summaryId} ${missingId}` : summaryId"
data-filter-edit
@click="emit('edit')"
>
<span class="filter-row__title">{{ title }}</span>
<span
:id="summaryId"
class="filter-row__summary"
>{{ summary }}</span>
<span
v-if="missing"
:id="missingId"
class="filter-row__missing"
data-filter-missing
>
<AppIcon
name="alert-circle"
:size="13"
:stroke-width="2"
/>
<span>{{ MAIL_FILTER_TEXT.missingMailbox }}</span>
</span>
</button>
<div class="filter-row__order">
<button
ref="upRef"
type="button"
:class="['filter-row__arrow', { 'is-edge': isFirst }]"
:aria-label="MAIL_FILTER_TEXT.moveUp(quoted)"
:aria-disabled="busy || isFirst ? 'true' : undefined"
data-filter-up
@click="move(-1)"
>
<AppIcon
name="chevron-up"
:size="15"
:stroke-width="1.9"
/>
</button>
<button
ref="downRef"
type="button"
:class="['filter-row__arrow', { 'is-edge': isLast }]"
:aria-label="MAIL_FILTER_TEXT.moveDown(quoted)"
:aria-disabled="busy || isLast ? 'true' : undefined"
data-filter-down
@click="move(1)"
>
<AppIcon
name="chevron-down"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
</li>
</template>
<style scoped>
/* 행 60 — 칸 22(순서) · 40(스위치) · 나머지(이름 · 요약) · ▲▼ (디자인 원본 SettingsFilter) */
.filter-row {
display: grid;
grid-template-columns: 1.375rem 2.5rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-10);
min-height: 3.75rem;
padding: var(--space-6) var(--space-8) var(--space-6) var(--space-14);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-row__no {
font-size: var(--font-size-small);
color: var(--color-icon-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* 누르는 곳은 칸 전체 40×44(밀도 예외 — tokens.css "6단계", 툴바 아이콘 단추와 같은 폭), 보이는 스위치는 36×20 */
.filter-row__switch {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-icon-button);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.filter-row__track {
position: relative;
display: block;
width: 2.25rem;
height: 1.25rem;
border-radius: 0.625rem;
background: var(--color-border-strong);
transition: background-color 0.15s;
}
.filter-row__knob {
position: absolute;
top: var(--space-2);
left: var(--space-2);
width: 1rem;
height: 1rem;
border-radius: var(--radius-round);
background: var(--color-surface);
box-shadow: var(--shadow-switch-knob);
transition: transform 0.15s;
}
.filter-row__switch[aria-checked='true'] .filter-row__track {
background: var(--color-brand);
}
.filter-row__switch[aria-checked='true'] .filter-row__knob {
transform: translateX(1rem);
}
.filter-row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) var(--space-8);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
}
.filter-row__main:hover {
background: var(--color-surface-panel);
}
.filter-row__title,
.filter-row__summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-row__title {
font-weight: 600;
}
.filter-row__summary {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 꺼 둔 규칙 — 글자를 흐리게 */
.filter-row.is-off .filter-row__title {
color: var(--color-text-muted);
}
.filter-row.is-off .filter-row__summary {
color: var(--color-icon-muted);
}
.filter-row__missing {
display: flex;
align-items: center;
gap: var(--space-6);
font-size: var(--font-size-label);
color: var(--color-danger);
white-space: normal;
}
.filter-row__order {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ▲ ▼ 32×40 — 밀도 예외 (메일함 행의 ⋯ 와 같은 크기 — tokens.css "6단계") */
.filter-row__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-mailbox-more);
height: var(--size-row-min);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.filter-row__arrow:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 맨 위의 ▲ · 맨 아래의 ▼ — 꺼진 모양 */
.filter-row__arrow.is-edge {
color: var(--color-border-strong);
cursor: default;
}
/* 저장을 기다리는 동안 — 모양은 그대로, 누를 수만 없다 */
.filter-row.is-busy .filter-row__switch,
.filter-row.is-busy .filter-row__arrow:not(.is-edge) {
cursor: progress;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
@@ -19,16 +20,24 @@ defineProps<Props>()
:aria-labelledby="titleId"
>
<div class="settings-page__column">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
<div :class="['settings-page__head', { 'settings-page__head--actions': $slots.actions }]">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
</div>
<div
v-if="$slots.actions"
class="settings-page__actions"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
<slot name="actions" />
</div>
</div>
<slot />
</div>
@@ -53,6 +62,18 @@ defineProps<Props>()
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/* 머리 오른쪽 단추 — 제목 · 설명이 360 보다 좁아지면 아래로 내려간다 (디자인 원본 SettingsFilter) */
.settings-page__head--actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-12) var(--space-20);
}
.settings-page__head--actions .settings-page__intro {
flex: 1 1 22.5rem;
}
.settings-page__intro {
display: flex;
flex-direction: column;
@@ -0,0 +1,4 @@
// 메일 필터 화면의 목록(MailFilterList · MailFilterRow)과 화면(MailFilterPage)이 주고받는 값
/** 행 안에서 초점을 줄 곳 — 고치기(행) · ▲ · ▼ (순서를 바꾸거나 지운 뒤 화면이 정한다) */
export type MailFilterRowPart = 'edit' | 'up' | 'down'