refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
|
||||
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||
|
||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
||||
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
|
||||
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
|
||||
// download dialog) would otherwise open invisibly.
|
||||
export default function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-lg",
|
||||
placement = "center",
|
||||
}: {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
|
||||
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
|
||||
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
|
||||
* two shells drift apart. */
|
||||
placement?: "center" | "drawer";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
useBackToClose(onClose);
|
||||
const fsRoot = useFullscreenElement();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
|
||||
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
|
||||
// focus on <body>. useState initializer runs exactly once, on first render.
|
||||
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
||||
|
||||
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
||||
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
|
||||
// the backdrop wrongly dismisses — the click event bubbles to the common ancestor (the backdrop).
|
||||
const pressedOnBackdrop = useRef(false);
|
||||
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!isTopmost(id)) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
|
||||
// page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
|
||||
if (e.key === "Tab") {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const items = focusablesIn(panel);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
if (!panel.contains(active) || active === panel) {
|
||||
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
||||
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
||||
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||
// focus to the first control so the dialog opens focused, not stranded on the background.
|
||||
const panel = panelRef.current;
|
||||
if (panel && !panel.contains(document.activeElement)) {
|
||||
(focusablesIn(panel)[0] ?? panel).focus();
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
removeLayer(id);
|
||||
document.body.style.overflow = prev;
|
||||
// Restore focus to the trigger, if it's still in the document and focusable.
|
||||
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo]);
|
||||
|
||||
const drawer = placement === "drawer";
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
||||
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (pressedOnBackdrop.current && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={
|
||||
drawer
|
||||
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
|
||||
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
fsRoot ?? document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user