feat(design): the header search is a 40 glass pill; a wrapper field dims for its input only (E28 S3c)

This commit is contained in:
2026-09-25 00:05:31 +02:00
parent dac9d89f91
commit db10bfbbca
13 changed files with 78 additions and 39 deletions
+7 -5
View File
@@ -69,7 +69,8 @@ const PRESSED =
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
// button on a picture is a pill instead (A2: round means "on media", and nothing else).
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
// means "floats over something", and a pill's inner button follows its radius).
const SIZES: Record<ButtonSize, string> = {
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
@@ -97,8 +98,9 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
pressed?: boolean;
/** Sits on a picture (a thumbnail, a poster): the pill shape. Implied by the `media` variant. */
onMedia?: boolean;
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
* `media` variant. */
round?: boolean;
fullWidth?: boolean;
}
@@ -119,7 +121,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
label,
loading = false,
pressed,
onMedia = false,
round = false,
fullWidth = false,
className,
children,
@@ -158,7 +160,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[resolvedSize],
resolvedSize !== "none" && (onMedia || variant === "media" ? "rounded-full" : "rounded-lg"),
resolvedSize !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
touchTarget && "touch-target",
fullWidth && "w-full",
className,
@@ -194,9 +194,7 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
return (
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
<div
className={clsx(fieldClass({ size: "free" }), "flex h-10 items-center gap-2 px-3 text-sm")}
>
<div className={clsx(fieldClass({ size: "lg" }), "flex items-center gap-2")}>
<Search className="w-4 h-4 shrink-0 text-muted" />
<InnerInput
autoFocus
+6 -5
View File
@@ -6,7 +6,8 @@ import { FocusSafeButton } from "./FocusSafeButton";
// A button that is only a glyph (docs/DESIGN.md A2): two sizes, r8, and a name it cannot ship without
// (`label` is required: it is the accessible name and the tooltip). On a picture it is round and dark
// translucent (`media`), readable on any thumbnail; round means "on media" and nothing else. The glyph
// translucent (`media`), readable on any thumbnail; round means "floats over something" (media, or a
// pill field it sits inside — `round`), and nothing else. The glyph
// is sized here, so a row of them never mixes 14 and 16px icons.
type IconButtonVariant =
@@ -67,8 +68,8 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
size?: IconButtonSize;
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
pressed?: boolean;
/** Sits on a picture: round (A2). Implied by the `media` variant. */
onMedia?: boolean;
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
round?: boolean;
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
loading?: boolean;
/** Leave the tooltip to a richer one the caller renders (a `Tooltip` around the button). */
@@ -82,7 +83,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
variant = "ghost",
size = "sm",
pressed,
onMedia = false,
round = false,
loading = false,
noTitle = false,
type,
@@ -102,7 +103,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
className={clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
VARIANTS[variant],
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
SIZES[size],
className,
@@ -4,6 +4,7 @@
// 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 { fieldClass } from "./fieldStyles";
import { Input, Textarea } from "./Input";
import { CheckRow, Slider } from "./NativeControls";
import { SelectTrigger } from "./SelectTrigger";
@@ -29,6 +30,15 @@ describe("Input", () => {
expect(search.getAttribute("aria-invalid")).toBe("true");
});
it("dims a wrapper frame for its disabled FIELD, never for a disabled button beside it", () => {
// The header search holds a Go button that is disabled while the query is empty; a bare
// `has-disabled:` faded the whole search box to 50% on the feed.
const frame = fieldClass({ size: "free", pill: true, floating: true });
expect(frame).not.toMatch(/(^| )has-disabled:/);
expect(frame).not.toMatch(/(^| )has-aria-invalid:/);
expect(frame).toContain("has-[input:disabled]:opacity-50");
});
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" });
+22 -7
View File
@@ -6,6 +6,7 @@ import clsx from "clsx";
// itself. Its own file so `Input.tsx` exports components only (react-refresh).
export type FieldSize =
| "lg" // 40 — the page's one hero field: the header search, the command palette
| "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
@@ -15,6 +16,7 @@ export type FieldSize =
// 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> = {
lg: "h-10 px-3 text-sm pointer-coarse:text-base",
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",
@@ -24,18 +26,31 @@ const SIZES: Record<FieldSize, string> = {
// 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.
// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
// descendant: the header search's Go button is disabled while the query is empty, and a bare
// `has-disabled:` faded the whole box. 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 " +
"min-w-0 border edge-strong 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 " +
"aria-invalid:border-danger aria-invalid:ring-danger " +
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
"has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 " +
"pointer-coarse:min-h-(--touch-target)";
// `floating`: the field hovers over the scrolling page (the header search), so it keeps the glass-menu
// surface under its edge instead of a see-through one (A2: what floats over content is glass). The
// shadow is a utility so the focus ring's box-shadow composes with it instead of dropping it.
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");
floating = false,
}: { size?: FieldSize; pill?: boolean; floating?: boolean } = {}) {
// A4: a search is a pill; every other field r8.
return clsx(
BASE,
SIZES[size],
pill ? "rounded-full" : "rounded-lg",
floating ? "glass-menu shadow-[0_18px_44px_-16px_rgba(0,0,0,0.6)]" : "bg-transparent",
);
}