swarm-ui: show an agent's details in a popover, not a modal dialog
The details open under the agent badge without dimming or blocking the page; a click outside or Escape closes it. New ui/popover primitive (anchored, portal, no backdrop) with a sample on the components page. A confirm or link dialog opened from inside it stays modal, and the popover ignores clicks and Escape that belong to that dialog. AgentDetailDialog becomes AgentDetailPopover.
This commit is contained in:
parent
74e588415b
commit
f957e19d0d
7 changed files with 221 additions and 42 deletions
15
frontend/packages/swarm-ui/src/ui/popover/Popover.css
Normal file
15
frontend/packages/swarm-ui/src/ui/popover/Popover.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* <Popover> — floating box under its anchor. Placement (`top`/`left`) is set
|
||||
inline from the anchor's rect; this is the rest. The content brings its own
|
||||
card (a `Panel`), so the box itself only floats, casts a shadow and caps its
|
||||
size to the viewport. */
|
||||
.ui-popover {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
box-sizing: border-box;
|
||||
width: 28em;
|
||||
max-width: calc(100vw - 1em);
|
||||
max-height: calc(100vh - 4em);
|
||||
overflow: auto;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0.4em 1.2em rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
115
frontend/packages/swarm-ui/src/ui/popover/Popover.tsx
Normal file
115
frontend/packages/swarm-ui/src/ui/popover/Popover.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// <Popover> — arbitrary content in a floating box anchored under the element
|
||||
// that opened it, with no backdrop: the page behind stays visible and
|
||||
// clickable, unlike `Dialog`'s modal overlay. For content that belongs to the
|
||||
// thing you clicked (an agent's details from its badge), not a task that
|
||||
// should hold the whole page.
|
||||
//
|
||||
// Not a native `<dialog>`: `showModal()` always dims and blocks the page, and a
|
||||
// non-modal `show()` gets no outside-click or Escape handling for free, so
|
||||
// this hand-rolls that contract the way `Dropdown` does. Rendered in a portal
|
||||
// at `position: fixed`, placed from the anchor's `getBoundingClientRect()`, so
|
||||
// a clipping ancestor (the terminal's scrolling box) can't cut it off.
|
||||
//
|
||||
// A modal `<dialog>` opened from inside it (a confirm, a form) is not
|
||||
// "outside": a click in an open dialog, or Escape while one is open, goes to
|
||||
// that dialog and leaves the popover alone.
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { ComponentChildren, RefObject } from "preact";
|
||||
import "./Popover.css";
|
||||
|
||||
// Space kept between the popover and the viewport's edges.
|
||||
const EDGE_PX = 8;
|
||||
const GAP_PX = 4;
|
||||
|
||||
export function Popover({
|
||||
open,
|
||||
onClose,
|
||||
anchorRef,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The element the popover hangs under. A click on it is not an outside
|
||||
* click, so its own toggle handler stays in charge of closing. */
|
||||
anchorRef: RefObject<HTMLElement>;
|
||||
/** `aria-label` for the floating box. */
|
||||
label: string;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Placed from the anchor on open, and again on scroll and resize, so it
|
||||
// follows the anchor instead of floating where it first appeared.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPos(null);
|
||||
return;
|
||||
}
|
||||
function place() {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const width = ref.current?.offsetWidth ?? 0;
|
||||
const maxLeft = window.innerWidth - width - EDGE_PX;
|
||||
setPos({
|
||||
top: r.bottom + GAP_PX,
|
||||
left: Math.max(EDGE_PX, Math.min(r.left, maxLeft)),
|
||||
});
|
||||
}
|
||||
place();
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [open, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function inside(target: EventTarget | null, el: Element | null) {
|
||||
return target instanceof Node && el !== null && el.contains(target);
|
||||
}
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (
|
||||
inside(e.target, ref.current) ||
|
||||
inside(e.target, anchorRef.current) ||
|
||||
inside(e.target, document.querySelector("dialog[open]"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape" || document.querySelector("dialog[open]")) return;
|
||||
onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
class="ui-popover"
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
style={
|
||||
pos
|
||||
? { top: `${pos.top}px`, left: `${pos.left}px` }
|
||||
: { visibility: "hidden" }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue