Replace ~48 magic z-index values (z-10..z-[9999] + 2 inline zIndex) with named tailwind zIndex tokens (base/menu/chrome/paneltab/panel/rail/overlay/popover/ tooltip/tuner). Values are UNCHANGED — a rename, not a renumber — so layering is identical; the names give one source of truth for the stack.
159 lines
6.4 KiB
TypeScript
159 lines
6.4 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
|
import clsx from "clsx";
|
|
import { useDismiss } from "../lib/useDismiss";
|
|
|
|
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 is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End
|
|
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the
|
|
// menu unmounts under the focused item, which would otherwise strand focus on <body>).
|
|
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);
|
|
// Which item holds the roving tabindex; seeded to the active one when the menu opens.
|
|
const [focusIdx, setFocusIdx] = useState(0);
|
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
|
|
function close({ refocus = true }: { refocus?: boolean } = {}) {
|
|
setOpen(false);
|
|
if (refocus) btnRef.current?.focus();
|
|
}
|
|
// Outside-click must NOT refocus the trigger — that would steal focus from whatever the user
|
|
// just clicked. Escape is handled on the menu itself (below), where returning focus is right.
|
|
useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]);
|
|
|
|
// 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)
|
|
);
|
|
// Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect
|
|
// would only schedule the update, so the focus effect below would still see the previous index
|
|
// and focus the wrong item for one render before correcting.
|
|
function openMenu() {
|
|
setFocusIdx(currentIdx);
|
|
setOpen(true);
|
|
}
|
|
// Clamped at render, so a caller whose option list shrinks while the menu is open can't leave
|
|
// the roving tabindex pointing past the end (which would make every item un-tabbable).
|
|
const rovingIdx = Math.min(focusIdx, options.length - 1);
|
|
useEffect(() => {
|
|
if (open) itemRefs.current[rovingIdx]?.focus();
|
|
}, [open, rovingIdx]);
|
|
|
|
const current = options[currentIdx];
|
|
if (!current) return null;
|
|
const CurrentIcon = current.icon;
|
|
|
|
function onMenuKeyDown(e: React.KeyboardEvent) {
|
|
const last = options.length - 1;
|
|
if (e.key === "Escape") {
|
|
// Stop it reaching useDismiss's document listener, which would close without refocusing.
|
|
e.stopPropagation();
|
|
close();
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setFocusIdx((i) => (i >= last ? 0 : i + 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setFocusIdx((i) => (i <= 0 ? last : i - 1));
|
|
} else if (e.key === "Home") {
|
|
e.preventDefault();
|
|
setFocusIdx(0);
|
|
} else if (e.key === "End") {
|
|
e.preventDefault();
|
|
setFocusIdx(last);
|
|
} else if (e.key === "Tab") {
|
|
// Tabbing out of a menu closes it, but the focus is moving on by itself.
|
|
close({ refocus: false });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
ref={btnRef}
|
|
onClick={() => (open ? close() : openMenu())}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
// 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 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
|
>
|
|
<CurrentIcon className="w-4 h-4 shrink-0" />
|
|
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
|
|
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
</button>
|
|
{open && (
|
|
<div
|
|
ref={menuRef}
|
|
role="menu"
|
|
aria-label={label}
|
|
onKeyDown={onMenuKeyDown}
|
|
className="glass-menu absolute right-0 top-full mt-1 z-chrome 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, i) => {
|
|
const Icon = o.icon;
|
|
const on = o.id === value;
|
|
return (
|
|
<button
|
|
key={o.id}
|
|
ref={(el) => {
|
|
itemRefs.current[i] = el;
|
|
}}
|
|
role="menuitemradio"
|
|
aria-checked={on}
|
|
tabIndex={i === rovingIdx ? 0 : -1}
|
|
onClick={() => {
|
|
onChange(o.id);
|
|
close();
|
|
}}
|
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
|
|
on ? "text-accent" : "text-fg hover:bg-card"
|
|
}`}
|
|
>
|
|
<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" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|