generated from ttipo/project-base
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:
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user