Files
pokopiawiki.tootaio.com/frontend/src/components/Modal.vue
xiaomai 6812ddc428 feat(ui): use modal dialogs for entity creation and editing
Introduce reusable Modal component for forms
Update router to preserve scroll position when toggling modals
Refactor admin and entity views to render editors as overlays
2026-05-01 13:44:34 +08:00

253 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
open?: boolean;
title: string;
subtitle?: string;
closeLabel: string;
size?: 'default' | 'wide';
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
}>(),
{
open: true,
size: 'default',
closeOnBackdrop: true,
closeOnEscape: true
}
);
const emit = defineEmits<{
close: [];
}>();
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
const dialog = ref<HTMLElement | null>(null);
const modalBody = ref<HTMLElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
const previousActiveElement = ref<HTMLElement | null>(null);
const focusedBodyControl = ref(false);
let bodyObserver: MutationObserver | null = null;
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const bodyInputSelector = [
'[autofocus]',
'input:not([disabled]):not([readonly]):not([type="hidden"])',
'textarea:not([disabled]):not([readonly])',
'select:not([disabled])'
].join(',');
const bodyFallbackSelector = [
'.tags-select__trigger:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
function lockPage() {
document.body.classList.add('lock-scroll');
}
function unlockPage() {
document.body.classList.remove('lock-scroll');
}
function restoreFocus() {
const target = previousActiveElement.value;
if (target?.isConnected) {
target.focus();
}
}
function stopBodyObserver() {
bodyObserver?.disconnect();
bodyObserver = null;
}
function isVisible(element: HTMLElement) {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
}
function firstVisibleElement(root: HTMLElement, selector: string) {
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find(isVisible);
}
function firstBodyControl() {
const body = modalBody.value;
if (!body) return undefined;
return firstVisibleElement(body, bodyInputSelector) ?? firstVisibleElement(body, bodyFallbackSelector);
}
function shouldMoveFocusIntoBody() {
const activeElement = document.activeElement;
return activeElement === closeButton.value || activeElement === dialog.value || activeElement === document.body;
}
function watchBodyForControls() {
stopBodyObserver();
if (!modalBody.value) return;
bodyObserver = new MutationObserver(() => {
if (!props.open || focusedBodyControl.value || !shouldMoveFocusIntoBody()) return;
focusFirstControl();
});
bodyObserver.observe(modalBody.value, { childList: true, subtree: true });
}
function focusFirstControl() {
void nextTick(() => {
const target = firstBodyControl();
if (target) {
target.focus();
focusedBodyControl.value = true;
stopBodyObserver();
return;
}
watchBodyForControls();
closeButton.value?.focus();
});
}
function handleOpen() {
previousActiveElement.value = document.activeElement instanceof HTMLElement ? document.activeElement : null;
focusedBodyControl.value = false;
lockPage();
focusFirstControl();
}
function handleClose() {
stopBodyObserver();
unlockPage();
restoreFocus();
}
function requestClose() {
emit('close');
}
function onBackdropClick(event: MouseEvent) {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
requestClose();
}
}
function focusableElements() {
return Array.from(dialog.value?.querySelectorAll<HTMLElement>(focusableSelector) ?? []).filter(isVisible);
}
function keepFocusInside(event: KeyboardEvent) {
const elements = focusableElements();
if (!elements.length) {
event.preventDefault();
dialog.value?.focus();
return;
}
const first = elements[0];
const last = elements[elements.length - 1];
const current = document.activeElement;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
}
function onDocumentKeydown(event: KeyboardEvent) {
if (!props.open) return;
if (event.key === 'Escape' && props.closeOnEscape) {
event.preventDefault();
requestClose();
return;
}
if (event.key === 'Tab') {
keepFocusInside(event);
}
}
onMounted(() => {
document.addEventListener('keydown', onDocumentKeydown);
if (props.open) {
handleOpen();
}
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onDocumentKeydown);
stopBodyObserver();
if (props.open) {
handleClose();
}
});
onUpdated(() => {
if (!props.open || focusedBodyControl.value) return;
if (shouldMoveFocusIntoBody()) {
focusFirstControl();
}
});
watch(
() => props.open,
(open, wasOpen) => {
if (open && !wasOpen) {
handleOpen();
}
if (!open && wasOpen) {
handleClose();
}
}
);
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal-backdrop is-open" role="presentation" @click="onBackdropClick">
<section
ref="dialog"
class="modal"
:class="{ 'modal--wide': size === 'wide' }"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
tabindex="-1"
>
<div class="modal-header">
<div class="modal-header__copy">
<h2 :id="titleId">{{ title }}</h2>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<button ref="closeButton" class="modal-close-button" type="button" :aria-label="closeLabel" @click="requestClose">
×
</button>
</div>
<div ref="modalBody" class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</section>
</div>
</Teleport>
</template>