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:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user