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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user