Skip to content

Commit c019b41

Browse files
authored
fix(a11y): inconsistent focus states (#385)
1 parent 431de04 commit c019b41

20 files changed

Lines changed: 100 additions & 54 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@cloudflare/kumo": patch
3+
"@cloudflare/kumo-docs-astro": patch
4+
---
5+
6+
Improved focus and keyboard accessibility styles across Kumo components and docs navigation.
7+
8+
- Added the `kumo-focus` semantic token to the theme generator config and generated `theme-kumo.css` output.
9+
- Updated focus ring behavior across interactive components (including `Button`, `Input`, `InputGroup`, `Select`, `Checkbox`, `Radio`, `Switch`, `Sidebar`, `Tabs`, `Menubar`, and related controls) for more consistent and visible keyboard focus visibility.
10+
- Text-entry controls use a lighter opacity `kumo-focus` ring to keep pointer and keyboard focus visually consistent where browsers apply `:focus-visible` heuristics to typed-input controls.
11+
- Refined `Select` and `Input` styling/state combinations to align focus visuals with current semantic token usage.
12+
- Updated docs `SidebarNav` keyboard-focus affordances (links, section toggles, search trigger) and adjusted collapsible list overflow so focus rings remain visible.
13+
- Replace raw colors in `Select` with kumo semantic tokens.

packages/kumo-docs-astro/src/components/SidebarNav.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ declare const __BUILD_COMMIT__: string;
103103
declare const __BUILD_DATE__: string;
104104

105105
const LI_STYLE =
106-
"block rounded-lg text-kumo-strong hover:text-kumo-default hover:bg-kumo-tint p-2 my-[.05rem] cursor-pointer transition-colors no-underline relative z-10";
106+
"block rounded-lg text-kumo-strong hover:text-kumo-default hover:bg-kumo-tint p-2 my-[.05rem] cursor-pointer transition-colors no-underline relative z-10 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-kumo-brand";
107107
const LI_ACTIVE_STYLE = "font-semibold text-kumo-default bg-kumo-tint";
108108

109109
interface SidebarNavProps {
@@ -127,6 +127,9 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
127127

128128
const toggleSidebar = () => setSidebarOpen((v) => !v);
129129
const toggleMobileMenu = () => setMobileMenuOpen((v) => !v);
130+
const preventPointerFocus = (e: React.MouseEvent<HTMLElement>) => {
131+
e.preventDefault();
132+
};
130133

131134
// Keyboard shortcut: Cmd+K / Ctrl+K + custom event from headers
132135
useEffect(() => {
@@ -195,7 +198,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
195198
<>
196199
<button
197200
onClick={() => setSearchOpen(true)}
198-
className="mb-3 flex w-full items-center gap-2 rounded-lg bg-kumo-control px-3 py-2 text-sm text-kumo-subtle ring-1 ring-kumo-hairline transition-all hover:ring-kumo-hairline"
201+
className="mb-3 flex w-full items-center gap-2 rounded-lg bg-kumo-control px-3 py-2 text-sm text-kumo-subtle ring-1 ring-kumo-line transition-all hover:ring-kumo-hairline focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-kumo-brand"
199202
>
200203
<MagnifyingGlassIcon size={16} className="shrink-0" />
201204
<span>Search...</span>
@@ -206,6 +209,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
206209
<li key={item.href}>
207210
<a
208211
href={item.href}
212+
onMouseDown={preventPointerFocus}
209213
className={cn(
210214
LI_STYLE,
211215
isActivePath(activePath, item.href) && LI_ACTIVE_STYLE,
@@ -223,7 +227,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
223227
{/* Components Section */}
224228
<button
225229
type="button"
226-
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-kumo-default transition-colors hover:bg-kumo-tint"
230+
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-kumo-default transition-colors hover:bg-kumo-tint focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-kumo-brand"
227231
onClick={() => setComponentsOpen(!componentsOpen)}
228232
>
229233
<span>Components</span>
@@ -237,14 +241,15 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
237241
</button>
238242
<ul
239243
className={cn(
240-
"flex flex-col gap-px overflow-hidden transition-all duration-300 ease-in-out mt-1",
244+
"mt-1 flex flex-col gap-px overflow-y-hidden overflow-x-visible transition-all duration-300 ease-in-out",
241245
componentsOpen ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0",
242246
)}
243247
>
244248
{componentItems.map((item) => (
245249
<li key={item.href}>
246250
<a
247251
href={item.href}
252+
onMouseDown={preventPointerFocus}
248253
className={cn(
249254
LI_STYLE,
250255
"pl-4",
@@ -262,7 +267,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
262267
<div className="mb-4">
263268
<button
264269
type="button"
265-
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-kumo-default transition-colors hover:bg-kumo-tint"
270+
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-kumo-default transition-colors hover:bg-kumo-tint focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-kumo-brand"
266271
onClick={() => setChartsOpen(!chartsOpen)}
267272
>
268273
<span>Charts</span>
@@ -276,14 +281,15 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
276281
</button>
277282
<ul
278283
className={cn(
279-
"flex flex-col gap-px overflow-hidden transition-all duration-300 ease-in-out mt-1",
284+
"mt-1 flex flex-col gap-px overflow-y-hidden overflow-x-visible transition-all duration-300 ease-in-out",
280285
chartsOpen ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0",
281286
)}
282287
>
283288
{chartItems.map((item) => (
284289
<li key={item.href}>
285290
<a
286291
href={item.href}
292+
onMouseDown={preventPointerFocus}
287293
className={cn(
288294
LI_STYLE,
289295
"pl-4",
@@ -301,7 +307,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
301307
{/* Blocks Section */}
302308
<button
303309
type="button"
304-
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-kumo-default transition-colors hover:bg-kumo-tint"
310+
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-kumo-default transition-colors hover:bg-kumo-tint focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-kumo-brand"
305311
onClick={() => setBlocksOpen(!blocksOpen)}
306312
>
307313
<span>Blocks</span>
@@ -315,14 +321,15 @@ export function SidebarNav({ currentPath }: SidebarNavProps) {
315321
</button>
316322
<ul
317323
className={cn(
318-
"flex flex-col gap-px overflow-hidden transition-all duration-300 ease-in-out mt-1",
324+
"mt-1 flex flex-col gap-px overflow-y-hidden overflow-x-visible transition-all duration-300 ease-in-out",
319325
blocksOpen ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0",
320326
)}
321327
>
322328
{blockItems.map((item) => (
323329
<li key={item.href}>
324330
<a
325331
href={item.href}
332+
onMouseDown={preventPointerFocus}
326333
className={cn(
327334
LI_STYLE,
328335
"pl-4",

packages/kumo/scripts/theme-generator/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,16 @@ export const THEME_CONFIG: ThemeConfig = {
330330
},
331331
},
332332
},
333+
"kumo-focus": {
334+
newName: "",
335+
description: "Primary focus ring/border color",
336+
theme: {
337+
kumo: {
338+
light: "var(--color-kumo-neutral-950, oklch(15% 0 0))",
339+
dark: "var(--color-kumo-neutral-150, oklch(93.5% 0 0))",
340+
},
341+
},
342+
},
333343
"kumo-shadow-edge": {
334344
newName: "",
335345
description: "Tight spread shadow color for control thumbs/knobs",

packages/kumo/src/components/button/button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const KUMO_BUTTON_VARIANTS = {
4848
variant: {
4949
primary: {
5050
classes:
51-
"bg-kumo-brand !text-white hover:bg-kumo-brand-hover focus:bg-kumo-brand-hover disabled:bg-kumo-brand/50",
51+
"bg-kumo-brand !text-white hover:bg-kumo-brand-hover disabled:bg-kumo-brand/50",
5252
description: "High-emphasis button for primary actions",
5353
},
5454
secondary: {
@@ -130,6 +130,7 @@ export function buttonVariants({
130130
// Base styles
131131
"group flex w-max shrink-0 items-center font-medium select-none",
132132
"border-0 shadow-xs",
133+
"focus:outline-none focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand",
133134
"cursor-pointer",
134135
// Disabled state
135136
"disabled:cursor-not-allowed disabled:text-kumo-subtle",
@@ -244,7 +245,6 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
244245
ref={ref}
245246
className={cn(
246247
buttonVariants({ variant, size, shape }),
247-
"focus:opacity-100 focus-visible:ring-1 focus-visible:ring-kumo-hairline *:in-focus:opacity-100", // Focus styles
248248
disabled && "cursor-not-allowed opacity-50",
249249
className,
250250
)}

packages/kumo/src/components/checkbox/checkbox.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const KUMO_CHECKBOX_VARIANTS = {
1717
variant: {
1818
default: {
1919
classes:
20-
"[&:focus-within>span]:ring-kumo-hairline [&:hover>span]:ring-kumo-hairline",
20+
"[&:focus-within>span]:ring-kumo-focus [&:hover>span]:ring-kumo-hairline",
2121
description: "Default checkbox appearance",
2222
},
2323
error: {
@@ -260,10 +260,10 @@ const CheckboxBase = forwardRef<HTMLButtonElement, CheckboxProps>(
260260
disabled={disabled}
261261
onCheckedChange={onCheckedChange}
262262
className={cn(
263-
"relative flex h-4 w-4 items-center justify-center rounded-sm border-0 bg-kumo-base ring after:absolute after:-inset-x-3 after:-inset-y-2",
263+
"relative flex h-4 w-4 items-center justify-center rounded-sm border-0 bg-kumo-base ring focus:outline-none after:absolute after:-inset-x-3 after:-inset-y-2",
264264
variant === "error" ? "ring-kumo-danger" : "ring-kumo-hairline",
265265
!disabled &&
266-
"hover:ring-kumo-hairline focus-visible:ring-kumo-hairline",
266+
"hover:ring-kumo-hairline focus:ring-kumo-focus focus:ring-2 focus-visible:ring-2 focus-visible:ring-kumo-brand",
267267
"data-[checked]:bg-kumo-contrast data-[checked]:ring-kumo-contrast data-[indeterminate]:bg-kumo-contrast data-[indeterminate]:ring-kumo-contrast",
268268
disabled && "cursor-not-allowed opacity-50",
269269
className,
@@ -359,7 +359,7 @@ const CheckboxItem = forwardRef<HTMLButtonElement, CheckboxItemProps>(
359359
"peer relative flex h-4 w-4 items-center justify-center rounded-sm border-0 bg-kumo-base ring after:absolute after:-inset-x-3 after:-inset-y-2",
360360
variant === "error" ? "ring-kumo-danger" : "ring-kumo-hairline",
361361
!disabled &&
362-
"group-hover:ring-kumo-hairline hover:ring-kumo-hairline focus-visible:ring-kumo-hairline",
362+
"group-hover:ring-kumo-hairline hover:ring-kumo-hairline focus:ring-kumo-focus focus:ring-2 focus-visible:ring-2 focus-visible:ring-kumo-brand",
363363
"data-[checked]:bg-kumo-contrast data-[checked]:ring-kumo-contrast data-[indeterminate]:bg-kumo-contrast data-[indeterminate]:ring-kumo-contrast",
364364
)}
365365
>

packages/kumo/src/components/clipboard-text/clipboard-text.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export const ClipboardText = forwardRef<HTMLDivElement, ClipboardTextProps>(
249249
ref={buttonRef}
250250
size={sizeConfig.buttonSize}
251251
variant="ghost"
252-
className="rounded-none border-l! border-kumo-hairline! px-3 relative overflow-hidden transition-all duration-200"
252+
className="rounded-none border-l! border-kumo-line! px-3 relative overflow-hidden transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand"
253253
onClick={copyToClipboard}
254254
aria-label={copyAction}
255255
>

packages/kumo/src/components/command-palette/command-palette.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ function PanelInput({
767767
placeholder={placeholder}
768768
className={cn(
769769
"flex-1 border-none bg-transparent text-base kumo-input-placeholder",
770-
"outline-none focus-visible:outline-none",
770+
"outline-none focus:ring-kumo-focus/50 focus:ring-[1.5px]",
771771
className,
772772
)}
773773
onKeyDown={handleKeyDown}

packages/kumo/src/components/date-range-picker/date-range-picker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ function DateRangeMonthHeader({
604604
aria-label="Edit month and year"
605605
defaultValue={`${month} ${year}`}
606606
className={cn(
607-
"w-full rounded-md border-none bg-transparent py-1.5 text-center font-semibold text-kumo-default transition-all duration-200 focus:outline-none",
607+
"w-full rounded-md border-none bg-transparent py-1.5 text-center font-semibold text-kumo-default transition-all duration-200 focus:outline-none focus:ring-kumo-focus/50 focus:ring-[1.5px]",
608608
sizeConfig.textSize,
609609
)}
610610
onBlur={(e) => {

packages/kumo/src/components/dropdown/dropdown.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
6565
className={cn(
6666
"flex cursor-default items-center rounded-sm text-base outline-hidden select-none", // base styles
6767
"px-2 py-1.5", // spacing
68-
"focus:bg-kumo-tint", // focus state
68+
"focus:bg-kumo-tint focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand", // focus state
6969
"data-[state=open]:bg-kumo-tint", // open state
7070
inset && "pl-8", // conditional inset
7171
className,
@@ -237,7 +237,7 @@ const DropdownMenuItem = React.forwardRef<
237237
<DropdownMenuPrimitive.Item
238238
ref={ref}
239239
className={cn(
240-
"relative flex cursor-default items-center rounded-md px-2 py-1.5 text-base outline-hidden select-none focus:text-kumo-default data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-kumo-overlay",
240+
"relative flex cursor-default items-center rounded-md px-2 py-1.5 text-base outline-hidden select-none focus:text-kumo-default focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-kumo-overlay",
241241
inset && "pl-8",
242242
dropdownVariants({ variant }),
243243
className,
@@ -301,7 +301,7 @@ const DropdownMenuLinkItem = React.forwardRef<
301301
ref={ref}
302302
className={cn(
303303
"relative flex cursor-default items-center rounded-md px-2 py-1.5 text-base outline-hidden select-none",
304-
"focus:text-kumo-default data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-kumo-overlay",
304+
"focus:text-kumo-default focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-kumo-overlay",
305305
"text-inherit no-underline",
306306
inset && "pl-8",
307307
dropdownVariants({ variant }),
@@ -325,7 +325,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
325325
<DropdownMenuPrimitive.CheckboxItem
326326
ref={ref}
327327
className={cn(
328-
"relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-base outline-hidden transition-colors select-none focus:bg-kumo-tint focus:text-kumo-default data-disabled:pointer-events-none data-disabled:opacity-50",
328+
"relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-base outline-hidden transition-colors select-none focus:bg-kumo-tint focus:text-kumo-default focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand data-disabled:pointer-events-none data-disabled:opacity-50",
329329
className,
330330
)}
331331
checked={checked}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe("Input", () => {
7070
it("renders with default variant 'default'", () => {
7171
render(<Input aria-label="Test" />);
7272
expect(screen.getByRole("textbox").className).toContain(
73-
"focus:ring-kumo-hairline",
73+
"focus:ring-kumo-focus/50",
7474
);
7575
});
7676

@@ -172,7 +172,7 @@ describe("Input", () => {
172172

173173
it("applies focusIndicator class when true", () => {
174174
const classes = inputVariants({ focusIndicator: true });
175-
expect(classes).toContain("focus:ring-kumo-hairline");
175+
expect(classes).toContain("focus:ring-kumo-focus/50");
176176
});
177177

178178
// Variants export

0 commit comments

Comments
 (0)