Files
siftlode/frontend/src/components/ViewSwitcher.tsx
T
peter c7b393901f style(type): correct the trigger text from 16px back to the view-switcher's real 14px
The earlier bump measured the view-switcher's BUTTON (16px, inherited) instead of its visible
label span (text-sm = 14px), and 14px is the size the user actually finds comfortable. Walk the
over-sized triggers back to text-sm: FilterSelect, SortControl, ViewSwitcher, and the Combobox
input (which was 14px before the bump anyway). The secondary text already at text-sm/text-xs
(count, chips, widget headers, section labels) was correct and stays.
2026-08-21 19:53:14 +02:00

114 lines
4.4 KiB
TypeScript

import { useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
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 (
<>
<button
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}`}
className={`shrink-0 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"
}`}
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-44 p-1.5 rounded-xl 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"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
);
}