/**
* 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;
}
});