Newer
Older
gnexus-ui-kit / src / vue / components / GnModal.js
/**
 * GnModal - Accessible modal dialog with focus trapping and teleport.
 *
 * @typedef {Object} GnModalProps
 * @property {boolean} [open=false] - Dialog visibility
 * @property {string} [title=''] - Dialog title
 * @property {boolean} [closeOnBackdrop=true] - Click backdrop to close
 *
 * @slots default - Modal body content
 * @slots title - Override header title
 * @slots footer - Footer content
 * @slots actions - Action buttons (receives { close })
 * @emits update:open
 * @emits close
 */
import { defineComponent, h, nextTick, onBeforeUnmount, ref, Teleport, watch } from "vue";
import { cx, iconNode, trapFocus } from "../utils.js";

let modalId = 0;

export default defineComponent({
	name: "GnModal",
	props: {
		open: { type: Boolean, default: false },
		title: { type: String, default: "" },
		closeOnBackdrop: { type: Boolean, default: true }
	},
	emits: ["update:open", "close"],
	setup(props, { emit, slots }) {
		const titleId = `gn-modal-title-${++modalId}`;
		const dialogRef = ref(null);
		const visible = ref(false);
		const closing = ref(false);
		let previousFocus = null;
		let closeTimer = null;

		const close = () => {
			emit("update:open", false);
			emit("close");
		};
		const onKeydown = event => {
			if(event.key === "Escape") {
				event.preventDefault();
				close();
			} else {
				trapFocus(event, dialogRef.value);
			}
		};
		const focusDialog = () => {
			nextTick(() => {
				dialogRef.value?.focus();
			});
		};

		watch(() => props.open, open => {
			if(open) {
				closing.value = false;
				visible.value = true;
				previousFocus = document.activeElement;
				document.addEventListener("keydown", onKeydown);
				focusDialog();
			} else {
				closing.value = true;
				document.removeEventListener("keydown", onKeydown);
				previousFocus?.focus?.();
				previousFocus = null;
				closeTimer = window.setTimeout(() => {
					visible.value = false;
					closing.value = false;
				}, 300);
			}
		}, { flush: "post" });

		onBeforeUnmount(() => {
			document.removeEventListener("keydown", onKeydown);
			window.clearTimeout(closeTimer);
		});

		return () => visible.value ? h(Teleport, { to: "body" }, [
			h("div", { class: cx("modal", closing.value ? "a-hide" : "a-show"), "aria-hidden": "false" }, [
				h("div", {
					class: "modal-backdrop",
					onClick: () => props.closeOnBackdrop && close()
				}),
				h("div", {
					ref: dialogRef,
					class: "modal-dialog",
					role: "dialog",
					"aria-modal": "true",
					"aria-labelledby": titleId,
					tabindex: "-1"
				}, [
					h("header", { class: "modal-header" }, [
						h("h4", { class: "modal-title", id: titleId }, slots.title?.() || props.title),
						h("button", {
							class: "btn-icon modal-close",
							type: "button",
							"aria-label": "Close",
							onClick: close
						}, [iconNode("ph-x")])
					]),
					h("div", { class: "modal-panel" }, [
						h("div", { class: "modal-body" }, slots.default?.()),
						(slots.footer || slots.actions) && h("footer", { class: "modal-footer" }, [
							slots.footer?.(),
							slots.actions && h("div", { class: "actions" }, slots.actions({ close }))
						])
					])
				])
			])
		]) : null;
	}
});