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