Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
},
Expand Down
64 changes: 64 additions & 0 deletions packages/ui/src/components/Checkbox/Checkbox.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
52 changes: 52 additions & 0 deletions packages/ui/src/components/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: "grid", gap: "8px", justifyItems: "start" }}>
<Checkbox checked={checked} onChange={setChecked}>
Start on connect
</Checkbox>
<Checkbox checked={false} onChange={() => undefined}>
Unchecked
</Checkbox>
<Checkbox checked disabled onChange={() => undefined}>
Disabled checked
</Checkbox>
<Checkbox checked={false} disabled onChange={() => undefined}>
Disabled unchecked
</Checkbox>
</div>
);
};

const meta: Meta<typeof CheckboxStates> = {
title: "UI/Checkbox",
component: CheckboxStates,
parameters: { pixel: PIXEL_ALL_THEMES },
};
export default meta;
type Story = StoryObj<typeof CheckboxStates>;

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();
},
};
58 changes: 58 additions & 0 deletions packages/ui/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>): void => {
onChange(event.currentTarget.checked);
};

return (
<label
className={cx(
"ui-checkbox",
disabled && "ui-checkbox--disabled",
className,
)}
style={style}
>
<input
{...props}
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
className="ui-checkbox__input"
/>
<span className="ui-checkbox__box" aria-hidden="true">
<Icon name="check" />
</span>
{children !== undefined && (
<span className="ui-checkbox__label">{children}</span>
)}
</label>
);
}
18 changes: 18 additions & 0 deletions packages/ui/src/components/Field/Field.css
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions packages/ui/src/components/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: "grid", gap: "16px", width: "260px" }}>
<Field
label="Region"
htmlFor="region"
description="Deploy the workspace close to you."
>
<Input id="region" value={region} onChange={setRegion} />
</Field>
<Field
label="CPU cores"
htmlFor="cores"
error="Value must be between 1 and 16."
>
<Input id="cores" type="number" value="32" onChange={() => undefined} />
</Field>
</div>
);
};

const meta: Meta<typeof FieldStates> = {
title: "UI/Field",
component: FieldStates,
parameters: { pixel: PIXEL_ALL_THEMES },
};
export default meta;
type Story = StoryObj<typeof FieldStates>;

export const States: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("Region"));
await expect(canvas.getByLabelText("Region")).toHaveFocus();
},
};
41 changes: 41 additions & 0 deletions packages/ui/src/components/Field/Field.tsx
Original file line number Diff line number Diff line change
@@ -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 <label {...props} className={cx("ui-label", className)} />;
}

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 (
<div {...props} className={cx("ui-field", className)}>
{label !== undefined && <Label htmlFor={htmlFor}>{label}</Label>}
{children}
{description !== undefined && (
<div className="ui-field__description">{description}</div>
)}
{error !== undefined && <div className="ui-field__error">{error}</div>}
</div>
);
}
48 changes: 48 additions & 0 deletions packages/ui/src/components/Input/Input.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading