- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title and hint (a label pointed at the switch). - F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge); labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows. - F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP) refuses the swap, contract-tested. - F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>. - F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the Config tests say why while dirty (testDirty, HU/EN). - F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle, Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest. - F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips. - F15: the "+N" / "More..." open state worded as it renders.
175 lines
7.2 KiB
TypeScript
175 lines
7.2 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import { ArrowDown, ArrowUp, Check } from "lucide-react";
|
|
import { SelectTrigger } from "./SelectTrigger";
|
|
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
|
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
|
import { SURFACE } from "./surfaces";
|
|
|
|
// 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 min-h-6 flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition touch-target ${
|
|
on
|
|
? "border-accent bg-accent/15 text-accent"
|
|
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
|
}`;
|
|
|
|
return (
|
|
<>
|
|
<SelectTrigger
|
|
context="toolbar"
|
|
open={open}
|
|
label={label}
|
|
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}` : ""
|
|
}`}
|
|
trailing={
|
|
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" />
|
|
))
|
|
}
|
|
>
|
|
{summary}
|
|
</SelectTrigger>
|
|
|
|
<PopoverSurface
|
|
popover={pop}
|
|
ariaLabel={label}
|
|
className={`${SURFACE.menu} min-w-56 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-lg 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-lg 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>
|
|
</>
|
|
);
|
|
}
|