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 ( + + ); +} 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