From c65bb26ca463ebcae78c22c4487932cefc32e7b5 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:09:26 +0100 Subject: [PATCH 01/19] feat(ui): add flat input surfaces and compact sizing (#60598) --- .../component-tests/input-surfaces.spec.ts | 110 ++++++++++++++++++ .../ui/src/forms/text-input/text-input.css | 46 +++++--- .../forms/text-input/text-input.stories.tsx | 28 ++++- .../ui/src/forms/text-input/text-input.tsx | 8 +- packages/ui/src/forms/textarea/textarea.css | 21 ++-- .../src/forms/textarea/textarea.stories.tsx | 18 +++ packages/ui/src/forms/textarea/textarea.tsx | 4 +- packages/ui/src/styles/tokens/theme.css | 13 +++ packages/ui/src/theme/themes/oc-2.json | 2 + packages/ui/src/theme/v2/mapping.ts | 20 ++-- packages/ui/src/theme/v2/resolve.test.ts | 31 +++++ 11 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 packages/app/component-tests/input-surfaces.spec.ts diff --git a/packages/app/component-tests/input-surfaces.spec.ts b/packages/app/component-tests/input-surfaces.spec.ts new file mode 100644 index 000000000000..b9679e188da6 --- /dev/null +++ b/packages/app/component-tests/input-surfaces.spec.ts @@ -0,0 +1,110 @@ +import { expect, story } from "../../stories/playwright/story" + +for (const control of ["text-input", "textarea"]) { + story(`${control} supports its surfaces with inset feedback and stable geometry`, async ({ mount, page }) => { + const root = await mount(`ui-${control}--surfaces`) + + const fields = root.locator(`[data-component="${control}-v2"]`) + + await page.addStyleTag({ + content: `[data-component="${control}-v2"] { + --v2-background-bg-base: rgb(20, 30, 40); + --v2-background-bg-layer-01: rgb(50, 60, 70); + --v2-overlay-simple-overlay-hover: rgba(10, 20, 30, 0.1); + --v2-elevation-input: 0px 0px 0px 3px rgb(1, 2, 3); + --v2-elevation-button-neutral: 0px 0px 0px 7px rgb(4, 5, 6); + }`, + }) + + await expect(fields.nth(0)).toHaveCSS("background-color", "rgb(20, 30, 40)") + await expect(fields.nth(1)).toHaveCSS("background-color", "rgb(50, 60, 70)") + await expect(fields.nth(0)).toHaveCSS("box-shadow", "rgb(1, 2, 3) 0px 0px 0px 3px") + await expect(fields.nth(1)).toHaveCSS("box-shadow", "rgb(1, 2, 3) 0px 0px 0px 3px") + + for (const field of await fields.all()) { + await expect(field).toHaveCSS("background-image", "none") + } + + const base = fields.nth(0) + + const geometry = await base.getByRole("textbox").boundingBox() + + await base.hover() + await expect + .poll(() => base.evaluate((element) => getComputedStyle(element).boxShadow)) + .toContain("rgb(1, 2, 3) 0px 0px 0px 3px") + await base.getByRole("textbox").fill("Still editable") + await expect(base.getByRole("textbox")).toHaveValue("Still editable") + await expect(base).toHaveCSS("outline-width", "1px") + await expect(base).toHaveCSS("outline-offset", "-1px") + await expect(base).not.toHaveCSS("outline-color", "rgba(0, 0, 0, 0)") + expect(await base.getByRole("textbox").boundingBox()).toEqual(geometry) + + const invalid = root.locator("[data-invalid]") + + const disabled = root.locator("[data-disabled]") + + await expect(invalid).toHaveCSS("outline-width", "1px") + await expect(invalid).toHaveCSS("outline-offset", "-1px") + await expect(invalid).not.toHaveCSS("outline-color", "rgba(0, 0, 0, 0)") + await expect(invalid.getByRole("textbox")).toHaveAttribute("aria-invalid", "true") + await expect(disabled.getByRole("textbox")).toBeDisabled() + + if (control === "textarea") return + + const invisible = root.locator('[data-surface="invisible"]').first() + + await base.getByRole("textbox").blur() + await expect(invisible).toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + await invisible.hover() + await expect(invisible).toHaveCSS("background-color", "rgba(10, 20, 30, 0.1)") + await expect(invisible).toHaveCSS("box-shadow", "none") + await invisible.getByRole("textbox").focus() + await expect(invisible).toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + await expect(invisible).toHaveCSS("outline-offset", "-1px") + await expect(invisible).not.toHaveCSS("outline-color", "rgba(0, 0, 0, 0)") + await expect(invalid).toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + await expect(disabled).toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + await expect(disabled).toHaveCSS("box-shadow", "none") + }) +} + +story( + "text inputs keep clear and copy actions equally sized and inset across sizes and directions", + async ({ mount, page }) => { + const root = await mount("ui-text-input--appearances") + + const fields = root.locator('[data-component="text-input-v2"]') + + for (const [index, height] of [24, 28, 32, 32, 24, 28, 32].entries()) { + await expect(fields.nth(index)).toHaveCSS("height", `${height}px`) + } + + for (const direction of ["ltr", "rtl"]) { + await page.evaluate((dir) => (document.documentElement.dir = dir), direction) + + for (const field of await fields.filter({ has: page.getByRole("button") }).all()) { + const button = field.getByRole("button") + + await expect(button).toHaveCSS("width", "20px") + await expect(button).toHaveCSS("height", "20px") + await expect(button).toHaveCSS("padding", "2px") + await expect(button).toHaveCSS("border-radius", "4px") + + expect( + await field.evaluate((element) => { + const bounds = element.getBoundingClientRect() + + const action = element.querySelector("button")!.getBoundingClientRect() + + return { + inset: + getComputedStyle(element).direction === "rtl" ? action.left - bounds.left : bounds.right - action.right, + center: action.y + action.height / 2 - bounds.y - bounds.height / 2, + } + }), + ).toEqual({ inset: 8, center: 0 }) + } + } + }, +) diff --git a/packages/ui/src/forms/text-input/text-input.css b/packages/ui/src/forms/text-input/text-input.css index a6a9c9776f32..93678c27f6fb 100644 --- a/packages/ui/src/forms/text-input/text-input.css +++ b/packages/ui/src/forms/text-input/text-input.css @@ -15,10 +15,9 @@ border: 0; border-radius: 6px; outline: 1px solid transparent; - outline-offset: 0; - background: - linear-gradient(180deg, var(--v2-alpha-light-6) 0%, var(--v2-alpha-light-2) 100%), var(--v2-background-bg-base); - box-shadow: var(--v2-elevation-button-neutral); + outline-offset: -1px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-input); flex: none; align-self: stretch; transition: @@ -27,14 +26,37 @@ box-shadow 85ms ease-out; } + [data-component="text-input-v2"][data-appearance="small"] { + height: 24px; + } + [data-component="text-input-v2"][data-appearance="large"] { height: 32px; } - [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) { - background: - linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)), - linear-gradient(180deg, var(--v2-alpha-light-6) 0%, var(--v2-alpha-light-2) 100%), var(--v2-background-bg-base); + [data-component="text-input-v2"][data-surface="layer-1"] { + background: var(--v2-background-bg-layer-01); + } + + [data-component="text-input-v2"][data-surface="invisible"] { + background: transparent; + box-shadow: none; + } + + @media (hover: hover) { + [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid], [data-surface="invisible"]):not( + :focus-within + ) { + box-shadow: + inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-hover), + var(--v2-elevation-input); + } + + [data-component="text-input-v2"][data-surface="invisible"]:where(:hover):not([data-disabled], [data-invalid]):not( + :focus-within + ) { + background: var(--v2-overlay-simple-overlay-hover); + } } [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { @@ -166,14 +188,6 @@ color: currentColor; } - [data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"][data-variant="clear"] { - width: 28px; - height: 28px; - padding: 0; - border-radius: 6px; - margin-inline-end: -8px; - } - /* Pulls the buttons' padding out so their glyphs sit at the same inset as the leading icon. */ [data-component="text-input-v2"] [data-slot="text-input-v2-trailing"] { display: flex; diff --git a/packages/ui/src/forms/text-input/text-input.stories.tsx b/packages/ui/src/forms/text-input/text-input.stories.tsx index 6c2f8c0fd064..235b994509dd 100644 --- a/packages/ui/src/forms/text-input/text-input.stories.tsx +++ b/packages/ui/src/forms/text-input/text-input.stories.tsx @@ -12,14 +12,15 @@ Compact single-line text field with neutral elevation, optional trailing copy ac - \`copyLabel\`: Accessible name for the copy button (default: "Copy"). - \`onCopyClick\`: Handler for the copy button. - \`invalid\`: Error outline and danger text color. -- \`appearance\`: \`"base"\` (28px) or \`"large"\` (32px). +- \`appearance\`: \`"small"\` (24px), \`"base"\` (28px), or \`"large"\` (32px). +- \`surface\`: \`"base"\` (default) or \`"layer-1"\` for a flat fill; \`"invisible"\` is unfilled at rest, with hover fill and inset focus/error outlines. ### States - **Hover**: neutral overlay on the raised surface. - **Focus** (\`:focus-within\`): focus border, elevation removed. - **Invalid**: danger border and text. - **Disabled**: 50% opacity. -- Uses \`data-component="text-input-v2"\` with \`--v2-background-bg-base\`, \`--v2-elevation-button-neutral\`, \`--v2-text-text-faint\` (placeholder), and \`--v2-icon-icon-muted\` (copy icon). +- Uses \`data-component="text-input-v2"\` with \`--v2-background-bg-base\`, \`--v2-elevation-input\`, \`--v2-text-text-faint\` (placeholder), and \`--v2-icon-icon-muted\` (copy icon). ### Field Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story. @@ -45,11 +46,16 @@ export default { disabled: false, invalid: false, appearance: "base", + surface: "base", }, argTypes: { + surface: { + control: "select", + options: ["base", "layer-1", "invisible"], + }, appearance: { control: "select", - options: ["base", "large"], + options: ["small", "base", "large"], }, showCopyButton: { control: "boolean", @@ -68,6 +74,18 @@ export default { export const Playground = {} +export const Surfaces = { + render: () => ( +
+ + + + + +
+ ), +} + export const WithCopyButton = { args: { placeholder: "api.example.com/v1", @@ -101,9 +119,13 @@ export const Controlled = { export const Appearances = { render: () => (
+ + + +
), } diff --git a/packages/ui/src/forms/text-input/text-input.tsx b/packages/ui/src/forms/text-input/text-input.tsx index fdc98b7d8132..1e493868486a 100644 --- a/packages/ui/src/forms/text-input/text-input.tsx +++ b/packages/ui/src/forms/text-input/text-input.tsx @@ -24,8 +24,10 @@ export interface TextInputProps extends Omit, "type"> { numeric?: boolean /** Error styling for the field and value text. */ invalid?: boolean - /** `base` is 28px tall; `large` is 32px tall. */ - appearance?: "base" | "large" + /** `small` is 24px tall; `base` is 28px tall; `large` is 32px tall. */ + appearance?: "small" | "base" | "large" + /** Background surface; invisible gains a fill on hover. */ + surface?: "base" | "layer-1" | "invisible" type?: ComponentProps<"input">["type"] } @@ -47,6 +49,7 @@ export function TextInput(props: TextInputProps) { "numeric", "invalid", "appearance", + "surface", "disabled", ]) @@ -57,6 +60,7 @@ export function TextInput(props: TextInputProps) { data-invalid={local.invalid ? "" : undefined} data-numeric={local.numeric ? "" : undefined} data-appearance={local.appearance ?? "base"} + data-surface={local.surface ?? "base"} data-leading-icon={local.leadingIcon ? "" : undefined} classList={{ ...local.classList, diff --git a/packages/ui/src/forms/textarea/textarea.css b/packages/ui/src/forms/textarea/textarea.css index 7c350ea4329d..b72dacee6e44 100644 --- a/packages/ui/src/forms/textarea/textarea.css +++ b/packages/ui/src/forms/textarea/textarea.css @@ -9,10 +9,9 @@ border: 0; border-radius: 6px; outline: 1px solid transparent; - outline-offset: 0; - background: - linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base); - box-shadow: var(--v2-elevation-button-neutral); + outline-offset: -1px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-input); flex: none; align-self: stretch; transition: @@ -21,10 +20,16 @@ box-shadow 85ms ease-out; } -[data-component="textarea-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) { - background: - linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)), - linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base); +[data-component="textarea-v2"][data-surface="layer-1"] { + background: var(--v2-background-bg-layer-01); +} + +@media (hover: hover) { + [data-component="textarea-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) { + box-shadow: + inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-hover), + var(--v2-elevation-input); + } } [data-component="textarea-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { diff --git a/packages/ui/src/forms/textarea/textarea.stories.tsx b/packages/ui/src/forms/textarea/textarea.stories.tsx index 8482aa1110b3..a5144f9020af 100644 --- a/packages/ui/src/forms/textarea/textarea.stories.tsx +++ b/packages/ui/src/forms/textarea/textarea.stories.tsx @@ -9,6 +9,7 @@ Multiline text field with the same neutral elevation, states, and tokens as Text ### API - Forwards native \`textarea\` props (\`value\`, \`defaultValue\`, \`placeholder\`, \`disabled\`, \`name\`, \`rows\`, etc.). - \`invalid\`: Error outline and danger text color. +- \`surface\`: \`"base"\` (default) or \`"layer-1"\` for a flat fill. ### States - **Hover**: neutral overlay on the raised surface. @@ -39,8 +40,13 @@ export default { disabled: false, invalid: false, rows: 3, + surface: "base", }, argTypes: { + surface: { + control: "select", + options: ["base", "layer-1"], + }, disabled: { control: "boolean", }, @@ -58,6 +64,18 @@ export default { export const Playground = {} +export const Surfaces = { + parameters: { frameHeight: "700px" }, + render: () => ( +
+