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.
This commit is contained in:
@@ -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 { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MAX_COLUMNS_VALUES, type MemoMaxColumns, useView } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
@@ -10,8 +11,15 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Derived from the context's canonical value list so the two can never drift; 0 renders as ∞.
|
||||
const MAX_COLUMNS_OPTIONS = MAX_COLUMNS_VALUES.map((value) => ({ value, label: value === 0 ? "∞" : String(value) }));
|
||||
// Keyed by the context's canonical value list, so adding a column option forces an icon and
|
||||
// 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) {
|
||||
const t = useTranslate();
|
||||
@@ -28,6 +36,9 @@ function MemoDisplaySettingMenu({ className }: Props) {
|
||||
setMaxColumns,
|
||||
} = useView();
|
||||
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 (
|
||||
<Popover>
|
||||
@@ -37,20 +48,59 @@ function MemoDisplaySettingMenu({ className }: Props) {
|
||||
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
|
||||
<div className="flex flex-col gap-2 p-1">
|
||||
<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>
|
||||
{/* Radix only reports rendered item values, so no re-validation is needed here. */}
|
||||
<Select value={String(maxColumns)} onValueChange={(value) => setMaxColumns(Number(value) as MemoMaxColumns)}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAX_COLUMNS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.layout")}</span>
|
||||
{/* A quiet muted track (28px tall, borderless); only the active option carries the accent
|
||||
fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("memo.layout")}
|
||||
className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"
|
||||
onKeyDown={(event) => {
|
||||
const delta =
|
||||
event.key === "ArrowRight" || event.key === "ArrowDown"
|
||||
? 1
|
||||
: event.key === "ArrowLeft" || event.key === "ArrowUp"
|
||||
? -1
|
||||
: 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 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>
|
||||
@@ -83,13 +133,12 @@ function MemoDisplaySettingMenu({ className }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.compact-mode")}</span>
|
||||
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
|
||||
{t("memo.compact-mode")}
|
||||
</span>
|
||||
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
|
||||
</div>
|
||||
<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>
|
||||
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
|
||||
|
||||
@@ -265,8 +265,14 @@
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filters"
|
||||
},
|
||||
"columns": "Columns",
|
||||
"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",
|
||||
"links": "Links",
|
||||
"load-more": "Load more",
|
||||
|
||||
Reference in New Issue
Block a user