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:
2026-09-05 16:58:29 +02:00
parent 31fa554b87
commit 5bfcf5d83a
138 changed files with 262 additions and 189 deletions
+158
View File
@@ -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,
);
}