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,172 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
|
||||
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
|
||||
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
|
||||
// — that opens a listbox of `SortOption`s with an asc/desc segment in the panel footer. Two-dimensional
|
||||
// (key + direction) unlike FilterSelect, so it stays its own component. Driven by the shared `SortState`.
|
||||
// `defaultLabel` opts a view into a "no sort" choice (tables); omit it for always-sorted views
|
||||
// (feed/Plex). `trailing` is an adornment slot (the feed's reshuffle, playlists' dirty-first toggle)
|
||||
// rendered in the footer beside the direction segment.
|
||||
//
|
||||
// Direction lives in the footer, not on the trigger, on purpose: the trigger stays a single plain
|
||||
// <button> (the popover reference) with no nested button — picking a key keeps the panel open so key
|
||||
// AND direction can be set in one visit; picking "no sort" closes it (nothing left to direct).
|
||||
export default function SortControl({
|
||||
options,
|
||||
sort,
|
||||
onChange,
|
||||
label,
|
||||
ascLabel,
|
||||
descLabel,
|
||||
defaultLabel,
|
||||
trailing,
|
||||
}: {
|
||||
options: SortOption[];
|
||||
sort: SortState;
|
||||
onChange: (next: SortState) => void;
|
||||
/** Accessible name + visible prefix for the trigger (e.g. "Sort"). */
|
||||
label: string;
|
||||
ascLabel: string;
|
||||
descLabel: string;
|
||||
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
|
||||
* always-sorted views (feed/Plex), which never surface a null sort. */
|
||||
defaultLabel?: string;
|
||||
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
|
||||
trailing?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
|
||||
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
|
||||
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
|
||||
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
|
||||
const showArrow = !!activeOpt && !activeOpt.directionless;
|
||||
// Trigger summary: the active option's label, or the "no sort" text, or (a stale/out-of-scope sort on
|
||||
// an always-sorted view) the first option's label rather than a blank pill.
|
||||
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
||||
|
||||
const segCls = (on: boolean) =>
|
||||
`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition ${
|
||||
on
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Carry the active sort key AND direction in the accessible name (the visible arrow is
|
||||
// aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending",
|
||||
// not a bare "Sort".
|
||||
aria-label={`${label}: ${summary}${
|
||||
showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : ""
|
||||
}`}
|
||||
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted">{label}:</span>
|
||||
<span className="text-fg">{summary}</span>
|
||||
{showArrow &&
|
||||
active &&
|
||||
(active.dir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
))}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
||||
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
||||
<div role="group" aria-label={label}>
|
||||
{defaultLabel !== undefined && (
|
||||
<PopoverItem
|
||||
role="menuitemradio"
|
||||
checked={active === null}
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false); // "no sort" is a final choice — nothing left to direct
|
||||
}}
|
||||
label={defaultLabel}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
active === null ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate">{defaultLabel}</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
{options.map((o) => {
|
||||
const on = active?.key === o.key;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.key}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{((showArrow && active) || trailing) && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="mt-1 flex items-center gap-1.5 border-t border-border px-1 pt-2"
|
||||
>
|
||||
{showArrow && active && (
|
||||
<div className="flex flex-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active.dir === "asc"}
|
||||
onClick={() => onChange({ key: active.key, dir: "asc" })}
|
||||
className={segCls(active.dir === "asc")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" aria-hidden="true" />
|
||||
{ascLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active.dir === "desc"}
|
||||
onClick={() => onChange({ key: active.key, dir: "desc" })}
|
||||
className={segCls(active.dir === "desc")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" aria-hidden="true" />
|
||||
{descLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user