generated from ttipo/project-base
feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기
확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) · 고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다. - 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 — 연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다 - 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다 - 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
Reference in New Issue
Block a user