diff --git a/packages/ui/README.md b/packages/ui/README.md
index ec79369344..380673af69 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -71,6 +71,24 @@ from the trigger corner and fade out on close, with Radix holding unmount
until the exit animation ends. High contrast, `forced-colors`, and
`prefers-reduced-motion` are handled.
+## Form controls
+
+`Input`, `Textarea`, `Checkbox`, `Select`, and `Field`/`Label` cover forms
+the way VS Code's own settings editor does: text field, number field,
+checkbox, and dropdown. Richer shapes map onto that vocabulary instead of
+getting bespoke widgets — a switch renders as `Checkbox`, a radio group or
+slider-bounded number as `Select` or a number `Input`, a multi-select as
+stacked `Checkbox`es inside a `Field`.
+
+Controls are controlled-only and follow the `SearchInput` precedent:
+`value` plus `onChange(next)`, native-element props passed through. `Select`
+wraps `@radix-ui/react-select` and keeps Radix's compound parts as flat
+named exports (`SelectTrigger`, `SelectItem`, …) with Radix naming
+(`onValueChange`), like the menus. A password `Input` shows a reveal toggle
+styled like the find widget's in-field option buttons. `Field` lays out a
+semibold `Label`, the control, and muted description or error text, like a
+settings-editor entry.
+
## Known gaps
- Overlay shadows are darker than native in dark themes: menus in VS Code
@@ -79,7 +97,8 @@ until the exit animation ends. High contrast, `forced-colors`, and
- Keybinding hints show the contributed defaults the consumer passes, not
user remaps: VS Code exposes no API for extensions to resolve a command's
effective keybinding.
-- List/selection-row tokens are deferred to the Tree suite (#1037).
+- List/selection-row tokens are deferred to the Tree suite (#1037); the
+ `--ui-list-focus-*` rungs cover only the select dropdown's row highlight.
## Codicons
@@ -91,7 +110,7 @@ without a generated source file or a runtime list in the public API.
ESLint rejects `@repo/*` imports and relative cross-package imports in
`packages/ui` TypeScript and TSX source. `react` remains a peer dependency;
-the only runtime dependencies are the Radix overlay primitives and
+the only runtime dependencies are the Radix primitives and
`@vscode/codicons`. Public consumers import from the package root or its
declared CSS exports.
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a2ea866658..bc99f35874 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -27,6 +27,7 @@
"dependencies": {
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dropdown-menu": "^2.1.24",
+ "@radix-ui/react-select": "catalog:",
"@radix-ui/react-tooltip": "^1.2.16",
"@vscode/codicons": "catalog:"
},
diff --git a/packages/ui/src/components/Checkbox/Checkbox.css b/packages/ui/src/components/Checkbox/Checkbox.css
new file mode 100644
index 0000000000..fca3ed5761
--- /dev/null
+++ b/packages/ui/src/components/Checkbox/Checkbox.css
@@ -0,0 +1,64 @@
+.ui-checkbox {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.ui-checkbox--disabled {
+ opacity: var(--ui-disabled-opacity);
+ cursor: default;
+}
+
+/* Invisible over the box, keeping the native hit target and focus source */
+.ui-checkbox__input {
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ opacity: 0;
+ cursor: inherit;
+}
+
+/* Native checkbox geometry (checkbox.css): 18px box, 3px parity-pinned radius */
+.ui-checkbox__box {
+ box-sizing: border-box;
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ color: var(--ui-checkbox-foreground);
+ background: var(--ui-checkbox-background);
+ border: 1px solid var(--ui-checkbox-border);
+ border-radius: 3px;
+}
+
+.ui-checkbox__box > .ui-icon {
+ visibility: hidden;
+}
+
+.ui-checkbox__input:checked + .ui-checkbox__box > .ui-icon {
+ visibility: visible;
+}
+
+.ui-checkbox__input:focus + .ui-checkbox__box {
+ border-color: var(--ui-focus-border);
+}
+
+@media (forced-colors: active) {
+ .ui-checkbox__box {
+ border-color: CanvasText;
+ }
+
+ .ui-checkbox__input:checked + .ui-checkbox__box {
+ color: Highlight;
+ }
+
+ .ui-checkbox__input:focus + .ui-checkbox__box {
+ border-color: Highlight;
+ }
+}
diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
new file mode 100644
index 0000000000..32e6080c2f
--- /dev/null
+++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
@@ -0,0 +1,52 @@
+import { useState } from "react";
+import { expect, userEvent, within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Checkbox } from "./Checkbox";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const CheckboxStates = (): React.JSX.Element => {
+ const [checked, setChecked] = useState(true);
+ return (
+
+
+ Start on connect
+
+ undefined}>
+ Unchecked
+
+ undefined}>
+ Disabled checked
+
+ undefined}>
+ Disabled unchecked
+
+
+ );
+};
+
+const meta: Meta = {
+ title: "UI/Checkbox",
+ component: CheckboxStates,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const checkbox = canvas.getByRole("checkbox", {
+ name: "Start on connect",
+ });
+ await expect(checkbox).toBeChecked();
+
+ await userEvent.click(checkbox);
+ await expect(checkbox).not.toBeChecked();
+
+ await userEvent.click(canvas.getByText("Start on connect"));
+ await expect(checkbox).toBeChecked();
+ },
+};
diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx
new file mode 100644
index 0000000000..5eef43f447
--- /dev/null
+++ b/packages/ui/src/components/Checkbox/Checkbox.tsx
@@ -0,0 +1,58 @@
+import { type ChangeEvent, type ComponentProps, type ReactNode } from "react";
+
+import { cx } from "#cx";
+
+import { Icon } from "../Icon/Icon";
+
+import "./Checkbox.css";
+
+export interface CheckboxProps extends Omit<
+ ComponentProps<"input">,
+ "checked" | "children" | "onChange" | "type"
+> {
+ checked: boolean;
+ children?: ReactNode;
+ onChange: (checked: boolean) => void;
+}
+
+/* The native input supplies state, focus, and semantics; the box paints
+ VS Code's checkbox geometry and shows a codicon check. */
+export function Checkbox({
+ checked,
+ onChange,
+ className,
+ style,
+ disabled,
+ children,
+ ...props
+}: CheckboxProps): React.JSX.Element {
+ const handleChange = (event: ChangeEvent): void => {
+ onChange(event.currentTarget.checked);
+ };
+
+ return (
+
+
+
+
+
+ {children !== undefined && (
+ {children}
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/Field/Field.css b/packages/ui/src/components/Field/Field.css
new file mode 100644
index 0000000000..e2af3c4558
--- /dev/null
+++ b/packages/ui/src/components/Field/Field.css
@@ -0,0 +1,18 @@
+.ui-label {
+ display: block;
+ font-weight: var(--ui-font-weight-semibold);
+}
+
+.ui-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ui-field__description {
+ color: var(--ui-description-foreground);
+}
+
+.ui-field__error {
+ color: var(--ui-error-foreground);
+}
diff --git a/packages/ui/src/components/Field/Field.stories.tsx b/packages/ui/src/components/Field/Field.stories.tsx
new file mode 100644
index 0000000000..1eeb733a33
--- /dev/null
+++ b/packages/ui/src/components/Field/Field.stories.tsx
@@ -0,0 +1,48 @@
+import { useState } from "react";
+import { expect, userEvent, within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Input } from "../Input/Input";
+
+import { Field } from "./Field";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const FieldStates = (): React.JSX.Element => {
+ const [region, setRegion] = useState("us-pittsburgh");
+ return (
+
+
+
+
+
+ undefined} />
+
+
+ );
+};
+
+const meta: Meta = {
+ title: "UI/Field",
+ component: FieldStates,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByText("Region"));
+ await expect(canvas.getByLabelText("Region")).toHaveFocus();
+ },
+};
diff --git a/packages/ui/src/components/Field/Field.tsx b/packages/ui/src/components/Field/Field.tsx
new file mode 100644
index 0000000000..7e3766bd49
--- /dev/null
+++ b/packages/ui/src/components/Field/Field.tsx
@@ -0,0 +1,41 @@
+import { type ComponentProps, type ReactNode } from "react";
+
+import { cx } from "#cx";
+
+import "./Field.css";
+
+export type LabelProps = ComponentProps<"label">;
+
+export function Label({ className, ...props }: LabelProps): React.JSX.Element {
+ return ;
+}
+
+export interface FieldProps extends ComponentProps<"div"> {
+ description?: ReactNode;
+ error?: ReactNode;
+ htmlFor?: string;
+ label?: ReactNode;
+}
+
+/* Lays out a labelled control like a settings-editor entry: semibold label,
+ control, then muted description or error text. */
+export function Field({
+ label,
+ htmlFor,
+ description,
+ error,
+ className,
+ children,
+ ...props
+}: FieldProps): React.JSX.Element {
+ return (
+
+ {label !== undefined &&
{label} }
+ {children}
+ {description !== undefined && (
+
{description}
+ )}
+ {error !== undefined &&
{error}
}
+
+ );
+}
diff --git a/packages/ui/src/components/Input/Input.css b/packages/ui/src/components/Input/Input.css
new file mode 100644
index 0000000000..8359e1c43b
--- /dev/null
+++ b/packages/ui/src/components/Input/Input.css
@@ -0,0 +1,48 @@
+.ui-input {
+ justify-content: flex-start;
+ width: 100%;
+ height: 26px;
+ color: var(--ui-input-foreground);
+ background: var(--ui-input-background);
+ border: 1px solid var(--ui-input-border);
+ border-radius: var(--ui-radius-small);
+}
+
+.ui-input:focus-within {
+ border-color: var(--ui-focus-border);
+}
+
+.ui-input__control {
+ min-width: 0;
+ flex: 1;
+ padding: 0 6px;
+ color: inherit;
+ background: transparent;
+ border: 0;
+ outline: 0;
+ font: inherit;
+}
+
+.ui-input__control::placeholder {
+ color: var(--ui-input-placeholder-foreground);
+ opacity: 1;
+}
+
+/* Native VS Code number fields are plain inputs without spinners */
+.ui-input__control::-webkit-inner-spin-button,
+.ui-input__control::-webkit-outer-spin-button {
+ display: none;
+}
+
+.ui-input__reveal {
+ flex: none;
+ margin-inline-end: 1px;
+}
+
+.ui-input--disabled {
+ opacity: var(--ui-disabled-opacity);
+}
+
+.ui-input__control:disabled {
+ cursor: not-allowed;
+}
diff --git a/packages/ui/src/components/Input/Input.stories.tsx b/packages/ui/src/components/Input/Input.stories.tsx
new file mode 100644
index 0000000000..b4af9c9ac0
--- /dev/null
+++ b/packages/ui/src/components/Input/Input.stories.tsx
@@ -0,0 +1,66 @@
+import { useState } from "react";
+import { expect, userEvent, within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Input } from "./Input";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const InputStates = (): React.JSX.Element => {
+ const [value, setValue] = useState("us-pittsburgh");
+ const [secret, setSecret] = useState("hunter2");
+ return (
+
+
+ undefined}
+ placeholder="Instance type"
+ aria-label="Placeholder"
+ />
+ undefined}
+ type="number"
+ min={1}
+ max={16}
+ aria-label="CPU cores"
+ />
+
+ undefined}
+ disabled
+ aria-label="Disabled"
+ />
+
+ );
+};
+
+const meta: Meta = {
+ title: "UI/Input",
+ component: InputStates,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const token = canvas.getByLabelText("API token");
+ await expect(token).toHaveAttribute("type", "password");
+
+ await userEvent.click(canvas.getByRole("button", { name: "Show value" }));
+ await expect(token).toHaveAttribute("type", "text");
+
+ await userEvent.click(canvas.getByRole("button", { name: "Hide value" }));
+ await expect(token).toHaveAttribute("type", "password");
+ },
+};
diff --git a/packages/ui/src/components/Input/Input.tsx b/packages/ui/src/components/Input/Input.tsx
new file mode 100644
index 0000000000..ffcb47a881
--- /dev/null
+++ b/packages/ui/src/components/Input/Input.tsx
@@ -0,0 +1,68 @@
+import { type ChangeEvent, type ComponentProps, useState } from "react";
+
+import { cx } from "#cx";
+
+import "../control.css";
+import { IconButton } from "../IconButton/IconButton";
+
+import "./Input.css";
+
+export interface InputProps extends Omit<
+ ComponentProps<"input">,
+ "onChange" | "value"
+> {
+ hideLabel?: string;
+ onChange: (value: string) => void;
+ showLabel?: string;
+ value: string;
+}
+
+/* A password input renders a reveal toggle, styled like the find widget's
+ in-field option buttons. */
+export function Input({
+ value,
+ onChange,
+ className,
+ style,
+ disabled,
+ type = "text",
+ showLabel = "Show value",
+ hideLabel = "Hide value",
+ ...props
+}: InputProps): React.JSX.Element {
+ const [revealed, setRevealed] = useState(false);
+ const handleChange = (event: ChangeEvent): void => {
+ onChange(event.currentTarget.value);
+ };
+
+ return (
+
+
+ {type === "password" && (
+ setRevealed(!revealed)}
+ />
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/Select/Select.css b/packages/ui/src/components/Select/Select.css
new file mode 100644
index 0000000000..e660fbe1f1
--- /dev/null
+++ b/packages/ui/src/components/Select/Select.css
@@ -0,0 +1,80 @@
+.ui-select__trigger {
+ justify-content: space-between;
+ gap: 6px;
+ width: 100%;
+ height: 26px;
+ padding: 0 6px;
+ color: var(--ui-dropdown-foreground);
+ background: var(--ui-dropdown-background);
+ border: 1px solid var(--ui-dropdown-border);
+ border-radius: var(--ui-radius-small);
+ cursor: pointer;
+ outline: 0;
+}
+
+.ui-select__trigger:focus {
+ border-color: var(--ui-focus-border);
+}
+
+.ui-select__trigger[data-placeholder] {
+ color: var(--ui-input-placeholder-foreground);
+}
+
+.ui-select__trigger > .ui-icon {
+ flex: none;
+}
+
+.ui-select__list {
+ --ui-overlay-border: var(--ui-dropdown-border);
+ --ui-overlay-available-height: var(--radix-select-content-available-height);
+
+ min-width: var(--radix-select-trigger-width);
+ padding: 2px;
+ color: var(--ui-dropdown-foreground);
+ background: var(--ui-dropdown-list-background);
+}
+
+.ui-select__item {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 22px;
+ padding: 2px 6px;
+ line-height: 1.4;
+ border-radius: var(--ui-radius-medium);
+ cursor: default;
+ user-select: none;
+ outline: 1px solid transparent;
+ outline-offset: -1px;
+}
+
+.ui-select__item[data-highlighted] {
+ forced-color-adjust: none;
+ color: var(--ui-list-focus-foreground);
+ background: var(--ui-list-focus-background);
+ outline-color: var(--ui-list-focus-outline);
+}
+
+.ui-select__item[data-disabled] {
+ color: var(--ui-disabled-foreground);
+}
+
+.ui-select__item-description {
+ color: var(--ui-description-foreground);
+ font-size: var(--ui-font-size-small);
+}
+
+.ui-select__item[data-highlighted] .ui-select__item-description {
+ color: inherit;
+}
+
+@media (forced-colors: active) {
+ .ui-select__item[data-highlighted] {
+ color: HighlightText;
+ background: Highlight;
+ }
+
+ .ui-select__item[data-disabled] {
+ color: GrayText;
+ }
+}
diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx
new file mode 100644
index 0000000000..2c9e2dd0ae
--- /dev/null
+++ b/packages/ui/src/components/Select/Select.stories.tsx
@@ -0,0 +1,86 @@
+import { useState } from "react";
+import { expect, screen, userEvent, within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./Select";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const RegionSelect = (): React.JSX.Element => {
+ const [region, setRegion] = useState("us-pittsburgh");
+ return (
+
+
+
+
+
+
+
+ US East (Pittsburgh)
+
+ EU North (Helsinki)
+
+ Asia Pacific (Sydney)
+
+
+
+ undefined}>
+
+
+
+
+ One
+
+
+ undefined} disabled>
+
+
+
+
+ One
+
+
+
+ );
+};
+
+const meta: Meta = {
+ title: "UI/Select",
+ component: RegionSelect,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {};
+
+export const Open: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("combobox", { name: "Region" }));
+ await screen.findByRole("listbox");
+ await expect(
+ screen.getByRole("option", { name: /US East/ }),
+ ).toBeInTheDocument();
+ await expect(screen.getByText("Lowest latency")).toBeInTheDocument();
+ },
+};
+
+export const Selection: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole("combobox", { name: "Region" });
+ await userEvent.click(trigger);
+ await userEvent.click(
+ await screen.findByRole("option", { name: /Helsinki/ }),
+ );
+ await expect(trigger).toHaveTextContent("EU North (Helsinki)");
+ },
+};
diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx
new file mode 100644
index 0000000000..dd3e001088
--- /dev/null
+++ b/packages/ui/src/components/Select/Select.tsx
@@ -0,0 +1,85 @@
+import * as SelectPrimitive from "@radix-ui/react-select";
+
+import { cx } from "#cx";
+
+import "../control.css";
+import { Icon } from "../Icon/Icon";
+import "../overlay.css";
+
+import "./Select.css";
+
+import type { ComponentPropsWithRef, ReactNode } from "react";
+
+/** Root state container. */
+export const Select = SelectPrimitive.Root;
+
+/** Renders the selected item's text, or `placeholder` when empty. */
+export const SelectValue = SelectPrimitive.Value;
+
+/** The closed control: current value and a chevron, styled like the
+ native dropdown. */
+export function SelectTrigger({
+ className,
+ children,
+ ...props
+}: ComponentPropsWithRef): React.JSX.Element {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+/** The floating option list, portalled to `body` and sized to the trigger.
+ Like the native select dropdown it appears without animation. */
+export function SelectContent({
+ className,
+ children,
+ ...props
+}: ComponentPropsWithRef): React.JSX.Element {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export interface SelectItemProps extends ComponentPropsWithRef<
+ typeof SelectPrimitive.Item
+> {
+ description?: ReactNode;
+}
+
+/** One option row; the highlighted row marks selection, like the native
+ list. An optional description renders as a muted second line. */
+export function SelectItem({
+ className,
+ children,
+ description,
+ ...props
+}: SelectItemProps): React.JSX.Element {
+ return (
+
+ {children}
+ {description !== undefined && (
+ {description}
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/Textarea/Textarea.css b/packages/ui/src/components/Textarea/Textarea.css
new file mode 100644
index 0000000000..e39133140e
--- /dev/null
+++ b/packages/ui/src/components/Textarea/Textarea.css
@@ -0,0 +1,29 @@
+.ui-textarea {
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ min-height: 60px;
+ padding: 4px 6px;
+ font: inherit;
+ color: var(--ui-input-foreground);
+ background: var(--ui-input-background);
+ border: 1px solid var(--ui-input-border);
+ border-radius: var(--ui-radius-small);
+ outline: 0;
+ resize: vertical;
+}
+
+.ui-textarea:focus {
+ border-color: var(--ui-focus-border);
+}
+
+.ui-textarea::placeholder {
+ color: var(--ui-input-placeholder-foreground);
+ opacity: 1;
+}
+
+.ui-textarea:disabled {
+ opacity: var(--ui-disabled-opacity);
+ cursor: not-allowed;
+ resize: none;
+}
diff --git a/packages/ui/src/components/Textarea/Textarea.stories.tsx b/packages/ui/src/components/Textarea/Textarea.stories.tsx
new file mode 100644
index 0000000000..57aa84ad7c
--- /dev/null
+++ b/packages/ui/src/components/Textarea/Textarea.stories.tsx
@@ -0,0 +1,46 @@
+import { useState } from "react";
+import { expect, userEvent, within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Textarea } from "./Textarea";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const TextareaStates = (): React.JSX.Element => {
+ const [value, setValue] = useState("#!/bin/sh\necho hello");
+ return (
+
+
+
+ );
+};
+
+const meta: Meta = {
+ title: "UI/Textarea",
+ component: TextareaStates,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const script = canvas.getByLabelText("Init script");
+ await userEvent.type(script, "!");
+ await expect(script).toHaveValue("#!/bin/sh\necho hello!");
+ },
+};
diff --git a/packages/ui/src/components/Textarea/Textarea.tsx b/packages/ui/src/components/Textarea/Textarea.tsx
new file mode 100644
index 0000000000..79951f0912
--- /dev/null
+++ b/packages/ui/src/components/Textarea/Textarea.tsx
@@ -0,0 +1,33 @@
+import { type ChangeEvent, type ComponentProps } from "react";
+
+import { cx } from "#cx";
+
+import "./Textarea.css";
+
+export interface TextareaProps extends Omit<
+ ComponentProps<"textarea">,
+ "onChange" | "value"
+> {
+ onChange: (value: string) => void;
+ value: string;
+}
+
+export function Textarea({
+ value,
+ onChange,
+ className,
+ ...props
+}: TextareaProps): React.JSX.Element {
+ const handleChange = (event: ChangeEvent): void => {
+ onChange(event.currentTarget.value);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 89b11ffb0a..723d3265b7 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,4 +1,5 @@
export { Button, type ButtonProps } from "./components/Button/Button";
+export { Checkbox, type CheckboxProps } from "./components/Checkbox/Checkbox";
export {
EmptyState,
type EmptyStateProps,
@@ -7,11 +8,18 @@ export {
ErrorState,
type ErrorStateProps,
} from "./components/ErrorState/ErrorState";
+export {
+ Field,
+ type FieldProps,
+ Label,
+ type LabelProps,
+} from "./components/Field/Field";
export { Icon, type IconProps } from "./components/Icon/Icon";
export {
IconButton,
type IconButtonProps,
} from "./components/IconButton/IconButton";
+export { Input, type InputProps } from "./components/Input/Input";
export {
LoadingState,
type LoadingStateProps,
@@ -24,12 +32,21 @@ export {
SearchInput,
type SearchInputProps,
} from "./components/SearchInput/SearchInput";
+export {
+ Select,
+ SelectContent,
+ SelectItem,
+ type SelectItemProps,
+ SelectTrigger,
+ SelectValue,
+} from "./components/Select/Select";
export { Spinner, type SpinnerProps } from "./components/Spinner/Spinner";
export {
StatusPill,
type StatusPillProps,
type StatusPillTone,
} from "./components/StatusPill/StatusPill";
+export { Textarea, type TextareaProps } from "./components/Textarea/Textarea";
export type { CodiconName } from "./codicons";
export {
ContextMenu,
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
index e4345fa09f..cad17a442b 100644
--- a/packages/ui/src/tokens.css
+++ b/packages/ui/src/tokens.css
@@ -79,6 +79,45 @@
var(--vscode-contrastBorder, transparent)
);
--ui-input-placeholder-foreground: var(--vscode-input-placeholderForeground);
+ --ui-checkbox-background: var(
+ --vscode-checkbox-background,
+ var(--ui-input-background)
+ );
+ --ui-checkbox-foreground: var(
+ --vscode-checkbox-foreground,
+ var(--ui-input-foreground)
+ );
+ --ui-checkbox-border: var(
+ --vscode-checkbox-border,
+ var(--vscode-contrastBorder, var(--ui-input-border))
+ );
+ --ui-dropdown-background: var(
+ --vscode-dropdown-background,
+ var(--ui-input-background)
+ );
+ --ui-dropdown-foreground: var(
+ --vscode-dropdown-foreground,
+ var(--ui-foreground)
+ );
+ --ui-dropdown-border: var(
+ --vscode-dropdown-border,
+ var(--vscode-contrastBorder, transparent)
+ );
+ --ui-dropdown-list-background: var(
+ --vscode-dropdown-listBackground,
+ var(--ui-dropdown-background)
+ );
+ /* Native select dropdowns highlight rows with the quick input list colors
+ (selectBoxStyles); the outline only resolves in high contrast. */
+ --ui-list-focus-background: var(
+ --vscode-quickInputList-focusBackground,
+ var(--vscode-list-activeSelectionBackground, transparent)
+ );
+ --ui-list-focus-foreground: var(
+ --vscode-quickInputList-focusForeground,
+ var(--vscode-list-activeSelectionForeground, var(--ui-dropdown-foreground))
+ );
+ --ui-list-focus-outline: var(--vscode-contrastActiveBorder, transparent);
--ui-button-background: var(--vscode-button-background, var(--ui-background));
--ui-button-foreground: var(--vscode-button-foreground, var(--ui-foreground));
--ui-button-border: var(
diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx
index a9a7e49a93..acde71cf0a 100644
--- a/packages/ui/src/vscode-parity.stories.tsx
+++ b/packages/ui/src/vscode-parity.stories.tsx
@@ -1,16 +1,21 @@
import {
VscodeBadge,
VscodeButton,
+ VscodeCheckbox,
VscodeContextMenu,
VscodeIcon,
+ VscodeOption,
VscodeProgressBar,
VscodeProgressRing,
+ VscodeSingleSelect,
+ VscodeTextarea,
VscodeTextfield,
VscodeToolbarButton,
} from "@vscode-elements/react-elements";
import { useState } from "react";
import { Button } from "./components/Button/Button";
+import { Checkbox } from "./components/Checkbox/Checkbox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,10 +25,19 @@ import {
DropdownMenuTrigger,
} from "./components/DropdownMenu/DropdownMenu";
import { IconButton } from "./components/IconButton/IconButton";
+import { Input } from "./components/Input/Input";
import { ProgressBar } from "./components/ProgressBar/ProgressBar";
import { SearchInput } from "./components/SearchInput/SearchInput";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./components/Select/Select";
import { Spinner } from "./components/Spinner/Spinner";
import { StatusPill } from "./components/StatusPill/StatusPill";
+import { Textarea } from "./components/Textarea/Textarea";
import { PIXEL_ALL_THEMES } from "./storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -116,6 +130,37 @@ const Parity = (): React.JSX.Element => (
}
/>
+ undefined}
+ aria-label="Region"
+ style={{ width: "180px" }}
+ />
+ }
+ reference={
+
+ }
+ />
+ undefined}
+ aria-label="Init script"
+ style={{ width: "180px" }}
+ />
+ }
+ reference={
+
+ }
+ />
(
>
}
/>
+ undefined}>
+
+
+
+
+ US East (Pittsburgh)
+ EU North (Helsinki)
+
+
+ }
+ reference={
+
+
+ US East (Pittsburgh)
+
+ EU North (Helsinki)
+
+ }
+ />
+ undefined}>
+ Start on connect
+
+ }
+ reference={ }
+ />
=14'}
+ '@radix-ui/number@1.1.3':
+ resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
+
'@radix-ui/primitive@1.1.7':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
@@ -1775,6 +1784,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-select@2.3.7':
+ resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-slot@1.3.3':
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
peerDependencies:
@@ -1842,6 +1864,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-previous@1.1.4':
+ resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-rect@1.1.4':
resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
peerDependencies:
@@ -6536,6 +6567,8 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@radix-ui/number@1.1.3': {}
+
'@radix-ui/primitive@1.1.7': {}
'@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)':
@@ -6733,6 +6766,36 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.5(@types/react@19.2.18)
+ '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)':
+ dependencies:
+ '@radix-ui/number': 1.1.3
+ '@radix-ui/primitive': 1.1.7
+ '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
+ aria-hidden: 1.2.6
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.5(@types/react@19.2.18)
+
'@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
@@ -6795,6 +6858,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
+ '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+ optionalDependencies:
+ '@types/react': 19.2.18
+
'@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/rect': 1.1.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 839ba3e17d..1452868cbc 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,6 +2,7 @@ packages:
- packages/*
catalog:
+ "@radix-ui/react-select": ^2.3.7
"@rolldown/plugin-babel": ^0.2.3
"@storybook/addon-a11y": ^10.5.10
"@storybook/addon-docs": ^10.5.10
diff --git a/test/webview/ui/Checkbox.test.tsx b/test/webview/ui/Checkbox.test.tsx
new file mode 100644
index 0000000000..ef9e553a89
--- /dev/null
+++ b/test/webview/ui/Checkbox.test.tsx
@@ -0,0 +1,67 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Checkbox } from "@repo/ui";
+
+describe("Checkbox", () => {
+ it("reports toggles without owning the checked state", () => {
+ const onChange = vi.fn();
+ const { rerender } = render(
+
+ Start on connect
+ ,
+ );
+ const checkbox = screen.getByRole("checkbox", { name: "Start on connect" });
+ fireEvent.click(checkbox);
+ expect(onChange).toHaveBeenCalledWith(true);
+ expect(checkbox).not.toBeChecked();
+
+ rerender(
+
+ Start on connect
+ ,
+ );
+ expect(checkbox).toBeChecked();
+ });
+
+ it("toggles from a click on its label text", () => {
+ const onChange = vi.fn();
+ render(
+
+ Start on connect
+ ,
+ );
+ fireEvent.click(screen.getByText("Start on connect"));
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("does not fire when disabled", () => {
+ const onChange = vi.fn();
+ render(
+
+ Disabled
+ ,
+ );
+ fireEvent.click(screen.getByText("Disabled"));
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByRole("checkbox", { name: "Disabled" })).toBeDisabled();
+ });
+
+ it("forwards className and style to the root element", () => {
+ render(
+
+ Styled
+ ,
+ );
+ const root = screen
+ .getByRole("checkbox", { name: "Styled" })
+ .closest(".ui-checkbox");
+ expect(root).toHaveClass("custom-checkbox");
+ expect(root).toHaveStyle({ marginTop: "4px" });
+ });
+});
diff --git a/test/webview/ui/Field.test.tsx b/test/webview/ui/Field.test.tsx
new file mode 100644
index 0000000000..5f00faa986
--- /dev/null
+++ b/test/webview/ui/Field.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Field, Input, Label } from "@repo/ui";
+
+describe("Label", () => {
+ it("labels a control through htmlFor", () => {
+ render(
+ <>
+ Region
+
+ >,
+ );
+ expect(screen.getByLabelText("Region")).toBeInTheDocument();
+ });
+});
+
+describe("Field", () => {
+ it("wires its label to the control and renders the description", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByLabelText("Region")).toBeInTheDocument();
+ expect(screen.getByText("Pick one.")).toHaveClass("ui-field__description");
+ });
+
+ it("renders error text", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Out of range.")).toHaveClass("ui-field__error");
+ });
+
+ it("forwards className and style to the root element", () => {
+ render(
+
+
+ ,
+ );
+ const root = screen.getByRole("textbox").closest(".ui-field");
+ expect(root).toHaveClass("custom-field");
+ expect(root).toHaveStyle({ width: "200px" });
+ });
+});
diff --git a/test/webview/ui/Input.test.tsx b/test/webview/ui/Input.test.tsx
new file mode 100644
index 0000000000..d0da5fbfcc
--- /dev/null
+++ b/test/webview/ui/Input.test.tsx
@@ -0,0 +1,86 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Input } from "@repo/ui";
+
+describe("Input", () => {
+ it("reports changes without owning the value", () => {
+ const onChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.change(screen.getByRole("textbox", { name: "Region" }), {
+ target: { value: "us" },
+ });
+ expect(onChange).toHaveBeenCalledWith("us");
+ expect(screen.getByRole("textbox", { name: "Region" })).toHaveValue("");
+
+ rerender( );
+ expect(screen.getByRole("textbox", { name: "Region" })).toHaveValue("us");
+ });
+
+ it("passes number constraints through to the native input", () => {
+ render(
+ ,
+ );
+ const input = screen.getByRole("spinbutton", { name: "CPU cores" });
+ expect(input).toHaveAttribute("min", "1");
+ expect(input).toHaveAttribute("max", "16");
+ });
+
+ it("reveals and re-masks a password value", () => {
+ render(
+ ,
+ );
+ const input = screen.getByLabelText("API token");
+ expect(input).toHaveAttribute("type", "password");
+
+ fireEvent.click(screen.getByRole("button", { name: "Show value" }));
+ expect(input).toHaveAttribute("type", "text");
+
+ fireEvent.click(screen.getByRole("button", { name: "Hide value" }));
+ expect(input).toHaveAttribute("type", "password");
+ });
+
+ it("disables the reveal toggle with the input", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Show value" })).toBeDisabled();
+ });
+
+ it("forwards className and style to the root element", () => {
+ render(
+ ,
+ );
+ const root = screen
+ .getByRole("textbox", { name: "Region" })
+ .closest(".ui-input");
+ expect(root).toHaveClass("custom-input");
+ expect(root).toHaveStyle({ width: "200px" });
+ });
+});
diff --git a/test/webview/ui/Select.test.tsx b/test/webview/ui/Select.test.tsx
new file mode 100644
index 0000000000..19311aacbe
--- /dev/null
+++ b/test/webview/ui/Select.test.tsx
@@ -0,0 +1,102 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@repo/ui";
+
+// jsdom lacks the pointer-capture and scrolling APIs Radix Select uses.
+window.HTMLElement.prototype.hasPointerCapture = vi.fn();
+window.HTMLElement.prototype.releasePointerCapture = vi.fn();
+window.HTMLElement.prototype.scrollIntoView = vi.fn();
+
+const RegionSelect = ({
+ onValueChange,
+ value = "",
+ disabled,
+}: {
+ onValueChange: (value: string) => void;
+ value?: string;
+ disabled?: boolean;
+}): React.JSX.Element => (
+
+
+
+
+
+
+ US East
+
+ EU North
+
+
+);
+
+describe("Select", () => {
+ it("opens with the keyboard and reports the selected value", () => {
+ const onValueChange = vi.fn();
+ render( );
+ const trigger = screen.getByRole("combobox", { name: "Region" });
+
+ fireEvent.keyDown(trigger, { key: "Enter" });
+ fireEvent.keyDown(screen.getByRole("option", { name: "US East" }), {
+ key: "Enter",
+ });
+ expect(onValueChange).toHaveBeenCalledWith("us-pittsburgh");
+ });
+
+ it("shows the placeholder until a value is set, then the item text", () => {
+ const { rerender } = render(
+ ,
+ );
+ const trigger = screen.getByRole("combobox", { name: "Region" });
+ expect(trigger).toHaveTextContent("Select a region");
+
+ rerender( );
+ expect(trigger).toHaveTextContent("EU North");
+ });
+
+ it("renders option descriptions in the open list", () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole("combobox", { name: "Region" }), {
+ key: "Enter",
+ });
+ expect(screen.getByText("Lowest latency")).toHaveClass(
+ "ui-select__item-description",
+ );
+ });
+
+ it("does not open when disabled", () => {
+ render(
+ ,
+ );
+ const trigger = screen.getByRole("combobox", { name: "Region" });
+ expect(trigger).toBeDisabled();
+ fireEvent.keyDown(trigger, { key: "Enter" });
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
+ });
+
+ it("forwards className and style to the trigger", () => {
+ render(
+
+
+
+
+
+ One
+
+ ,
+ );
+ const trigger = screen.getByRole("combobox", { name: "Region" });
+ expect(trigger).toHaveClass("ui-select__trigger", "custom-trigger");
+ expect(trigger).toHaveStyle({ width: "120px" });
+ });
+});
diff --git a/test/webview/ui/Textarea.test.tsx b/test/webview/ui/Textarea.test.tsx
new file mode 100644
index 0000000000..7f1a11730a
--- /dev/null
+++ b/test/webview/ui/Textarea.test.tsx
@@ -0,0 +1,49 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Textarea } from "@repo/ui";
+
+describe("Textarea", () => {
+ it("reports changes without owning the value", () => {
+ const onChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.change(screen.getByRole("textbox", { name: "Init script" }), {
+ target: { value: "echo hi" },
+ });
+ expect(onChange).toHaveBeenCalledWith("echo hi");
+ expect(screen.getByRole("textbox", { name: "Init script" })).toHaveValue(
+ "",
+ );
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("textbox", { name: "Init script" })).toHaveValue(
+ "echo hi",
+ );
+ });
+
+ it("disables the native control", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("textbox", { name: "Disabled" })).toBeDisabled();
+ });
+
+ it("forwards className and style to the root element", () => {
+ render(
+ ,
+ );
+ const root = screen.getByRole("textbox", { name: "Init script" });
+ expect(root).toHaveClass("ui-textarea", "custom-textarea");
+ expect(root).toHaveStyle({ height: "120px" });
+ });
+});