feat(feed): pick the feed's view from its own toolbar

The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.

lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
This commit is contained in:
2026-07-16 02:29:32 +02:00
parent 303c896d87
commit eae65456ac
13 changed files with 185 additions and 38 deletions
+80
View File
@@ -0,0 +1,80 @@
import { useRef, useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
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.
export default function ViewSwitcher<T extends string>({
value,
options,
onChange,
label,
}: {
value: T;
options: readonly ViewOption<T>[];
onChange: (v: T) => void;
label: string;
}) {
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
useDismiss(open, () => setOpen(false), [btnRef, menuRef]);
const current = options.find((o) => o.id === value) ?? options[0];
if (!current) return null;
const CurrentIcon = current.icon;
return (
<div className="relative">
<button
ref={btnRef}
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className="shrink-0 inline-flex items-center gap-1 pl-1.5 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" />
<ChevronDown className={`w-3 h-3 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div
ref={menuRef}
role="menu"
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) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
role="menuitemradio"
aria-checked={on}
onClick={() => {
onChange(o.id);
setOpen(false);
}}
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>
);
}