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
+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>