tmp/webapp-docs/frontend/components/DialogHost.js

/**
 * Renders the app's in-app dialogs (the Promise-based replacement for native alert/confirm/prompt).
 *
 * Mounted ONCE at the app root, inside the i18n boundary, it subscribes to the `lib/dialog.js`
 * external store and shows the active request (`queue[0]`) as an accessible modal — reusing the
 * existing `.modal` / `.modal-overlay` / `.modal-actions` styling so it looks native to the app.
 * Resolving the pending promise (via `settle`) follows the native contract: confirm → boolean,
 * prompt → string|null, alert → undefined.
 *
 * Accessibility: `role="dialog"` + `aria-modal`, labelled by its title (or the message), Escape and
 * backdrop cancel, Enter accepts, Tab is trapped within the modal, and focus moves to the input
 * (prompt) or the accept button on open and is restored to the previously-focused element on close.
 * @module gui/components/DialogHost
 */
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useIntl, defineMessages } from "react-intl";
import { subscribe, getSnapshot, settle } from "../lib/dialog.js";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  ok: {
    id: "dialog.ok",
    defaultMessage: "OK"
  },
  cancel: {
    id: "dialog.cancel",
    defaultMessage: "Cancel"
  }
});

/** Focusable elements inside `el`, in tab order. */
function focusable(el) {
  if (!el) return [];
  return Array.from(el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')).filter(n => !n.disabled && n.tabIndex !== -1);
}

/**
 * The dialog host. Takes no props; reads the active dialog from the `lib/dialog.js` store.
 * @returns {JSX.Element|null} The active modal (via a portal), or null when the queue is empty.
 */
export default function DialogHost() {
  const intl = useIntl();
  const queue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
  const active = queue[0] || null;
  const [input, setInput] = useState("");
  const cardRef = useRef(null);
  const inputRef = useRef(null);
  const acceptRef = useRef(null);
  const restoreRef = useRef(null);
  const titleId = useId();
  const descId = useId();

  // Seed (and reset) the prompt input whenever a new dialog becomes active.
  useEffect(() => {
    if (active) setInput(active.kind === "prompt" ? active.defaultValue ?? "" : "");
  }, [active]);

  // Move focus into the modal on open; restore it to the prior element on close.
  useLayoutEffect(() => {
    if (!active) return undefined;
    restoreRef.current = document.activeElement;
    const target = active.kind === "prompt" ? inputRef.current : acceptRef.current;
    target?.focus();
    if (active.kind === "prompt") inputRef.current?.select?.();
    return () => {
      const el = restoreRef.current;
      if (el && typeof el.focus === "function") el.focus();
    };
  }, [active]);
  if (!active) return null;
  const isPrompt = active.kind === "prompt";
  const isAlert = active.kind === "alert";
  const accept = () => settle(active.id, isPrompt ? input : isAlert ? undefined : true);
  const cancel = () => settle(active.id, isPrompt ? null : isAlert ? undefined : false);
  const onKeyDown = e => {
    if (e.key === "Escape") {
      e.preventDefault();
      cancel();
      return;
    }
    if (e.key === "Enter" && (isAlert || !e.shiftKey)) {
      // In the prompt input, Enter submits; elsewhere Enter accepts (unless focused on Cancel).
      if (document.activeElement?.dataset?.dialogCancel === undefined) {
        e.preventDefault();
        accept();
      }
      return;
    }
    if (e.key === "Tab") {
      const items = focusable(cardRef.current);
      if (items.length === 0) return;
      const first = items[0];
      const last = items[items.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  };
  const acceptLabel = active.confirmLabel || intl.formatMessage(msgs.ok);
  const cancelLabel = active.cancelLabel || intl.formatMessage(msgs.cancel);
  return /*#__PURE__*/createPortal(/*#__PURE__*/_jsxDEV(_Fragment, {
    children: [/*#__PURE__*/_jsxDEV("div", {
      className: "modal-overlay",
      onClick: cancel
    }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
      className: "modal",
      role: "dialog",
      "aria-modal": "true",
      "aria-labelledby": active.title ? titleId : undefined,
      "aria-label": active.title ? undefined : active.message,
      "aria-describedby": active.message ? descId : undefined,
      ref: cardRef,
      onKeyDown: onKeyDown,
      children: [active.title && /*#__PURE__*/_jsxDEV("h2", {
        id: titleId,
        children: active.title
      }, void 0, false), active.message && /*#__PURE__*/_jsxDEV("p", {
        id: descId,
        children: active.message
      }, void 0, false), active.note && /*#__PURE__*/_jsxDEV("p", {
        className: "modal-note",
        children: active.note
      }, void 0, false), isPrompt && /*#__PURE__*/_jsxDEV("input", {
        ref: inputRef,
        className: "modal-input",
        type: "text",
        value: input,
        placeholder: active.placeholder || "",
        onChange: e => setInput(e.target.value)
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "modal-actions",
        children: [!isAlert && /*#__PURE__*/_jsxDEV("button", {
          type: "button",
          className: "btn-ghost",
          "data-dialog-cancel": "",
          onClick: cancel,
          children: cancelLabel
        }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
          type: "button",
          className: active.destructive ? "btn-destructive" : "btn-danger",
          ref: acceptRef,
          onClick: accept,
          children: acceptLabel
        }, void 0, false)]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true), document.body);
}