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:
parent
177d65a90e
commit
14a1c9a53b
2 changed files with 80 additions and 25 deletions
|
|
@ -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…
Reference in a new issue