fix(feed): give the view switcher the keyboard model its menu role promises

Arrows/Home/End move a roving focus and Escape hands focus back to the
trigger — the menu unmounts under the focused item, which stranded focus
on <body>. Outside-click deliberately does not refocus: that would steal
focus from whatever was just clicked.
This commit is contained in:
2026-07-16 02:36:56 +02:00
parent eae65456ac
commit 805441ac58
3 changed files with 69 additions and 10 deletions
+62 -6
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react"; import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
@@ -11,6 +11,10 @@ export interface ViewOption<T extends string> {
// "How should this list look" — the current mode's icon in the toolbar, the named modes in a // "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 // 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. // 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>({ export default function ViewSwitcher<T extends string>({
value, value,
options, options,
@@ -23,19 +27,65 @@ export default function ViewSwitcher<T extends string>({
label: string; label: string;
}) { }) {
const [open, setOpen] = useState(false); 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 btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useDismiss(open, () => setOpen(false), [btnRef, menuRef]); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const current = options.find((o) => o.id === value) ?? options[0]; 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]);
const currentIdx = Math.max(
0,
options.findIndex((o) => o.id === value)
);
// Open on the active item, the way a menu is expected to.
useEffect(() => {
if (open) setFocusIdx(currentIdx);
}, [open, currentIdx]);
useEffect(() => {
if (open) itemRefs.current[focusIdx]?.focus();
}, [open, focusIdx]);
const current = options[currentIdx];
if (!current) return null; if (!current) return null;
const CurrentIcon = current.icon; 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 ( return (
<div className="relative"> <div className="relative">
<button <button
ref={btnRef} ref={btnRef}
onClick={() => setOpen((o) => !o)} onClick={() => (open ? close() : setOpen(true))}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
aria-label={`${label}: ${current.label}`} aria-label={`${label}: ${current.label}`}
@@ -49,19 +99,25 @@ export default function ViewSwitcher<T extends string>({
<div <div
ref={menuRef} ref={menuRef}
role="menu" role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-30 min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]" className="glass-menu absolute right-0 top-full mt-1 z-30 min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
> >
{options.map((o) => { {options.map((o, i) => {
const Icon = o.icon; const Icon = o.icon;
const on = o.id === value; const on = o.id === value;
return ( return (
<button <button
key={o.id} key={o.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio" role="menuitemradio"
aria-checked={on} aria-checked={on}
tabIndex={i === focusIdx ? 0 : -1}
onClick={() => { onClick={() => {
onChange(o.id); onChange(o.id);
setOpen(false); close();
}} }}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${ 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" on ? "text-accent" : "text-fg hover:bg-card"
+3 -2
View File
@@ -4,9 +4,10 @@
export type FeedView = "cards" | "rows"; export type FeedView = "cards" | "rows";
export const FEED_VIEWS: readonly FeedView[] = ["cards", "rows"] as const; export const FEED_VIEWS: readonly FeedView[] = ["cards", "rows"] as const;
export const FEED_VIEW_DEFAULT: FeedView = "cards";
export function isFeedView(v: unknown): v is FeedView { const FEED_VIEW_DEFAULT: FeedView = "cards";
function isFeedView(v: unknown): v is FeedView {
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v); return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
} }
+4 -2
View File
@@ -17,8 +17,10 @@ export const LS = {
defaultViewFilters: "siftlode.defaultViewFilters", defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page", page: "siftlode.page",
perfMode: "siftlode.perfMode", perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts). A local cache of the server pref, so the feed // The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// renders in your chosen view on first paint instead of flashing the default. // established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
feedView: "siftlode.feedView", feedView: "siftlode.feedView",
channelFilter: "siftlode.channelFilter", channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView", channelsView: "siftlode.channelsView",