feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)

- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
This commit is contained in:
2026-09-24 23:48:22 +02:00
parent 81936e9f56
commit dac9d89f91
63 changed files with 923 additions and 416 deletions
+4 -2
View File
@@ -50,15 +50,17 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
// Shape (docs/DESIGN.md A2): round because it floats over the content, 3rem (48) on mouse and touch —
// a lone corner button at a toolbar rung gets lost.
return createPortal(
<Pressable
align="center"
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<ArrowUp className="size-5" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</Pressable>,
document.body,
+9 -3
View File
@@ -1,9 +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 { PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { InnerInput } from "./Input";
import { fieldClass } from "./fieldStyles";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
@@ -176,7 +179,10 @@ export default function Combobox({
setOpenAnd(true);
}
}}
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
className={clsx(
fieldClass({ size: "free" }),
"flex min-h-8 w-full cursor-text flex-wrap items-center gap-1 px-1.5 py-0.5 text-sm",
)}
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
@@ -196,7 +202,8 @@ export default function Combobox({
</button>
</span>
))}
<input
<InnerInput
className="min-w-8 px-1 py-0.5"
ref={setInputRef}
{...pop.getReferenceProps({
onChange: (e) => {
@@ -226,7 +233,6 @@ export default function Combobox({
// made a single chip + the reserved input + the chevron overflow a narrow rail field — wrapping
// the chevron to a second line while the (empty) input filled the first. A small floor keeps the
// chip + input + chevron on one line; flex-1 still grows the input to fill whatever room is left.
className="min-w-8 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
@@ -5,6 +6,8 @@ import Modal from "./Modal";
import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging";
import { Pressable } from "./Pressable";
import { InnerInput } from "./Input";
import { fieldClass } from "./fieldStyles";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
@@ -191,9 +194,11 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
return (
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2">
<div
className={clsx(fieldClass({ size: "free" }), "flex h-10 items-center gap-2 px-3 text-sm")}
>
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
<InnerInput
autoFocus
value={query}
onChange={(e) => {
@@ -210,7 +215,6 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
aria-expanded={flat.length > 0}
aria-controls={listId}
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
/>
</div>
+8 -10
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { ChevronDown, Check } from "lucide-react";
import { Check } from "lucide-react";
import { SelectTrigger } from "./SelectTrigger";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
@@ -89,22 +90,19 @@ export default function FilterSelect({
return (
<>
<button
type="button"
<SelectTrigger
context="toolbar"
open={open}
label={hideLabel ? undefined : label}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
// Carry the current value in the accessible name (not just the group label), matching
// ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state".
aria-label={`${label}: ${summary}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
{!hideLabel && <span className="text-muted">{label}:</span>}
<span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// E28 S3c: the field primitives (docs/DESIGN.md A4-A6): one frame per rung, the native controls handing
// back values, and the dropdown trigger by context.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Input, Textarea } from "./Input";
import { CheckRow, Slider } from "./NativeControls";
import { SelectTrigger } from "./SelectTrigger";
afterEach(cleanup);
describe("Input", () => {
it("wears the A4 frame at the chosen rung and says when it is invalid", () => {
render(
<>
<Input aria-label="name" />
<Input aria-label="search" size="sm" pill invalid />
</>,
);
const name = screen.getByRole("textbox", { name: "name" });
expect(name.className).toContain("edge-strong");
expect(name.className).toContain("h-8");
expect(name.className).toContain("rounded-lg");
expect(name.getAttribute("aria-invalid")).toBeNull();
const search = screen.getByRole("textbox", { name: "search" });
expect(search.className).toContain("h-7");
expect(search.className).toContain("rounded-full");
expect(search.getAttribute("aria-invalid")).toBe("true");
});
it("lets a caller lay it out but not re-paint its edge", () => {
render(<Textarea aria-label="body" className="w-full" />);
const body = screen.getByRole("textbox", { name: "body" });
expect(body.className).toContain("w-full");
expect(body.className).toContain("edge-strong");
});
});
describe("the native controls", () => {
it("hand back a boolean and a number, not the event", () => {
const onCheck = vi.fn();
const onSlide = vi.fn();
render(
<>
<CheckRow checked={false} onChange={onCheck}>
Only unwatched
</CheckRow>
<Slider aria-label="size" min={0} max={10} value={3} onChange={onSlide} />
</>,
);
// The label is the target: a click on its text toggles the box.
fireEvent.click(screen.getByText("Only unwatched"));
expect(onCheck).toHaveBeenCalledWith(true);
fireEvent.change(screen.getByRole("slider", { name: "size" }), { target: { value: "7" } });
expect(onSlide).toHaveBeenCalledWith(7);
});
});
describe("SelectTrigger", () => {
it("is a pill in a toolbar, r8 and full width in a form, and shows open", () => {
render(
<>
<SelectTrigger context="toolbar" open={false} label="Sort">
Newest
</SelectTrigger>
<SelectTrigger context="form" open>
MP4
</SelectTrigger>
</>,
);
const toolbar = screen.getByRole("button", { name: /Newest/ });
const form = screen.getByRole("button", { name: "MP4" });
expect(toolbar.textContent).toBe("Sort:Newest");
expect(toolbar.className).toContain("rounded-full");
expect(toolbar.className).toContain("border-border");
expect(form.className).toContain("rounded-lg");
expect(form.className).toContain("w-full");
expect(form.className).toContain("border-accent");
});
});
+79
View File
@@ -0,0 +1,79 @@
import clsx from "clsx";
import type { InputHTMLAttributes, Ref, TextareaHTMLAttributes } from "react";
import { fieldClass, type FieldSize } from "./fieldStyles";
// The text-field primitives (docs/DESIGN.md A4, E28 S3c). Every text, number, password, e-mail, search
// and date box is an `Input`; a multi-line one a `Textarea`; a box whose frame also holds an icon,
// chips or a clear button is a wrapper painted with `fieldClass` around an `InnerInput`. A raw
// `<input>` or `<textarea>` anywhere else is a lint error (`local/input-primitive`), so the
// frame, the focus edge and the coarse-pointer 1rem text cannot drift apart again.
//
// `className` lays the field out (width, margin, font weight); its box and edge are the rung's.
type Common = {
/** The rung (`FieldSize`): `md` 32 in a form (default), `sm` 28 in a toolbar, a popover or an inline rename, `xs` 24 in a row, `heading` for a title rename. */
size?: FieldSize;
/** A toolbar search (A4): a pill instead of r8. */
pill?: boolean;
/** Marks the field invalid (the danger edge) and says so to a screen reader. */
invalid?: boolean;
};
export function Input({
size,
pill,
invalid,
className,
ref,
...rest
}: Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & Common & { ref?: Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
aria-invalid={invalid || undefined}
className={clsx(fieldClass({ size, pill }), className)}
{...rest}
/>
);
}
export function Textarea({
invalid,
className,
ref,
...rest
}: TextareaHTMLAttributes<HTMLTextAreaElement> & {
invalid?: boolean;
ref?: Ref<HTMLTextAreaElement>;
}) {
return (
<textarea
ref={ref}
aria-invalid={invalid || undefined}
className={clsx(
fieldClass({ size: "free" }),
"px-3 py-1.5 text-sm pointer-coarse:text-base",
className,
)}
{...rest}
/>
);
}
/** The bare text box inside a `fieldClass` frame: no edge, no box, it fills the frame's free width. */
export function InnerInput({
className,
ref,
...rest
}: InputHTMLAttributes<HTMLInputElement> & { ref?: Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
className={clsx(
"min-w-0 flex-1 bg-transparent text-fg outline-hidden placeholder:text-muted",
className,
)}
{...rest}
/>
);
}
@@ -0,0 +1,98 @@
import clsx from "clsx";
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
// A6 (docs/DESIGN.md, E28 S3c): checkbox, slider and colour well are the BROWSER's controls, tinted
// with the accent — keyboard, screen reader and forced-colours support come free, and a drawn copy
// would have to rebuild all three. What the browser does not give is the 44 touch target: the labelled
// row (`CheckRow`) or the slider's own box grows to it on a coarse pointer. Like `Input`, a raw
// `<input>` for any of them anywhere else is a lint error.
type Native = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange">;
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`. */
export function Checkbox({
onChange,
className,
ref,
...rest
}: Native & { onChange?: (checked: boolean) => void; ref?: Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
type="checkbox"
onChange={(e) => onChange?.(e.target.checked)}
className={clsx(
"size-4 shrink-0 cursor-pointer accent-accent disabled:cursor-not-allowed",
className,
)}
{...rest}
/>
);
}
/** A checkbox and its label as one row, the whole row the target (44 on touch). */
export function CheckRow({
checked,
onChange,
disabled,
className,
children,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
/** Lays the row out (text size, colour, margin); the target geometry is the primitive's. */
className?: string;
children: ReactNode;
}) {
return (
<label
className={clsx(
"inline-flex cursor-pointer items-center gap-2 select-none pointer-coarse:min-h-(--touch-target)",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
<Checkbox checked={checked} onChange={onChange} disabled={disabled} />
{children}
</label>
);
}
/** A range slider; `onChange` hands back the number. */
export function Slider({
onChange,
className,
...rest
}: Native & { onChange: (value: number) => void }) {
return (
<input
type="range"
onChange={(e) => onChange(Number(e.target.value))}
className={clsx(
"w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
className,
)}
{...rest}
/>
);
}
/** The browser's colour well, a small swatch. */
export function ColorInput({
onChange,
className,
...rest
}: Native & { onChange: (value: string) => void }) {
return (
<input
type="color"
onChange={(e) => onChange(e.target.value)}
className={clsx(
"h-6 w-10 cursor-pointer rounded-sm bg-transparent pointer-coarse:h-(--touch-target) pointer-coarse:w-14",
className,
)}
{...rest}
/>
);
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
import SegmentedToggle from "./SegmentedToggle";
import { Button } from "./Button";
import { Input } from "./Input";
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
@@ -77,7 +78,9 @@ export default function Pager({
</Button>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
<Input
className="w-14 text-center tabular-nums"
size="xs"
type="number"
min={1}
max={totalPages}
@@ -86,7 +89,6 @@ export default function Pager({
onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
aria-label={t("datatable.pager.pageLabel")}
className="w-14 bg-surface border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
@@ -22,6 +22,20 @@ const lower = () => screen.getByRole("button", { name: "common.priority.lower" }
const valueBtn = () => screen.getByRole("button", { name: "common.priority.edit" });
describe("PriorityStepper", () => {
it("hands the focus back to the value after Enter or Escape, not to <body>", () => {
const onSet = vi.fn();
render(
<PriorityStepper hint="what it orders" priority={3} onPriority={vi.fn()} onSet={onSet} />,
);
for (const key of ["Escape", "Enter"]) {
fireEvent.click(valueBtn());
const field = screen.getByRole("spinbutton");
field.focus();
fireEvent.keyDown(field, { key });
expect(document.activeElement).toBe(valueBtn());
}
});
it("shows the current priority and both step buttons", () => {
render(
<PriorityStepper hint="what it orders" priority={12} onPriority={vi.fn()} onSet={vi.fn()} />,
+10 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Minus, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import Tooltip from "./Tooltip";
import { useReturnFocus } from "./useReturnFocus";
type Size = "sm" | "md";
@@ -114,6 +115,7 @@ export function PriorityStepper({
onPriority(delta);
};
const editFocus = useReturnFocus(editing);
const commitEdit = () => {
const parsed = parseInt(draft, 10);
if (!Number.isNaN(parsed) && parsed !== priority) onSet(parsed);
@@ -154,6 +156,7 @@ export function PriorityStepper({
} ${cls.value}`}
>
<button
ref={editFocus.target}
onClick={beginEdit}
aria-label={t("common.priority.edit")}
title={t("common.priority.edit")}
@@ -170,8 +173,13 @@ export function PriorityStepper({
onChange={(e) => setDraft(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
else if (e.key === "Escape") setEditing(false);
if (e.key === "Enter") {
editFocus.arm();
commitEdit();
} else if (e.key === "Escape") {
editFocus.arm();
setEditing(false);
}
}}
aria-label={hint}
className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
+7 -7
View File
@@ -36,13 +36,13 @@ domain-free.
## What lives here
| 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), `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), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
| 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), `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`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
## The size contract (E10 S1c)
@@ -0,0 +1,50 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
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
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
export const SelectTrigger = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement> & {
context: "toolbar" | "form";
open: boolean;
/** The muted "Sort:" prefix before the value. */
label?: ReactNode;
/** A leading glyph (the view's icon, a calendar). */
icon?: ReactNode;
/** Between the value and the chevron (the sort direction arrow). */
trailing?: ReactNode;
}
>(function SelectTrigger(
{ context, open, label, icon, trailing, className, children, ...rest },
ref,
) {
return (
<FocusSafeButton
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",
className,
)}
{...rest}
>
{icon}
{label != null && <span className="text-muted">{label}:</span>}
<span className={clsx("text-fg", context === "form" && "flex-1 truncate")}>{children}</span>
{trailing}
<ChevronDown
aria-hidden="true"
className={clsx("size-3.5 shrink-0 text-muted transition-transform", open && "rotate-180")}
/>
</FocusSafeButton>
);
});
+13 -13
View File
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
import { ArrowDown, ArrowUp, Check } from "lucide-react";
import { SelectTrigger } from "./SelectTrigger";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
@@ -64,8 +65,10 @@ export default function SortControl({
return (
<>
<button
type="button"
<SelectTrigger
context="toolbar"
open={open}
label={label}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Carry the active sort key AND direction in the accessible name (the visible arrow is
@@ -74,21 +77,18 @@ export default function SortControl({
aria-label={`${label}: ${summary}${
showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : ""
}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
{showArrow &&
trailing={
showArrow &&
active &&
(active.dir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
))}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
))
}
>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
+41
View File
@@ -0,0 +1,41 @@
import clsx from "clsx";
// The A4 field frame (docs/DESIGN.md): one bordered box for every text field, whether the frame is the
// <input> itself (`Input`, `Textarea`) or a wrapper that holds an icon, chips or a clear button beside
// a bare `InnerInput` (a log search, the Combobox). `focus-within` covers both: an input is within
// itself. Its own file so `Input.tsx` exports components only (react-refresh).
export type FieldSize =
| "md" // 32 — a form field, lines up with a `md` Button
| "sm" // 28 — a toolbar search, a field in a popover or an inline rename, lines up with the toolbar row
| "xs" // 24 — inside a table row or a pager
| "heading" // an inline rename of a page title: the field takes the heading's own type (the caller's)
| "free"; // the caller lays the box out (a multi-line field, a chip field that wraps)
// On a coarse pointer the text is 1rem, so a tablet browser does not zoom the page on focus (not on a
// heading, whose type is already larger).
const SIZES: Record<FieldSize, string> = {
md: "h-8 px-3 text-sm pointer-coarse:text-base",
sm: "h-7 px-2.5 text-sm pointer-coarse:text-base",
xs: "h-6 px-2 text-xs pointer-coarse:text-base",
heading: "px-2 py-0.5",
free: "",
};
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
// is the danger edge, focused or not. On a coarse pointer the box grows to the 44 touch target.
const BASE =
"min-w-0 border edge-strong bg-transparent text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
"aria-invalid:border-danger aria-invalid:ring-danger has-aria-invalid:border-danger has-aria-invalid:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 has-disabled:cursor-not-allowed has-disabled:opacity-50 " +
"pointer-coarse:min-h-(--touch-target)";
export function fieldClass({
size = "md",
pill = false,
}: { size?: FieldSize; pill?: boolean } = {}) {
// A4: a toolbar search is a pill; every other field r8.
return clsx(BASE, SIZES[size], pill ? "rounded-full" : "rounded-lg");
}
-9
View File
@@ -5,15 +5,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// 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.
/** The ONE text/select/number input recipe (full-width, surface bg, rounded-lg, 2px accent focus
* RING). R10 S3 unified every input onto this: the old settings-family border-focus style
* (`bg-card rounded-xl focus:border-accent`) and its three copy-pasted local `inputCls` constants
* (KeyGate/SettingsPanel/SetupWizard) are gone. Size variants (a small pager/table-filter/chat
* input) keep their own width/radius/padding but adopt the same bg-surface + ring-focus look
* inline — reach for this const for a default full-size field, match its look for a smaller one. */
export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({
+4 -2
View File
@@ -11,6 +11,7 @@ import { useStickyTop } from "./stickyTop";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { Input } from "./Input";
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
@@ -95,12 +96,13 @@ function FilterFunnel<T>({
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
<Input
className="w-full"
size="sm"
ref={inputRef}
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
)}
{f.kind === "select" && (
@@ -0,0 +1,21 @@
import { useCallback, useEffect, useRef } from "react";
// An in-place editor (a title rename, a number field over a stepper) swaps its trigger for a field
// and back. Leaving it by Enter or Escape unmounts the focused field, and the browser drops the focus
// on <body>: the next Tab starts again at the top of the page (WCAG 2.4.3). This hands the focus back
// to the trigger once the editor has CLOSED — after the commit, so the trigger is mounted and visible
// again whether it stayed in the tree or was swapped out. `arm()` it from the keys only: leaving by a
// click elsewhere means the user already put the focus where they wanted it.
export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open: boolean) {
const target = useRef<T | null>(null);
const armed = useRef(false);
useEffect(() => {
if (open || !armed.current) return;
armed.current = false;
target.current?.focus();
}, [open]);
const arm = useCallback(() => {
armed.current = true;
}, []);
return { target, arm };
}