feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -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>
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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()")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user