- AppSheet: Escape closes any sheet branch; non-modal/inline sheets move focus into panel on open; modal<->inline transition keeps overlay stack - Task detail: initial focus on close button; note textarea aria-label - Global sr-only status region for toast announcements (visual toast hidden from AT to avoid double reading) - Focus view: drop duplicate topbar h1 (page keeps its own single h1) - Archived lists toggle hidden until loaded (no more 'archived 0' flash) - Touch: date-clear/time-remove 44px; edit/preview switch hit area 44px with 3px gap; calendar filter row gets a scroll-right mask hint
108 lines
4.6 KiB
Vue
108 lines
4.6 KiB
Vue
<script setup lang="ts">
|
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { isTopOverlay, overlayRoot, overlayZIndex, 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
|
|
inlineTarget?: string
|
|
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '', inlineTarget: '' })
|
|
defineOptions({ inheritAttrs: false })
|
|
const emit = defineEmits<{ close: [] }>()
|
|
const panel = ref<HTMLElement | null>(null)
|
|
const layerZIndex = ref(80)
|
|
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 (event.key === 'Escape') {
|
|
if (props.busy || (props.modal && !isTopOverlay(overlayId))) return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
emit('close')
|
|
return
|
|
}
|
|
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) return
|
|
if (!props.modal && overlayId) deactivate()
|
|
if (props.modal) {
|
|
if (!overlayId) {
|
|
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
|
layerZIndex.value = overlayZIndex(overlayId)
|
|
}
|
|
await nextTick()
|
|
if (props.open && overlayId) focusIntoPanel()
|
|
return
|
|
}
|
|
await nextTick()
|
|
if (props.open) focusIntoPanel()
|
|
}
|
|
function deactivate() {
|
|
if (overlayId) popOverlay(overlayId)
|
|
overlayId = null
|
|
}
|
|
watch([() => props.open, () => props.modal], ([open]) => {
|
|
if (open) void activate()
|
|
else deactivate()
|
|
}, { immediate: true })
|
|
onBeforeUnmount(deactivate)
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport v-if="modal" :to="overlayRoot()">
|
|
<Transition name="app-sheet">
|
|
<div v-if="open" class="app-overlay app-sheet-mask" :style="{ zIndex: layerZIndex }" :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>
|
|
</Transition>
|
|
</Teleport>
|
|
<Teleport v-else-if="inlineTarget" :to="inlineTarget">
|
|
<component v-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" :aria-busy="busy || undefined" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
|
<slot />
|
|
</component>
|
|
</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" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
|
<slot />
|
|
</component>
|
|
</template>
|