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
253 lines
6.0 KiB
Vue
253 lines
6.0 KiB
Vue
<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>
|