diff --git a/.changeset/quiet-input-groups.md b/.changeset/quiet-input-groups.md new file mode 100644 index 000000000..384bc06eb --- /dev/null +++ b/.changeset/quiet-input-groups.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +Fix the accessible-name warning for InputGroup inputs named by the parent label. diff --git a/packages/kumo/src/components/input-group/context.ts b/packages/kumo/src/components/input-group/context.ts index 8612854f7..653e9e5bb 100644 --- a/packages/kumo/src/components/input-group/context.ts +++ b/packages/kumo/src/components/input-group/context.ts @@ -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; } diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx index 6513caadd..c6d680dd7 100644 --- a/packages/kumo/src/components/input-group/input-group-input.tsx +++ b/packages/kumo/src/components/input-group/input-group-input.tsx @@ -51,6 +51,9 @@ export const Input = forwardRef( // 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 ( ( 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 diff --git a/packages/kumo/src/components/input-group/input-group.test.tsx b/packages/kumo/src/components/input-group/input-group.test.tsx index c40aa4f16..bf5951599 100644 --- a/packages/kumo/src/components/input-group/input-group.test.tsx +++ b/packages/kumo/src/components/input-group/input-group.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[Kumo Input]"), + ); + warnSpy.mockRestore(); + }); + it("input has accessible name via aria-label", () => { render( diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx index 8471897c2..47bbe096e 100644 --- a/packages/kumo/src/components/input-group/input-group.tsx +++ b/packages/kumo/src/components/input-group/input-group.tsx @@ -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( @@ -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