+
);
diff --git a/packages/ui/src/components/Switch.tsx b/packages/ui/src/components/Switch.tsx
index af86e81..cabe73d 100644
--- a/packages/ui/src/components/Switch.tsx
+++ b/packages/ui/src/components/Switch.tsx
@@ -1,5 +1,6 @@
import { useId, type ReactNode } from "react";
import { cn } from "../lib/cn.js";
+import { controlHeightClass, type ControlSize } from "../lib/controlSize.js";
export type SwitchProps = {
checked: boolean;
@@ -7,6 +8,7 @@ export type SwitchProps = {
label?: ReactNode;
description?: ReactNode;
disabled?: boolean;
+ size?: ControlSize;
className?: string;
"aria-label"?: string;
};
@@ -17,6 +19,7 @@ export function Switch({
label,
description,
disabled = false,
+ size = "md",
className,
"aria-label": ariaLabel,
}: SwitchProps) {
@@ -26,7 +29,9 @@ export function Switch({
return (
onCheckedChange(!checked)}
- className="inline-flex min-h-hit-target min-w-hit-target shrink-0 items-center justify-center outline-none focus-visible:[outline-color:var(--pw-focus-color)] focus-visible:[outline-offset:var(--pw-focus-offset)] focus-visible:[outline-style:solid] focus-visible:[outline-width:var(--pw-focus-width)]"
+ className={cn(
+ "inline-flex shrink-0 items-center justify-center outline-none",
+ "focus-visible:[outline-color:var(--pw-focus-color)] focus-visible:[outline-offset:var(--pw-focus-offset)] focus-visible:[outline-style:solid] focus-visible:[outline-width:var(--pw-focus-width)]",
+ size === "sm"
+ ? "min-h-control-compact min-w-control-compact"
+ : "min-h-control-default min-w-control-default",
+ )}
>
@@ -60,7 +79,13 @@ export function Switch({
{label || description ? (
{label ? (
-
+
{label}
) : null}
diff --git a/packages/ui/src/components/Textarea.tsx b/packages/ui/src/components/Textarea.tsx
index 85f8dfd..79715a6 100644
--- a/packages/ui/src/components/Textarea.tsx
+++ b/packages/ui/src/components/Textarea.tsx
@@ -1,13 +1,16 @@
import type { ComponentPropsWithRef } from "react";
import { cn } from "../lib/cn.js";
+import type { ControlSize } from "../lib/controlSize.js";
export type TextareaProps = {
invalid?: boolean;
+ size?: ControlSize;
className?: string;
} & Omit, "className">;
export function Textarea({
invalid = false,
+ size = "md",
className,
rows = 4,
...rest
@@ -17,17 +20,14 @@ export function Textarea({
rows={rows}
aria-invalid={invalid || undefined}
className={cn(
- "w-full resize-y border border-foreground/25 bg-transparent px-3 py-2 font-sans text-body outline-none transition-colors",
+ "w-full resize-y border border-foreground/25 bg-transparent font-sans outline-none transition-colors duration-quick ease-edit motion-reduce:transition-none",
"focus-visible:[outline-color:var(--pw-focus-color)] focus-visible:[outline-offset:var(--pw-focus-offset)] focus-visible:[outline-style:solid] focus-visible:[outline-width:var(--pw-focus-width)]",
"disabled:cursor-not-allowed disabled:opacity-[var(--pw-opacity-disabled)]",
"placeholder:opacity-[var(--pw-opacity-subtle)]",
+ size === "sm" ? "px-2 py-1 text-signal" : "px-3 py-2 text-body",
invalid && "border-state-error",
className,
)}
- style={{
- transitionDuration: "var(--pw-duration-quick)",
- transitionTimingFunction: "var(--pw-ease-edit)",
- }}
{...rest}
/>
);
diff --git a/packages/ui/src/lib/controlSize.ts b/packages/ui/src/lib/controlSize.ts
new file mode 100644
index 0000000..3f71059
--- /dev/null
+++ b/packages/ui/src/lib/controlSize.ts
@@ -0,0 +1,11 @@
+export type ControlSize = "sm" | "md";
+
+export const controlHeightClass: Record = {
+ sm: "min-h-control-compact",
+ md: "min-h-control-default",
+};
+
+export const controlLabelClass: Record = {
+ sm: "text-signal",
+ md: "text-caption uppercase tracking-caption",
+};
diff --git a/packages/ui/tailwind.css b/packages/ui/tailwind.css
index 562dd66..3d9f334 100644
--- a/packages/ui/tailwind.css
+++ b/packages/ui/tailwind.css
@@ -1,4 +1,5 @@
@source "./dist";
+@source inline("min-h-control-compact min-h-control-default min-w-control-compact min-w-control-default w-control-compact w-control-default");
.pw-surface-light {
--pw-color-background: var(--pw-palette-paper);
diff --git a/packages/ui/test/consumer.css b/packages/ui/test/consumer.css
index c566862..2aefe03 100644
--- a/packages/ui/test/consumer.css
+++ b/packages/ui/test/consumer.css
@@ -1,3 +1,3 @@
@import "tailwindcss";
-@import "@protocol-works/design-tokens";
+@import "../../../index.css";
@import "../tailwind.css";
diff --git a/packages/ui/test/contracts.test.mjs b/packages/ui/test/contracts.test.mjs
index 4f2fb36..fc44126 100644
--- a/packages/ui/test/contracts.test.mjs
+++ b/packages/ui/test/contracts.test.mjs
@@ -22,6 +22,7 @@ const componentSubpaths = [
"field",
"grid",
"heading",
+ "icon-button",
"input",
"lead",
"list",
diff --git a/packages/ui/test/interactions.test.mjs b/packages/ui/test/interactions.test.mjs
new file mode 100644
index 0000000..75af509
--- /dev/null
+++ b/packages/ui/test/interactions.test.mjs
@@ -0,0 +1,217 @@
+import assert from "node:assert/strict";
+import test, { afterEach } from "node:test";
+import { JSDOM } from "jsdom";
+import { createElement as h, createRef } from "react";
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+for (const key of [
+ "CustomEvent",
+ "Document",
+ "Element",
+ "Event",
+ "FocusEvent",
+ "HTMLElement",
+ "HTMLInputElement",
+ "HTMLSelectElement",
+ "HTMLTextAreaElement",
+ "KeyboardEvent",
+ "MouseEvent",
+ "MutationObserver",
+ "Node",
+ "NodeFilter",
+]) {
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ value: dom.window[key],
+ });
+}
+
+Object.defineProperties(globalThis, {
+ document: { configurable: true, value: dom.window.document },
+ getComputedStyle: {
+ configurable: true,
+ value: dom.window.getComputedStyle.bind(dom.window),
+ },
+ navigator: { configurable: true, value: dom.window.navigator },
+ window: { configurable: true, value: dom.window },
+});
+
+globalThis.PointerEvent = dom.window.MouseEvent;
+globalThis.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+dom.window.PointerEvent = dom.window.MouseEvent;
+dom.window.ResizeObserver = globalThis.ResizeObserver;
+dom.window.requestAnimationFrame = (callback) =>
+ dom.window.setTimeout(callback, 0);
+dom.window.cancelAnimationFrame = (id) => dom.window.clearTimeout(id);
+dom.window.matchMedia = () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+});
+
+const { cleanup, render, screen, waitFor } =
+ await import("@testing-library/react");
+const userEvent = (await import("@testing-library/user-event")).default;
+const { Checkbox } = await import("../dist/components/Checkbox.js");
+const { Field } = await import("../dist/components/Field.js");
+const { IconButton } = await import("../dist/components/IconButton.js");
+const { Input } = await import("../dist/components/Input.js");
+const { Modal } = await import("../dist/components/Modal.js");
+const { Popover, PopoverContent, PopoverTrigger } =
+ await import("../dist/components/Popover.js");
+
+afterEach(() => {
+ cleanup();
+ document.body.innerHTML = "";
+});
+
+test("compact controls preserve labels and existing descriptions", () => {
+ render(
+ h(
+ Field,
+ {
+ label: "Host",
+ helper: "Local or remote hostname",
+ size: "sm",
+ },
+ h(Input, {
+ size: "sm",
+ defaultValue: "localhost",
+ "aria-describedby": "consumer-description",
+ }),
+ ),
+ );
+
+ const input = screen.getByRole("textbox", { name: "Host" });
+ const descriptions = input
+ .getAttribute("aria-describedby")
+ ?.split(/\s+/)
+ .filter(Boolean);
+
+ assert.match(input.className, /min-h-control-compact/);
+ assert.ok(descriptions?.includes("consumer-description"));
+ assert.ok(
+ descriptions?.includes(
+ screen.getByText("Local or remote hostname").getAttribute("id"),
+ ),
+ );
+});
+
+test("IconButton requires an accessible name and uses square compact sizing", () => {
+ render(
+ h(
+ IconButton,
+ { "aria-label": "Close inspector", size: "sm" },
+ h("span", { "aria-hidden": true }, "×"),
+ ),
+ );
+
+ const button = screen.getByRole("button", { name: "Close inspector" });
+ assert.equal(button.getAttribute("type"), "button");
+ assert.match(button.className, /min-h-control-compact/);
+ assert.match(button.className, /w-control-compact/);
+});
+
+test("Checkbox preserves consumer descriptions alongside its own", () => {
+ render(
+ h(Checkbox, {
+ label: "Enabled",
+ description: "Available to both products",
+ "aria-describedby": "consumer-description",
+ }),
+ );
+
+ const checkbox = screen.getByRole("checkbox", { name: "Enabled" });
+ const descriptions = checkbox
+ .getAttribute("aria-describedby")
+ ?.split(/\s+/)
+ .filter(Boolean);
+
+ assert.ok(descriptions?.includes("consumer-description"));
+ assert.ok(
+ descriptions?.includes(
+ screen.getByText("Available to both products").getAttribute("id"),
+ ),
+ );
+});
+
+test("Popover opens from an as-child trigger and restores focus on Escape", async () => {
+ const user = userEvent.setup({ document });
+ render(
+ h(
+ Popover,
+ null,
+ h(
+ PopoverTrigger,
+ { asChild: true },
+ h("button", { type: "button" }, "Show details"),
+ ),
+ h(
+ PopoverContent,
+ { "aria-label": "Details", side: "bottom", align: "start" },
+ h("button", { type: "button" }, "Inside action"),
+ ),
+ ),
+ );
+
+ const trigger = screen.getByRole("button", { name: "Show details" });
+ await user.click(trigger);
+ assert.ok(await screen.findByRole("dialog", { name: "Details" }));
+
+ await user.keyboard("{Escape}");
+ await waitFor(() => {
+ assert.equal(screen.queryByRole("dialog", { name: "Details" }), null);
+ });
+ assert.equal(document.activeElement, trigger);
+});
+
+test("Modal focuses the requested control and blocks dismissal while busy", async () => {
+ const user = userEvent.setup({ document });
+ const initialFocusRef = createRef();
+ let closeCount = 0;
+ const view = render(
+ h(
+ Modal,
+ {
+ open: true,
+ onClose: () => {
+ closeCount += 1;
+ },
+ title: "Connection",
+ description: "Configure the endpoint",
+ initialFocusRef,
+ },
+ h("input", { ref: initialFocusRef, "aria-label": "Endpoint" }),
+ ),
+ );
+
+ assert.ok(screen.getByRole("dialog", { name: "Connection" }));
+ await waitFor(() => {
+ assert.equal(document.activeElement, initialFocusRef.current);
+ });
+
+ view.rerender(
+ h(
+ Modal,
+ {
+ open: true,
+ busy: true,
+ onClose: () => {
+ closeCount += 1;
+ },
+ title: "Connection",
+ },
+ h("input", { "aria-label": "Endpoint" }),
+ ),
+ );
+ await user.keyboard("{Escape}");
+ assert.equal(closeCount, 0);
+});
diff --git a/packages/ui/test/render.test.mjs b/packages/ui/test/render.test.mjs
index 36a553b..563236e 100644
--- a/packages/ui/test/render.test.mjs
+++ b/packages/ui/test/render.test.mjs
@@ -17,12 +17,17 @@ import { Divider } from "../dist/components/Divider.js";
import { EmptyState } from "../dist/components/EmptyState.js";
import { Field } from "../dist/components/Field.js";
import { Grid } from "../dist/components/Grid.js";
+import { IconButton } from "../dist/components/IconButton.js";
import { Input } from "../dist/components/Input.js";
import { List, ListItem } from "../dist/components/List.js";
import { Modal } from "../dist/components/Modal.js";
import { Note } from "../dist/components/Note.js";
import { NumberField } from "../dist/components/NumberField.js";
-import { Popover } from "../dist/components/Popover.js";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "../dist/components/Popover.js";
import { Quote } from "../dist/components/Quote.js";
import { Section } from "../dist/components/Section.js";
import { SectionHeader } from "../dist/components/SectionHeader.js";
@@ -64,6 +69,12 @@ function gallery(theme) {
h(Button, { variant: "primary" }, "Primary"),
h(Button, { variant: "outline" }, "Outline"),
h(Button, { variant: "ghost" }, "Ghost"),
+ h(Button, { variant: "outline", size: "sm" }, "Compact"),
+ h(
+ IconButton,
+ { "aria-label": "Close", size: "sm" },
+ h("span", { "aria-hidden": true }, "×"),
+ ),
),
h(SectionHeader, {
title: "Controls",
@@ -77,25 +88,42 @@ function gallery(theme) {
),
h(
Field,
- { label: "Name", helper: "Public label", required: true },
- h(Input, { defaultValue: "Rabbithole" }),
+ {
+ label: "Name",
+ helper: "Public label",
+ required: true,
+ size: "sm",
+ },
+ h(Input, {
+ defaultValue: "Rabbithole",
+ size: "sm",
+ "aria-describedby": "existing-description",
+ }),
),
- h(Textarea, { defaultValue: "Description" }),
- h(Checkbox, { label: "Enabled", defaultChecked: true }),
+ h(Textarea, { defaultValue: "Description", size: "sm" }),
+ h(Checkbox, {
+ label: "Enabled",
+ description: "Available to both products",
+ defaultChecked: true,
+ size: "sm",
+ }),
h(Switch, {
label: "Listening",
checked: true,
onCheckedChange: noop,
+ size: "sm",
}),
h(Slider, {
label: "Volume",
value: 50,
onValueChange: noop,
+ size: "sm",
}),
h(NumberField, {
label: "Agents",
value: 2,
onValueChange: noop,
+ size: "sm",
}),
h(SegmentedControl, {
label: "View",
@@ -105,6 +133,7 @@ function gallery(theme) {
{ value: "two", label: "Two" },
],
onValueChange: noop,
+ size: "sm",
}),
h(Divider, null),
h(List, null, h(ListItem, null, "One"), h(ListItem, null, "Two")),
@@ -133,7 +162,16 @@ function gallery(theme) {
h("button", null, "Run"),
),
),
- h(Popover, { label: "Details", trigger: "Details" }, "Content"),
+ h(
+ Popover,
+ null,
+ h(
+ PopoverTrigger,
+ { asChild: true },
+ h("button", { type: "button" }, "Details"),
+ ),
+ h(PopoverContent, { "aria-label": "Details" }, "Content"),
+ ),
h(
Modal,
{ open: false, onClose: noop, title: "Hidden modal" },
diff --git a/packages/ui/test/tailwind.test.mjs b/packages/ui/test/tailwind.test.mjs
index 7979529..3dfe060 100644
--- a/packages/ui/test/tailwind.test.mjs
+++ b/packages/ui/test/tailwind.test.mjs
@@ -25,6 +25,8 @@ test("Tailwind discovers utilities used by the installed component package", ()
assert.match(output, /\.font-display/);
assert.match(output, /\.border-state-error/);
assert.match(output, /\.text-caption/);
+ assert.match(output, /\.min-h-control-compact/);
+ assert.match(output, /\.w-control-compact/);
assert.match(output, /var\(--pw-width-narrow\)/);
} finally {
rmSync(outputDirectory, { force: true, recursive: true });
diff --git a/tailwind.css b/tailwind.css
index 0bca5f5..0bafe86 100644
--- a/tailwind.css
+++ b/tailwind.css
@@ -98,6 +98,8 @@
--spacing-page-wide: var(--pw-inset-page-wide);
--spacing-panel: var(--pw-panel-padding);
--spacing-hit-target: var(--pw-hit-target-min);
+ --spacing-control-compact: var(--pw-control-sm);
+ --spacing-control-default: var(--pw-control-md);
--spacing-icon-sm: var(--pw-icon-sm);
--spacing-icon-md: var(--pw-icon-md);
--spacing-icon-lg: var(--pw-icon-lg);
diff --git a/test/contracts.test.mjs b/test/contracts.test.mjs
index e53c466..8ea06c2 100644
--- a/test/contracts.test.mjs
+++ b/test/contracts.test.mjs
@@ -60,6 +60,8 @@ test("generates the public Tailwind utility vocabulary", async () => {
"duration-quick",
"ease-edit",
"size-hit-target",
+ "size-control-compact",
+ "size-control-default",
"size-icon-sm",
"aspect-hero",
]);
@@ -77,6 +79,8 @@ test("generates the public Tailwind utility vocabulary", async () => {
"duration-quick",
"ease-edit",
"size-hit-target",
+ "size-control-compact",
+ "size-control-default",
"size-icon-sm",
"aspect-hero",
]) {
diff --git a/tokens.css b/tokens.css
index c3cd615..6ab6b0e 100644
--- a/tokens.css
+++ b/tokens.css
@@ -85,6 +85,8 @@
/* Interaction and sizing */
--pw-hit-target-min: 2.75rem;
+ --pw-control-sm: 2rem;
+ --pw-control-md: var(--pw-hit-target-min);
--pw-focus-width: 2px;
--pw-focus-offset: 2px;
--pw-opacity-disabled: 0.4;