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