Skip to content

Commit 41fa903

Browse files
authored
feat(ui): add form controls for webview parameter forms (#1101)
Adds the form-control vocabulary packages/ui needs for the #972 dynamic-parameter webview form: Input (text / number / password with reveal toggle), Textarea, Checkbox, Select (Radix), and Field/Label. Controls render like VS Code's settings editor rather than the dashboard, so richer dashboard form_types map onto this vocabulary (input+mask, textarea, dropdown/radio, checkbox/switch, slider, multi-select, tag-select). - Input/Textarea are controlled with value + onChange(next); Checkbox uses checked + onChange(next). Select preserves Radix controlled and uncontrolled modes with flat compound exports. - Shared text-control.css paints Input, SearchInput, and Textarea from one set of ui-text-control rules; new --ui-checkbox-*, --ui-dropdown-*, and --ui-list-focus-* tokens with high-contrast fallbacks. - Fixed select value clipping (trigger line-height) and a duplicate --ui-list-focus-outline declaration that painted a focus outline on highlighted rows in regular themes. - Split PasswordInput out of Input and unified overlay row styles between Menu and Select. - Behavior covered by per-component unit tests consolidated into forms.test.tsx; Pixel-snapshot stories drive state only, with separate unfocused/Focused variants per control.
1 parent a91ae70 commit 41fa903

33 files changed

Lines changed: 1460 additions & 149 deletions

‎packages/ui/README.md‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,40 @@ lets one place decide when a hover is instant rather than delayed.
196196

197197
Overlay content is portalled to `body`, inherits webview typography from
198198
there, and shares the `.ui-overlay` base for stacking, border, shadow,
199-
and scrolling. Menus default to the Modern UI motion: they scale and fade in
200-
from the trigger corner and fade out on close, with Radix holding unmount
201-
until the exit animation ends. High contrast, `forced-colors`, and
199+
scrolling, and highlighted rows. Menus default to the Modern UI motion:
200+
they scale and fade in from the trigger corner and fade out on close, with
201+
Radix holding unmount until the exit animation ends. High contrast, `forced-colors`, and
202202
`prefers-reduced-motion` are handled.
203203

204+
## Form controls
205+
206+
`Input`, `Textarea`, `Checkbox`, `Select`, and `Field`/`Label` cover forms
207+
the way VS Code's own settings editor does: text field, number field,
208+
checkbox, and dropdown. Richer shapes map onto that vocabulary instead of
209+
getting bespoke widgets: a switch renders as `Checkbox`, a radio group or
210+
slider-bounded number as `Select` or a number `Input`, a multi-select as
211+
stacked `Checkbox` controls inside a `Field`.
212+
213+
`Input` and `Textarea` are controlled with `value` and `onChange(next)`;
214+
`Checkbox` uses `checked` and `onChange(next)`. Native-element props and
215+
refs pass through to the control; `className` and `style` target the root.
216+
`Select` wraps `@radix-ui/react-select` and preserves its controlled
217+
(`value` / `onValueChange`) and uncontrolled (`defaultValue`) modes, with
218+
flat compound exports such as `SelectTrigger` and `SelectItem`, as the
219+
menus do.
220+
`Input` renders `children` after the control for trailing in-field
221+
actions; `PasswordInput` uses that slot for a reveal toggle styled like the
222+
find widget's option buttons.
223+
224+
`Field` lays out a semibold `Label`, children, description, and error text.
225+
It does not clone children or require a form context, so native elements
226+
and third-party controls work the same way: connect `htmlFor` to the
227+
control's `id`, and pass `descriptionId` / `errorId` to give the rendered
228+
text IDs the control can point `aria-describedby` at. The consumer owns
229+
`aria-describedby`, `aria-invalid`, validation, and when to announce
230+
errors. For a group of checkboxes, use a native `fieldset` with a `legend`
231+
for the group name rather than pointing a single label at several controls.
232+
204233
## Known gaps
205234

206235
- Overlay shadows are darker than native in dark themes: menus in VS Code
@@ -220,7 +249,7 @@ without a generated source file or a runtime list in the public API.
220249

221250
ESLint rejects `@repo/*` imports and relative cross-package imports in
222251
`packages/ui` TypeScript and TSX source. `react` remains a peer dependency;
223-
the only runtime dependencies are the Radix overlay primitives and
252+
the only runtime dependencies are the Radix primitives and
224253
`@vscode/codicons`. Public consumers import from the package root or its
225254
declared CSS exports.
226255

‎packages/ui/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dependencies": {
2929
"@radix-ui/react-context-menu": "^2.3.7",
3030
"@radix-ui/react-dropdown-menu": "^2.1.24",
31+
"@radix-ui/react-select": "^2.3.7",
3132
"@radix-ui/react-slot": "^1.3.3",
3233
"@radix-ui/react-tooltip": "^1.2.16",
3334
"@vscode/codicons": "catalog:"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
.ui-checkbox {
2+
position: relative;
3+
display: inline-flex;
4+
align-items: center;
5+
gap: 6px;
6+
cursor: pointer;
7+
user-select: none;
8+
}
9+
10+
.ui-checkbox:has(> :disabled) {
11+
opacity: var(--ui-disabled-opacity);
12+
cursor: default;
13+
}
14+
15+
/* Invisible over the box, keeping the native hit target and focus source */
16+
.ui-checkbox__input {
17+
position: absolute;
18+
width: 18px;
19+
height: 18px;
20+
margin: 0;
21+
opacity: 0;
22+
cursor: inherit;
23+
}
24+
25+
/* Native checkbox geometry (checkbox.css): 18px box, 3px parity-pinned radius */
26+
.ui-checkbox__box {
27+
flex: none;
28+
width: 18px;
29+
height: 18px;
30+
color: var(--ui-checkbox-foreground);
31+
background: var(--ui-checkbox-background);
32+
border: 1px solid var(--ui-checkbox-border);
33+
border-radius: 3px;
34+
}
35+
36+
.ui-checkbox__input:focus + .ui-checkbox__box {
37+
border-color: var(--ui-focus-border);
38+
}
39+
40+
@media (forced-colors: active) {
41+
.ui-checkbox__box {
42+
color: Highlight;
43+
border-color: CanvasText;
44+
}
45+
46+
.ui-checkbox__input:focus + .ui-checkbox__box {
47+
border-color: Highlight;
48+
}
49+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useState } from "react";
2+
import { within } from "storybook/test";
3+
4+
import { PIXEL_ALL_THEMES } from "#storybook";
5+
6+
import { Checkbox } from "./Checkbox";
7+
8+
import type { Meta, StoryObj } from "@storybook/react-vite";
9+
10+
const CheckboxStates = (): React.JSX.Element => {
11+
const [checked, setChecked] = useState(true);
12+
return (
13+
<div style={{ display: "grid", gap: "8px", justifyItems: "start" }}>
14+
<Checkbox checked={checked} onChange={setChecked}>
15+
Start on connect
16+
</Checkbox>
17+
<Checkbox checked={false} onChange={() => undefined}>
18+
Unchecked
19+
</Checkbox>
20+
<Checkbox checked disabled onChange={() => undefined}>
21+
Disabled checked
22+
</Checkbox>
23+
<Checkbox checked={false} disabled onChange={() => undefined}>
24+
Disabled unchecked
25+
</Checkbox>
26+
</div>
27+
);
28+
};
29+
30+
const meta: Meta<typeof CheckboxStates> = {
31+
title: "UI/Checkbox",
32+
component: CheckboxStates,
33+
parameters: { pixel: PIXEL_ALL_THEMES },
34+
};
35+
export default meta;
36+
type Story = StoryObj<typeof CheckboxStates>;
37+
38+
export const States: Story = {};
39+
40+
export const Focused: Story = {
41+
play: ({ canvasElement }) => {
42+
within(canvasElement)
43+
.getByRole("checkbox", { name: "Start on connect" })
44+
.focus();
45+
},
46+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { type ComponentProps } from "react";
2+
3+
import { cx } from "#cx";
4+
5+
import "../control.css";
6+
import { Icon } from "../Icon/Icon";
7+
8+
import "./Checkbox.css";
9+
10+
export interface CheckboxProps extends Omit<
11+
ComponentProps<"input">,
12+
"checked" | "onChange" | "type"
13+
> {
14+
checked: boolean;
15+
onChange: (checked: boolean) => void;
16+
}
17+
18+
/* The native input supplies state, focus, and semantics; the box paints
19+
VS Code's checkbox geometry and shows a codicon check. */
20+
export function Checkbox({
21+
checked,
22+
onChange,
23+
className,
24+
style,
25+
children,
26+
...props
27+
}: CheckboxProps): React.JSX.Element {
28+
return (
29+
<label className={cx("ui-checkbox", className)} style={style}>
30+
<input
31+
{...props}
32+
type="checkbox"
33+
checked={checked}
34+
onChange={(event) => onChange(event.currentTarget.checked)}
35+
className="ui-checkbox__input"
36+
/>
37+
<span className="ui-control ui-checkbox__box" aria-hidden="true">
38+
{checked && <Icon name="check" />}
39+
</span>
40+
{children}
41+
</label>
42+
);
43+
}

‎packages/ui/src/components/ContextMenu/ContextMenu.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
2525
/** One selectable action row; a leading `Icon` sits in the gutter. */
2626
export const ContextMenuItem = menuPart(
2727
ContextMenuPrimitive.Item,
28-
"ui-menu__item",
28+
"ui-overlay__item ui-menu__item",
2929
);
3030

3131
/** Non-interactive heading above a group. */
@@ -43,21 +43,21 @@ export const ContextMenuSeparator = menuPart(
4343
/** A toggleable row; checked shows a gutter check. */
4444
export const ContextMenuCheckboxItem = menuPart(
4545
ContextMenuPrimitive.CheckboxItem,
46-
"ui-menu__item",
46+
"ui-overlay__item ui-menu__item",
4747
{ indicator: ContextMenuPrimitive.ItemIndicator },
4848
);
4949

5050
/** One choice in a radio group. */
5151
export const ContextMenuRadioItem = menuPart(
5252
ContextMenuPrimitive.RadioItem,
53-
"ui-menu__item",
53+
"ui-overlay__item ui-menu__item",
5454
{ indicator: ContextMenuPrimitive.ItemIndicator },
5555
);
5656

5757
/** The row that opens its submenu. */
5858
export const ContextMenuSubTrigger = menuPart(
5959
ContextMenuPrimitive.SubTrigger,
60-
"ui-menu__item",
60+
"ui-overlay__item ui-menu__item",
6161
{ chevron: true },
6262
);
6363

‎packages/ui/src/components/DropdownMenu/DropdownMenu.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
2525
/** One selectable action row; a leading `Icon` sits in the gutter. */
2626
export const DropdownMenuItem = menuPart(
2727
DropdownMenuPrimitive.Item,
28-
"ui-menu__item",
28+
"ui-overlay__item ui-menu__item",
2929
);
3030

3131
/** Non-interactive heading above a group. */
@@ -43,21 +43,21 @@ export const DropdownMenuSeparator = menuPart(
4343
/** A toggleable row; checked shows a gutter check. */
4444
export const DropdownMenuCheckboxItem = menuPart(
4545
DropdownMenuPrimitive.CheckboxItem,
46-
"ui-menu__item",
46+
"ui-overlay__item ui-menu__item",
4747
{ indicator: DropdownMenuPrimitive.ItemIndicator },
4848
);
4949

5050
/** One choice in a radio group. */
5151
export const DropdownMenuRadioItem = menuPart(
5252
DropdownMenuPrimitive.RadioItem,
53-
"ui-menu__item",
53+
"ui-overlay__item ui-menu__item",
5454
{ indicator: DropdownMenuPrimitive.ItemIndicator },
5555
);
5656

5757
/** The row that opens its submenu. */
5858
export const DropdownMenuSubTrigger = menuPart(
5959
DropdownMenuPrimitive.SubTrigger,
60-
"ui-menu__item",
60+
"ui-overlay__item ui-menu__item",
6161
{ chevron: true },
6262
);
6363

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.ui-label {
2+
display: block;
3+
font-weight: var(--ui-font-weight-semibold);
4+
}
5+
6+
.ui-field {
7+
display: flex;
8+
flex-direction: column;
9+
gap: 4px;
10+
}
11+
12+
.ui-field__description {
13+
color: var(--ui-description-foreground);
14+
}
15+
16+
.ui-field__error {
17+
color: var(--ui-error-foreground);
18+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useId, useState } from "react";
2+
3+
import { PIXEL_ALL_THEMES } from "#storybook";
4+
5+
import { Input } from "../Input/Input";
6+
7+
import { Field } from "./Field";
8+
9+
import type { Meta, StoryObj } from "@storybook/react-vite";
10+
11+
const FieldStates = (): React.JSX.Element => {
12+
const [region, setRegion] = useState("us-pittsburgh");
13+
const regionId = useId();
14+
const coresId = useId();
15+
return (
16+
<div style={{ display: "grid", gap: "16px", width: "260px" }}>
17+
<Field
18+
label="Region"
19+
htmlFor={regionId}
20+
description="Deploy the workspace close to you."
21+
descriptionId={`${regionId}-description`}
22+
>
23+
<Input
24+
id={regionId}
25+
value={region}
26+
onChange={setRegion}
27+
aria-describedby={`${regionId}-description`}
28+
/>
29+
</Field>
30+
<Field
31+
label="CPU cores"
32+
htmlFor={coresId}
33+
error="Value must be between 1 and 16."
34+
errorId={`${coresId}-error`}
35+
>
36+
<Input
37+
id={coresId}
38+
type="number"
39+
value="32"
40+
onChange={() => undefined}
41+
aria-describedby={`${coresId}-error`}
42+
aria-invalid="true"
43+
/>
44+
</Field>
45+
</div>
46+
);
47+
};
48+
49+
const meta: Meta<typeof FieldStates> = {
50+
title: "UI/Field",
51+
component: FieldStates,
52+
parameters: { pixel: PIXEL_ALL_THEMES },
53+
};
54+
export default meta;
55+
type Story = StoryObj<typeof FieldStates>;
56+
57+
export const States: Story = {};

0 commit comments

Comments
 (0)