Files
siftlode/frontend/src/components/ui/ViewSwitcher.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

116 lines
4.3 KiB
TypeScript

import { useState } from "react";
import { type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { MenuCheck } from "./MenuCheck";
import { SelectTrigger } from "./SelectTrigger";
import { SURFACE } from "./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 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 { setReference } = pop.refs;
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={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>
</>
);
}