feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue'
import AppSheet from './AppSheet.vue'
export type AppDialogOptions = {
title: string
description?: string
label?: string
initial?: string
confirmText?: string
cancelText?: string
danger?: boolean
validate?: (value: string) => string | null
}
const open = ref(false)
const busy = ref(false)
const options = ref<AppDialogOptions>({ title: '' })
const value = ref('')
const error = ref('')
let resolveDialog: ((value: boolean | string | null) => void) | null = null
function finish(result: boolean | string | null) {
if (!open.value || busy.value) return
open.value = false
resolveDialog?.(result)
resolveDialog = null
}
function cancel() { finish(options.value.label ? null : false) }
function confirm() {
if (options.value.label) {
const message = options.value.validate?.(value.value) ?? null
if (message) { error.value = message; void nextTick(() => document.querySelector<HTMLElement>('#app-dialog-error')?.focus()); return }
finish(value.value)
} else finish(true)
}
function show(next: AppDialogOptions) {
if (resolveDialog) resolveDialog(options.value.label ? null : false)
options.value = next
value.value = next.initial ?? ''
error.value = ''
open.value = true
return new Promise<boolean | string | null>((resolve) => { resolveDialog = resolve })
}
onBeforeUnmount(() => {
resolveDialog?.(options.value.label ? null : false)
resolveDialog = null
})
defineExpose({ show })
</script>
<template>
<AppSheet :open="open" variant="actions" panel-class="app-dialog" title-id="app-dialog-title" :description-id="options.description ? 'app-dialog-description' : undefined" initial-focus="[data-dialog-initial]" :busy="busy" @close="cancel" @submit.prevent="confirm">
<header class="app-sheet__header"><div><h2 id="app-dialog-title">{{ options.title }}</h2><p v-if="options.description" id="app-dialog-description">{{ options.description }}</p></div></header>
<div v-if="options.label" class="app-sheet__body"><label>{{ options.label }}<input v-model="value" data-dialog-initial class="modal-input" :aria-invalid="Boolean(error)" :aria-describedby="error ? 'app-dialog-error' : undefined" @input="error=''" /></label><small v-if="error" id="app-dialog-error" class="field-error" role="alert" tabindex="-1">{{ error }}</small></div>
<footer class="app-sheet__footer"><button type="button" class="secondary" data-dialog-initial :disabled="busy" @click="cancel">{{ options.cancelText ?? '取消' }}</button><button type="submit" :class="options.danger ? 'danger-button' : 'primary-small'" :disabled="busy">{{ options.confirmText ?? '确定' }}</button></footer>
</AppSheet>
</template>
+262
View File
@@ -0,0 +1,262 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import AppSheet from './AppSheet.vue'
import AppDialog from './AppDialog.vue'
const cleanups: Array<() => void> = []
afterEach(() => { cleanups.splice(0).forEach((fn) => fn()); document.body.innerHTML = '' })
async function mountSheet(options: { busy?: boolean; initialFocus?: string; modal?: boolean } = {}) {
const host = document.createElement('main')
const opener = document.createElement('button')
opener.textContent = 'open'
document.body.append(host, opener)
opener.focus()
const open = ref(true)
const close = vi.fn(() => { open.value = false })
const app = createApp({
setup: () => () => h(AppSheet, {
open: open.value,
titleId: 'sheet-title',
descriptionId: 'sheet-description',
busy: options.busy,
modal: options.modal,
initialFocus: options.initialFocus,
onClose: close,
}, {
default: () => [h('h2', { id: 'sheet-title' }, '标题'), h('p', { id: 'sheet-description' }, '说明'), h('button', { id: 'first' }, 'first'), h('button', { id: 'last' }, 'last')],
}),
})
app.mount(host)
cleanups.push(() => app.unmount())
for (const element of document.querySelectorAll<HTMLElement>('#first,#last')) {
Object.defineProperty(element, 'getClientRects', { configurable: true, value: () => [{ width: 20, height: 20 }] })
}
await nextTick(); await nextTick()
return { host, opener, open, close }
}
describe('AppSheet', () => {
it('teleports an accessible modal and makes application background inert', async () => {
const { host } = await mountSheet({ initialFocus: '#last' })
const dialog = document.querySelector<HTMLElement>('#overlay-root [role="dialog"]')!
expect(dialog.getAttribute('aria-modal')).toBe('true')
expect(dialog.getAttribute('aria-labelledby')).toBe('sheet-title')
expect(dialog.getAttribute('aria-describedby')).toBe('sheet-description')
expect(document.activeElement?.id).toBe('last')
expect(host.hasAttribute('inert')).toBe(true)
expect(host.getAttribute('aria-hidden')).toBe('true')
})
it('traps Tab and restores focus after closing', async () => {
const { opener } = await mountSheet({ initialFocus: '#first' })
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
const first = document.querySelector<HTMLButtonElement>('#first')!
const last = document.querySelector<HTMLButtonElement>('#last')!
last.focus()
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
expect(document.activeElement).toBe(first)
first.focus()
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
expect(document.activeElement).toBe(last)
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
await nextTick(); await nextTick()
expect(document.activeElement).toBe(opener)
})
it('blocks scrim and Escape closing while busy', async () => {
const { close } = await mountSheet({ busy: true })
document.querySelector<HTMLElement>('.app-overlay')!.click()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
expect(close).not.toHaveBeenCalled()
})
it('renders a real form when submit listeners are provided', async () => {
const host = document.createElement('div'); document.body.append(host)
const submitted = vi.fn()
const app = createApp({ setup: () => () => h(AppSheet, { open:true, titleId:'form-title', onSubmit:(event: Event) => { event.preventDefault(); submitted() } }, {
default:() => [h('h2',{id:'form-title'},'form'), h('button',{type:'submit'},'save')],
}) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
expect(dialog.tagName).toBe('FORM')
dialog.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
expect(submitted).toHaveBeenCalledOnce()
})
it('keeps desktop non-modal details inline without inerting the app', async () => {
const host = document.createElement('main'); document.body.append(host)
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:false, titleId:'detail-title' }, {
default:() => [h('h2',{id:'detail-title'},'detail'), h('button','close')],
}) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
const dialog = host.querySelector<HTMLElement>('[role="dialog"]')!
expect(dialog).not.toBeNull()
expect(dialog.getAttribute('aria-modal')).toBeNull()
expect(document.querySelector('#overlay-root [role="dialog"]')).toBeNull()
expect(host.hasAttribute('inert')).toBe(false)
})
it('activates and deactivates the overlay when modal changes while open', async () => {
const host = document.createElement('main'); document.body.append(host)
const modal = ref(false)
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:modal.value, titleId:'dynamic-title' }, {
default:() => [h('h2',{id:'dynamic-title'},'detail'), h('button',{id:'dynamic-close'},'close')],
}) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
expect(host.hasAttribute('inert')).toBe(false)
modal.value = true; await nextTick(); await nextTick()
expect(document.querySelector('#overlay-root [role="dialog"]')).not.toBeNull()
expect(host.hasAttribute('inert')).toBe(true)
modal.value = false; await nextTick(); await nextTick()
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
expect(host.hasAttribute('inert')).toBe(false)
})
it('keeps background inert until the last stacked modal closes', async () => {
const host = document.createElement('main'); document.body.append(host)
const first = ref(true); const second = ref(true)
const app = createApp({ setup: () => () => h('div', [
h(AppSheet, { open:first.value, titleId:'stack-one', onClose:() => { first.value=false } }, { default:() => h('h2',{id:'stack-one'},'one') }),
h(AppSheet, { open:second.value, titleId:'stack-two', onClose:() => { second.value=false } }, { default:() => h('h2',{id:'stack-two'},'two') }),
]) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
expect(host.hasAttribute('inert')).toBe(true)
second.value=false; await nextTick(); await nextTick()
expect(host.hasAttribute('inert')).toBe(true)
first.value=false; await nextTick(); await nextTick()
expect(host.hasAttribute('inert')).toBe(false)
expect(host.getAttribute('aria-hidden')).toBeNull()
})
it('focuses prompt input and confirm dialog cancel action', async () => {
const host = document.createElement('div'); document.body.append(host)
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
void dialog.value!.show({ title:'prompt', label:'name' }); await nextTick(); await nextTick()
expect(document.activeElement?.tagName).toBe('INPUT')
document.querySelector<HTMLButtonElement>('.app-dialog .secondary')!.click(); await nextTick()
void dialog.value!.show({ title:'confirm' }); await nextTick(); await nextTick()
expect(document.activeElement).toBe(document.querySelector('.app-dialog .secondary'))
})
it('settles replaced and unmounted dialog promises safely', async () => {
const host = document.createElement('div'); document.body.append(host)
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
const first = dialog.value!.show({ title:'one' })
const second = dialog.value!.show({ title:'two', label:'name' })
await expect(first).resolves.toBe(false)
app.unmount()
await expect(second).resolves.toBe(null)
})
it('returns focus to the lower overlay when same-tick upper overlay closes', async () => {
const host = document.createElement('main')
const opener = document.createElement('button')
opener.id = 'stack-opener'
document.body.append(host, opener)
opener.focus()
const lowerOpen = ref(true); const upperOpen = ref(true)
const visibleRef = (element: unknown) => {
if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] })
}
const app = createApp({ setup: () => () => h('div', [
h(AppSheet, { open:lowerOpen.value, titleId:'focus-lower' }, { default:() => [h('h2',{id:'focus-lower'},'lower'), h('button',{id:'focus-lower-button', ref:visibleRef},'lower button')] }),
h(AppSheet, { open:upperOpen.value, titleId:'focus-upper' }, { default:() => [h('h2',{id:'focus-upper'},'upper'), h('button',{id:'focus-upper-button', ref:visibleRef},'upper button')] }),
]) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
expect(document.activeElement?.id).toBe('focus-upper-button')
upperOpen.value=false; await nextTick(); await nextTick()
expect(document.activeElement?.id).toBe('focus-lower-button')
})
it('restores the background opener only after the last stacked overlay closes', async () => {
const host = document.createElement('main')
const opener = document.createElement('button')
opener.id = 'last-stack-opener'
document.body.append(host, opener)
opener.focus()
const lowerOpen = ref(true); const upperOpen = ref(true)
const app = createApp({ setup: () => () => h('div', [
h(AppSheet, { open:lowerOpen.value, titleId:'last-lower' }, { default:() => [h('h2',{id:'last-lower'},'lower'), h('button',{id:'last-lower-button'},'lower button')] }),
h(AppSheet, { open:upperOpen.value, titleId:'last-upper' }, { default:() => h('h2',{id:'last-upper'},'upper') }),
]) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
upperOpen.value=false; await nextTick(); await nextTick()
expect(document.activeElement).not.toBe(opener)
lowerOpen.value=false; await nextTick(); await nextTick()
expect(document.activeElement).toBe(opener)
})
it('keeps focus in the upper overlay when a non-top lower overlay closes', async () => {
const host = document.createElement('main')
const opener = document.createElement('button')
opener.id = 'lower-opener'
document.body.append(host, opener)
opener.focus()
const first = ref(true); const second = ref(false)
const app = createApp({ setup: () => () => h('div', [
h(AppSheet, { open:first.value, titleId:'lower' }, { default:() => [h('h2',{id:'lower'},'lower'), h('button',{id:'lower-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'lower button')] }),
h(AppSheet, { open:second.value, titleId:'upper' }, { default:() => [h('h2',{id:'upper'},'upper'), h('button',{id:'upper-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'upper button')] }),
]) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
second.value=true; await nextTick(); await nextTick()
const upper = document.querySelector<HTMLButtonElement>('#upper-button')!
expect(document.activeElement).toBe(upper)
first.value=false; await nextTick(); await nextTick()
expect(document.activeElement).toBe(upper)
})
it('skips focusables hidden by ancestors, aria-hidden, inert, styles, disabled state, or empty client rects', async () => {
const { host } = await mountSheet({ initialFocus: '#first' })
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
dialog.querySelector('#first')?.remove()
dialog.querySelector('#last')?.remove()
const hiddenParent = document.createElement('div')
hiddenParent.hidden = true
hiddenParent.innerHTML = '<button id="hidden-child">hidden</button>'
const ariaParent = document.createElement('div')
ariaParent.setAttribute('aria-hidden', 'true')
ariaParent.innerHTML = '<button id="aria-child">aria</button>'
const inertParent = document.createElement('div')
inertParent.setAttribute('inert', '')
inertParent.innerHTML = '<button id="inert-child">inert</button>'
const displayNone = document.createElement('button')
displayNone.id = 'display-none'; displayNone.style.display = 'none'
const invisible = document.createElement('button')
invisible.id = 'invisible'; invisible.style.visibility = 'hidden'
const disabled = document.createElement('button')
disabled.id = 'disabled'; disabled.disabled = true
const noRect = document.createElement('button')
noRect.id = 'no-rect'
const visible = document.createElement('button')
visible.id = 'visible'
Object.defineProperty(visible, 'getClientRects', { value: () => [{ width: 20, height: 20 }] })
dialog.append(hiddenParent, ariaParent, inertParent, displayNone, invisible, disabled, noRect, visible)
dialog.focus()
dialog.dispatchEvent(new KeyboardEvent('keydown', { key:'Tab', bubbles:true, cancelable:true }))
expect(document.activeElement).toBe(visible)
host.remove()
})
it('only closes the top overlay on Escape', async () => {
const host = document.createElement('div'); document.body.append(host)
const first = ref(true); const second = ref(true); const calls: string[] = []
const app = createApp({ setup: () => () => h('div', [
h(AppSheet, { open:first.value, titleId:'one', onClose:() => { calls.push('one'); first.value=false } }, { default:() => h('h2',{id:'one'},'one') }),
h(AppSheet, { open:second.value, titleId:'two', onClose:() => { calls.push('two'); second.value=false } }, { default:() => h('h2',{id:'two'},'two') }),
]) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', bubbles:true, cancelable:true }))
await nextTick()
expect(calls).toEqual(['two'])
})
})
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { isTopOverlay, overlayRoot, popOverlay, pushOverlay } from '../composables/useOverlayStack'
const props = withDefaults(defineProps<{
open: boolean
titleId?: string
descriptionId?: string
label?: string
busy?: boolean
initialFocus?: string
modal?: boolean
closeOnScrim?: boolean
variant?: 'create' | 'detail' | 'actions'
panelClass?: string
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '' })
defineOptions({ inheritAttrs: false })
const emit = defineEmits<{ close: [] }>()
const panel = ref<HTMLElement | null>(null)
let overlayId: symbol | null = null
function requestClose() {
if (!props.busy && (!props.modal || isTopOverlay(overlayId))) emit('close')
}
function scrimClose(event: MouseEvent) {
if (props.closeOnScrim && event.target === event.currentTarget) requestClose()
}
function isVisibleFocusable(element: HTMLElement) {
if (element.matches(':disabled') || element.closest('[hidden],[aria-hidden="true"],[inert]')) return false
const style = window.getComputedStyle(element)
if (style.display === 'none' || style.visibility === 'hidden') return false
return element.getClientRects().length > 0 || (element.offsetWidth > 0 && element.offsetHeight > 0)
}
function focusables() {
if (!panel.value) return []
return Array.from(panel.value.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
.filter(isVisibleFocusable)
}
function keydown(event: KeyboardEvent) {
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
const controls = focusables()
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
const first = controls[0]
const last = controls[controls.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.value)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) { event.preventDefault(); first.focus() }
}
function focusIntoPanel() {
if (!panel.value?.contains(document.activeElement)) {
const target = props.initialFocus ? panel.value?.querySelector<HTMLElement>(props.initialFocus) : null
;(target ?? focusables()[0] ?? panel.value)?.focus()
}
}
async function activate() {
if (!props.open || !props.modal || overlayId) return
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
await nextTick()
if (!props.open || !props.modal || !overlayId) return
focusIntoPanel()
}
function deactivate() {
if (overlayId) popOverlay(overlayId)
overlayId = null
}
watch([() => props.open, () => props.modal], ([open, modal]) => {
if (open && modal) void activate()
else deactivate()
}, { immediate: true })
onBeforeUnmount(deactivate)
</script>
<template>
<Teleport v-if="modal" :to="overlayRoot()">
<div v-if="open" class="app-overlay app-sheet-mask" :aria-busy="busy || undefined" @click="scrimClose">
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
<slot />
</component>
</div>
</Teleport>
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
<slot />
</component>
</template>
@@ -36,5 +36,6 @@ describe('add-task CalendarPicker integration', () => {
expect(picker).toContain('data-action="clear"')
expect(picker).toContain('data-action="cancel"')
expect(picker).toContain('data-action="done"')
expect(picker).toContain("event.preventDefault(); event.stopPropagation(); close()")
})
})
+1 -1
View File
@@ -46,7 +46,7 @@ function onGridKey(event: KeyboardEvent) {
}
}
function onDialogKey(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close(); return }
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return }
if (event.key !== 'Tab' || !dialog.value) return
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
if (!focusables.length) return
+21 -27
View File
@@ -17,7 +17,8 @@ async function mount(overrides: Record<string, unknown> = {}) {
const events: Record<string, unknown[]> = { saved: [], close: [] }
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
const confirmAction = (overrides.confirmAction ?? vi.fn(async () => true)) as (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean>
const app = createApp(() => h(MemoEditor, { memo, request, confirmAction, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
return { host, request: request as ReturnType<typeof vi.fn>, events }
}
@@ -105,19 +106,19 @@ describe('MemoEditor', () => {
})
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const confirmAction = vi.fn(async () => false)
const request = vi.fn()
const clean = await mount({ memo: draft, request })
const clean = await mount({ memo: draft, request, confirmAction })
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
expect(confirm).not.toHaveBeenCalled()
expect(confirmAction).not.toHaveBeenCalled()
expect(request).not.toHaveBeenCalled()
expect(clean.events.close).toEqual([true])
const edited = await mount({ memo: draft, request })
const edited = await mount({ memo: draft, request, confirmAction })
const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
expect(confirm).toHaveBeenCalledOnce()
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
expect(confirmAction).toHaveBeenCalledOnce()
expect(edited.events.close).toEqual([])
})
@@ -226,22 +227,15 @@ describe('MemoEditor', () => {
expect(events.close).toEqual([])
})
it('traps mobile focus, closes on Escape, and leaves focus restoration to the panel owner', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
it('leaves mobile modal focus trapping and Escape close to AppSheet', async () => {
const { host, events } = await mount({ mobile: true })
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
expect(dialog.getAttribute('aria-modal')).toBe('true')
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
last.focus()
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
dialog.dispatchEvent(tab)
expect(tab.defaultPrevented).toBe(true)
expect(document.activeElement).toBe(dialog.querySelector('button'))
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
const editor = host.querySelector<HTMLElement>('.memo-editor')!
expect(editor.getAttribute('aria-modal')).toBeNull()
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
editor.dispatchEvent(escape)
await nextTick()
expect(events.close).toEqual([true])
expect(document.activeElement).not.toBe(opener)
opener.remove()
expect(escape.defaultPrevented).toBe(false)
expect(events.close).toEqual([])
})
it('ignores a stale reload after its editor selection changes', async () => {
@@ -424,15 +418,15 @@ describe('MemoEditor', () => {
})
it('guards dirty close and allows clean close', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { host, events } = await mount()
const confirmAction = vi.fn(async () => false)
const { host, events } = await mount({ confirmAction })
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
expect(confirm).toHaveBeenCalled()
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
expect(confirmAction).toHaveBeenCalled()
expect(events.close).toEqual([])
confirm.mockReturnValue(true)
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
confirmAction.mockResolvedValue(true)
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
expect(events.close).toEqual([true])
})
})
+8 -16
View File
@@ -8,7 +8,7 @@ export type MemoDraft = { id: null; title: string; content: string; version: nul
export type Memo = MemoRecord
export type MemoEditorValue = MemoRecord | MemoDraft
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number }>(), { selectionToken: 0 })
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number; confirmAction?: (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean> }>(), { selectionToken: 0 })
const emit = defineEmits<{ saveStarted: [id: string | null, selectionToken: number]; saveFinished: [selectionToken: number]; lifecycleStarted: [id: string, selectionToken: number]; lifecycleFinished: [selectionToken: number]; saved: [memo: MemoRecord, selectionToken: number]; close: []; deleted: [id: string, selectionToken: number]; restored: [memo: MemoRecord, selectionToken: number]; purged: [id: string, selectionToken: number]; notice: [message: string] }>()
const title = ref('')
const content = ref('')
@@ -18,7 +18,6 @@ let lifecycleGeneration = 0
const error = ref('')
const conflict = ref(false)
const titleInput = ref<HTMLInputElement | null>(null)
const root = ref<HTMLElement | null>(null)
const initial = ref({ title: '', content: '' })
const memoPreview = ref(false)
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
@@ -35,8 +34,8 @@ watch(() => props.selectionToken, () => {
lifecycleGeneration += 1
lifecycleBusy.value = false
})
function close() {
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
async function close() {
if (dirty.value && !(await props.confirmAction?.({ title: '放弃未保存的更改?', description: '关闭后,当前草稿不会保存。', confirmText: '放弃更改', danger: true }))) return
emit('close')
}
function validate() {
@@ -91,7 +90,7 @@ async function reload() {
async function remove() {
const memoId = props.memo.id
if (memoId === null || lifecycleBusy.value) return
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
if (!(await props.confirmAction?.({ title: `把“${props.memo.title}”移到回收站?`, description: '之后可以在回收站恢复。', confirmText: '移到回收站', danger: true }))) return
error.value = ''
lifecycleBusy.value = true
const operationToken = ++lifecycleGeneration
@@ -130,7 +129,7 @@ async function restore() {
async function purge() {
const memoId = props.memo.id
if (memoId === null || lifecycleBusy.value) return
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
if (!(await props.confirmAction?.({ title: `永久删除“${props.memo.title}”?`, description: '此操作无法撤销。', confirmText: '永久删除', danger: true }))) return
error.value = ''
lifecycleBusy.value = true
const operationToken = ++lifecycleGeneration
@@ -170,14 +169,7 @@ function handleMemoBodyShortcut(event: KeyboardEvent) {
formatMemoBody(format)
}
function keydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
if (event.key === 'Escape') { event.preventDefault(); close(); return }
if (!props.mobile || event.key !== 'Tab' || !root.value) return
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
if (!controls.length) return
const first = controls[0], last = controls.at(-1)!
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save() }
}
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
@@ -186,7 +178,7 @@ defineExpose({ dirty, requestClose: close })
</script>
<template>
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
<div class="memo-editor" @keydown="keydown">
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
<div class="memo-editor__fields">
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
@@ -213,5 +205,5 @@ defineExpose({ dirty, requestClose: close })
</div>
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存' : '保存' }}</button></footer>
<footer v-else><button type="button" class="secondary" :disabled="lifecycleBusy" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" :disabled="lifecycleBusy" @click="purge"><Trash2/>永久删除</button></footer>
</aside>
</div>
</template>