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 { 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
// 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,
@@ -23,19 +27,65 @@ export default function ViewSwitcher<T extends string>({
label: 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);
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;
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={() => setOpen((o) => !o)}
onClick={() => (open ? close() : setOpen(true))}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`${label}: ${current.label}`}
@@ -49,19 +99,25 @@ export default function ViewSwitcher<T extends string>({
<div
ref={menuRef}
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]"
>
{options.map((o) => {
{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 === focusIdx ? 0 : -1}
onClick={() => {
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 ${
on ? "text-accent" : "text-fg hover:bg-card"
+3 -2
View File
@@ -4,9 +4,10 @@
export type FeedView = "cards" | "rows";
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);
}
+4 -2
View File
@@ -17,8 +17,10 @@ export const LS = {
defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts). A local cache of the server pref, so the feed
// renders in your chosen view on first paint instead of flashing the default.
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// 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",
channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",