fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)

- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title
  and hint (a label pointed at the switch).
- F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge);
  labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows.
- F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP)
  refuses the swap, contract-tested.
- F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>.
- F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler
  runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the
  Config tests say why while dirty (testDirty, HU/EN).
- F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target
  is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle,
  Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest.
- F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips.
- F15: the "+N" / "More..." open state worded as it renders.
This commit is contained in:
2026-09-25 03:58:17 +02:00
parent 0521149cf1
commit 295c1db5f1
62 changed files with 552 additions and 265 deletions
@@ -208,7 +208,8 @@ export function PopoverItem({
tabIndex={virtual ? -1 : focusActive ? 0 : -1}
data-active={visualActive ? "true" : undefined}
disabled={disabled}
className={`outline-hidden ${
// touch-target: a menu row is a control primitive, 44 tall on a coarse pointer (A3).
className={`outline-hidden touch-target ${
checked || selected
? keepSelectedPaint
? HIGHLIGHT_SELECTED_RING_ONLY
+20 -5
View File
@@ -156,17 +156,18 @@ describe("Button primitive (S5)", () => {
}
});
it("carries the coarse-pointer minimum target, except on a link", () => {
it("carries the coarse-pointer minimum target and the 24 floor, a link variant too", () => {
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
// out of it by accident. A link is text in a sentence and is excluded by design.
// out of it by accident. A link variant was once excluded as "text in a sentence", but every
// one stands alone, and the E28 audit measured them at 16–20px.
const { unmount } = render(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
unmount();
for (const variant of ["link", "link-muted"] as const) {
const { unmount: u } = render(<Button variant={variant}>Read more</Button>);
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
"touch-target",
);
const cls = screen.getByRole("button", { name: "Read more" }).className;
expect(cls).toContain("touch-target");
expect(cls).toContain("min-h-6");
u();
}
});
@@ -223,6 +224,20 @@ describe("Button · disabled while it holds the focus", () => {
active.mockRestore();
});
it("keeps a caller's aria-disabled focusable and inert to a click (unavailable with a reason)", () => {
const onClick = vi.fn();
render(
<Button onClick={onClick} aria-disabled>
Run all
</Button>,
);
const btn = screen.getByRole("button", { name: "Run all" });
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("is natively disabled again once the focus is elsewhere", () => {
render(
<Button onClick={() => {}} disabled>
+6 -6
View File
@@ -52,14 +52,12 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
aria-pressed={pressed}
aria-label={removeLabel}
title={removeLabel}
className={clsx(chipClass(variant, size), removeLabel && "group pr-1.5", className)}
className={clsx(chipClass(variant, size), removeLabel && "pr-1.5", className)}
{...rest}
>
{icon}
{children}
{removeLabel && (
<X className="size-3 opacity-60 group-hover:opacity-100" aria-hidden="true" />
)}
{removeLabel && <X className="size-3" aria-hidden="true" />}
</FocusSafeButton>
);
});
@@ -104,7 +102,9 @@ export function DismissibleChip({
onClick={onClick}
aria-label={bodyLabel}
title={bodyTitle}
className="inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
// -my-px + stretch: the body reaches over the chip's 1px edges to the chip's full 24 (44 on
// touch), as the × does; stretched inside the edges it was 22, under the B1 floor.
className="-my-px inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
>
{body}
</FocusSafeButton>
@@ -116,7 +116,7 @@ export function DismissibleChip({
onClick={onDismiss}
aria-label={dismissLabel}
title={dismissLabel}
className="-my-px grid size-6 shrink-0 place-items-center rounded-full opacity-60 transition hover:opacity-100 hover:bg-accent/20 touch-target"
className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target"
>
<X className="size-3" aria-hidden="true" />
</FocusSafeButton>
@@ -47,7 +47,7 @@ describe("Combobox", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
expect(screen.getByText("Drama")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
fireEvent.click(screen.getByRole("button", { name: "common.removeNamed" }));
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
+17 -18
View File
@@ -1,11 +1,12 @@
import clsx from "clsx";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { Check, ChevronDown } from "lucide-react";
import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { InnerInput } from "./Input";
import { DismissibleChip } from "./Chip";
import { fieldClass } from "./fieldStyles";
import { SURFACE } from "./surfaces";
@@ -173,7 +174,13 @@ export default function Combobox({
ref={pop.refs.setPositionReference}
onMouseDown={(e) => {
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
// focus drives "open", so a caret click never toggles it shut.
// focus drives "open", so a caret click never toggles it shut. A press on a chip's × is its
// own control: it neither opens the list nor takes the focus (the × unmounts on the click,
// and a focused one would drop the focus to <body>).
if (e.target instanceof Element && e.target.closest("button")) {
e.preventDefault();
return;
}
if (e.target !== inputRef.current) {
e.preventDefault();
inputRef.current?.focus();
@@ -186,22 +193,14 @@ export default function Combobox({
)}
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
<span className="max-w-36 truncate">{labelFor(v)}</span>
<button
type="button"
onMouseDown={(e) => {
// Keep the box's focus-the-field handler from firing when removing a chip.
e.stopPropagation();
e.preventDefault();
}}
onClick={() => onChange(selected.filter((x) => x !== v))}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
<DismissibleChip
key={v}
className="max-w-40"
onDismiss={() => onChange(selected.filter((x) => x !== v))}
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
>
{labelFor(v)}
</DismissibleChip>
))}
<InnerInput
className="min-w-8 px-1 py-0.5"
+1 -1
View File
@@ -297,7 +297,7 @@ export default function DataTable<T>({
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
className={`border-b border-border/50 state-layer transition ${
rowClassName?.(row) ?? ""
}`}
>
@@ -38,8 +38,9 @@ export const FocusSafeButton = forwardRef<
const focused =
focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current);
const soft = busy || (!!disabled && focused);
// A caller's own aria-disabled is the "unavailable, and here is why" state (the tooltip or label
// carries the reason, so the button stays focusable): it is kept, never overwritten by ours.
// A caller's own aria-disabled is the "unavailable, and here is why" state (DESIGN.md: the tooltip or
// label carries the reason, so the button stays focusable): it is kept, never overwritten by ours, and
// it is inert to a click like ours, so no caller has to guard its own handler.
const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true";
return (
<button
@@ -50,7 +51,7 @@ export const FocusSafeButton = forwardRef<
aria-disabled={soft || callerSoft || undefined}
aria-busy={busy || undefined}
onClick={(e) => {
if (soft) {
if (soft || callerSoft) {
e.preventDefault();
return;
}
@@ -88,4 +88,16 @@ describe("SelectTrigger", () => {
expect(form.className).toContain("w-full");
expect(form.className).toContain("border-accent");
});
it("wears the field's ≥ 3:1 edge in a form, among the inputs it lines up with (F14)", () => {
render(
<SelectTrigger context="form" open={false}>
MP4
</SelectTrigger>,
);
const form = screen.getByRole("button", { name: "MP4" });
expect(form.className).toContain("edge-strong");
expect(form.className).not.toContain("border-border");
expect(form.className).not.toContain("glass-card");
});
});
+3 -2
View File
@@ -60,7 +60,8 @@ export function Textarea({
);
}
/** The bare text box inside a `fieldClass` frame: no edge, no box, it fills the frame's free width. */
/** The bare text box inside a `fieldClass` frame: no edge, no box, it fills the frame's free width AND
* height (`self-stretch`), so the whole frame is the target, not a 20px line of text in it (B1, A3). */
export function InnerInput({
className,
ref,
@@ -70,7 +71,7 @@ export function InnerInput({
<input
ref={ref}
className={clsx(
"min-w-0 flex-1 bg-transparent text-fg outline-hidden placeholder:text-muted",
"min-w-0 flex-1 self-stretch bg-transparent text-fg outline-hidden placeholder:text-muted",
className,
)}
{...rest}
@@ -48,7 +48,7 @@ export function CheckRow({
return (
<label
className={clsx(
"inline-flex cursor-pointer items-center gap-2 select-none pointer-coarse:min-h-(--touch-target)",
"inline-flex min-h-6 cursor-pointer items-center gap-2 select-none pointer-coarse:min-h-(--touch-target)",
disabled && "cursor-not-allowed opacity-50",
className,
)}
@@ -70,7 +70,7 @@ export function Slider({
type="range"
onChange={(e) => onChange(Number(e.target.value))}
className={clsx(
"w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
"min-h-6 w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
className,
)}
{...rest}
+1 -1
View File
@@ -64,7 +64,7 @@ export function NavRailDivider() {
/** The shape of one rail row, for a row that is not a page (the account button). */
export const RAIL_ROW =
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition";
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition touch-target";
export function NavRailItem({
icon: Icon,
+2 -1
View File
@@ -49,8 +49,9 @@ export default function PanelGroup({
// A non-collapsible group's title acts as a trigger when one is provided.
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
// min-h-6 + touch-target: when the title is a control (a collapse, a trigger) it is a 24 / 44 target.
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide";
"flex-1 min-w-0 min-h-6 flex items-center gap-1 select-none text-sm uppercase tracking-wide touch-target";
return (
<div
@@ -6,9 +6,15 @@ import { useReturnFocus } from "./useReturnFocus";
type Size = "sm" | "md";
// Every part is a target: 24 / 28 on a mouse (B1), 44 on a coarse pointer (A3).
const COARSE_VALUE = "pointer-coarse:h-(--touch-target) pointer-coarse:w-(--touch-target)";
const SIZED = {
sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" },
md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" },
sm: {
btn: "w-6 h-6 touch-target",
icon: "w-3.5 h-3.5",
value: `w-8 h-6 text-sm ${COARSE_VALUE}`,
},
md: { btn: "w-7 h-7 touch-target", icon: "w-4 h-4", value: `w-10 h-7 text-sm ${COARSE_VALUE}` },
} as const;
function PriorityStepButton({
@@ -160,7 +166,7 @@ export function PriorityStepper({
onClick={beginEdit}
aria-label={t("common.priority.edit")}
title={t("common.priority.edit")}
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
className={`h-full w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
>
{priority}
</button>
+4 -2
View File
@@ -64,11 +64,13 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co
against a container's own pixel width rather than sized against text. A px-frozen BOX utility
(`w-[Npx]`, `max-w-[Npx]`, …) is an eslint error too, since the 2026-09 sweep: use the spacing
scale (`w-37.5` is 150px at the default size and grows with the slider).
- **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon
- **Minimum target: 2.75rem, on coarse pointers only** (the `touch-target` utility, index.css). A 32px icon
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
on neighbouring icon buttons would overlap, and an overlap fires the _wrong_ button.
- The link variants are excluded from the minimum: they are text inside a sentence.
- The link variants carry it too (and the 24 mouse floor): every one stands on its own. Text inside a
sentence is a plain `<a>`, which WCAG 2.5.8 exempts. On a coarse pointer the target wins over a
caller's own `min-h-6` / `min-w-6` (it is `!important` there), or a control carrying both stays 24.
E28 S3b brings every button in the app onto these primitives (the chips and the player's overlay
buttons last), after which a raw `<button>` outside this directory is a lint error.
@@ -44,7 +44,7 @@ export default function SegmentedToggle<T extends string>({
const hintId = useId();
const track = (
<div
className={`inline-flex items-center rounded-full border border-border bg-card p-0.5 ${
className={`inline-flex items-center rounded-full border border-border bg-card p-px ${
size === "md" ? "text-xs" : "text-3xs"
}${fullWidth ? " w-full" : ""}`}
role="group"
@@ -63,8 +63,10 @@ export default function SegmentedToggle<T extends string>({
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={value === o.value}
className={`pill inline-flex items-center justify-center gap-1 rounded-full ${
size === "md" ? "px-2.5 py-1" : "px-2 py-0.5"
// A segment is the target: 24 (sm) / 28 (md), so the track lands on the ladder's 28 / 32
// with its 1px padding and edge (B1); 44 on a coarse pointer (A3).
className={`pill touch-target inline-flex items-center justify-center gap-1 rounded-full ${
size === "md" ? "h-7 px-2.5" : "h-6 px-2"
}${fullWidth ? " flex-1" : ""}`}
>
{o.icon}
+9 -4
View File
@@ -7,7 +7,10 @@ import { FocusSafeButton } from "./FocusSafeButton";
// The dropdown trigger (docs/DESIGN.md A5, E28 S3c): the one button that opens a list of choices —
// `SortControl`, `FilterSelect`, the view switcher, the date filter. By context: a pill at the toolbar's
// 28 (in a toolbar the pill means "a filter or a choice"), or r8 at the form's 32, full width, lining up
// with the inputs around it. Open is the accent edge and the chevron turned over. The popover wiring
// with the inputs around it. The form trigger is one of the field family, so it wears the field's frame
// (see-through, the ≥ 3:1 `edge-strong`) and does not stand out among the inputs; the toolbar pill keeps
// the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is
// the accent edge and the chevron turned over. The popover wiring
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
export const SelectTrigger = forwardRef<
HTMLButtonElement,
@@ -30,9 +33,11 @@ export const SelectTrigger = forwardRef<
ref={ref}
type="button"
className={clsx(
"glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 border text-left text-sm transition touch-target",
context === "toolbar" ? "h-7 rounded-full px-3" : "h-8 w-full rounded-lg px-2.5",
open ? "border-accent" : "border-border",
"glass-hover inline-flex shrink-0 items-center gap-1.5 border text-left text-sm transition touch-target",
context === "toolbar"
? "glass-card h-7 rounded-full px-3"
: "h-8 w-full rounded-lg bg-transparent px-2.5",
open ? "border-accent" : context === "toolbar" ? "border-border" : "edge-strong",
className,
)}
{...rest}
+1 -1
View File
@@ -58,7 +58,7 @@ export default function SortControl({
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
const segCls = (on: boolean) =>
`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition ${
`flex min-h-6 flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition touch-target ${
on
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
+2 -2
View File
@@ -48,11 +48,11 @@ export default function Tabs({
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
aria-pressed={on}
className={`pill items-center rounded-lg px-3 text-sm ${
className={`pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
+7 -5
View File
@@ -68,7 +68,8 @@ const SIZES: Record<ButtonSize, string> = {
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
// No box of its own, but still a 24 target on a mouse (B1).
none: "gap-1.5 min-h-6",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
@@ -151,10 +152,11 @@ export function buttonClass({
pressed !== undefined && PRESSED,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link variant is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
!isLinkVariant(variant) && "touch-target",
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
// The link variants were once excluded as "a run of text inside a sentence", but none is: every
// one stands on its own ("See all", "Forgot your passphrase?", a notification's action), and the
// E28 audit measured them at 16–20px on a tablet. Text inside a sentence is a plain <a>.
"touch-target",
fullWidth && "w-full",
);
}
+23 -1
View File
@@ -5,7 +5,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Switch } from "./form";
import { Switch, SwitchRow } from "./form";
afterEach(cleanup);
@@ -27,3 +27,25 @@ describe("Switch", () => {
expect(screen.getByRole("switch")).toBeTruthy();
});
});
describe("SwitchRow", () => {
// Owner decision F12: a settings row toggles from its whole surface, as on iOS and in Material.
it("toggles from its text as well as from the switch, once per click", () => {
const onChange = vi.fn();
render(
<SwitchRow label="Dark mode" hint="Easier on the eyes" checked={false} onChange={onChange} />,
);
fireEvent.click(screen.getByText("Dark mode"));
expect(onChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("switch", { name: "Dark mode" }));
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(true);
});
it("does nothing from its text while disabled", () => {
const onChange = vi.fn();
render(<SwitchRow label="Sync" checked onChange={onChange} disabled />);
fireEvent.click(screen.getByText("Sync"));
expect(onChange).not.toHaveBeenCalled();
});
});
+36 -4
View File
@@ -6,26 +6,30 @@ import { SURFACE } from "./surfaces";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. `label` is the
* accessible name (the visible row text), required: the control has no inner text to be named by.
* The button is its own box around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44
* touch target on a coarse pointer (A3), a real box rather than an invisible hit area. */
/** Pill on/off switch — the bare control. In a settings list use `SwitchRow`, whose whole row toggles;
* a row laid out by hand points a `<label htmlFor={id}>` at it. `label` is the accessible name (the
* visible row text), required: the control has no inner text to be named by. The button is its own box
* around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44 touch target on a coarse pointer
* (A3), a real box rather than an invisible hit area. */
export function Switch({
checked,
onChange,
label,
disabled,
id,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
disabled?: boolean;
id?: string;
}) {
// FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a
// natively disabled switch that holds the focus throws it to <body>.
return (
<FocusSafeButton
type="button"
id={id}
role="switch"
aria-checked={checked}
aria-label={label}
@@ -85,6 +89,34 @@ function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
);
}
/** A settings row whose control is a switch: the whole row is a `<label>`, so its text toggles the switch
* as the switch does (the iOS / Material settings row, owner decision F12). A `<button>` is labelable, so
* a click on the text is forwarded to it; a click on the switch itself is not forwarded twice. */
export function SwitchRow({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<label
className={`flex items-center justify-between gap-3 py-1.5 text-sm select-none ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
>
<HintLabel hint={hint}>{label}</HintLabel>
<Switch label={label} checked={checked} onChange={onChange} disabled={disabled} />
</label>
);
}
/** A settings row: a (hint-able) label on the left, a control on the right. */
export function SettingRow({
label,
+3 -3
View File
@@ -115,7 +115,7 @@ function FilterFunnel<T>({
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
!filterOn ? "bg-accent text-accent-fg" : "text-muted state-layer"
}`}
>
{t("datatable.all")}
@@ -129,7 +129,7 @@ function FilterFunnel<T>({
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
value === o.value ? "bg-accent text-accent-fg" : "text-muted state-layer"
}`}
>
{o.label}
@@ -237,7 +237,7 @@ export function ColumnHead<T>({
{col.sortable ? (
<Pressable
onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
className="inline-flex min-h-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
>
{col.header}
{isSorted &&