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
37 changes: 33 additions & 4 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,40 @@ lets one place decide when a hover is instant rather than delayed.

Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
and scrolling. Menus default to the Modern UI motion: they scale and fade in
from the trigger corner and fade out on close, with Radix holding unmount
until the exit animation ends. High contrast, `forced-colors`, and
scrolling, and highlighted rows. Menus default to the Modern UI motion:
they scale and fade in 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` controls inside a `Field`.

`Input` and `Textarea` are controlled with `value` and `onChange(next)`;
`Checkbox` uses `checked` and `onChange(next)`. Native-element props and
refs pass through to the control; `className` and `style` target the root.
`Select` wraps `@radix-ui/react-select` and preserves its controlled
(`value` / `onValueChange`) and uncontrolled (`defaultValue`) modes, with
flat compound exports such as `SelectTrigger` and `SelectItem`, as the
menus do.
`Input` renders `children` after the control for trailing in-field
actions; `PasswordInput` uses that slot for a reveal toggle styled like the
find widget's option buttons.

`Field` lays out a semibold `Label`, children, description, and error text.
It does not clone children or require a form context, so native elements
and third-party controls work the same way: connect `htmlFor` to the
control's `id`, and pass `descriptionId` / `errorId` to give the rendered
text IDs the control can point `aria-describedby` at. The consumer owns
`aria-describedby`, `aria-invalid`, validation, and when to announce
errors. For a group of checkboxes, use a native `fieldset` with a `legend`
for the group name rather than pointing a single label at several controls.

## Known gaps

- Overlay shadows are darker than native in dark themes: menus in VS Code
Expand All @@ -220,7 +249,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 @@ -28,6 +28,7 @@
"dependencies": {
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tooltip": "^1.2.16",
"@vscode/codicons": "catalog:"
Expand Down
49 changes: 49 additions & 0 deletions packages/ui/src/components/Checkbox/Checkbox.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
.ui-checkbox {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}

.ui-checkbox:has(> :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 {
flex: none;
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__input:focus + .ui-checkbox__box {
border-color: var(--ui-focus-border);
}

@media (forced-colors: active) {
.ui-checkbox__box {
color: Highlight;
border-color: CanvasText;
}

.ui-checkbox__input:focus + .ui-checkbox__box {
border-color: Highlight;
}
}
46 changes: 46 additions & 0 deletions packages/ui/src/components/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useState } from "react";
import { 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 = {};

export const Focused: Story = {
play: ({ canvasElement }) => {
within(canvasElement)
.getByRole("checkbox", { name: "Start on connect" })
.focus();
},
};
43 changes: 43 additions & 0 deletions packages/ui/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type ComponentProps } from "react";

import { cx } from "#cx";

import "../control.css";
import { Icon } from "../Icon/Icon";

import "./Checkbox.css";

export interface CheckboxProps extends Omit<
ComponentProps<"input">,
"checked" | "onChange" | "type"
> {
checked: boolean;
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,
children,
...props
}: CheckboxProps): React.JSX.Element {
return (
<label className={cx("ui-checkbox", className)} style={style}>
<input
{...props}
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.currentTarget.checked)}
className="ui-checkbox__input"
/>
<span className="ui-control ui-checkbox__box" aria-hidden="true">
{checked && <Icon name="check" />}
</span>
{children}
</label>
);
}
8 changes: 4 additions & 4 deletions packages/ui/src/components/ContextMenu/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
/** One selectable action row; a leading `Icon` sits in the gutter. */
export const ContextMenuItem = menuPart(
ContextMenuPrimitive.Item,
"ui-menu__item",
"ui-overlay__item ui-menu__item",
);

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

/** One choice in a radio group. */
export const ContextMenuRadioItem = menuPart(
ContextMenuPrimitive.RadioItem,
"ui-menu__item",
"ui-overlay__item ui-menu__item",
{ indicator: ContextMenuPrimitive.ItemIndicator },
);

/** The row that opens its submenu. */
export const ContextMenuSubTrigger = menuPart(
ContextMenuPrimitive.SubTrigger,
"ui-menu__item",
"ui-overlay__item ui-menu__item",
{ chevron: true },
);

Expand Down
8 changes: 4 additions & 4 deletions packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
/** One selectable action row; a leading `Icon` sits in the gutter. */
export const DropdownMenuItem = menuPart(
DropdownMenuPrimitive.Item,
"ui-menu__item",
"ui-overlay__item ui-menu__item",
);

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

/** One choice in a radio group. */
export const DropdownMenuRadioItem = menuPart(
DropdownMenuPrimitive.RadioItem,
"ui-menu__item",
"ui-overlay__item ui-menu__item",
{ indicator: DropdownMenuPrimitive.ItemIndicator },
);

/** The row that opens its submenu. */
export const DropdownMenuSubTrigger = menuPart(
DropdownMenuPrimitive.SubTrigger,
"ui-menu__item",
"ui-overlay__item ui-menu__item",
{ chevron: true },
);

Expand Down
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);
}
57 changes: 57 additions & 0 deletions packages/ui/src/components/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useId, useState } from "react";

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");
const regionId = useId();
const coresId = useId();
return (
<div style={{ display: "grid", gap: "16px", width: "260px" }}>
<Field
label="Region"
htmlFor={regionId}
description="Deploy the workspace close to you."
descriptionId={`${regionId}-description`}
>
<Input
id={regionId}
value={region}
onChange={setRegion}
aria-describedby={`${regionId}-description`}
/>
</Field>
<Field
label="CPU cores"
htmlFor={coresId}
error="Value must be between 1 and 16."
errorId={`${coresId}-error`}
>
<Input
id={coresId}
type="number"
value="32"
onChange={() => undefined}
aria-describedby={`${coresId}-error`}
aria-invalid="true"
/>
</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 = {};
Loading