Files
siftlode/frontend/src/components/ui/SortControl.tsx
T

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 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 (
<>
<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>
</>
);
}