feat(frontend): 필터 고치기 창 — 이미 받은 메일에도 적용 · 나가기 확인, 부재중 동작 안 함 줄

- 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」(창을 열 때마다 꺼짐): 저장한 뒤 cursor 가 끝날 때까지
  보내며 진행 알림을 고치고, 끝나면 그 자리에서 결과(적용한 수 · [{메일함} 보기], 멈추면 [다시 시도]로 이어서)
- 이미 받은 메일에 할 일이 없는 필터(지워진 메일함 · 받은편지함으로만 · 전달만)와 이 탭에서 적용 중에는 칸을 끈다.
  실패 알림은 응답이 온 때 세션이 그대로일 때만, 나아가지 못한 응답이 세 번이면 멈춘다
- 고치기 창의 나가기 확인([취소] · Esc · 다른 화면 · 탭 닫기 — 6단계의 한계를 고친다). 브라우저가 스스로 닫은 창은
  ModalDialog 의 reshow() 로 다시 띄우고 초점을 닫히기 전 자리로 돌려준다
- 부재중 화면: 켜 둔 자동 응답이 엔진에서 꺼져 있으면(isActive: false) 머리 아래 노란 줄
- 진행 알림의 결과에도 단추를 붙인다(useToast)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:38:01 +09:00
parent 6fd0911b86
commit aef67aa310
33 changed files with 2479 additions and 84 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 화면을 떠나기 전 확인 — 세션 만료 대화상자와 같은 모양 (ModalDialog · role=alertdialog)
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명)
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명) · 부재중 자동 응답 ·
// 필터 고치기 창(MailFilterDialog — 창 위에 뜬다: [취소] · Esc 의 "창을 닫으면 …" [닫기] · 다른 화면으로 갈 때 [나가기])
// [머무르기](계속 쓰기 · 계속 고치기)가 처음 초점이고 Esc 도 같다. 쓰는 쪽이 답을 받아 이동을 취소하거나(머무르기) 그대로 보낸다(나가기).
// 단추는 오른쪽 정렬 — 머무르기는 채운 기본 단추 · 나가기는 보조 단추 (디자인 README "나가기 전 확인").
// 고정 id — {idPrefix}-dialog · {idPrefix}-title · {idPrefix}-message · {idPrefix}-stay · {idPrefix}-confirm
@@ -13,6 +13,16 @@ afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
})
/**
* 브라우저가 스스로 닫는 것을 흉내 낸다 (Esc 를 거듭 눌러 막을 수 없게 닫힘) — open 을 떼고, 초점을 연 때의 자리(창 밖)로 옮긴 뒤 close 를 쏜다
* (브라우저는 close 이벤트를 쏘기 전에 초점을 창 밖으로 옮긴다)
*/
function browserCloses(dialog: HTMLDialogElement) {
dialog.removeAttribute('open')
;(document.activeElement as HTMLElement | null)?.blur()
dialog.dispatchEvent(new Event('close'))
}
function mountDialog(open: boolean) {
const wrapper = mount(ModalDialog, {
attachTo: document.body,
@@ -75,6 +85,49 @@ describe('ModalDialog', () => {
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('브라우저가 스스로 닫았는데 쓰는 쪽이 닫지 않기로 했으면 reshow() 가 다시 띄운다 — 열려 있으면 아무것도 하지 않는다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const dialog = wrapper.get('dialog').element
const reshow = () => (wrapper.vm as unknown as { reshow: () => void }).reshow()
// 열려 있는 동안은 그대로 (초점도 옮기지 않는다)
;(wrapper.get('#leave').element as HTMLElement).focus()
reshow()
expect(dialog.hasAttribute('open')).toBe(true)
expect(document.activeElement?.id).toBe('leave')
// Esc 를 거듭 눌러 브라우저가 막을 수 없게 닫았다 — 쓰는 쪽은 확인 창을 띄우고 열린 채로 둔다
browserCloses(dialog)
expect(wrapper.emitted('cancel')).toHaveLength(1)
reshow()
expect(dialog.hasAttribute('open')).toBe(true)
// 쓰는 쪽이 닫았으면 다시 띄우지 않는다
await wrapper.setProps({ open: false })
reshow()
expect(dialog.hasAttribute('open')).toBe(false)
})
it('reshow() 의 초점 — 닫히기 전 창 안에서 마지막으로 있던 요소로, 그 요소가 없어졌으면 처음 초점(autofocus)으로', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const dialog = wrapper.get('dialog').element
const reshow = () => (wrapper.vm as unknown as { reshow: () => void }).reshow()
;(wrapper.get('#leave').element as HTMLElement).focus()
browserCloses(dialog)
reshow()
expect(document.activeElement?.id).toBe('leave')
// 마지막 자리가 사라졌다 — 처음 초점으로
const leave = wrapper.get('#leave').element
browserCloses(dialog)
leave.remove()
reshow()
expect(document.activeElement?.id).toBe('stay')
})
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
const first = ref<boolean>(true)
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
@@ -1,10 +1,12 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답 · 필터 고치기 창 위) ·
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// 브라우저가 막지 못하게 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면(나가기 확인의 "계속 고치기") reshow() 로 다시 띄운다 —
// 초점은 닫히기 전에 창 안에서 마지막으로 있던 요소로 돌려준다(브라우저는 close 이벤트 전에 초점을 창 밖으로 옮기므로 열린 동안 focusin 으로 기억해 둔다)
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
@@ -45,6 +47,8 @@ const descriptionId = computed(() => (props.message ? props.messageId : props.de
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
let returnFocus: HTMLElement | null = null
/** 열린 동안 창 안에서 마지막으로 초점이 있던 요소 — 브라우저가 스스로 닫은 뒤 다시 띄울 때(reshow) 그 자리로 돌려준다 */
let lastFocused: HTMLElement | null = null
function show() {
const dialog = dialogRef.value
@@ -66,6 +70,11 @@ function hide() {
const active = document.activeElement
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
returnFocus = null
lastFocused = null
}
function onFocusIn(event: FocusEvent) {
if (event.target instanceof HTMLElement) lastFocused = event.target
}
function onCancel(event: Event) {
@@ -78,6 +87,21 @@ function onClose() {
if (props.open) emit('cancel')
}
/**
* 열려 있어야 하는데 브라우저가 닫았으면 다시 띄운다 — 위 onClose 의 cancel 에 쓰는 쪽이 닫지 않기로 했을 때 부른다
* (예: 필터 고치기 창 — 저장하지 않은 것이 있어 확인 창을 띄웠고 "계속 고치기"를 골랐다). 열려 있으면 아무것도 하지 않는다.
* 초점은 닫히기 전에 창 안에서 마지막으로 있던 요소로 — 그 요소가 아직 창 안에 있을 때만 (없으면 처음 초점 autofocus 그대로)
*/
function reshow() {
const dialog = dialogRef.value
if (!props.open || !dialog || dialog.open) return
const previous = lastFocused
show()
if (previous?.isConnected && dialog.contains(previous)) previous.focus()
}
defineExpose({ reshow })
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
watch(
() => props.open,
@@ -107,6 +131,7 @@ onMounted(() => {
:aria-describedby="descriptionId"
@cancel="onCancel"
@close="onClose"
@focusin="onFocusIn"
>
<h2
:id="titleId"
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailVacation, Mailbox } from '@/shared/types/mail'
import type { MailVacationState, Mailbox } from '@/shared/types/mail'
import { endOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -458,12 +458,13 @@ describe('MailboxPanel — 메일함 관리', () => {
})
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
const vacation: MailVacation = {
const vacation: MailVacationState = {
isEnabled: true,
fromDate: null,
toDate: endOfDayIso('2099-11-03'),
subject: '부재중 안내',
textBody: '휴가입니다.',
isActive: true,
}
const { wrapper } = await mountPanel()
useMailVacationStore().vacation = vacation
@@ -0,0 +1,132 @@
<script setup lang="ts">
// 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
// 회색 상자(바탕 #F8F9FB) 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5 — 칸의 aria-describedby) → 상자 아래 한 줄 "스팸으로 판정되어 …"
// - 켤지는 부모가 정한다(창을 열 때마다 꺼짐 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다)
// - 고를 수 없으면(block — mail-filter-run.ts 의 filterApplyBlock) 칸을 disabled · 이름을 흐리게, 설명이 까닭을 말한다:
// 이 탭에서 다른 필터를 적용하는 중 · 옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 하는 필터 (이미 받은 메일에 할 일이 없다).
// 고를 수 있을 때 전달도 하는 필터면 설명에 "전달은 새로 오는 메일에만 합니다."를 더한다
// - 줄 높이는 할 일 칸과 같은 44 (보드는 40 — 고치기 창의 밀도 예외는 칸 · 단추 40 뿐이라 새 예외를 만들지 않는다)
// 고정 id — #mail-filter-apply · #mail-filter-apply-help
import { computed } from 'vue'
import { MAIL_FILTER_APPLY_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterApplyBlock } from '@/shared/utils/mail-filter-run'
interface Props {
/** 골랐다 — 고를 수 없는 동안은 꺼진 채로 보인다 */
checked: boolean
/** 고를 수 없는 까닭 — null 이면 고를 수 있다 */
block: MailFilterApplyBlock | null
/** 전달도 하는 필터 */
forwards: boolean
}
interface Emits {
(e: 'update:checked', checked: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** 고를 수 없는 까닭의 설명 */
const BLOCK_HELP: Readonly<Record<MailFilterApplyBlock, string>> = {
busy: MAIL_FILTER_APPLY_TEXT.busy,
missingMailbox: MAIL_FILTER_APPLY_TEXT.missingMailbox,
inboxOnly: MAIL_FILTER_APPLY_TEXT.inboxOnly,
forwardOnly: MAIL_FILTER_APPLY_TEXT.forwardOnly,
}
const help = computed(() => {
if (props.block !== null) return BLOCK_HELP[props.block]
return props.forwards ? `${MAIL_FILTER_APPLY_TEXT.help}${MAIL_FILTER_APPLY_TEXT.helpForward}` : MAIL_FILTER_APPLY_TEXT.help
})
function onChange(event: Event) {
emit('update:checked', (event.target as HTMLInputElement).checked)
}
</script>
<template>
<div
class="filter-apply"
data-filter-apply
>
<div class="filter-apply__box">
<label :class="['filter-apply__check', { 'is-disabled': block !== null }]">
<input
id="mail-filter-apply"
type="checkbox"
class="filter-apply__input"
:checked="checked && block === null"
:disabled="block !== null"
aria-describedby="mail-filter-apply-help"
@change="onChange"
>
<span class="filter-apply__label">{{ MAIL_FILTER_APPLY_TEXT.label }}</span>
</label>
<p
id="mail-filter-apply-help"
class="form-message filter-apply__help"
>
{{ help }}
</p>
</div>
<p
class="form-message"
data-filter-apply-spam
>
{{ MAIL_FILTER_APPLY_TEXT.spam }}
</p>
</div>
</template>
<style scoped>
/* 상자와 아래 한 줄 사이 10 (디자인 원본 MailFilterApply — 창의 묶음 사이 16 에서 6 을 당긴다) */
.filter-apply {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.filter-apply__box {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) var(--space-12) var(--space-10);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
}
.filter-apply__check {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.filter-apply__check.is-disabled {
cursor: default;
}
.filter-apply__input {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
cursor: inherit;
}
.filter-apply__label {
font-weight: 600;
}
.filter-apply__check.is-disabled .filter-apply__label {
color: var(--color-icon-muted);
}
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 — 할 일 칸의 안내와 같은 선 */
.filter-apply__help {
margin-left: calc(var(--size-option-input) + var(--space-10));
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일에도 적용」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
// [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
@@ -10,20 +11,36 @@
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
// - 「받은편지함에 이미 있는 메일에도 적용」은 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
// 이미 받은 메일에 할 일이 없으면(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 — filterApplyBlock) 고를 수 없다.
// 고른 채 [저장]하면 save 의 apply 가 true — 저장한 뒤의 적용 · 알림은 부모가 한다(useMailFilterActions)
// - 나가기 확인(7단계 — 6단계 한계를 고친다, useDialogLeaveConfirm): 창을 연 때의 값(새 필터는 빈 값 · 읽기 화면에서 채운 값)과 다르면
// [취소] · Esc 는 확인 창 "저장하지 않은 필터가 있습니다"([계속 고치기] — 처음 초점 · 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'
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel · 나가기 확인 #mail-filter-leave-dialog · #mail-filter-leave-stay · #mail-filter-leave-confirm
// (조건 · 할 일 · 적용 칸의 id 는 각 칸 컴포넌트에)
import { computed, nextTick, reactive, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
import MailFilterApplyField from '@/components/settings/MailFilterApplyField.vue'
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
import { getErrorMessage } from '@/composables/useApi'
import { useDialogLeaveConfirm } from '@/composables/useDialogLeaveConfirm'
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 {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_LEAVE_DIALOG,
MAIL_FILTER_NAME_MAX_LENGTH,
MAIL_FILTER_TEXT,
} from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
import {
copyFilterDraft,
defaultMoveTarget,
emptyCondition,
filterDraftOf,
@@ -36,12 +53,14 @@ import {
newFilterDraft,
newFilterRuleBase,
noFilterProblems,
sameFilterDraft,
type MailFilterActionDraft,
type MailFilterConditionProblem,
type MailFilterDraft,
type MailFilterForwardProblem,
type MailFilterProblems,
} from '@/shared/utils/mail-filter'
import { filterApplyBlock } from '@/shared/utils/mail-filter-run'
interface Props {
open: boolean
@@ -60,10 +79,13 @@ interface Props {
error?: string | null
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
conflict?: boolean
/** 이 탭에서 다른 필터를 받은편지함에 적용하는 중 — 「…에도 적용」을 고를 수 없다 */
applyBusy?: boolean
}
interface Emits {
(e: 'save', rule: MailFilterRule): void
/** apply — 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 (저장한 뒤 이번에 한 번) */
(e: 'save', rule: MailFilterRule, apply: boolean): void
(e: 'remove'): void
(e: 'cancel'): void
(e: 'clear-error'): void
@@ -77,9 +99,11 @@ const props = withDefaults(defineProps<Props>(), {
busy: false,
error: null,
conflict: false,
applyBusy: false,
})
const emit = defineEmits<Emits>()
const modalRef = ref<InstanceType<typeof ModalDialog> | null>(null)
const nameRef = ref<HTMLInputElement | null>(null)
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
@@ -98,6 +122,18 @@ const draft = reactive<MailFilterDraft>(initialDraft())
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
let base = baseOf()
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인) */
const opened = shallowRef<MailFilterDraft>(copyFilterDraft(draft))
/** 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 — 열 때마다 꺼진다 */
const applyChosen = ref<boolean>(false)
const applyBlock = computed(() => filterApplyBlock(draft, props.mailboxes, props.applyBusy))
/** 저장할 때 적용할지 — 고를 수 없는 동안은 골라 둔 것도 쓰지 않는다 (다시 고를 수 있게 되면 돌아온다) */
const applyChecked = computed(() => applyChosen.value && applyBlock.value === null)
/** 창을 연 뒤 바꾼 것이 있다 — 적용 칸을 고른 것도 바꾼 것이다 */
const changed = computed(() => props.open && (applyChecked.value || !sameFilterDraft(draft, opened.value)))
/** 저장 · 지우기를 보내는 중 — 닫거나 떠나도 묻지 않는다 (결과는 부모가 알림으로 알린다) */
const sending = computed(() => props.pending === 'save' || props.pending === 'remove')
const actionsValue = computed<MailFilterActionDraft>(() => ({
move: draft.move,
@@ -123,11 +159,13 @@ 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()
opened.value = copyFilterDraft(draft)
applyChosen.value = false
}
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
@@ -187,6 +225,11 @@ function updateActions(patch: Partial<MailFilterActionDraft>) {
})
}
function setApply(checked: boolean) {
applyChosen.value = checked
edited({})
}
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
function focusFirstProblem(found: MailFilterProblems) {
const condition = found.conditions.findIndex((problem) => problem !== null)
@@ -204,7 +247,7 @@ function save() {
focusFirstProblem(found)
return
}
emit('save', filterRuleOf(draft, base))
emit('save', filterRuleOf(draft, base), applyChecked.value)
}
function remove() {
@@ -215,6 +258,17 @@ function reload() {
if (props.pending === null) emit('reload')
}
const { asking: leaveAsking, requestClose, stay: stayEditing, leave: leaveEditing } = useDialogLeaveConfirm({
changed: () => changed.value,
sending: () => sending.value,
close: () => emit('cancel'),
// 확인 창이 닫힌 뒤 — 브라우저가 고치기 창을 스스로 닫았으면(Esc 를 거듭 누름) 다시 띄운다
stayed: () => void nextTick(() => modalRef.value?.reshow()),
})
const leaveCopy = computed(() =>
leaveAsking.value === 'route' ? MAIL_FILTER_LEAVE_DIALOG.route : MAIL_FILTER_LEAVE_DIALOG.close,
)
defineExpose({
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
focusSave: () => saveRef.value?.focus(),
@@ -232,11 +286,12 @@ watch(
<template>
<ModalDialog
id="mail-filter-dialog"
ref="modalRef"
:open="open"
:title="title"
title-id="mail-filter-dialog-title"
wide
@cancel="emit('cancel')"
@cancel="requestClose"
>
<div class="filter-dialog">
<div class="filter-dialog__field">
@@ -299,9 +354,12 @@ watch(
@update="updateActions"
/>
<p class="filter-dialog__notice">
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
</p>
<MailFilterApplyField
:checked="applyChecked"
:block="applyBlock"
:forwards="draft.forward"
@update:checked="setApply"
/>
<div
v-if="error"
@@ -365,12 +423,21 @@ watch(
id="mail-filter-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
@click="requestClose"
>
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
<!-- 저장하지 않고 닫거나 떠나려 할 때 — 고치기 창 위에 뜬다 (닫기는 답을 받아 창을 닫고, 떠나기는 이동이 답을 기다린다) -->
<LeaveConfirmDialog
:open="leaveAsking !== null"
id-prefix="mail-filter-leave"
:copy="leaveCopy"
@stay="stayEditing"
@leave="leaveEditing"
/>
</template>
<style scoped>
@@ -431,15 +498,6 @@ watch(
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);
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter · 7단계 MailFilterApply 의 글 — 바탕 #F3F8F8, 최대 700):
// 차례로 적용 · 옮기면 멈춤 / 이미 받은 메일에도 적용하려면 저장할 때 고른다(7단계) / 스팸 제외 / 사내 전달만 · 새로 오는 메일만 · 한 곳 · 사본 남김 /
// 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게
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
@@ -17,6 +18,15 @@ const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FIL
MAIL_FILTER_TEXT.noteOrderAfter
}}</span>
</p>
<p
class="filter-notes__note"
data-filter-note-apply
>
<span aria-hidden="true">·</span>
<span>{{ MAIL_FILTER_TEXT.noteApplyBefore }}<strong class="filter-notes__strong">{{
MAIL_FILTER_TEXT.noteApplyStrong
}}</strong>{{ MAIL_FILTER_TEXT.noteApplyAfter }}</span>
</p>
<p
v-for="note in NOTES"
:key="note"
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
// 설정 화면 머리 아래 노란 줄 (디자인 원본 MailFilterStates · MailFilterApplyStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
// 필터 목록 위의 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]),
// 그리고 부재중 자동 응답 화면의 "켜 둔 자동 응답이 지금은 동작하지 않습니다"(7단계 — 단추 없음: actionLabel 을 주지 않는다)에 쓴다.
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
@@ -8,7 +9,8 @@ import type { IconName } from '@/shared/constants/icons'
interface Props {
icon: IconName
message: string
actionLabel: string
/** 줄 안의 단추 — 없으면 글만 */
actionLabel?: string
busy?: boolean
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
live?: 'alert' | 'status'
@@ -19,6 +21,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
actionLabel: '',
busy: false,
live: 'status',
})
@@ -31,7 +34,7 @@ function onAction() {
<template>
<div
class="filter-notice"
:class="['filter-notice', { 'filter-notice--plain': !actionLabel }]"
:role="live"
>
<AppIcon
@@ -42,6 +45,7 @@ function onAction() {
/>
<span class="filter-notice__text">{{ message }}</span>
<button
v-if="actionLabel"
type="button"
class="filter-notice__action"
:aria-disabled="busy ? 'true' : undefined"
@@ -64,6 +68,11 @@ function onAction() {
color: var(--color-warning-text);
}
/* 단추 없는 줄 — 좌우 여백이 같다 (디자인 원본 MailFilterApplyStates 의 부재중 안내) */
.filter-notice--plain {
padding-right: var(--space-12);
}
.filter-notice__icon {
margin-top: var(--space-2);
}