E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
175 lines
7.1 KiB
TypeScript
175 lines
7.1 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
import { MenuCheck } from "./MenuCheck";
|
|
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",
|
|
});
|
|
const { setReference } = pop.refs;
|
|
|
|
// 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={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"
|
|
}`}
|
|
>
|
|
<MenuCheck kind="one" on={active === null} />
|
|
<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"
|
|
}`}
|
|
>
|
|
<MenuCheck kind="one" on={on} />
|
|
<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>
|
|
</>
|
|
);
|
|
}
|