refactor(design): one drawn check for every checkable row that cannot be an input (E28 S3e3b)

ui/MenuCheck: a box filled with the accent for a set (a minus for a group
only partly in), a bare tick for one choice, in the row's leading slot.
Replaces seven hand-drawn marks: three box sizes, a tinted box, a cross
for "on" in the table filter, a trailing tick in the language menu.
This commit is contained in:
2026-09-26 17:34:46 +02:00
parent 9c91eb5d22
commit 2b3094ffc5
10 changed files with 56 additions and 55 deletions
+3 -8
View File
@@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { ListPlus, Plus, Youtube } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
@@ -177,13 +178,7 @@ function MembershipPanel({
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
+3 -8
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { Check, Plus, Sparkles } from "lucide-react";
import { Plus, Sparkles } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
@@ -232,13 +233,7 @@ function TagEditorMenu({
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<MenuCheck kind="many" on={on} />
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
+3 -2
View File
@@ -1,6 +1,7 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check } from "lucide-react";
import { CalendarDays } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
@@ -132,7 +133,7 @@ export default function DateFilterSelect({
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<MenuCheck kind="one" on={on} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { Globe } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
@@ -83,10 +84,10 @@ export default function LanguageSwitcher({
setOpen(false);
}}
label={l.label}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
>
<MenuCheck kind="one" on={l.code === value} />
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</PopoverItem>
))}
</PopoverSurface>
@@ -2,7 +2,8 @@ import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { Plus } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { api } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
@@ -202,13 +203,7 @@ export default function PlexCollectionEditor({
inIt ? "text-accent" : ""
}`}
>
<span
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
inIt ? "border-accent bg-accent/20" : "border-border"
}`}
>
{inIt && <Check className="h-3.5 w-3.5" />}
</span>
<MenuCheck kind="many" on={inIt} />
<span className="truncate">{c.title}</span>
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
+3 -12
View File
@@ -2,7 +2,8 @@ import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react";
import { Plus } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type PlexPlaylist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
@@ -158,17 +159,7 @@ export default function PlexPlaylistAdd({
all ? "text-accent" : ""
}`}
>
<span
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
all || partial ? "border-accent bg-accent/20" : "border-border"
}`}
>
{all ? (
<Check className="h-3.5 w-3.5" />
) : partial ? (
<Minus className="h-3.5 w-3.5" />
) : null}
</span>
<MenuCheck kind="many" on={all ? true : partial ? "mixed" : false} />
<span className="min-w-0 flex-1 truncate">{p.title}</span>
{isGroup ? (
<span className="shrink-0 text-xs text-muted tabular-nums">
+29
View File
@@ -0,0 +1,29 @@
import { Check, Minus } from "lucide-react";
// The ONE drawn check of a checkable row that cannot hold the browser's own checkbox (A6) — a menu
// row, or a whole-row button whose `role` says checkbox: an `<input>` inside a `<button>` is invalid
// nesting. It sits in the row's leading icon slot, so every label in a list starts at the same x.
// - `many`: one of a set that can all be on (tags, playlists, a filter's values) — a box, filled
// with the accent when on; `mixed` for a group only partly in (a Plex show's episodes).
// - `one`: one choice of several (a language, a sort, a priority step) — a bare tick; an unticked
// choice keeps the slot empty.
// Decorative: the row carries the state (`aria-checked` on its menuitemcheckbox / radio / checkbox).
export function MenuCheck({ kind, on }: { kind: "many" | "one"; on: boolean | "mixed" }) {
if (kind === "one")
return (
<Check
aria-hidden="true"
className={`size-4 shrink-0 ${on === true ? "text-accent" : "opacity-0"}`}
/>
);
return (
<span
aria-hidden="true"
className={`grid size-4 shrink-0 place-items-center rounded border ${
on ? "border-accent bg-accent text-accent-fg" : "border-border"
}`}
>
{on === "mixed" ? <Minus className="size-3" /> : on && <Check className="size-3" />}
</span>
);
}
+1 -1
View File
@@ -39,7 +39,7 @@ domain-free.
| area | components |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
+4 -5
View File
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp, Check } from "lucide-react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { MenuCheck } from "./MenuCheck";
import { SelectTrigger } from "./SelectTrigger";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
@@ -112,9 +113,7 @@ export default function SortControl({
active === null ? "text-fg" : "text-muted"
}`}
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
/>
<MenuCheck kind="one" on={active === null} />
<span className="flex-1 truncate">{defaultLabel}</span>
</PopoverItem>
)}
@@ -131,7 +130,7 @@ export default function SortControl({
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<MenuCheck kind="one" on={on} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
+3 -8
View File
@@ -1,6 +1,7 @@
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
import { ArrowDown, ArrowUp, ListFilter } from "lucide-react";
import { MenuCheck } from "./MenuCheck";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
@@ -158,13 +159,7 @@ function FilterFunnel<T>({
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <X className="w-2.5 h-2.5" />}
</span>
<MenuCheck kind="many" on={on} />
{o.label}
</PopoverItem>
);