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,78 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
|
||||
export interface ActionMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
items,
|
||||
ariaLabel,
|
||||
className,
|
||||
trigger,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||||
ariaLabel: string;
|
||||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||||
className?: string;
|
||||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||||
trigger?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={ariaLabel}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
|
||||
>
|
||||
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user