Skip to content

Commit c65bb26

Browse files
authored
feat(ui): add flat input surfaces and compact sizing (#60598)
1 parent da87c47 commit c65bb26

11 files changed

Lines changed: 263 additions & 38 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { expect, story } from "../../stories/playwright/story"
2+
3+
for (const control of ["text-input", "textarea"]) {
4+
story(`${control} supports its surfaces with inset feedback and stable geometry`, async ({ mount, page }) => {
5+
const root = await mount(`ui-${control}--surfaces`)
6+
7+
const fields = root.locator(`[data-component="${control}-v2"]`)
8+
9+
await page.addStyleTag({
10+
content: `[data-component="${control}-v2"] {
11+
--v2-background-bg-base: rgb(20, 30, 40);
12+
--v2-background-bg-layer-01: rgb(50, 60, 70);
13+
--v2-overlay-simple-overlay-hover: rgba(10, 20, 30, 0.1);
14+
--v2-elevation-input: 0px 0px 0px 3px rgb(1, 2, 3);
15+
--v2-elevation-button-neutral: 0px 0px 0px 7px rgb(4, 5, 6);
16+
}`,
17+
})
18+
19+
await expect(fields.nth(0)).toHaveCSS("background-color", "rgb(20, 30, 40)")
20+
await expect(fields.nth(1)).toHaveCSS("background-color", "rgb(50, 60, 70)")
21+
await expect(fields.nth(0)).toHaveCSS("box-shadow", "rgb(1, 2, 3) 0px 0px 0px 3px")
22+
await expect(fields.nth(1)).toHaveCSS("box-shadow", "rgb(1, 2, 3) 0px 0px 0px 3px")
23+
24+
for (const field of await fields.all()) {
25+
await expect(field).toHaveCSS("background-image", "none")
26+
}
27+
28+
const base = fields.nth(0)
29+
30+
const geometry = await base.getByRole("textbox").boundingBox()
31+
32+
await base.hover()
33+
await expect
34+
.poll(() => base.evaluate((element) => getComputedStyle(element).boxShadow))
35+
.toContain("rgb(1, 2, 3) 0px 0px 0px 3px")
36+
await base.getByRole("textbox").fill("Still editable")
37+
await expect(base.getByRole("textbox")).toHaveValue("Still editable")
38+
await expect(base).toHaveCSS("outline-width", "1px")
39+
await expect(base).toHaveCSS("outline-offset", "-1px")
40+
await expect(base).not.toHaveCSS("outline-color", "rgba(0, 0, 0, 0)")
41+
expect(await base.getByRole("textbox").boundingBox()).toEqual(geometry)
42+
43+
const invalid = root.locator("[data-invalid]")
44+
45+
const disabled = root.locator("[data-disabled]")
46+
47+
await expect(invalid).toHaveCSS("outline-width", "1px")
48+
await expect(invalid).toHaveCSS("outline-offset", "-1px")
49+
await expect(invalid).not.toHaveCSS("outline-color", "rgba(0, 0, 0, 0)")
50+
await expect(invalid.getByRole("textbox")).toHaveAttribute("aria-invalid", "true")
51+
await expect(disabled.getByRole("textbox")).toBeDisabled()
52+
53+
if (control === "textarea") return
54+
55+
const invisible = root.locator('[data-surface="invisible"]').first()
56+
57+
await base.getByRole("textbox").blur()
58+
await expect(invisible).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
59+
await invisible.hover()
60+
await expect(invisible).toHaveCSS("background-color", "rgba(10, 20, 30, 0.1)")
61+
await expect(invisible).toHaveCSS("box-shadow", "none")
62+
await invisible.getByRole("textbox").focus()
63+
await expect(invisible).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
64+
await expect(invisible).toHaveCSS("outline-offset", "-1px")
65+
await expect(invisible).not.toHaveCSS("outline-color", "rgba(0, 0, 0, 0)")
66+
await expect(invalid).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
67+
await expect(disabled).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
68+
await expect(disabled).toHaveCSS("box-shadow", "none")
69+
})
70+
}
71+
72+
story(
73+
"text inputs keep clear and copy actions equally sized and inset across sizes and directions",
74+
async ({ mount, page }) => {
75+
const root = await mount("ui-text-input--appearances")
76+
77+
const fields = root.locator('[data-component="text-input-v2"]')
78+
79+
for (const [index, height] of [24, 28, 32, 32, 24, 28, 32].entries()) {
80+
await expect(fields.nth(index)).toHaveCSS("height", `${height}px`)
81+
}
82+
83+
for (const direction of ["ltr", "rtl"]) {
84+
await page.evaluate((dir) => (document.documentElement.dir = dir), direction)
85+
86+
for (const field of await fields.filter({ has: page.getByRole("button") }).all()) {
87+
const button = field.getByRole("button")
88+
89+
await expect(button).toHaveCSS("width", "20px")
90+
await expect(button).toHaveCSS("height", "20px")
91+
await expect(button).toHaveCSS("padding", "2px")
92+
await expect(button).toHaveCSS("border-radius", "4px")
93+
94+
expect(
95+
await field.evaluate((element) => {
96+
const bounds = element.getBoundingClientRect()
97+
98+
const action = element.querySelector("button")!.getBoundingClientRect()
99+
100+
return {
101+
inset:
102+
getComputedStyle(element).direction === "rtl" ? action.left - bounds.left : bounds.right - action.right,
103+
center: action.y + action.height / 2 - bounds.y - bounds.height / 2,
104+
}
105+
}),
106+
).toEqual({ inset: 8, center: 0 })
107+
}
108+
}
109+
},
110+
)

‎packages/ui/src/forms/text-input/text-input.css‎

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@
1515
border: 0;
1616
border-radius: 6px;
1717
outline: 1px solid transparent;
18-
outline-offset: 0;
19-
background:
20-
linear-gradient(180deg, var(--v2-alpha-light-6) 0%, var(--v2-alpha-light-2) 100%), var(--v2-background-bg-base);
21-
box-shadow: var(--v2-elevation-button-neutral);
18+
outline-offset: -1px;
19+
background: var(--v2-background-bg-base);
20+
box-shadow: var(--v2-elevation-input);
2221
flex: none;
2322
align-self: stretch;
2423
transition:
@@ -27,14 +26,37 @@
2726
box-shadow 85ms ease-out;
2827
}
2928

29+
[data-component="text-input-v2"][data-appearance="small"] {
30+
height: 24px;
31+
}
32+
3033
[data-component="text-input-v2"][data-appearance="large"] {
3134
height: 32px;
3235
}
3336

34-
[data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) {
35-
background:
36-
linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
37-
linear-gradient(180deg, var(--v2-alpha-light-6) 0%, var(--v2-alpha-light-2) 100%), var(--v2-background-bg-base);
37+
[data-component="text-input-v2"][data-surface="layer-1"] {
38+
background: var(--v2-background-bg-layer-01);
39+
}
40+
41+
[data-component="text-input-v2"][data-surface="invisible"] {
42+
background: transparent;
43+
box-shadow: none;
44+
}
45+
46+
@media (hover: hover) {
47+
[data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid], [data-surface="invisible"]):not(
48+
:focus-within
49+
) {
50+
box-shadow:
51+
inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-hover),
52+
var(--v2-elevation-input);
53+
}
54+
55+
[data-component="text-input-v2"][data-surface="invisible"]:where(:hover):not([data-disabled], [data-invalid]):not(
56+
:focus-within
57+
) {
58+
background: var(--v2-overlay-simple-overlay-hover);
59+
}
3860
}
3961

4062
[data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
@@ -166,14 +188,6 @@
166188
color: currentColor;
167189
}
168190

169-
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
170-
width: 28px;
171-
height: 28px;
172-
padding: 0;
173-
border-radius: 6px;
174-
margin-inline-end: -8px;
175-
}
176-
177191
/* Pulls the buttons' padding out so their glyphs sit at the same inset as the leading icon. */
178192
[data-component="text-input-v2"] [data-slot="text-input-v2-trailing"] {
179193
display: flex;

‎packages/ui/src/forms/text-input/text-input.stories.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ Compact single-line text field with neutral elevation, optional trailing copy ac
1212
- \`copyLabel\`: Accessible name for the copy button (default: "Copy").
1313
- \`onCopyClick\`: Handler for the copy button.
1414
- \`invalid\`: Error outline and danger text color.
15-
- \`appearance\`: \`"base"\` (28px) or \`"large"\` (32px).
15+
- \`appearance\`: \`"small"\` (24px), \`"base"\` (28px), or \`"large"\` (32px).
16+
- \`surface\`: \`"base"\` (default) or \`"layer-1"\` for a flat fill; \`"invisible"\` is unfilled at rest, with hover fill and inset focus/error outlines.
1617
1718
### States
1819
- **Hover**: neutral overlay on the raised surface.
1920
- **Focus** (\`:focus-within\`): focus border, elevation removed.
2021
- **Invalid**: danger border and text.
2122
- **Disabled**: 50% opacity.
22-
- 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).
23+
- 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).
2324
2425
### Field
2526
Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story.
@@ -45,11 +46,16 @@ export default {
4546
disabled: false,
4647
invalid: false,
4748
appearance: "base",
49+
surface: "base",
4850
},
4951
argTypes: {
52+
surface: {
53+
control: "select",
54+
options: ["base", "layer-1", "invisible"],
55+
},
5056
appearance: {
5157
control: "select",
52-
options: ["base", "large"],
58+
options: ["small", "base", "large"],
5359
},
5460
showCopyButton: {
5561
control: "boolean",
@@ -68,6 +74,18 @@ export default {
6874

6975
export const Playground = {}
7076

77+
export const Surfaces = {
78+
render: () => (
79+
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
80+
<TextInput placeholder="Base (default)" />
81+
<TextInput surface="layer-1" placeholder="Layer 1" />
82+
<TextInput surface="invisible" placeholder="Invisible" />
83+
<TextInput surface="invisible" placeholder="Invisible, invalid" invalid />
84+
<TextInput surface="invisible" placeholder="Invisible, disabled" disabled />
85+
</div>
86+
),
87+
}
88+
7189
export const WithCopyButton = {
7290
args: {
7391
placeholder: "api.example.com/v1",
@@ -101,9 +119,13 @@ export const Controlled = {
101119
export const Appearances = {
102120
render: () => (
103121
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
122+
<TextInput appearance="small" placeholder="Small (24px)" />
104123
<TextInput appearance="base" placeholder="Base (28px)" defaultValue="Base" />
105124
<TextInput appearance="large" placeholder="Large (32px)" defaultValue="Large" />
106125
<TextInput appearance="large" placeholder="Large with copy" defaultValue="copy-me" showCopyButton />
126+
<TextInput appearance="small" placeholder="Small with clear" showClearButton />
127+
<TextInput appearance="base" placeholder="Base with clear" showClearButton />
128+
<TextInput appearance="large" placeholder="Large with clear" showClearButton />
107129
</div>
108130
),
109131
}

‎packages/ui/src/forms/text-input/text-input.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ export interface TextInputProps extends Omit<ComponentProps<"input">, "type"> {
2424
numeric?: boolean
2525
/** Error styling for the field and value text. */
2626
invalid?: boolean
27-
/** `base` is 28px tall; `large` is 32px tall. */
28-
appearance?: "base" | "large"
27+
/** `small` is 24px tall; `base` is 28px tall; `large` is 32px tall. */
28+
appearance?: "small" | "base" | "large"
29+
/** Background surface; invisible gains a fill on hover. */
30+
surface?: "base" | "layer-1" | "invisible"
2931
type?: ComponentProps<"input">["type"]
3032
}
3133

@@ -47,6 +49,7 @@ export function TextInput(props: TextInputProps) {
4749
"numeric",
4850
"invalid",
4951
"appearance",
52+
"surface",
5053
"disabled",
5154
])
5255

@@ -57,6 +60,7 @@ export function TextInput(props: TextInputProps) {
5760
data-invalid={local.invalid ? "" : undefined}
5861
data-numeric={local.numeric ? "" : undefined}
5962
data-appearance={local.appearance ?? "base"}
63+
data-surface={local.surface ?? "base"}
6064
data-leading-icon={local.leadingIcon ? "" : undefined}
6165
classList={{
6266
...local.classList,

‎packages/ui/src/forms/textarea/textarea.css‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@
99
border: 0;
1010
border-radius: 6px;
1111
outline: 1px solid transparent;
12-
outline-offset: 0;
13-
background:
14-
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
15-
box-shadow: var(--v2-elevation-button-neutral);
12+
outline-offset: -1px;
13+
background: var(--v2-background-bg-base);
14+
box-shadow: var(--v2-elevation-input);
1615
flex: none;
1716
align-self: stretch;
1817
transition:
@@ -21,10 +20,16 @@
2120
box-shadow 85ms ease-out;
2221
}
2322

24-
[data-component="textarea-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) {
25-
background:
26-
linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
27-
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
23+
[data-component="textarea-v2"][data-surface="layer-1"] {
24+
background: var(--v2-background-bg-layer-01);
25+
}
26+
27+
@media (hover: hover) {
28+
[data-component="textarea-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) {
29+
box-shadow:
30+
inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-hover),
31+
var(--v2-elevation-input);
32+
}
2833
}
2934

3035
[data-component="textarea-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {

‎packages/ui/src/forms/textarea/textarea.stories.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Multiline text field with the same neutral elevation, states, and tokens as Text
99
### API
1010
- Forwards native \`textarea\` props (\`value\`, \`defaultValue\`, \`placeholder\`, \`disabled\`, \`name\`, \`rows\`, etc.).
1111
- \`invalid\`: Error outline and danger text color.
12+
- \`surface\`: \`"base"\` (default) or \`"layer-1"\` for a flat fill.
1213
1314
### States
1415
- **Hover**: neutral overlay on the raised surface.
@@ -39,8 +40,13 @@ export default {
3940
disabled: false,
4041
invalid: false,
4142
rows: 3,
43+
surface: "base",
4244
},
4345
argTypes: {
46+
surface: {
47+
control: "select",
48+
options: ["base", "layer-1"],
49+
},
4450
disabled: {
4551
control: "boolean",
4652
},
@@ -58,6 +64,18 @@ export default {
5864

5965
export const Playground = {}
6066

67+
export const Surfaces = {
68+
parameters: { frameHeight: "700px" },
69+
render: () => (
70+
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
71+
<Textarea placeholder="Base (default)" />
72+
<Textarea surface="layer-1" placeholder="Layer 1" />
73+
<Textarea surface="layer-1" placeholder="Layer 1, invalid" invalid />
74+
<Textarea surface="layer-1" placeholder="Layer 1, disabled" disabled />
75+
</div>
76+
),
77+
}
78+
6179
export const Controlled = {
6280
render: () => {
6381
const [value, setValue] = createSignal("Controlled value")

‎packages/ui/src/forms/textarea/textarea.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import "./textarea.css"
44
export interface TextareaProps extends ComponentProps<"textarea"> {
55
/** Error styling for the field and value text. */
66
invalid?: boolean
7+
surface?: "base" | "layer-1"
78
}
89

910
export function Textarea(props: TextareaProps) {
10-
const [local, textareaProps] = splitProps(props, ["class", "classList", "invalid", "disabled", "rows"])
11+
const [local, textareaProps] = splitProps(props, ["class", "classList", "invalid", "disabled", "rows", "surface"])
1112

1213
return (
1314
<div
1415
data-component="textarea-v2"
16+
data-surface={local.surface ?? "base"}
1517
data-disabled={local.disabled ? "" : undefined}
1618
data-invalid={local.invalid ? "" : undefined}
1719
classList={{

0 commit comments

Comments
 (0)