From 1ce6e87811fb9d5723a5b815a3f6bfeab8574753 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 28 Aug 2026 17:46:17 +0000 Subject: [PATCH 1/6] feat(ui): add Input form control --- packages/ui/src/components/Input/Input.css | 48 +++++++++++ .../ui/src/components/Input/Input.stories.tsx | 66 ++++++++++++++ packages/ui/src/components/Input/Input.tsx | 68 +++++++++++++++ packages/ui/src/index.ts | 1 + packages/ui/src/vscode-parity.stories.tsx | 15 ++++ test/webview/ui/Input.test.tsx | 86 +++++++++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 packages/ui/src/components/Input/Input.css create mode 100644 packages/ui/src/components/Input/Input.stories.tsx create mode 100644 packages/ui/src/components/Input/Input.tsx create mode 100644 test/webview/ui/Input.test.tsx 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/index.ts b/packages/ui/src/index.ts index 89b11ffb0a..15cb742cb6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,6 +12,7 @@ export { IconButton, type IconButtonProps, } from "./components/IconButton/IconButton"; +export { Input, type InputProps } from "./components/Input/Input"; export { LoadingState, type LoadingStateProps, diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index a9a7e49a93..4f2dd0c05f 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -20,6 +20,7 @@ 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 { Spinner } from "./components/Spinner/Spinner"; @@ -116,6 +117,20 @@ const Parity = (): React.JSX.Element => ( } /> + undefined} + aria-label="Region" + style={{ width: "180px" }} + /> + } + reference={ + + } + /> { + 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" }); + }); +}); From d413f6e62e0ae5c99fe275f8f5395552c30ddf69 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 28 Aug 2026 17:47:07 +0000 Subject: [PATCH 2/6] feat(ui): add Textarea form control --- .../ui/src/components/Textarea/Textarea.css | 29 +++++++++++ .../components/Textarea/Textarea.stories.tsx | 46 +++++++++++++++++ .../ui/src/components/Textarea/Textarea.tsx | 33 +++++++++++++ packages/ui/src/index.ts | 4 ++ packages/ui/src/vscode-parity.stories.tsx | 19 +++++++ test/webview/ui/Textarea.test.tsx | 49 +++++++++++++++++++ 6 files changed, 180 insertions(+) create mode 100644 packages/ui/src/components/Textarea/Textarea.css create mode 100644 packages/ui/src/components/Textarea/Textarea.stories.tsx create mode 100644 packages/ui/src/components/Textarea/Textarea.tsx create mode 100644 test/webview/ui/Textarea.test.tsx 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 ( +
+