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