chore(web): icon layout switcher with radio semantics

Replace the layout Select with a lean 28px segmented control: Rows3 /
Columns2 / Columns3 / Infinity icons on a quiet muted track, only the
active option filled. Each option carries a tooltip with a label and an
honest description of the ceiling semantics ("up to N columns when the
width allows").

The control is a proper radiogroup — roving tabindex, arrow-key
movement, aria-checked — restoring the single-choice semantics the
Select used to provide. Options derive from ViewContext's canonical
value list, and one exhaustive record maps each value to its icon and
wording, so a future column option fails to compile until both exist.

The Compact mode row now stays visible in multi-column layouts, shown
as on and locked (multi-column always renders compact tiles); the
stored preference is untouched and resurfaces at a single column.
pull/6071/head
boojack 20 hours ago
parent 177d65a90e
commit 14a1c9a53b

@ -1,6 +1,7 @@
import { Settings2Icon } from "lucide-react"; import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, Settings2Icon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { MAX_COLUMNS_VALUES, type MemoMaxColumns, useView } from "@/contexts/ViewContext"; import { MAX_COLUMNS_VALUES, type MemoMaxColumns, useView } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
@ -10,8 +11,15 @@ interface Props {
className?: string; className?: string;
} }
// Derived from the context's canonical value list so the two can never drift; 0 renders as ∞. // Keyed by the context's canonical value list, so adding a column option forces an icon and
const MAX_COLUMNS_OPTIONS = MAX_COLUMNS_VALUES.map((value) => ({ value, label: value === 0 ? "∞" : String(value) })); // wording here at compile time. The i18n param is deliberately named `n`, not `count` —
// i18next would route `count` through plural-form lookup.
const LAYOUT_OPTIONS: Record<MemoMaxColumns, { icon: LucideIcon; key: "layout-list" | "layout-columns" | "layout-auto" }> = {
1: { icon: Rows3Icon, key: "layout-list" },
2: { icon: Columns2Icon, key: "layout-columns" },
3: { icon: Columns3Icon, key: "layout-columns" },
0: { icon: InfinityIcon, key: "layout-auto" },
};
function MemoDisplaySettingMenu({ className }: Props) { function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate(); const t = useTranslate();
@ -28,6 +36,9 @@ function MemoDisplaySettingMenu({ className }: Props) {
setMaxColumns, setMaxColumns,
} = useView(); } = useView();
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode || !linkPreview || maxColumns !== 1; const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode || !linkPreview || maxColumns !== 1;
// Multi-column grids always render compact tiles, so the toggle is shown as on and locked
// there; it only becomes a real choice at a single column.
const compactLocked = maxColumns !== 1;
return ( return (
<Popover> <Popover>
@ -37,20 +48,59 @@ function MemoDisplaySettingMenu({ className }: Props) {
<PopoverContent align="end" alignOffset={-12} sideOffset={14}> <PopoverContent align="end" alignOffset={-12} sideOffset={14}>
<div className="flex flex-col gap-2 p-1"> <div className="flex flex-col gap-2 p-1">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.columns")}</span> <span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.layout")}</span>
{/* Radix only reports rendered item values, so no re-validation is needed here. */} {/* A quiet muted track (28px tall, borderless); only the active option carries the accent
<Select value={String(maxColumns)} onValueChange={(value) => setMaxColumns(Number(value) as MemoMaxColumns)}> fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */}
<SelectTrigger size="sm" className="w-32"> <div
<SelectValue /> role="radiogroup"
</SelectTrigger> aria-label={t("memo.layout")}
<SelectContent> className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"
{MAX_COLUMNS_OPTIONS.map((option) => ( onKeyDown={(event) => {
<SelectItem key={option.value} value={String(option.value)}> const delta =
{option.label} event.key === "ArrowRight" || event.key === "ArrowDown"
</SelectItem> ? 1
))} : event.key === "ArrowLeft" || event.key === "ArrowUp"
</SelectContent> ? -1
</Select> : 0;
if (delta === 0) return;
event.preventDefault();
const index = MAX_COLUMNS_VALUES.indexOf(maxColumns);
const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length];
setMaxColumns(next);
event.currentTarget.querySelector<HTMLButtonElement>(`[data-value="${next}"]`)?.focus();
}}
>
{MAX_COLUMNS_VALUES.map((value) => {
const { icon: Icon, key } = LAYOUT_OPTIONS[value];
const label = t(`memo.${key}`, { n: value });
const active = maxColumns === value;
return (
<Tooltip key={value}>
<TooltipTrigger asChild>
<button
type="button"
role="radio"
aria-checked={active}
aria-label={label}
tabIndex={active ? 0 : -1}
data-value={value}
onClick={() => setMaxColumns(value)}
className={cn(
"grid h-6 w-7 place-items-center rounded-md transition-colors",
active ? "bg-accent text-accent-foreground" : "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{label}</p>
<p className="text-primary-foreground/70">{t(`memo.${key}-description`, { n: value })}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div> </div>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span> <span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span>
@ -83,13 +133,12 @@ function MemoDisplaySettingMenu({ className }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Multi-column grids always render compact tiles, so the toggle only applies to a single column. */}
{maxColumns === 1 && (
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.compact-mode")}</span> <span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
<Switch checked={compactMode} onCheckedChange={setCompactMode} /> {t("memo.compact-mode")}
</span>
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
</div> </div>
)}
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span> <span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} /> <Switch checked={linkPreview} onCheckedChange={setLinkPreview} />

@ -265,8 +265,14 @@
"has-task-list": "hasTaskList", "has-task-list": "hasTaskList",
"label": "Filters" "label": "Filters"
}, },
"columns": "Columns",
"compact-mode": "Compact mode", "compact-mode": "Compact mode",
"layout": "Layout",
"layout-auto": "Auto",
"layout-auto-description": "As many columns as fit the screen",
"layout-columns": "{{n}} columns",
"layout-columns-description": "Up to {{n}} columns when the width allows",
"layout-list": "List",
"layout-list-description": "A single reading-width column",
"link-preview": "Link preview", "link-preview": "Link preview",
"links": "Links", "links": "Links",
"load-more": "Load more", "load-more": "Load more",

Loading…
Cancel
Save