feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림

- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기,
  401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기
- 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만
- 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기
- 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 13:32:09 +09:00
parent f725908053
commit 50165bd3b7
52 changed files with 5234 additions and 38 deletions
+16 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기]) + 닫기
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
import AppIcon from '@/components/AppIcon.vue'
@@ -216,6 +217,20 @@ function runAction(toast: Toast) {
outline-color: var(--color-on-brand-link);
}
/* 흰 오류 카드 위의 단추 · 링크 (예: 바탕화면 알림을 막아 둠 — [설정 열기]) — 브랜드 바탕용 밝은 색은 흰 바탕에서 보이지 않는다 */
.toast--error .toast__action {
align-self: center;
color: var(--color-brand);
}
.toast--error .toast__action:hover {
color: var(--color-brand-strong);
}
.toast--error .toast__action:focus-visible {
outline-color: var(--color-brand);
}
.toast__close {
flex: 0 0 auto;
display: inline-flex;
@@ -1,15 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import MessageList from './MessageList.vue'
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
// 목록 틀 — 새 메일 알림 줄 · 바탕화면 알림 권하기 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리" · "5단계")
const INBOX: Mailbox = {
id: 'inbox',
@@ -127,6 +130,44 @@ describe('MailListFrame (목록 칸의 틀)', () => {
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
})
describe('바탕화면 알림 권하기 (디자인 README "5단계 › 목록 위에서 한 번 권하기")', () => {
beforeEach(() => {
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
})
afterEach(() => {
uninstallFakeLocks()
vi.unstubAllGlobals()
})
it('메일함 목록 머리 아래에 둔다 — 검색 결과에는 두지 않는다', async () => {
const { wrapper, store } = await mountList([summary('m1')])
const top = wrapper.get('.mail-list-frame__top')
expect(top.find('.list-header + .notify-offer').exists()).toBe(true)
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
await flushPromises()
expect(wrapper.find('.notify-offer').exists()).toBe(false)
})
it('[✕] 로 닫으면 누른 단추와 함께 사라진 초점을 목록 칸으로 옮긴다', async () => {
const { wrapper, section } = await mountList([summary('m1')])
const close = wrapper.get('.notify-offer__close')
;(close.element as HTMLElement).focus()
await close.trigger('click')
await flushPromises()
expect(wrapper.find('.notify-offer').exists()).toBe(false)
expect(document.activeElement).toBe(section)
})
})
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
store.toggleCheck('m1')
+16 -2
View File
@@ -1,16 +1,17 @@
<script setup lang="ts">
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue'
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
@@ -70,6 +71,14 @@ function focusSection() {
sectionRef.value?.focus({ preventScroll: true })
}
/** 알림 권하기 줄이 사라졌다 — 누른 단추와 함께 초점이 사라졌으면 목록 칸으로 */
function onOfferLeave() {
void nextTick(() => {
const active = document.activeElement
if (active === null || active === document.body) focusSection()
})
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
const target = event.target instanceof Element ? event.target : null
@@ -119,6 +128,11 @@ watch(
:variant="variant"
/>
<MailSearchScope v-if="search" />
<MailNotifyOffer
v-else
:variant="variant"
@leave="onOfferLeave"
/>
<div
class="mail-list-frame__notice"
role="status"
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { mailNotifyStorageKey, useMailNotifyStore } from '@/stores/mail-notify.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import MailNotifyOffer from './MailNotifyOffer.vue'
// 목록 위에서 한 번 권하기 (디자인 원본 MailRealtime · README "5단계 › 목록 위에서 한 번 권하기")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
describe('MailNotifyOffer (바탕화면 알림 권하기 줄)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
uninstallFakeLocks()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function mountOffer(variant: 'split' | 'list' = 'list') {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(MailNotifyOffer, {
props: { variant },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
await flushPromises()
return wrapper
}
it('알림을 켜지도 닫지도 않았고 브라우저가 아직 묻지 않았으면 보인다 — 종 아이콘 · 글 · [알림 켜기] · [✕ 닫기]', async () => {
const wrapper = await mountOffer()
const bar = wrapper.get('[role="region"]')
expect(bar.attributes('aria-label')).toBe('바탕화면 알림 권하기')
expect(bar.text()).toContain('바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.')
expect(bar.get('.notify-offer__action').text()).toBe('알림 켜기')
expect(bar.get('.notify-offer__close').attributes('aria-label')).toBe('닫기')
})
it('분할 보기의 좁은 목록 칸에서는 짧은 글 "바탕화면 알림으로 새 메일 받기" — 긴 글은 title 로', async () => {
const split = await mountOffer('split')
const text = split.get('.notify-offer__text')
expect(text.text()).toBe('바탕화면 알림으로 새 메일 받기')
expect(text.attributes('title')).toBe('바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.')
split.unmount()
const list = await mountOffer('list')
expect(list.get('.notify-offer__text').text()).toBe(
'바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.',
)
})
it('허락을 묻는 동안 단추를 끄지 않고 aria-disabled — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
const wrapper = await mountOffer()
const answer = FakeNotification.holdAnswer()
const action = wrapper.get('.notify-offer__action')
;(action.element as HTMLElement).focus()
await action.trigger('click')
await flushPromises()
expect(action.attributes('disabled')).toBeUndefined()
expect(action.attributes('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(action.element)
await action.trigger('click')
await flushPromises()
expect(FakeNotification.requestCount).toBe(1)
answer('default')
await flushPromises()
expect(wrapper.get('.notify-offer__action').attributes('aria-disabled')).toBeUndefined()
expect(document.activeElement).toBe(action.element)
})
it.each([
['브라우저가 이미 막았으면', () => FakeNotification.reset('denied')],
['브라우저가 이미 허락했으면', () => FakeNotification.reset('granted')],
['지원하지 않으면', () => vi.stubGlobal('Notification', undefined)],
[
'닫은 적이 있으면',
() =>
window.localStorage.setItem(mailNotifyStorageKey(USER.email), JSON.stringify({ offerDismissed: true })),
],
['이미 켰으면', () => window.localStorage.setItem(mailNotifyStorageKey(USER.email), JSON.stringify({ enabled: true }))],
])('%s 보이지 않는다', async (_label, arrange) => {
// 설정 스토어는 줄을 그릴 때 만들어진다 — 그 전에 저장값 · 허락을 정한다
arrange()
const wrapper = await mountOffer()
expect(wrapper.find('[role="region"]').exists()).toBe(false)
})
it('[알림 켜기] → 허용하면 켜고 "바탕화면 알림을 켰습니다." — 줄은 사라진다', async () => {
const wrapper = await mountOffer()
FakeNotification.nextAnswer = 'granted'
await wrapper.get('.notify-offer__action').trigger('click')
await flushPromises()
expect(useMailNotifyStore().settings.enabled).toBe(true)
expect(useToastStore().toasts).toMatchObject([{ kind: 'success', message: '바탕화면 알림을 켰습니다.' }])
expect(wrapper.find('[role="region"]').exists()).toBe(false)
expect(wrapper.emitted('leave')).toHaveLength(1)
})
it('[알림 켜기] → 막으면 오류 알림 + [설정 열기](설정 › 알림) — 줄은 사라진다', async () => {
const wrapper = await mountOffer()
FakeNotification.nextAnswer = 'denied'
await wrapper.get('.notify-offer__action').trigger('click')
await flushPromises()
expect(useMailNotifyStore().settings.enabled).toBe(false)
expect(useToastStore().toasts).toMatchObject([
{
kind: 'error',
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
action: { label: '설정 열기', to: { name: 'settings-mail-notify' } },
},
])
expect(wrapper.find('[role="region"]').exists()).toBe(false)
})
it('[알림 켜기] → 그냥 닫으면 줄은 그대로 · 알림도 없다', async () => {
const wrapper = await mountOffer()
FakeNotification.nextAnswer = 'default'
await wrapper.get('.notify-offer__action').trigger('click')
await flushPromises()
expect(wrapper.find('[role="region"]').exists()).toBe(true)
expect(useToastStore().toasts).toEqual([])
expect(wrapper.emitted('leave')).toBeUndefined()
})
it('[✕] → 다시 보이지 않는다 (브라우저 · 사용자마다 저장)', async () => {
const wrapper = await mountOffer()
await wrapper.get('.notify-offer__close').trigger('click')
expect(wrapper.find('[role="region"]').exists()).toBe(false)
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(USER.email)) ?? '{}')).toMatchObject({
offerDismissed: true,
})
expect(FakeNotification.requestCount).toBe(0)
expect(wrapper.emitted('leave')).toHaveLength(1)
})
})
@@ -0,0 +1,170 @@
<script setup lang="ts">
// 목록 위에서 한 번 권하기 — 바탕화면 알림을 켜지도 닫지도 않은 사람에게 (디자인 원본 MailRealtime · README "5단계 › 목록 위에서 한 번 권하기")
// 목록 머리 아래 40 줄(바탕 #F3F8F8 — 새 메일 알림 줄과 같은 높이, 밀도 예외 — 줄 안의 단추는 36):
// 종 아이콘 + 글 + [알림 켜기](강조 글자 굵게) + [✕ 닫기]. 분할 · 목록 보기 모두 (목록 틀 MailListFrame 이 메일함 목록에 둔다).
// 분할 보기의 좁은 목록 칸(384)에서는 짧은 글 "바탕화면 알림으로 새 메일 받기"(긴 글은 title) — 단추와 함께 한 줄에 든다 (2026-10-28 화면 점검)
// - 보이는 조건: 지원하고 · 브라우저가 아직 묻지 않았고(default) · 켜지 않았고 · 닫은 적이 없을 때 (mail-notify.store 의 offerVisible)
// - [알림 켜기] → 브라우저가 묻는다 → 허용: 켜고 "바탕화면 알림을 켰습니다." · 막힘: 오류 알림 + [설정 열기] · 그냥 닫음: 줄은 그대로
// 묻는 동안은 단추를 끄지 않고 aria-disabled 로만 막는다 — 초점이 그대로 남게 (다시 누르는 것은 스토어가 막는다)
// - [✕] → 다시 보이지 않는다 (브라우저 · 사용자마다)
// 줄이 사라지면 leave 를 알린다 — 누른 단추가 없어져 초점을 잃지 않게 목록 틀이 목록 칸에 초점을 준다
import { computed, onMounted } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useToast } from '@/composables/useToast'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
interface Props {
/** 분할 보기의 목록 칸(좁다) · 목록 보기의 표 (넓다) */
variant?: 'split' | 'list'
}
interface Emits {
(e: 'leave'): void
}
const props = withDefaults(defineProps<Props>(), {
variant: 'list',
})
const emit = defineEmits<Emits>()
const notifyStore = useMailNotifyStore()
const toast = useToast()
const text = computed(() => (props.variant === 'split' ? MAIL_NOTIFY_TEXT.offerShortText : MAIL_NOTIFY_TEXT.offerText))
async function turnOn() {
if (notifyStore.requesting) return
const result = await notifyStore.turnOn()
if (result === 'granted') {
toast.success(MAIL_NOTIFY_TEXT.enabledToast)
emit('leave')
} else if (result === 'denied') {
toast.error(MAIL_NOTIFY_TEXT.blockedToast, {
label: MAIL_NOTIFY_TEXT.openSettings,
to: { name: SETTINGS_ROUTES.notify },
})
emit('leave')
}
}
function dismiss() {
notifyStore.dismissOffer()
emit('leave')
}
// 사이트 설정에서 허락을 바꿨을 수 있다 — 목록을 열 때 다시 읽는다
onMounted(() => notifyStore.refreshPermission())
</script>
<template>
<div
v-if="notifyStore.offerVisible"
:class="['notify-offer', `notify-offer--${variant}`]"
role="region"
:aria-label="MAIL_NOTIFY_TEXT.offerRegion"
>
<AppIcon
name="bell"
:size="15"
:stroke-width="1.9"
class="notify-offer__icon"
/>
<span
class="notify-offer__text"
:title="MAIL_NOTIFY_TEXT.offerText"
>{{ text }}</span>
<button
type="button"
class="notify-offer__action"
:aria-disabled="notifyStore.requesting ? 'true' : undefined"
@click="turnOn"
>
{{ MAIL_NOTIFY_TEXT.offerAction }}
</button>
<button
type="button"
class="notify-offer__close"
:aria-label="MAIL_NOTIFY_TEXT.offerClose"
:title="MAIL_NOTIFY_TEXT.offerClose"
@click="dismiss"
>
<AppIcon
name="x"
:size="14"
:stroke-width="2"
/>
</button>
</div>
</template>
<style scoped>
.notify-offer {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-notice-bar);
padding: 0 var(--space-4) 0 var(--space-14);
border-bottom: var(--border-width) solid var(--color-brand-tint-border);
background: var(--color-brand-tint);
color: var(--color-brand-surface);
}
.notify-offer__icon {
color: var(--color-brand-strong);
}
/* 좁으면 글이 먼저 줄어든다 — 단추는 늘 보인다 */
.notify-offer__text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notify-offer__action,
.notify-offer__close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--size-notice-action);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.notify-offer__action {
padding: 0 var(--space-10);
color: var(--color-brand-strong);
font-weight: 600;
}
.notify-offer__action:hover:not([aria-disabled='true']),
.notify-offer__close:hover {
background: var(--color-brand-subtle);
}
/* 허락을 묻는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.notify-offer__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.notify-offer__close {
width: var(--size-notice-action);
color: var(--color-text-muted);
}
.notify-offer__close:hover {
color: var(--color-text-strong);
}
.notify-offer__action:focus-visible,
.notify-offer__close:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,281 @@
<script setup lang="ts">
// 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
// - [x] 새 메일이 오면 바탕화면 알림 보내기(44) — 켜면 브라우저가 허락을 묻는다(누른 뒤에만). 칸 아래 안내(12.5px):
// 묻기 전(회색) · 허용됨(초록 + 체크) · 막힘(칸을 끄고 빨강 + 경고) · 지원하지 않음(칸을 끄고 회색) — notifyEnableState
// 허락하지 않았으면(막음 · 그냥 닫음) 칸은 비운 채 돌아간다. 묻는 동안은 칸을 끄지 않고 aria-disabled 로만 막는다(초점이 그대로 남게).
// 막혀서(또는 지원하지 않아서) 칸이 꺼지면 초점을 안내 글로 옮긴다 — 왜 꺼졌는지 바로 읽게
// - 알림에 보일 것: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
// - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 지금 탭에서 바로 "ARA 웹메일" / "알림이 잘 옵니다. …" (알림을 끄면 누를 수 없다).
// 못 띄우면 조용히 넘어가지 않는다 — 만들 수 없는 브라우저(휴대폰 Chrome 등)면 "지원하지 않음", 허락이 사라졌으면 그 안내로 바꾸고 초점을 옮긴다
// 고정 id — #notify-enabled · #notify-enabled-note · #notify-show-subject · #notify-test
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { showDesktopNotification } from '@/composables/useDesktopNotification'
import type { IconName } from '@/shared/constants/icons'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { notifyEnableState, type NotifyEnableNote } from '@/shared/utils/desktop-notify'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
interface EnableNote {
text: string
tone: 'muted' | 'success' | 'danger'
icon: IconName | null
}
const ENABLE_NOTES: Readonly<Record<NotifyEnableNote, EnableNote>> = {
ask: { text: MAIL_NOTIFY_TEXT.noteAsk, tone: 'muted', icon: null },
granted: { text: MAIL_NOTIFY_TEXT.noteGranted, tone: 'success', icon: 'check' },
blocked: { text: MAIL_NOTIFY_TEXT.noteBlocked, tone: 'danger', icon: 'alert-circle' },
unsupported: { text: MAIL_NOTIFY_TEXT.noteUnsupported, tone: 'muted', icon: null },
}
const notifyStore = useMailNotifyStore()
const noteRef = ref<HTMLElement | null>(null)
const enableState = computed(() => notifyEnableState(notifyStore.permission, notifyStore.settings))
const note = computed(() => ENABLE_NOTES[enableState.value.note])
/** 알림을 끈 동안은 보일 것 · 시험 알림을 흐리게 */
const optionsDisabled = computed(() => !enableState.value.checked)
/** 칸 · 단추가 꺼져 초점을 잃을 때 — 까닭을 적은 안내 글로 옮긴다 */
async function focusNote() {
await nextTick()
noteRef.value?.focus()
}
async function onToggle(event: Event) {
const input = event.target as HTMLInputElement
if (notifyStore.requesting) {
// 허락을 묻는 중 — 방금 누른 것만 되돌린다 (칸은 aria-disabled · 켜려던 모양 그대로 답을 기다린다)
input.checked = !input.checked
return
}
if (!input.checked) {
notifyStore.turnOff()
return
}
const result = await notifyStore.turnOn()
// 허락하지 않았으면 칸을 되돌린다 — 그려야 할 값(비움)이 그대로라 화면이 다시 그리지 않는다
input.checked = enableState.value.checked
if (enableState.value.disabled) await focusNote()
else if (result !== 'granted') input.focus()
}
function onShowSubject(event: Event) {
notifyStore.setShowSubject((event.target as HTMLInputElement).checked)
}
async function sendTest() {
const result = showDesktopNotification({ title: MAIL_NOTIFY_TEXT.testTitle, body: MAIL_NOTIFY_TEXT.testBody })
if (result.status === 'shown') return
if (result.status === 'unsupported') notifyStore.markUnsupported()
else notifyStore.refreshPermission()
// 칸 · 이 단추가 꺼진다 — 바뀐 안내 글로
await focusNote()
}
</script>
<template>
<div class="notify-form">
<div class="notify-form__enable">
<label :class="['notify-form__toggle', { 'is-disabled': enableState.disabled }]">
<input
id="notify-enabled"
type="checkbox"
class="notify-form__checkbox"
:checked="enableState.checked"
:disabled="enableState.disabled"
:aria-disabled="notifyStore.requesting ? 'true' : undefined"
aria-describedby="notify-enabled-note"
@change="onToggle"
>
<span>{{ MAIL_NOTIFY_TEXT.enable }}</span>
</label>
<p
id="notify-enabled-note"
ref="noteRef"
:class="['form-message', 'notify-form__note', `notify-form__note--${note.tone}`]"
role="status"
tabindex="-1"
:data-note="enableState.note"
>
<AppIcon
v-if="note.icon"
:name="note.icon"
:size="15"
:stroke-width="note.tone === 'success' ? 2.2 : 1.9"
/>
<span>{{ note.text }}</span>
</p>
</div>
<fieldset
class="notify-form__content"
:disabled="optionsDisabled"
>
<legend class="notify-form__legend">
{{ MAIL_NOTIFY_TEXT.contentLegend }}
</legend>
<label class="notify-form__option">
<input
id="notify-show-subject"
type="checkbox"
class="notify-form__checkbox"
:checked="notifyStore.settings.showSubject"
aria-describedby="notify-show-subject-hint"
@change="onShowSubject"
>
<span>{{ MAIL_NOTIFY_TEXT.showSubject }}</span>
</label>
<p
id="notify-show-subject-hint"
class="form-message notify-form__note"
>
{{ MAIL_NOTIFY_TEXT.showSubjectHint }}
</p>
</fieldset>
<div class="notify-form__test">
<button
id="notify-test"
type="button"
class="notify-form__test-button"
:disabled="optionsDisabled"
@click="sendTest"
>
<AppIcon
name="bell"
:size="15"
/>
<span>{{ MAIL_NOTIFY_TEXT.test }}</span>
</button>
<span class="notify-form__test-hint">{{ MAIL_NOTIFY_TEXT.testHint }}</span>
</div>
</div>
</template>
<style scoped>
.notify-form {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.notify-form__enable {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.notify-form__toggle,
.notify-form__option {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.notify-form__toggle {
font-weight: 600;
}
.notify-form__toggle.is-disabled {
color: var(--color-icon-muted);
cursor: default;
}
.notify-form__checkbox {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
/* 칸 아래 안내 — 고르기 상자 글자 선(28)에 맞춰 들여 쓴다 */
.notify-form__note {
margin-left: var(--space-28);
}
/* 프로그램이 옮긴 초점(칸이 꺼졌을 때)에는 고리를 그리지 않는다 — 글을 읽게 할 뿐이다 */
.notify-form__note:focus {
outline: none;
}
.notify-form__checkbox[aria-disabled='true'] {
cursor: progress;
}
.notify-form__note--success {
color: var(--color-success-text);
}
.notify-form__note--danger {
color: var(--color-danger);
}
.notify-form__content {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
}
.notify-form__content:disabled {
color: var(--color-icon-muted);
}
.notify-form__content:disabled .notify-form__option {
cursor: default;
}
.notify-form__content:disabled .notify-form__note {
color: var(--color-icon-muted);
}
.notify-form__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.notify-form__test {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
}
.notify-form__test-button {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.notify-form__test-button:hover:not(:disabled) {
background: var(--color-surface-panel);
color: var(--color-text);
}
.notify-form__test-button:disabled {
background: var(--color-surface-panel);
color: var(--color-icon-muted);
cursor: not-allowed;
}
.notify-form__test-hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
// 설정 › 알림의 "실시간 연결" (디자인 원본 SettingsNotify) — 이 탭이 보는 연결 상태 (stores/mail-realtime.store.ts)
// ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
// 끊긴 동안은 상태 줄 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다." (2026-10-28 화면 점검 반영)
// 메일 화면에서는 끊겨도 알리지 않는다 — 여기서만 볼 수 있다 (디자인 README "5단계 › 실시간 반영")
import { computed } from 'vue'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { realtimeLinkText } from '@/shared/utils/mail-realtime'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
const realtimeStore = useMailRealtimeStore()
const link = computed(() => realtimeLinkText(realtimeStore.status))
</script>
<template>
<section
class="realtime-link"
aria-labelledby="notify-realtime-title"
>
<h2
id="notify-realtime-title"
class="realtime-link__title"
>
{{ MAIL_NOTIFY_TEXT.realtimeTitle }}
</h2>
<!-- 상태가 바뀌면 끊긴 동안의 알림까지 함께 읽힌다 -->
<div
class="realtime-link__live"
role="status"
>
<p
id="notify-realtime-status"
class="realtime-link__status"
:data-status="realtimeStore.status"
>
<span
:class="['realtime-link__dot', { 'is-live': link.live }]"
aria-hidden="true"
/>
<span>{{ link.text }}</span>
</p>
<p
v-if="link.notice"
id="notify-realtime-notice"
class="realtime-link__notice"
>
{{ link.notice }}
</p>
</div>
<p class="form-message">
{{ link.hint }}
</p>
</section>
</template>
<style scoped>
.realtime-link {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.realtime-link__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.realtime-link__live {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.realtime-link__status {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
}
.realtime-link__dot {
flex: 0 0 auto;
width: var(--size-status-dot);
height: var(--size-status-dot);
border-radius: var(--radius-round);
background: var(--color-icon-muted);
}
.realtime-link__dot.is-live {
background: var(--color-status-live);
}
/* 끊긴 동안 — 상태 줄의 글자 선(점 8 + 간격 8)에 맞춰 들여 쓴다 */
.realtime-link__notice {
margin: 0 0 0 calc(var(--size-status-dot) + var(--space-8));
color: var(--color-text-strong);
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
@@ -40,6 +41,8 @@ defineProps<Props>()
min-width: 0;
display: flex;
flex-direction: column;
word-break: keep-all;
overflow-wrap: anywhere;
}
.settings-page__column {