Skip to content

Commit 68685c0

Browse files
authored
feat: more accessible keyboard nav focus outlines for menu items (#890)
* feat(theme): add genericMenu focus stroke tokens Add `stroke.focus` to genericMenu item color tokens for both default and danger variants in light and dark themes, scoped to the component namespace rather than reaching into global.color.outline. * feat(hooks): add useInputModality for keyboard vs pointer tracking Tracks input modality on a menu container via React event props so descendant CSS can show focus rings only during keyboard navigation, avoiding visual disruption on hover (not required by WCAG). * fix(a11y): add keyboard-only focus ring to menu items Wire useInputModality into Dropdown, ContextMenu, and Select containers and add a CSS outline rule on GenericMenuItem that activates only during keyboard navigation, fixing WCAG SC 2.4.7 (Focus Visible) and SC 1.4.11 (Non-text Contrast) violations. * fix(a11y): address PR review feedback on useInputModality - Broaden keydown condition to exclude only modifier keys, covering typeahead characters and Space alongside navigation keys - Add onPointerDown so clicking without mouse movement correctly clears keyboard modality - Add global modality tracker and onFocusCapture prop so menus opened via keyboard show the focus ring on the first highlighted item - Add co-located unit tests for the hook - Add changeset * style: format useInputModality files with prettier * fix(a11y): ensure inputModality props cannot be overridden by spread
1 parent 53a03d7 commit 68685c0

10 files changed

Lines changed: 188 additions & 4 deletions

File tree

.changeset/kind-menus-focus.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clickhouse/click-ui': patch
3+
---
4+
5+
Add visible keyboard focus ring to menu items (Dropdown, Select, ContextMenu) for WCAG SC 2.4.7 and SC 1.4.11 compliance. Introduces `useInputModality` hook and `stroke.focus` theme tokens.

src/components/ContextMenu/ContextMenu.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { IconName } from '@/components/Icon';
77
import { Arrow, GenericMenuItem, GenericMenuPanel } from '@/components/GenericMenu';
88
import Popover_Arrow from '@/components/Assets/Icons/Popover-Arrow';
99
import { IconWrapper } from '@/components/IconWrapper/IconWrapper';
10+
import { useInputModality } from '@/hooks';
1011
import type { ArrowProps, ContextMenuItemProps } from './ContextMenu.types';
1112

1213
export const ContextMenu = (props: RightMenu.ContextMenuProps) => (
@@ -128,13 +129,15 @@ const ContextMenuContent = ({
128129
...props
129130
}: ContextMenuContentProps | ContextMenuSubContentProps) => {
130131
const ContentElement = sub ? RightMenu.SubContent : RightMenu.Content;
132+
const inputModalityProps = useInputModality();
131133
return (
132134
<RightMenu.Portal>
133135
<RightMenuContent
136+
{...props}
134137
$type="context-menu"
135138
$showArrow={showArrow}
136139
as={ContentElement}
137-
{...props}
140+
{...inputModalityProps}
138141
>
139142
{showArrow && (
140143
<Arrow

src/components/Dropdown/Dropdown.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
22
import { ReactNode } from 'react';
33
import { styled } from 'styled-components';
44
import { Arrow, GenericMenuItem, GenericMenuPanel } from '@/components/GenericMenu';
5+
import { useInputModality } from '@/hooks';
56
import Popover_Arrow from '@/components/Assets/Icons/Popover-Arrow';
67
import { IconWrapper } from '@/components/IconWrapper';
78
import { Icon } from '@/components/Icon';
@@ -114,17 +115,19 @@ const DropdownContent = ({
114115
...props
115116
}: DropdownContentProps | DropdownSubContentProps) => {
116117
const ContentElement = sub ? DropdownMenu.SubContent : DropdownMenu.Content;
118+
const inputModalityProps = useInputModality();
117119
return (
118120
<DropdownMenu.Portal>
119121
<DropdownMenuContent
122+
{...props}
120123
$type="dropdown-menu"
121124
$showArrow={showArrow}
122125
as={ContentElement}
123126
sideOffset={4}
124127
loop
125128
avoidCollisions={responsivePositioning}
126129
collisionPadding={responsivePositioning ? 100 : undefined}
127-
{...props}
130+
{...inputModalityProps}
128131
>
129132
{showArrow && (
130133
<Arrow

src/components/GenericMenu/GenericMenu.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ export const GenericMenuItem = styled.div<{ $type?: 'default' | 'danger' }>`
8282
overflow: hidden;
8383
white-space: nowrap;
8484
text-overflow: ellipsis;
85-
outline: none;
86-
&[aria-selected] {
85+
&:focus {
8786
outline: none;
8887
}
8988
@@ -101,6 +100,10 @@ export const GenericMenuItem = styled.div<{ $type?: 'default' | 'danger' }>`
101100
color:${theme.click.genericMenu.item.color[colorKey].text.hover};
102101
cursor: pointer;
103102
}
103+
[data-input-modality="keyboard"] &[data-highlighted] {
104+
outline: 2px solid ${theme.click.genericMenu.item.color[colorKey].stroke.focus};
105+
outline-offset: -2px;
106+
}
104107
&[data-state="open"] {
105108
background:${theme.click.genericMenu.item.color[colorKey].background.hover};
106109
color:${theme.click.genericMenu.item.color[colorKey].text.hover};

src/components/Select/common/InternalSelect.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { useOption, useSearch } from './useOption';
5858
import { mergeRefs } from '@/utils/mergeRefs';
5959
import { GenericMenuItem } from '@/components/GenericMenu';
6060
import { IconWrapper } from '@/components/IconWrapper';
61+
import { useInputModality } from '@/hooks';
6162
import { styled } from 'styled-components';
6263
import { getTextFromNodes } from '@/lib/getTextFromNodes';
6364

@@ -329,6 +330,7 @@ export const InternalSelect = ({
329330
}, [children, options, updateList]);
330331

331332
const inputRef = useRef<HTMLInputElement>(null);
333+
const inputModalityProps = useInputModality();
332334

333335
const onFocus = () => {
334336
inputRef.current?.focus();
@@ -474,6 +476,7 @@ export const InternalSelect = ({
474476
)}
475477
<Portal container={container}>
476478
<SelectPopoverContent
479+
{...inputModalityProps}
477480
sideOffset={5}
478481
onFocus={onFocus}
479482
onCloseAutoFocus={() => {

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { useUpdateEffect } from './useUpdateEffect';
22
export { useInitialTheme } from './useInitialTheme';
3+
export { useInputModality } from './useInputModality';
34
export { useCUITheme } from './useCUITheme';
45
export type { CUIThemeType } from './useCUITheme';
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { render } from '@testing-library/react';
2+
import { fireEvent } from '@testing-library/dom';
3+
import { useInputModality } from './useInputModality';
4+
5+
function TestContainer() {
6+
const props = useInputModality();
7+
return (
8+
<div
9+
data-testid="container"
10+
{...props}
11+
/>
12+
);
13+
}
14+
15+
describe('useInputModality', () => {
16+
function setup() {
17+
const { getByTestId } = render(<TestContainer />);
18+
return getByTestId('container') as HTMLElement;
19+
}
20+
21+
it('sets keyboard modality for navigation keys', () => {
22+
const el = setup();
23+
for (const key of ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab']) {
24+
fireEvent.keyDown(el, { key });
25+
expect(el.dataset.inputModality).toBe('keyboard');
26+
delete el.dataset.inputModality;
27+
}
28+
});
29+
30+
it('sets keyboard modality for typeahead characters', () => {
31+
const el = setup();
32+
for (const key of ['d', 'a', 'z']) {
33+
fireEvent.keyDown(el, { key });
34+
expect(el.dataset.inputModality).toBe('keyboard');
35+
delete el.dataset.inputModality;
36+
}
37+
});
38+
39+
it('sets keyboard modality for Space', () => {
40+
const el = setup();
41+
fireEvent.keyDown(el, { key: ' ' });
42+
expect(el.dataset.inputModality).toBe('keyboard');
43+
});
44+
45+
it('does not set keyboard modality for bare modifier keys', () => {
46+
const el = setup();
47+
for (const key of ['Meta', 'Control', 'Alt', 'Shift', 'CapsLock']) {
48+
fireEvent.keyDown(el, { key });
49+
expect(el.dataset.inputModality).toBeUndefined();
50+
}
51+
});
52+
53+
it('sets pointer modality on pointer move', () => {
54+
const el = setup();
55+
fireEvent.keyDown(el, { key: 'ArrowDown' });
56+
expect(el.dataset.inputModality).toBe('keyboard');
57+
58+
fireEvent.pointerMove(el);
59+
expect(el.dataset.inputModality).toBe('pointer');
60+
});
61+
62+
it('sets pointer modality on pointer down', () => {
63+
const el = setup();
64+
fireEvent.keyDown(el, { key: 'ArrowDown' });
65+
expect(el.dataset.inputModality).toBe('keyboard');
66+
67+
fireEvent.pointerDown(el);
68+
expect(el.dataset.inputModality).toBe('pointer');
69+
});
70+
71+
it('seeds modality from global state on focus after keyboard', () => {
72+
fireEvent.keyDown(document, { key: 'Enter' });
73+
74+
const el = setup();
75+
fireEvent.focusIn(el);
76+
77+
expect(el.dataset.inputModality).toBe('keyboard');
78+
});
79+
80+
it('seeds pointer modality from global state on focus after pointer', () => {
81+
fireEvent.pointerDown(document);
82+
83+
const el = setup();
84+
fireEvent.focusIn(el);
85+
86+
expect(el.dataset.inputModality).toBe('pointer');
87+
});
88+
});

src/hooks/useInputModality.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
type FocusEvent,
3+
type KeyboardEvent,
4+
type PointerEvent,
5+
useCallback,
6+
} from 'react';
7+
8+
const MODIFIER_KEYS = new Set(['Meta', 'Control', 'Alt', 'Shift', 'CapsLock']);
9+
10+
/**
11+
* WCAG SC 2.4.7 requires a visible keyboard focus indicator, but hover
12+
* styling is not mandated and adding a focus ring on hover would be
13+
* visually disruptive. Radix uses a single `data-highlighted` attribute
14+
* for both hover and keyboard focus, so we track input modality on the
15+
* container to let CSS distinguish the two.
16+
*
17+
* Spread the returned props onto a menu container element.
18+
*/
19+
20+
// Global modality tracks the last input method so that menus opened via
21+
// keyboard can seed their container's data attribute before any per-container
22+
// event fires (the triggering keydown happens on the trigger, not the content).
23+
let lastGlobalModality: 'keyboard' | 'pointer' = 'pointer';
24+
25+
if (typeof document !== 'undefined') {
26+
document.addEventListener(
27+
'keydown',
28+
(e: globalThis.KeyboardEvent) => {
29+
if (!MODIFIER_KEYS.has(e.key)) {
30+
lastGlobalModality = 'keyboard';
31+
}
32+
},
33+
true
34+
);
35+
document.addEventListener(
36+
'pointerdown',
37+
() => {
38+
lastGlobalModality = 'pointer';
39+
},
40+
true
41+
);
42+
document.addEventListener(
43+
'pointermove',
44+
() => {
45+
lastGlobalModality = 'pointer';
46+
},
47+
true
48+
);
49+
}
50+
51+
export function useInputModality() {
52+
const onKeyDownCapture = useCallback((e: KeyboardEvent<HTMLElement>) => {
53+
if (!MODIFIER_KEYS.has(e.key)) {
54+
e.currentTarget.dataset.inputModality = 'keyboard';
55+
}
56+
}, []);
57+
58+
const onPointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
59+
e.currentTarget.dataset.inputModality = 'pointer';
60+
}, []);
61+
62+
const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
63+
e.currentTarget.dataset.inputModality = 'pointer';
64+
}, []);
65+
66+
const onFocusCapture = useCallback((e: FocusEvent<HTMLElement>) => {
67+
e.currentTarget.dataset.inputModality = lastGlobalModality;
68+
}, []);
69+
70+
return { onKeyDownCapture, onPointerMove, onPointerDown, onFocusCapture };
71+
}

src/theme/tokens/variables.dark.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,7 @@ const theme = {
20482048
},
20492049
stroke: {
20502050
default: '#323232',
2051+
focus: '#faff69',
20512052
},
20522053
},
20532054
format: {
@@ -2078,6 +2079,7 @@ const theme = {
20782079
},
20792080
stroke: {
20802081
default: 'rgba(0, 0, 0, 0)',
2082+
focus: '#faff69',
20812083
},
20822084
},
20832085
},

src/theme/tokens/variables.light.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,7 @@ const theme = {
20332033
},
20342034
stroke: {
20352035
default: '#e6e7e9',
2036+
focus: '#437eef',
20362037
},
20372038
},
20382039
format: {
@@ -2060,6 +2061,10 @@ const theme = {
20602061
active: 'rgb(100% 13.725% 13.725% / 0.3)',
20612062
disabled: '#ffffff',
20622063
},
2064+
stroke: {
2065+
default: 'rgba(0, 0, 0, 0)',
2066+
focus: '#437eef',
2067+
},
20632068
},
20642069
},
20652070
},

0 commit comments

Comments
 (0)