Skip to content

Commit 93d04bd

Browse files
fix(input): render error and description without requiring label (#513)
1 parent bccc684 commit 93d04bd

8 files changed

Lines changed: 87 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/kumo": patch
3+
---
4+
5+
fix(input): render error and description props without requiring a label

packages/kumo-docs-astro/src/components/demos/InputDemo.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ export function InputBareDemo() {
6666
return <Input placeholder="Search..." aria-label="Search products" />;
6767
}
6868

69+
/** Input without a visible label, showing error and description via `aria-label`. */
70+
export function InputErrorWithoutLabelDemo() {
71+
return (
72+
<div className="flex flex-col gap-4">
73+
<Input
74+
aria-label="Hostname"
75+
placeholder="example.com"
76+
value="not a host"
77+
error="Please enter a valid hostname"
78+
/>
79+
<Input
80+
aria-label="Path"
81+
placeholder="/api/v1/users"
82+
value="missing-slash"
83+
error={{ message: "Path must start with /", match: true }}
84+
/>
85+
</div>
86+
);
87+
}
88+
6989
export function InputTypesDemo() {
7090
return (
7191
<div className="flex flex-col gap-4">

packages/kumo-docs-astro/src/pages/components/input.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
InputSizesDemo,
1919
InputDisabledDemo,
2020
InputBareDemo,
21+
InputErrorWithoutLabelDemo,
2122
InputTypesDemo,
2223
InputOptionalFieldDemo,
2324
InputLabelTooltipDemo,
@@ -196,6 +197,16 @@ export default function Example() {
196197
<InputBareDemo client:visible />
197198
</ComponentExample>
198199

200+
### Error Without Label
201+
202+
<p>
203+
Error messages and descriptions render even without a visible `label` — use
204+
`aria-label` to keep the input accessible.
205+
</p>
206+
<ComponentExample demo="InputErrorWithoutLabelDemo">
207+
<InputErrorWithoutLabelDemo client:visible />
208+
</ComponentExample>
209+
199210
### Input Types
200211

201212
<p>

packages/kumo/src/components/field/field.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ import type { ReactNode } from "react";
33
import { cn } from "../../utils/cn";
44
import { Label } from "../label";
55

6+
/**
7+
* Normalizes an error prop that may be a string or structured object
8+
* into the `{ message, match }` shape expected by `<Field>`.
9+
*
10+
* Returns `undefined` when the input is falsy.
11+
*/
12+
export function normalizeFieldError(
13+
error: string | { message: ReactNode; match: FieldErrorMatch } | undefined,
14+
): { message: ReactNode; match: FieldErrorMatch } | undefined {
15+
if (!error) return undefined;
16+
if (typeof error === "string") return { message: error, match: true };
17+
return error;
18+
}
19+
620
/** Field variant definitions (currently empty, reserved for future additions). */
721
export const KUMO_FIELD_VARIANTS = {
822
// Field currently has no variant options but structure is ready for future additions

packages/kumo/src/components/field/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
Field,
3+
normalizeFieldError,
34
type FieldProps,
45
type FieldErrorMatch,
56
fieldVariants,

packages/kumo/src/components/input/input-area.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { cn } from "../../utils/cn";
33
import { useCallback, type ReactNode } from "react";
44
import * as React from "react";
55
import { Field as FieldBase } from "@base-ui/react/field";
6-
import { Field as KumoField, type FieldErrorMatch } from "../field/field";
6+
import { Field as KumoField, normalizeFieldError, type FieldErrorMatch } from "../field/field";
77

88
export const InputArea = React.forwardRef<HTMLTextAreaElement, InputAreaProps>(
99
(props, ref) => {
@@ -49,23 +49,17 @@ export const InputArea = React.forwardRef<HTMLTextAreaElement, InputAreaProps>(
4949
className,
5050
);
5151

52-
// Render with Field wrapper if label is provided
52+
// Render with Field wrapper if label, error, or description is provided
5353
// Use FieldBase.Control with render callback to ensure proper label-textarea association.
5454
// The render callback receives props with the correct id/aria-labelledby from Field context.
55-
if (label) {
55+
if (label || error || description) {
5656
return (
5757
<KumoField
5858
label={label}
5959
required={required}
6060
labelTooltip={labelTooltip}
6161
description={description}
62-
error={
63-
error
64-
? typeof error === "string"
65-
? { message: error, match: true }
66-
: error
67-
: undefined
68-
}
62+
error={normalizeFieldError(error)}
6963
>
7064
<FieldBase.Control
7165
render={(controlProps) => (

packages/kumo/src/components/input/input.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,34 @@ describe("Input", () => {
116116
expect(screen.getByText("Required field")).toBeTruthy();
117117
});
118118

119+
// Error without label
120+
it("renders error message without label when error is a string", () => {
121+
render(<Input aria-label="Email" error="Invalid email" />);
122+
expect(screen.getByText("Invalid email")).toBeTruthy();
123+
});
124+
125+
it("renders error message without label when error is an object", () => {
126+
render(
127+
<Input
128+
aria-label="Email"
129+
error={{ message: "Required field", match: true }}
130+
/>,
131+
);
132+
expect(screen.getByText("Required field")).toBeTruthy();
133+
});
134+
135+
it("applies error variant styling without label", () => {
136+
render(<Input aria-label="Email" error="Bad value" />);
137+
expect(screen.getByRole("textbox").className).toContain("ring-kumo-danger");
138+
});
139+
140+
it("renders description without label", () => {
141+
render(
142+
<Input aria-label="Email" description="Enter your work email" />,
143+
);
144+
expect(screen.getByText("Enter your work email")).toBeTruthy();
145+
});
146+
119147
// Accessibility
120148
it("warns in dev when no accessible name is provided", () => {
121149
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

packages/kumo/src/components/input/input.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type ReactNode,
77
} from "react";
88
import { Input as BaseInput } from "@base-ui/react/input";
9-
import { Field, type FieldErrorMatch } from "../field/field";
9+
import { Field, normalizeFieldError, type FieldErrorMatch } from "../field/field";
1010

1111
/** Input size and variant definitions mapping names to their Tailwind classes. */
1212
export const KUMO_INPUT_VARIANTS = {
@@ -181,21 +181,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
181181
/>
182182
);
183183

184-
// Render with Field wrapper if label is provided
185-
if (label) {
184+
// Render with Field wrapper if label, error, or description is provided
185+
if (label || error || description) {
186186
return (
187187
<Field
188188
label={label}
189189
required={required}
190190
labelTooltip={labelTooltip}
191191
description={description}
192-
error={
193-
error
194-
? typeof error === "string"
195-
? { message: error, match: true }
196-
: error
197-
: undefined
198-
}
192+
error={normalizeFieldError(error)}
199193
>
200194
{input}
201195
</Field>

0 commit comments

Comments
 (0)