Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-input-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

Fix the accessible-name warning for InputGroup inputs named by the parent label.
2 changes: 2 additions & 0 deletions packages/kumo/src/components/input-group/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export interface InputGroupContextValue {
focusMode: "container" | "individual" | "hybrid";
disabled: boolean;
error?: FieldProps["error"];
/** ID of visible label content supplied by InputGroup. */
labelId?: string;
/** Auto-generated id for the input element; used by the invisible label overlay. */
inputId: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const Input = forwardRef<HTMLInputElement, InputGroupInputProps>(
// Use explicit id if provided, otherwise fall back to context id
// (links the input to the invisible label overlay for click-to-focus).
const inputId = props.id ?? context?.inputId;
const ariaLabelledBy =
props["aria-labelledby"] ??
(props["aria-label"] ? undefined : context?.labelId);

return (
<InputExternal
Expand All @@ -59,6 +62,7 @@ export const Input = forwardRef<HTMLInputElement, InputGroupInputProps>(
disabled={context?.disabled || (props as any).disabled}
aria-invalid={hasError || props["aria-invalid"]}
{...props}
aria-labelledby={ariaLabelledBy}
id={inputId}
className={cn(
// Base input layout: fill height, allow shrinking, strip native border/radius
Expand Down
36 changes: 36 additions & 0 deletions packages/kumo/src/components/input-group/input-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,42 @@ describe("InputGroup", () => {
});

describe("accessibility", () => {
it("does not warn when the parent InputGroup provides the input label", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { container } = render(
<InputGroup label="Search">
<InputGroup.Input placeholder="Search..." />
</InputGroup>,
);

const input = screen.getByRole("textbox", { name: "Search" });
const labelledBy = input.getAttribute("aria-labelledby");

expect(input.getAttribute("aria-label")).toBeNull();
expect(labelledBy).toBeTruthy();
expect(document.getElementById(labelledBy!)?.textContent).toBe("Search");
expect(container.querySelectorAll("label label")).toHaveLength(0);
expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining("[Kumo Input]"),
);
warnSpy.mockRestore();
});

it("warns when InputGroup.Input has no accessible name", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

render(
<InputGroup>
<InputGroup.Input />
</InputGroup>,
);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[Kumo Input]"),
);
warnSpy.mockRestore();
});

it("input has accessible name via aria-label", () => {
render(
<InputGroup>
Expand Down
9 changes: 6 additions & 3 deletions packages/kumo/src/components/input-group/input-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const Root = forwardRef<
forwardedRef,
) => {
const inputId = useId();
const labelId = useId();
const hasLabel = Boolean(label);
const focusMode = detectFocusMode(children);

const contextValue = useMemo(
Expand All @@ -109,9 +111,10 @@ const Root = forwardRef<
focusMode,
disabled,
error,
labelId: hasLabel ? labelId : undefined,
inputId,
}),
[size, focusMode, disabled, error, inputId],
[size, focusMode, disabled, error, hasLabel, inputId, labelId],
);

// When label is provided, Field already renders a <label> with htmlFor
Expand Down Expand Up @@ -250,7 +253,7 @@ const Root = forwardRef<
if (label) {
return (
<Field
label={label}
label={<span id={labelId}>{label}</span>}
description={description}
error={error}
required={required}
Expand Down Expand Up @@ -313,7 +316,7 @@ const Root = forwardRef<
if (label) {
return (
<Field
label={label}
label={<span id={labelId}>{label}</span>}
description={description}
error={error}
required={required}
Expand Down