Skip to content

Commit 64a4bda

Browse files
fix: align InputGroup focus ring with standalone Input (#531)
1 parent 0003bf5 commit 64a4bda

7 files changed

Lines changed: 117 additions & 77 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 focus ring thickness and color: container mode uses 1.5px ring (wraps entire group including buttons), hybrid container zone and individual mode use 1px ring (thin to avoid colliding with adjacent buttons), and all modes use `ring-kumo-focus/50` (50% opacity) to match the standalone Input component.

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export function InputGroupButtonsDemo() {
104104
/>
105105
<InputGroup.Addon align="end">
106106
<InputGroup.Button
107+
shape="square"
107108
className="text-kumo-subtle"
108109
icon={show ? EyeSlashIcon : EyeIcon}
109110
aria-label={show ? "Hide password" : "Show password"}
@@ -125,11 +126,11 @@ export function InputGroupButtonsDemo() {
125126
{searchValue && (
126127
<InputGroup.Addon align="end" className="pr-1">
127128
<InputGroup.Button
129+
shape="square"
130+
icon={XIcon}
128131
aria-label="Clear search"
129132
onClick={() => setSearchValue("")}
130-
>
131-
<XIcon />
132-
</InputGroup.Button>
133+
/>
133134
</InputGroup.Addon>
134135
)}
135136
<InputGroup.Button variant="secondary" onClick={() => {}}>
@@ -152,8 +153,10 @@ export function InputGroupTooltipButtonDemo() {
152153
/>
153154
<InputGroup.Addon align="end">
154155
<InputGroup.Button
156+
shape="square"
155157
className="text-kumo-subtle"
156158
icon={QuestionIcon}
159+
aria-label="Query language help"
157160
tooltip="Query language help"
158161
onClick={() => {}}
159162
/>
@@ -322,6 +325,7 @@ export function InputGroupStatesDemo() {
322325
/>
323326
<InputGroup.Addon align="end">
324327
<InputGroup.Button
328+
shape="square"
325329
className="text-kumo-subtle"
326330
icon={show ? EyeSlashIcon : EyeIcon}
327331
aria-label={show ? "Hide password" : "Show password"}

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,21 +149,24 @@ export const Button = forwardRef<
149149
className={cn(
150150
// Ensure clicks register even when parent has pointer-events-none (e.g. disabled overlay)
151151
"pointer-events-auto",
152+
// Suppress the base Button's non-visible focus ring in all modes
153+
"focus:ring-0",
154+
// Container-zone buttons: use a subtle ring as focus indicator
155+
// (outline doesn't work because the base Button's `focus:outline-none`
156+
// sets `outline-style: none` which our outline-width/color can't override)
157+
!isIndividual &&
158+
"focus-visible:ring-[1.5px] focus-visible:ring-kumo-focus/50",
152159
// Individual mode: each button owns its own border and focus indicator
153160
isIndividual && [
154161
// Own border replaces the container's shared ring; force full height
155-
"relative h-full! rounded-none ring-0 border border-kumo-line",
156-
// Inherit border-radius only on outer edges; inner edges are flat
162+
"relative h-full! rounded-none ring-0 focus-visible:ring-0 border border-kumo-line",
157163
"first:rounded-l-[inherit] last:rounded-r-[inherit]",
158-
// Collapse double borders between adjacent elements
159-
"not-first:border-l-0",
160-
// Hovered element renders above idle siblings to show full border
161-
"hover:z-[1]",
162-
// Focused element renders above hovered siblings for focus indicator
163-
"focus:z-[2] focus:border-kumo-line focus:outline focus:-outline-offset-1",
164-
// Suppress the base Button's focus ring so only our outline shows
165-
"focus-visible:ring-2 focus-visible:ring-kumo-focus",
166-
// Match the group's disabled visual treatment
164+
// Negative margin (not border-l-0) so the border is still paintable on focus
165+
"not-first:-ml-px",
166+
"hover:z-1",
167+
// z-2 lifts above hovered siblings so focus border isn't clipped
168+
"focus:z-2",
169+
"focus-visible:border-kumo-focus/50",
167170
"disabled:bg-kumo-overlay disabled:text-kumo-inactive!",
168171
],
169172
className,

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

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,28 +63,24 @@ export const Input = forwardRef<HTMLInputElement, InputGroupInputProps>(
6363
className={cn(
6464
// Base input layout: fill height, allow shrinking, strip native border/radius
6565
"flex h-full min-w-0 grow items-center rounded-none border-0 bg-transparent font-sans",
66-
// Always use full outer padding — the container's has-[] rules reduce
67-
// pl/pr to inputSeam on sides that touch an addon.
66+
// Always use full outer padding — the container's has-[] rules reduce pl/pr to inputSeam on sides that touch an addon.
6867
tokens.inputOuter,
6968
// Truncate overflowing text with "…" instead of expanding the input
7069
"text-ellipsis",
7170
// Individual mode: each element owns its own border instead of sharing a container ring
7271
isIndividual
7372
? [
74-
// Own border replaces the container's shared ring
75-
"relative ring-0 border border-kumo-line",
76-
// Inherit border-radius only on outer edges; inner edges are flat
73+
// Own border replaces the container's shared ring; suppress the base Input's focus:ring so only the border-swap shows
74+
"relative ring-0 focus:ring-0 border border-kumo-line",
7775
"first:rounded-l-[inherit] last:rounded-r-[inherit]",
78-
// Collapse double borders between adjacent elements
79-
"not-first:border-l-0",
80-
// Hovered element renders above idle siblings to show full border
81-
"hover:z-[1] hover:border-kumo-line",
82-
// Focused element renders above hovered siblings for focus indicator
83-
"focus:z-[2] focus:border-kumo-line focus:outline focus:-outline-offset-1",
76+
// Negative margin (not border-l-0) so the border is still paintable on focus
77+
"not-first:-ml-px",
78+
"hover:z-1 hover:border-kumo-line",
79+
// z-[2] lifts above hovered siblings so focus border isn't clipped
80+
"focus:z-2 focus:border-kumo-focus/50",
8481
].join(" ")
85-
: // Container mode: kill all focus indicators — the container handles them
86-
// z-[1] lifts the input above the invisible label overlay so cursor/selection work
87-
"relative z-[1] ring-0! shadow-none outline-none focus:ring-0! focus:outline-none",
82+
: // Container mode: kill all focus indicators — the container handles them z-1 lifts the input above the invisible label overlay so cursor/selection work
83+
"relative z-1 ring-0! shadow-none outline-none focus:ring-0! focus:outline-none",
8884
props.className,
8985
)}
9086
/>

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

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,7 +1217,7 @@ describe("InputGroup", () => {
12171217
expect(group.className).toContain("overflow-visible");
12181218
expect(group.className).toContain("ring-0");
12191219
expect(group.className).not.toContain("overflow-hidden");
1220-
expect(group.className).not.toContain("focus-within:ring-kumo-focus");
1220+
expect(group.className).not.toContain("focus-within:ring-kumo-focus/50");
12211221
});
12221222

12231223
it("container mode gets shared focus ring classes", () => {
@@ -1231,7 +1231,8 @@ describe("InputGroup", () => {
12311231
"[data-slot='input-group']",
12321232
) as HTMLElement;
12331233
expect(group.className).toContain("overflow-hidden");
1234-
expect(group.className).toContain("focus-within:ring-kumo-focus");
1234+
expect(group.className).toContain("focus-within:ring-kumo-focus/50");
1235+
expect(group.className).toContain("focus-within:ring-[1.5px]");
12351236
expect(group.className).not.toContain("overflow-visible");
12361237
});
12371238

@@ -1253,7 +1254,7 @@ describe("InputGroup", () => {
12531254
expect(group.className).toContain("overflow-visible");
12541255
expect(group.className).toContain("ring-0");
12551256
expect(group.className).not.toContain("overflow-hidden");
1256-
expect(group.className).not.toContain("focus-within:ring-kumo-focus");
1257+
expect(group.className).not.toContain("focus-within:ring-kumo-focus/50");
12571258
});
12581259
});
12591260

@@ -1352,14 +1353,65 @@ describe("InputGroup", () => {
13521353
expect(containerZone.className).toContain("border-kumo-line");
13531354
expect(containerZone.className).toContain("ring-0");
13541355
expect(containerZone.className).toContain("shadow-none");
1355-
expect(containerZone.className).toContain("focus-within:ring-1");
1356-
expect(containerZone.className).toContain("focus-within:ring-kumo-focus");
1357-
// Double-border prevention with adjacent individual-mode buttons
1358-
expect(containerZone.className).toContain("not-first:border-l-0");
1359-
// No focus-within ring — CSS outline in kumo-binding.css handles focus
1360-
expect(containerZone.className).not.toContain(
1361-
"focus-within:ring-kumo-line",
1356+
// Focus swaps border color instead of adding a ring (no double-line effect)
1357+
expect(containerZone.className).toContain(
1358+
"focus-within:border-kumo-focus/50",
13621359
);
1360+
expect(containerZone.className).not.toContain("focus-within:ring-1");
1361+
// Double-border prevention with adjacent individual-mode buttons (negative margin preserves border for focus)
1362+
expect(containerZone.className).toContain("not-first:-ml-px");
1363+
});
1364+
1365+
it("clear button inside addon works in hybrid mode", async () => {
1366+
const user = userEvent.setup();
1367+
1368+
function SearchDemo() {
1369+
const [searchValue, setSearchValue] = React.useState("search");
1370+
return (
1371+
<InputGroup>
1372+
<InputGroup.Addon>icon</InputGroup.Addon>
1373+
<InputGroup.Input
1374+
value={searchValue}
1375+
placeholder="Search"
1376+
aria-label="Search"
1377+
onChange={(e) => setSearchValue(e.target.value)}
1378+
/>
1379+
{searchValue && (
1380+
<InputGroup.Addon align="end">
1381+
<InputGroup.Button
1382+
shape="square"
1383+
aria-label="Clear search"
1384+
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
1385+
onClick={() => setSearchValue("")}
1386+
>
1387+
X
1388+
</InputGroup.Button>
1389+
</InputGroup.Addon>
1390+
)}
1391+
<InputGroup.Button variant="secondary" onClick={() => {}}>
1392+
Search
1393+
</InputGroup.Button>
1394+
</InputGroup>
1395+
);
1396+
}
1397+
1398+
render(<SearchDemo />);
1399+
1400+
// Verify initial state
1401+
const input = screen.getByRole("textbox", {
1402+
name: "Search",
1403+
}) as HTMLInputElement;
1404+
expect(input.value).toBe("search");
1405+
expect(screen.getByRole("button", { name: "Clear search" })).toBeTruthy();
1406+
1407+
// Click the clear button
1408+
await user.click(screen.getByRole("button", { name: "Clear search" }));
1409+
1410+
// Value should be cleared
1411+
expect(input.value).toBe("");
1412+
1413+
// Clear button should be gone (conditional rendering)
1414+
expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull();
13631415
});
13641416
});
13651417
});

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

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ const Root = forwardRef<
133133
? [
134134
"overflow-hidden",
135135
// Focus state must come AFTER inputVariants to override ring-kumo-line
136-
"focus-within:ring-kumo-focus",
136+
"focus-within:ring-kumo-focus/50 focus-within:ring-[1.5px]",
137137
]
138138
: // isolate creates a new stacking context so z-index in children doesn't leak out
139139
"isolate overflow-visible ring-0 shadow-none",
@@ -200,29 +200,30 @@ const Root = forwardRef<
200200
// so the zone matches adjacent individual-mode buttons exactly.
201201
"ring-0 shadow-none",
202202
"border border-kumo-line",
203-
"focus-within:ring-1 focus-within:ring-kumo-focus",
204-
// Collapse double borders between zone and adjacent individual-mode button
205-
"not-first:border-l-0",
206-
// Inherit border-radius from the outer container on outer edges only;
207-
// inner edges are flat so they butt cleanly against sibling buttons
203+
"focus-within:border-kumo-focus/50",
204+
// z-[2] lifts above adjacent button's -ml-px overlap so focus border shows
205+
"focus-within:z-2",
206+
// Negative margin (not border-l-0) so the border is still paintable on focus
207+
"not-first:-ml-px",
208+
// Outer edges inherit radius; inner edges are flat against sibling buttons
208209
"first:rounded-l-[inherit] last:rounded-r-[inherit] rounded-none",
209210
// Size-specific padding adjustments when addons or suffixes are present
210211
INPUT_GROUP_HAS_CLASSES[size],
211212
// When a suffix is present, let the input shrink to its content width
212-
"has-[[data-slot=input-group-suffix]]:[&_input]:[field-sizing:content]",
213-
"has-[[data-slot=input-group-suffix]]:[&_input]:max-w-full",
214-
"has-[[data-slot=input-group-suffix]]:[&_input]:grow-0",
215-
"has-[[data-slot=input-group-suffix]]:[&_input]:pr-0",
213+
"has-data-[slot=input-group-suffix]:[&_input]:field-sizing-content",
214+
"has-data-[slot=input-group-suffix]:[&_input]:max-w-full",
215+
"has-data-[slot=input-group-suffix]:[&_input]:grow-0",
216+
"has-data-[slot=input-group-suffix]:[&_input]:pr-0",
216217
)}
217218
>
218-
{/* When label exists, an invisible <label> overlay enables click-to-focus
219-
inside the container zone without nesting visible <label> elements */}
219+
{/* When label exists, an invisible <label> overlay enables click-to-focus inside the container zone without nesting visible <label> elements */}
220220
{label && (
221-
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- invisible overlay for click-to-focus; the visible Field label handles a11y
221+
// Invisible overlay for click-to-focus; the visible Field label handles a11y
222+
// eslint-disable-next-line jsx-a11y/label-has-associated-control
222223
<label
223224
htmlFor={inputId}
224225
// Positioned behind children (z-0) so it catches clicks on empty space
225-
className="absolute inset-0 z-0 cursor-text !mb-0"
226+
className="absolute inset-0 z-0 cursor-text mb-0!"
226227
aria-hidden="true"
227228
/>
228229
)}
@@ -234,9 +235,7 @@ const Root = forwardRef<
234235
</>
235236
);
236237

237-
// Hybrid always uses a <div> container (never <label>) because
238-
// individual-zone buttons are siblings — wrapping them in a <label>
239-
// would be semantically incorrect.
238+
// Hybrid always uses a <div> container (never <label>) because individual-zone buttons are siblings — wrapping them in a <label> would be semantically incorrect.
240239
const hybridContainer = (
241240
<InputGroupContext.Provider value={contextValue}>
242241
<div
@@ -272,8 +271,7 @@ const Root = forwardRef<
272271
const useLabelContainer = !label && focusMode === "container";
273272
const container = (
274273
<InputGroupContext.Provider value={contextValue}>
275-
{/* When label is set, use <div> to avoid nested <label> (Field provides one).
276-
An invisible <label> overlay handles click-to-focus on empty space. */}
274+
{/* When label is set, use <div> to avoid nested <label> (Field provides one). An invisible <label> overlay handles click-to-focus on empty space. */}
277275
{label ? (
278276
<div
279277
ref={forwardedRef as React.Ref<HTMLDivElement>}
@@ -285,7 +283,7 @@ const Root = forwardRef<
285283
<label
286284
htmlFor={inputId}
287285
// Positioned behind children (z-0) so it catches clicks on empty space
288-
className="absolute inset-0 z-0 !mb-0"
286+
className="absolute inset-0 z-0 mb-0!"
289287
aria-hidden="true"
290288
/>
291289
{children}
@@ -295,7 +293,7 @@ const Root = forwardRef<
295293
<label
296294
ref={forwardedRef as React.Ref<HTMLLabelElement>}
297295
{...dataProps}
298-
className={cn(containerClassName, "!mb-0")}
296+
className={cn(containerClassName, "mb-0!")}
299297
{...rest}
300298
>
301299
{children}

packages/kumo/src/styles/kumo-binding.css

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -292,21 +292,3 @@
292292
.kumo-input-placeholder::placeholder {
293293
color: var(--text-color-kumo-placeholder);
294294
}
295-
296-
/* InputGroup container mode — native browser outline on keyboard focus.
297-
Uses vanilla CSS because Tailwind focus-visible: targets the focused element itself,
298-
but we need :has(:focus-visible) to outline the container when a descendant is focused. */
299-
[data-slot="input-group"][data-focus-mode="container"]:has(:focus-visible) {
300-
/* 1px solid matches the browser's default focus ring weight */
301-
outline: solid 1px var(--color-kumo-focus);
302-
}
303-
304-
/* InputGroup hybrid mode — keyboard outline for the container zone wrapper.
305-
Uses outline-offset: -1px to sit on top of the 1px border (same as individual mode)
306-
so there's no double visual indicator between the outline and the border. */
307-
[data-slot="input-group-container-zone"]:has(:focus-visible) {
308-
/* 1px to match the individual-mode outline weight (thinner than container's 2px
309-
because the zone is nested — a thick outline would overwhelm sibling buttons) */
310-
outline: solid 1px var(--color-kumo-focus);
311-
outline-offset: -1px;
312-
}

0 commit comments

Comments
 (0)