Dialog and Panel both drew their own bordered/backgrounded card in the same --bg-elev, so a Panel-based dialog (create-agent, link-matrix-account) rendered as two concentric cards with a floating close button on the outer one and no purpose to it. Give Dialog a "plain" mode that drops its own card chrome (border, background, padding) and floating close button, and give Panel an optional onClose that renders a close button at the end of its own header row instead. AgentsPage's two Panel-backed dialogs now use plain + Panel's onClose, so the Panel is the dialog's only visible card. ConfirmDialog (no Panel of its own) is unaffected — plain defaults to false, unchanged card + floating close button. Added a ComponentsPage sample demonstrating the plain + onClose pairing. Verified both dialog modes via a real headless-chromium screenshot (plain dialog: single card, close button in the header bar; default dialog: unchanged floating close button).
104 lines
4 KiB
TypeScript
104 lines
4 KiB
TypeScript
// <Dialog> — a generic modal overlay, native `<dialog>` element rather
|
|
// than a hand-rolled focus-trap + backdrop: `showModal()` gives focus
|
|
// trapping, Escape-to-close, and a real `::backdrop` for free, and
|
|
// `@hive/shared`'s own shadow-DOM `hive-dialog` custom element isn't an
|
|
// option here — swarm-ui's esbuild config can't consume those custom
|
|
// elements at all (a separate, already-tracked gap).
|
|
// No opinion on content beyond a close button; the caller's own markup
|
|
// supplies whatever heading/layout it needs (its first real caller,
|
|
// the create-agent form, already has an established two-panel layout
|
|
// from its former life as a standalone route — reusing that unchanged
|
|
// is simpler and lower-risk than re-deriving a modal-specific layout
|
|
// for content mara has already reviewed).
|
|
//
|
|
// `plain` (mara, screenshot of the matrix-account dialog: "the outer
|
|
// card does not serve a purpose. move the close btn into the bar and
|
|
// drop one layer"): content that's already a `Panel` — a bordered
|
|
// card with its own header bar — nested inside this dialog's own
|
|
// bordered card produced two concentric boxes in the same `--bg-elev`,
|
|
// one serving no purpose. `plain` drops this dialog's own card chrome
|
|
// (border/background/padding) and its floating close button, so a
|
|
// `Panel` (with its own `onClose` wired into its header) becomes the
|
|
// dialog's sole visible card instead of a second one nested inside it.
|
|
// Escape and backdrop-click still close it either way — those are the
|
|
// native `<dialog>`'s own behavior, not this button's.
|
|
import { useEffect, useRef } from "preact/hooks";
|
|
import type { ComponentChildren } from "preact";
|
|
import "./Dialog.css";
|
|
|
|
export function Dialog({
|
|
open,
|
|
onClose,
|
|
label,
|
|
children,
|
|
plain = false,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
// `aria-label` only, no visible title row — see file-top comment on
|
|
// why content owns its own heading.
|
|
label: string;
|
|
children: ComponentChildren;
|
|
/** Drop this dialog's own card chrome + floating close button — see
|
|
* file-top comment. Pair with a `Panel` (or similar) that renders its
|
|
* own header + `onClose` button as the dialog's content. */
|
|
plain?: boolean;
|
|
}) {
|
|
const ref = useRef<HTMLDialogElement>(null);
|
|
|
|
// Drives the native open/closed state from the `open` prop rather
|
|
// than mounting/unmounting the element — `showModal()`/`close()` are
|
|
// imperative, there's no declarative `<dialog open>` equivalent that
|
|
// also gets you the backdrop + focus trap.
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
if (open && !el.open) el.showModal();
|
|
else if (!open && el.open) el.close();
|
|
}, [open]);
|
|
|
|
// The dialog's own `close` event fires on Escape (and would fire on
|
|
// a native <form method="dialog"> submit, unused here) — syncing it
|
|
// back to the caller's state keeps `open` truthful even when nothing
|
|
// in this component's own JS drove the close.
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
function handleClose() {
|
|
onClose();
|
|
}
|
|
el.addEventListener("close", handleClose);
|
|
return () => el.removeEventListener("close", handleClose);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<dialog
|
|
ref={ref}
|
|
class={plain ? "ui-dialog ui-dialog-plain" : "ui-dialog"}
|
|
aria-label={label}
|
|
// A click lands on the `<dialog>` element itself (not any child)
|
|
// exactly when it's outside the rendered content box — inside
|
|
// the box, the click target is always some descendant. Standard
|
|
// "click the backdrop to close" trick for native `<dialog>`.
|
|
onClick={(e) => {
|
|
if (e.target === ref.current) onClose();
|
|
}}
|
|
>
|
|
{plain ? null : (
|
|
<button
|
|
type="button"
|
|
class="ui-dialog-close"
|
|
aria-label="close"
|
|
onClick={onClose}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
<div
|
|
class={plain ? "ui-dialog-body ui-dialog-body-plain" : "ui-dialog-body"}
|
|
>
|
|
{children}
|
|
</div>
|
|
</dialog>
|
|
);
|
|
}
|