Files
pokopiawiki.tootaio.com/frontend/src/components/Modal.vue
xiaomai fa656a8d02 refactor(auth): migrate fully to HTTP-only cookie sessions
Remove client-side token storage and Authorization header injection
Backend login now only returns user data, omitting the session token
Remove Authorization from backend CORS allowed headers
Clean up obsolete VITE_* environment variable fallbacks
Update Modal component to use Vue useId() instead of Math.random()
2026-05-06 17:15:46 +08:00

263 lines
6.2 KiB
Vue

<script lang="ts">
let openModalCount = 0;
</script>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, watch } from 'vue';
import { iconClose } from '../icons';
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 = useId();
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() {
openModalCount += 1;
document.body.classList.add('lock-scroll');
}
function unlockPage() {
openModalCount = Math.max(0, openModalCount - 1);
if (openModalCount === 0) {
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">
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
</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>