Skip to content

Commit 488daae

Browse files
committed
field controls: drive danger from data-invalid, drop the tone prop
Removed the tone axis from field-control-surface and every bordered control (input, text-area, select, combobox, autocomplete, number-field, otp). Danger now derives from Base UI's validity: the surface recolors off data-invalid (when it IS the focusable control: select trigger, otp digit) or :has([data-invalid]) (when it WRAPS one: input/textarea/etc.). Danger wins on focus (danger ring instead of accent). The *Field components derive invalid from error only; otp dropped its hand-rolled danger ring (the surface now supplies it). Renamed the danger stories Error->Invalid (a story named Error shadowed the global Error, forcing globalThis.Error). Verified via play tests that an invalid field renders the danger border.
1 parent 286adf5 commit 488daae

32 files changed

Lines changed: 348 additions & 253 deletions

packages/propel/src/components/autocomplete-field/autocomplete-field.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,15 @@ export const RendersInput: Story = {
3232
await expect(canvas.getByRole("combobox", { name: "Container image" })).toBeInTheDocument();
3333
},
3434
};
35+
36+
/** Setting `error` marks the field invalid, which recolors the input group border to danger. */
37+
export const Invalid: Story = {
38+
args: { error: "Enter a container image." },
39+
play: async ({ canvas }) => {
40+
const input = canvas.getByRole("combobox", { name: "Container image" });
41+
await expect(input).toHaveAttribute("aria-invalid", "true");
42+
await expect(input).toHaveAttribute("data-invalid");
43+
const group = input.closest<HTMLElement>(":has([data-invalid])");
44+
await expect(group).toHaveClass("has-[[data-invalid]]:border-danger-strong");
45+
},
46+
};

packages/propel/src/components/checkbox/checkbox.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const Indeterminate: Story = {
116116
* checked, the fill is the same accent blue as every other state. A resting checkbox is shown
117117
* alongside for contrast.
118118
*/
119-
export const Error: Story = {
119+
export const Invalid: Story = {
120120
parameters: { controls: { disable: true } },
121121
render: () => (
122122
<div className="flex items-center gap-4">

packages/propel/src/components/combobox-field/combobox-field.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,15 @@ export const RendersInput: Story = {
3232
await expect(canvas.getByRole("combobox", { name: "Region" })).toBeInTheDocument();
3333
},
3434
};
35+
36+
/** Setting `error` marks the field invalid, which recolors the input group border to danger. */
37+
export const Invalid: Story = {
38+
args: { error: "Choose a deployment region." },
39+
play: async ({ canvas }) => {
40+
const input = canvas.getByRole("combobox", { name: "Region" });
41+
await expect(input).toHaveAttribute("aria-invalid", "true");
42+
await expect(input).toHaveAttribute("data-invalid");
43+
const group = input.closest<HTMLElement>(":has([data-invalid])");
44+
await expect(group).toHaveClass("has-[[data-invalid]]:border-danger-strong");
45+
},
46+
};

packages/propel/src/components/fieldset/fieldset.stories.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,13 @@ export const Default: Story = {
3535
<>
3636
<InputField
3737
magnitude="md"
38-
tone="neutral"
3938
orientation="vertical"
4039
name="company"
4140
label="Company"
4241
placeholder="Acme Inc."
4342
/>
4443
<InputField
4544
magnitude="md"
46-
tone="neutral"
4745
orientation="vertical"
4846
name="taxId"
4947
label="Tax ID"
@@ -67,15 +65,13 @@ export const Bordered: Story = {
6765
<>
6866
<InputField
6967
magnitude="md"
70-
tone="neutral"
7168
orientation="vertical"
7269
name="company"
7370
label="Company"
7471
placeholder="Acme Inc."
7572
/>
7673
<InputField
7774
magnitude="md"
78-
tone="neutral"
7975
orientation="vertical"
8076
name="taxId"
8177
label="Tax ID"
@@ -91,9 +87,7 @@ export const GroupSemantics: Story = {
9187
tags: ["!dev", "!autodocs", "!manifest"],
9288
args: {
9389
legend: "Shipping address",
94-
children: (
95-
<InputField magnitude="md" tone="neutral" orientation="vertical" name="city" label="City" />
96-
),
90+
children: <InputField magnitude="md" orientation="vertical" name="city" label="City" />,
9791
},
9892
play: async ({ canvas }) => {
9993
await expect(canvas.getByRole("group", { name: "Shipping address" })).toBeInTheDocument();

packages/propel/src/components/form/form.stories.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ function ExampleForm({ onFormSubmit }: ExampleFormProps) {
6060
<FormBody layout="single">
6161
<InputField
6262
magnitude="md"
63-
tone={homepageError ? "danger" : "neutral"}
6463
orientation="vertical"
6564
name="homepage"
6665
label="Homepage"

packages/propel/src/components/input-field/input-field.stories.tsx

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ type Story = StoryObj<typeof meta>;
3030
export const Default: Story = {
3131
args: {
3232
magnitude: "md",
33-
tone: "neutral",
3433
orientation: "vertical",
3534
label: "Email",
3635
placeholder: "you@example.com",
@@ -43,7 +42,6 @@ export const Default: Story = {
4342
export const Horizontal: Story = {
4443
args: {
4544
magnitude: "md",
46-
tone: "neutral",
4745
orientation: "horizontal",
4846
label: "Email",
4947
placeholder: "you@example.com",
@@ -57,29 +55,21 @@ export const Horizontal: Story = {
5755
*/
5856
export const HorizontalShowcase: Story = {
5957
// Required axes for the args table; the custom `render` ignores them.
60-
args: { magnitude: "md", tone: "neutral", orientation: "horizontal" },
58+
args: { magnitude: "md", orientation: "horizontal" },
6159
parameters: { controls: { disable: true } },
6260
render: () => (
6361
<div className="flex w-110 flex-col gap-4">
64-
<InputField
65-
orientation="horizontal"
66-
magnitude="md"
67-
tone="neutral"
68-
label="Email"
69-
placeholder="md"
70-
/>
62+
<InputField orientation="horizontal" magnitude="md" label="Email" placeholder="md" />
7163
<InputField
7264
orientation="horizontal"
7365
magnitude="lg"
74-
tone="neutral"
7566
label="Email"
7667
placeholder="lg"
7768
hint="Use your work email."
7869
/>
7970
<InputField
8071
orientation="horizontal"
8172
magnitude="xl"
82-
tone="neutral"
8373
label="Email"
8474
placeholder="xl"
8575
description="We never share your email with anyone."
@@ -88,7 +78,6 @@ export const HorizontalShowcase: Story = {
8878
orientation="horizontal"
8979
magnitude="md"
9080
label="Email"
91-
tone="danger"
9281
error="Enter a valid email address"
9382
defaultValue="not-an-email"
9483
/>
@@ -100,7 +89,6 @@ export const HorizontalShowcase: Story = {
10089
export const WithIcons: Story = {
10190
args: {
10291
magnitude: "md",
103-
tone: "neutral",
10492
orientation: "vertical",
10593
label: "Search",
10694
placeholder: "Search…",
@@ -111,47 +99,37 @@ export const WithIcons: Story = {
11199

112100
/**
113101
* The element-driven states side by side. Hover/focus/filled aren't props — they come from
114-
* interacting with the control; `disabled` and the error treatment (`tone="danger"`) are shown
102+
* interacting with the control; `disabled` and the error treatment (set via `error`) are shown
115103
* statically.
116104
*/
117105
export const States: Story = {
118106
// Required axes for the args table; the custom `render` ignores them.
119-
args: { magnitude: "md", tone: "neutral", orientation: "vertical" },
107+
args: { magnitude: "md", orientation: "vertical" },
120108
parameters: { controls: { disable: true } },
121109
render: () => (
122110
<div className="flex w-72 flex-col gap-4">
111+
<InputField magnitude="md" orientation="vertical" label="Default" placeholder="Placeholder" />
123112
<InputField
124113
magnitude="md"
125-
tone="neutral"
126-
orientation="vertical"
127-
label="Default"
128-
placeholder="Placeholder"
129-
/>
130-
<InputField
131-
magnitude="md"
132-
tone="neutral"
133114
orientation="vertical"
134115
label="Focus / hover (interact)"
135116
placeholder="Click me"
136117
/>
137118
<InputField
138119
magnitude="md"
139-
tone="neutral"
140120
orientation="vertical"
141121
label="Filled"
142122
defaultValue="Ada Lovelace"
143123
/>
144124
<InputField
145125
magnitude="md"
146-
tone="neutral"
147126
orientation="vertical"
148127
label="Disabled"
149128
placeholder="Placeholder"
150129
disabled
151130
/>
152131
<InputField
153132
magnitude="md"
154-
tone="danger"
155133
orientation="vertical"
156134
label="Error"
157135
defaultValue="not-an-email"
@@ -163,7 +141,7 @@ export const States: Story = {
163141

164142
/** Every magnitude (`md` / `lg` / `xl`) stacked. */
165143
export const Magnitudes: Story = {
166-
args: { magnitude: "md", tone: "neutral", orientation: "vertical", placeholder: "Placeholder" },
144+
args: { magnitude: "md", orientation: "vertical", placeholder: "Placeholder" },
167145
argTypes: { magnitude: { control: false }, label: { control: false } },
168146
render: (args) => (
169147
<div className="flex w-72 flex-col gap-4">
@@ -175,12 +153,11 @@ export const Magnitudes: Story = {
175153
};
176154

177155
/** The error treatment: danger border, danger helper text, and `aria-invalid`. */
178-
export const Error: Story = {
156+
export const Invalid: Story = {
179157
args: {
180158
magnitude: "md",
181159
orientation: "vertical",
182160
label: "Email",
183-
tone: "danger",
184161
defaultValue: "not-an-email",
185162
error: "Enter a valid email address.",
186163
required: true,
@@ -196,13 +173,12 @@ export const Error: Story = {
196173
export const RtlVerify: Story = {
197174
name: "RTL Verify",
198175
tags: ["!autodocs", "!manifest"],
199-
args: { magnitude: "md", tone: "neutral", orientation: "vertical" },
176+
args: { magnitude: "md", orientation: "vertical" },
200177
parameters: { controls: { disable: true } },
201178
render: () => (
202179
<div dir="rtl" className="flex w-80 flex-col gap-6">
203180
<InputField
204181
magnitude="md"
205-
tone="neutral"
206182
orientation="vertical"
207183
label="البريد الإلكتروني"
208184
placeholder="you@example.com"
@@ -211,14 +187,12 @@ export const RtlVerify: Story = {
211187
/>
212188
<InputField
213189
magnitude="md"
214-
tone="neutral"
215190
orientation="horizontal"
216191
label="البريد"
217192
placeholder="you@example.com"
218193
/>
219194
<InputField
220195
magnitude="md"
221-
tone="neutral"
222196
orientation="vertical"
223197
label="بحث"
224198
placeholder="Search…"
@@ -228,7 +202,6 @@ export const RtlVerify: Story = {
228202
<InputField
229203
magnitude="md"
230204
orientation="vertical"
231-
tone="danger"
232205
label="البريد الإلكتروني"
233206
required
234207
defaultValue="not-an-email"
@@ -242,13 +215,12 @@ export const RtlVerify: Story = {
242215
export const LtrVerify: Story = {
243216
name: "LTR Verify",
244217
tags: ["!autodocs", "!manifest"],
245-
args: { magnitude: "md", tone: "neutral", orientation: "vertical" },
218+
args: { magnitude: "md", orientation: "vertical" },
246219
parameters: { controls: { disable: true } },
247220
render: () => (
248221
<div dir="ltr" className="flex w-80 flex-col gap-6">
249222
<InputField
250223
magnitude="md"
251-
tone="neutral"
252224
orientation="vertical"
253225
label="Email"
254226
placeholder="you@example.com"
@@ -257,14 +229,12 @@ export const LtrVerify: Story = {
257229
/>
258230
<InputField
259231
magnitude="md"
260-
tone="neutral"
261232
orientation="horizontal"
262233
label="Email"
263234
placeholder="you@example.com"
264235
/>
265236
<InputField
266237
magnitude="md"
267-
tone="neutral"
268238
orientation="vertical"
269239
label="Search"
270240
placeholder="Search…"
@@ -274,7 +244,6 @@ export const LtrVerify: Story = {
274244
<InputField
275245
magnitude="md"
276246
orientation="vertical"
277-
tone="danger"
278247
label="Email"
279248
required
280249
defaultValue="not-an-email"
@@ -292,7 +261,6 @@ export const TypingUpdatesValue: Story = {
292261
tags: ["!dev", "!autodocs", "!manifest"],
293262
args: {
294263
magnitude: "md",
295-
tone: "neutral",
296264
orientation: "vertical",
297265
label: "Name",
298266
placeholder: "Your name",
@@ -311,7 +279,6 @@ export const DisabledBlocksInput: Story = {
311279
tags: ["!dev", "!autodocs", "!manifest"],
312280
args: {
313281
magnitude: "md",
314-
tone: "neutral",
315282
orientation: "vertical",
316283
label: "Name",
317284
placeholder: "Your name",
@@ -325,14 +292,13 @@ export const DisabledBlocksInput: Story = {
325292
},
326293
};
327294

328-
/** `tone="danger"` sets `aria-invalid` and renders the announced error text. */
295+
/** Setting `error` marks the field invalid (`aria-invalid`) and renders the announced error text. */
329296
export const ErrorAnnouncesInvalid: Story = {
330297
tags: ["!dev", "!autodocs", "!manifest"],
331298
args: {
332299
magnitude: "md",
333300
orientation: "vertical",
334301
label: "Email",
335-
tone: "danger",
336302
defaultValue: "x",
337303
error: "Enter a valid email address.",
338304
},
@@ -348,7 +314,6 @@ export const NativeAriaLabel: Story = {
348314
tags: ["!dev", "!autodocs", "!manifest"],
349315
args: {
350316
magnitude: "md",
351-
tone: "neutral",
352317
orientation: "vertical",
353318
"aria-label": "Search projects",
354319
placeholder: "Search",
@@ -357,3 +322,44 @@ export const NativeAriaLabel: Story = {
357322
await expect(canvas.getByRole("textbox", { name: "Search projects" })).toBeInTheDocument();
358323
},
359324
};
325+
326+
/**
327+
* Setting `error` marks the field invalid; Base UI's `Field.Root` propagates that validity to the
328+
* control as `data-invalid`, and the wrapping `InputBox` recolors its border to `danger` via
329+
* `:has([data-invalid])` — no `tone` prop. A resting field is shown alongside so the danger border
330+
* is assertably different.
331+
*/
332+
export const InvalidShowsDangerBorder: Story = {
333+
tags: ["!dev", "!autodocs", "!manifest"],
334+
parameters: { controls: { disable: true } },
335+
args: { magnitude: "md", orientation: "vertical" },
336+
render: () => (
337+
<div className="flex w-72 flex-col gap-4">
338+
<InputField magnitude="md" orientation="vertical" label="Resting" placeholder="Resting" />
339+
<InputField
340+
magnitude="md"
341+
orientation="vertical"
342+
label="Invalid"
343+
defaultValue="not-an-email"
344+
error="Enter a valid email address."
345+
/>
346+
</div>
347+
),
348+
play: async ({ canvas }) => {
349+
const resting = canvas.getByRole<HTMLInputElement>("textbox", { name: "Resting" });
350+
const invalid = canvas.getByRole<HTMLInputElement>("textbox", { name: "Invalid" });
351+
await expect(invalid).toHaveAttribute("aria-invalid", "true");
352+
// The box is the input's wrapping `div`; danger keys off `:has([data-invalid])` on it.
353+
const restingBox = resting.parentElement;
354+
const invalidBox = invalid.parentElement;
355+
// `Error` is shadowed by this file's `Error` story, so reach for the global constructor.
356+
if (restingBox == null || invalidBox == null) {
357+
throw new Error("expected an InputBox wrapper");
358+
}
359+
await expect(invalidBox).toHaveClass("has-[[data-invalid]]:border-danger-strong");
360+
// ...and the danger border actually renders: its color differs from the resting box's border.
361+
await expect(getComputedStyle(invalidBox).borderColor).not.toBe(
362+
getComputedStyle(restingBox).borderColor,
363+
);
364+
},
365+
};

0 commit comments

Comments
 (0)