Skip to content

Commit b923281

Browse files
fix(input-group): prevent :hover propagation to first button in individual mode (#444)
HTML spec implicitly associates a <label> with its first labelable descendant, and browsers propagate :hover from the label to that control. When InputGroup contains multiple sibling labelable controls (e.g. Pagination.Controls with First/Prev/Next/Last buttons around an Input), hovering any button caused the first button to also render as hovered. Render the root as <div> when focusMode === "individual" (multiple sibling controls), matching the existing hybrid-mode behavior. Container mode (single input + addons) still renders as <label> so click-to-focus affordance is preserved.
1 parent 267ba7a commit b923281

4 files changed

Lines changed: 87 additions & 8 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 `InputGroup` hover state incorrectly propagating to the first child button (e.g. in `Pagination.Controls`). Root now renders as `<div>` instead of `<label>` when it contains multiple labelable controls.

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,53 @@ describe("InputGroup", () => {
449449
const nestedLabels = container.querySelectorAll("label label");
450450
expect(nestedLabels.length).toBe(0);
451451
});
452+
453+
// Regression: <label> root propagates :hover to first labelable child.
454+
it("container is a <div> element (not <label>) in individual focus mode", () => {
455+
const { container } = render(
456+
<InputGroup>
457+
<InputGroup.Button variant="secondary" aria-label="First">
458+
First
459+
</InputGroup.Button>
460+
<InputGroup.Button variant="secondary" aria-label="Prev">
461+
Prev
462+
</InputGroup.Button>
463+
<InputGroup.Input aria-label="Page" />
464+
<InputGroup.Button variant="secondary" aria-label="Next">
465+
Next
466+
</InputGroup.Button>
467+
<InputGroup.Button variant="secondary" aria-label="Last">
468+
Last
469+
</InputGroup.Button>
470+
</InputGroup>,
471+
);
472+
const group = container.querySelector(
473+
"[data-slot='input-group']",
474+
) as HTMLElement;
475+
expect(group).toBeTruthy();
476+
expect(group.tagName).toBe("DIV");
477+
expect(group.tagName).not.toBe("LABEL");
478+
expect(group.getAttribute("data-focus-mode")).toBe("individual");
479+
});
480+
481+
// Regression guard: hybrid mode must also not render root as <label>.
482+
it("container is a <div> element (not <label>) in hybrid focus mode", () => {
483+
const { container } = render(
484+
<InputGroup>
485+
<InputGroup.Addon>@</InputGroup.Addon>
486+
<InputGroup.Input aria-label="Email" />
487+
<InputGroup.Button variant="secondary" aria-label="Submit">
488+
Go
489+
</InputGroup.Button>
490+
</InputGroup>,
491+
);
492+
const group = container.querySelector(
493+
"[data-slot='input-group']",
494+
) as HTMLElement;
495+
expect(group).toBeTruthy();
496+
expect(group.tagName).toBe("DIV");
497+
expect(group.getAttribute("data-focus-mode")).toBe("hybrid");
498+
});
452499
});
453500

454501
describe("Button", () => {

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,11 @@ export const KUMO_INPUT_GROUP_DEFAULT_VARIANTS = {
5656
* suffixes, and action buttons. Accepts Field props and wraps content in
5757
* Field when label is provided.
5858
*
59-
* The container element is **conditional** to avoid nested `<label>` elements:
60-
* - When `label` is provided, the container renders as a `<div>` because Field
61-
* already provides a `<label>` with `htmlFor` that handles click-to-focus.
62-
* - When `label` is absent (standalone usage), the container renders as a
63-
* native `<label>` so clicking anywhere focuses the wrapped input — no
64-
* imperative JS needed.
59+
* Renders as `<label>` only in standalone container mode (single input, no
60+
* sibling buttons) so clicking empty space focuses the input. Otherwise
61+
* renders as `<div>` to avoid nested `<label>` (when Field provides one) or
62+
* the browser's `:hover` propagation from `<label>` to its first labelable
63+
* descendant (when multiple labelable controls are siblings).
6564
*
6665
* @note Do not wrap InputGroup inside an external Field without using the `label` prop —
6766
* this creates invalid nested `<label>` elements. Use InputGroup's own `label` prop instead.
@@ -267,6 +266,8 @@ const Root = forwardRef<
267266
}
268267

269268
// Container / Individual mode (non-hybrid)
269+
// Use <label> only when there's exactly one labelable descendant; otherwise <label> would propagate :hover to its first labelable descendant.
270+
const useLabelContainer = !label && focusMode === "container";
270271
const container = (
271272
<InputGroupContext.Provider value={contextValue}>
272273
{/* When label is set, use <div> to avoid nested <label> (Field provides one).
@@ -287,8 +288,8 @@ const Root = forwardRef<
287288
/>
288289
{children}
289290
</div>
290-
) : (
291-
// Standalone (no label): native <label> wraps everything for click-to-focus
291+
) : useLabelContainer ? (
292+
// Standalone container mode: <label> enables click-to-focus on empty space.
292293
<label
293294
ref={forwardedRef as React.Ref<HTMLLabelElement>}
294295
{...dataProps}
@@ -297,6 +298,16 @@ const Root = forwardRef<
297298
>
298299
{children}
299300
</label>
301+
) : (
302+
// Individual mode: <div> avoids :hover propagating to the first labelable sibling.
303+
<div
304+
ref={forwardedRef as React.Ref<HTMLDivElement>}
305+
{...dataProps}
306+
className={containerClassName}
307+
{...rest}
308+
>
309+
{children}
310+
</div>
300311
)}
301312
</InputGroupContext.Provider>
302313
);

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ describe("Pagination", () => {
104104
fireEvent.click(screen.getByLabelText("Last page"));
105105
expect(setPage).toHaveBeenCalledWith(10);
106106
});
107+
108+
// Regression: <label> wrapper made "First page" appear hovered on sibling hover.
109+
it("InputGroup wrapper around controls is a <div>, not a <label>", () => {
110+
const { container } = renderPagination({
111+
page: 5,
112+
perPage: 10,
113+
totalCount: 100,
114+
});
115+
const group = container.querySelector(
116+
"[data-slot='input-group']",
117+
) as HTMLElement;
118+
expect(group).toBeTruthy();
119+
expect(group.tagName).toBe("DIV");
120+
expect(group.tagName).not.toBe("LABEL");
121+
expect(group.getAttribute("data-focus-mode")).toBe("individual");
122+
});
107123
});
108124

109125
describe("Enter key navigation (input mode)", () => {

0 commit comments

Comments
 (0)