- The saved views, the view switcher, the year filter and the table's single-choice filter mark the chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone. - A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The Plex search box's Loading line likewise. - The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load it pushed the error and its Retry out at the first keystroke.
114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
import { useState } from "react";
|
|
import { type LucideIcon } from "lucide-react";
|
|
import clsx from "clsx";
|
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|
import { MenuCheck } from "./ui/MenuCheck";
|
|
import { SelectTrigger } from "./ui/SelectTrigger";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
export interface ViewOption<T extends string> {
|
|
id: T;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
// "How should this list look" — the current mode's icon in the toolbar, the named modes in a
|
|
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
|
|
// density modes; the managers' layouts) without re-deriving the menu behaviour.
|
|
//
|
|
// This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine.
|
|
// It now rides the R8 popover platform like every other menu, which supplies the same model
|
|
// (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the
|
|
// two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead
|
|
// of being absolutely positioned inside whatever ancestor happens to clip it.
|
|
export default function ViewSwitcher<T extends string>({
|
|
value,
|
|
options,
|
|
onChange,
|
|
label,
|
|
labelClassName = "hidden",
|
|
}: {
|
|
value: T;
|
|
options: readonly ViewOption<T>[];
|
|
onChange: (v: T) => void;
|
|
label: string;
|
|
/** Where the current mode's NAME is visible beside the icon — a Tailwind display class, e.g.
|
|
* `"hidden xl:inline"`. The caller sets it because only it knows how crowded its own toolbar is:
|
|
* the feed's is packed (show chips, content chips, source, count, sort) and can't spare the room
|
|
* until xl, while an emptier one could show it always. Defaults to icon-only. */
|
|
labelClassName?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
|
|
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
|
|
const currentIdx = Math.max(
|
|
0,
|
|
options.findIndex((o) => o.id === value),
|
|
);
|
|
|
|
const pop = useAnchoredPopover({
|
|
open,
|
|
onOpenChange: setOpen,
|
|
placement: "bottom-end",
|
|
// Opening a "pick one of these modes" menu should land on the mode that is already picked.
|
|
selectedIndex: currentIdx,
|
|
});
|
|
|
|
const current = options[currentIdx];
|
|
if (!current) return null;
|
|
const CurrentIcon = current.icon;
|
|
|
|
return (
|
|
<>
|
|
<SelectTrigger
|
|
context="toolbar"
|
|
open={open}
|
|
icon={<CurrentIcon className="w-4 h-4 shrink-0" />}
|
|
ref={pop.refs.setReference}
|
|
{...pop.getReferenceProps()}
|
|
// The aria-label carries the mode's name at every size — the visible text below is a
|
|
// nicety for wide windows, not the accessible name.
|
|
aria-label={`${label}: ${current.label}`}
|
|
title={`${label}: ${current.label}`}
|
|
>
|
|
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
|
|
</SelectTrigger>
|
|
<PopoverSurface
|
|
popover={pop}
|
|
ariaLabel={label}
|
|
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
|
>
|
|
{/* The modes are one exclusive choice, so the radio items need a group to scope their
|
|
checked-ness to — otherwise they read as independent checkable items. */}
|
|
<div role="group">
|
|
{options.map((o) => {
|
|
const Icon = o.icon;
|
|
const on = o.id === value;
|
|
return (
|
|
<PopoverItem
|
|
key={o.id}
|
|
role="menuitemradio"
|
|
checked={on}
|
|
onSelect={() => {
|
|
onChange(o.id);
|
|
setOpen(false);
|
|
}}
|
|
label={o.label}
|
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition ${
|
|
on ? "text-accent" : "text-fg"
|
|
}`}
|
|
>
|
|
<MenuCheck kind="one" on={on} />
|
|
<Icon className="w-4 h-4 shrink-0" />
|
|
<span className="flex-1 truncate">{o.label}</span>
|
|
</PopoverItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverSurface>
|
|
</>
|
|
);
|
|
}
|