Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kind-menus-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clickhouse/click-ui': patch
---

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.
5 changes: 4 additions & 1 deletion src/components/ContextMenu/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Arrow, GenericMenuItem, GenericMenuPanel } from '@/components/GenericMenu';
import Popover_Arrow from '@/components/Assets/Icons/Popover-Arrow';
import { IconWrapper } from '@/components/IconWrapper/IconWrapper';
import { useInputModality } from '@/hooks';
import type { ArrowProps, ContextMenuItemProps } from './ContextMenu.types';

export const ContextMenu = (props: RightMenu.ContextMenuProps) => (
Expand Down Expand Up @@ -122,19 +123,21 @@
showArrow,
// TODO: remove deprecated side and align
// eslint-disable-next-line @typescript-eslint/no-unused-vars
side,

Check warning on line 126 in src/components/ContextMenu/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

// eslint-disable-next-line @typescript-eslint/no-unused-vars
align,

Check warning on line 128 in src/components/ContextMenu/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

...props
}: ContextMenuContentProps | ContextMenuSubContentProps) => {
const ContentElement = sub ? RightMenu.SubContent : RightMenu.Content;
const inputModalityProps = useInputModality();
return (
<RightMenu.Portal>
<RightMenuContent
{...props}
$type="context-menu"
$showArrow={showArrow}
as={ContentElement}
{...props}
{...inputModalityProps}
>
{showArrow && (
<Arrow
Expand Down
5 changes: 4 additions & 1 deletion src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { ReactNode } from 'react';
import { styled } from 'styled-components';
import { Arrow, GenericMenuItem, GenericMenuPanel } from '@/components/GenericMenu';
import { useInputModality } from '@/hooks';
import Popover_Arrow from '@/components/Assets/Icons/Popover-Arrow';
import { IconWrapper } from '@/components/IconWrapper';
import { Icon } from '@/components/Icon';
Expand Down Expand Up @@ -114,17 +115,19 @@ const DropdownContent = ({
...props
}: DropdownContentProps | DropdownSubContentProps) => {
const ContentElement = sub ? DropdownMenu.SubContent : DropdownMenu.Content;
const inputModalityProps = useInputModality();
return (
<DropdownMenu.Portal>
<DropdownMenuContent
{...props}
$type="dropdown-menu"
$showArrow={showArrow}
as={ContentElement}
sideOffset={4}
loop
avoidCollisions={responsivePositioning}
collisionPadding={responsivePositioning ? 100 : undefined}
{...props}
{...inputModalityProps}
>
{showArrow && (
<Arrow
Expand Down
7 changes: 5 additions & 2 deletions src/components/GenericMenu/GenericMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@ export const GenericMenuItem = styled.div<{ $type?: 'default' | 'danger' }>`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
outline: none;
&[aria-selected] {
&:focus {
outline: none;
}

Expand All @@ -101,6 +100,10 @@ export const GenericMenuItem = styled.div<{ $type?: 'default' | 'danger' }>`
color:${theme.click.genericMenu.item.color[colorKey].text.hover};
cursor: pointer;
}
[data-input-modality="keyboard"] &[data-highlighted] {
outline: 2px solid ${theme.click.genericMenu.item.color[colorKey].stroke.focus};
outline-offset: -2px;
}
&[data-state="open"] {
background:${theme.click.genericMenu.item.color[colorKey].background.hover};
color:${theme.click.genericMenu.item.color[colorKey].text.hover};
Expand Down
3 changes: 3 additions & 0 deletions src/components/Select/common/InternalSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { useOption, useSearch } from './useOption';
import { mergeRefs } from '@/utils/mergeRefs';
import { GenericMenuItem } from '@/components/GenericMenu';
import { IconWrapper } from '@/components/IconWrapper';
import { useInputModality } from '@/hooks';
import { styled } from 'styled-components';
import { getTextFromNodes } from '@/lib/getTextFromNodes';

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

const inputRef = useRef<HTMLInputElement>(null);
const inputModalityProps = useInputModality();

const onFocus = () => {
inputRef.current?.focus();
Expand Down Expand Up @@ -474,6 +476,7 @@ export const InternalSelect = ({
)}
<Portal container={container}>
<SelectPopoverContent
{...inputModalityProps}
sideOffset={5}
onFocus={onFocus}
onCloseAutoFocus={() => {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useUpdateEffect } from './useUpdateEffect';
export { useInitialTheme } from './useInitialTheme';
export { useInputModality } from './useInputModality';
export { useCUITheme } from './useCUITheme';
export type { CUIThemeType } from './useCUITheme';
88 changes: 88 additions & 0 deletions src/hooks/useInputModality.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { useInputModality } from './useInputModality';

function TestContainer() {

Check warning on line 5 in src/hooks/useInputModality.test.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

Prefer using arrow functions over plain functions
const props = useInputModality();
return (
<div
data-testid="container"
{...props}
/>
);
}

describe('useInputModality', () => {
function setup() {

Check warning on line 16 in src/hooks/useInputModality.test.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

Prefer using arrow functions over plain functions
const { getByTestId } = render(<TestContainer />);
return getByTestId('container') as HTMLElement;
}

it('sets keyboard modality for navigation keys', () => {
const el = setup();
for (const key of ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab']) {
fireEvent.keyDown(el, { key });
expect(el.dataset.inputModality).toBe('keyboard');
delete el.dataset.inputModality;
}
});

it('sets keyboard modality for typeahead characters', () => {
const el = setup();
for (const key of ['d', 'a', 'z']) {
fireEvent.keyDown(el, { key });
expect(el.dataset.inputModality).toBe('keyboard');
delete el.dataset.inputModality;
}
});

it('sets keyboard modality for Space', () => {
const el = setup();
fireEvent.keyDown(el, { key: ' ' });
expect(el.dataset.inputModality).toBe('keyboard');
});

it('does not set keyboard modality for bare modifier keys', () => {
const el = setup();
for (const key of ['Meta', 'Control', 'Alt', 'Shift', 'CapsLock']) {
fireEvent.keyDown(el, { key });
expect(el.dataset.inputModality).toBeUndefined();
}
});

it('sets pointer modality on pointer move', () => {
const el = setup();
fireEvent.keyDown(el, { key: 'ArrowDown' });
expect(el.dataset.inputModality).toBe('keyboard');

fireEvent.pointerMove(el);
expect(el.dataset.inputModality).toBe('pointer');
});

it('sets pointer modality on pointer down', () => {
const el = setup();
fireEvent.keyDown(el, { key: 'ArrowDown' });
expect(el.dataset.inputModality).toBe('keyboard');

fireEvent.pointerDown(el);
expect(el.dataset.inputModality).toBe('pointer');
});

it('seeds modality from global state on focus after keyboard', () => {
fireEvent.keyDown(document, { key: 'Enter' });

const el = setup();
fireEvent.focusIn(el);

expect(el.dataset.inputModality).toBe('keyboard');
});

it('seeds pointer modality from global state on focus after pointer', () => {
fireEvent.pointerDown(document);

const el = setup();
fireEvent.focusIn(el);

expect(el.dataset.inputModality).toBe('pointer');
});
});
71 changes: 71 additions & 0 deletions src/hooks/useInputModality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
type FocusEvent,
type KeyboardEvent,
type PointerEvent,
useCallback,
} from 'react';

const MODIFIER_KEYS = new Set(['Meta', 'Control', 'Alt', 'Shift', 'CapsLock']);

/**
* WCAG SC 2.4.7 requires a visible keyboard focus indicator, but hover
* styling is not mandated and adding a focus ring on hover would be
* visually disruptive. Radix uses a single `data-highlighted` attribute
* for both hover and keyboard focus, so we track input modality on the
* container to let CSS distinguish the two.
*
* Spread the returned props onto a menu container element.
*/

// Global modality tracks the last input method so that menus opened via
// keyboard can seed their container's data attribute before any per-container
// event fires (the triggering keydown happens on the trigger, not the content).
let lastGlobalModality: 'keyboard' | 'pointer' = 'pointer';

if (typeof document !== 'undefined') {
document.addEventListener(
'keydown',
(e: globalThis.KeyboardEvent) => {
if (!MODIFIER_KEYS.has(e.key)) {
lastGlobalModality = 'keyboard';
}
},
true
);
document.addEventListener(
'pointerdown',
() => {
lastGlobalModality = 'pointer';
},
true
);
document.addEventListener(
'pointermove',
() => {
lastGlobalModality = 'pointer';
},
true
);
}

export function useInputModality() {

Check warning on line 51 in src/hooks/useInputModality.ts

View workflow job for this annotation

GitHub Actions / code-quality-checks

Prefer using arrow functions over plain functions
const onKeyDownCapture = useCallback((e: KeyboardEvent<HTMLElement>) => {
if (!MODIFIER_KEYS.has(e.key)) {
e.currentTarget.dataset.inputModality = 'keyboard';
}
}, []);
Comment thread
dustinhealy marked this conversation as resolved.

const onPointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
e.currentTarget.dataset.inputModality = 'pointer';
}, []);
Comment thread
dustinhealy marked this conversation as resolved.

const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
e.currentTarget.dataset.inputModality = 'pointer';
}, []);

const onFocusCapture = useCallback((e: FocusEvent<HTMLElement>) => {
e.currentTarget.dataset.inputModality = lastGlobalModality;
}, []);

return { onKeyDownCapture, onPointerMove, onPointerDown, onFocusCapture };
}
2 changes: 2 additions & 0 deletions src/theme/tokens/variables.dark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2048,6 +2048,7 @@ const theme = {
},
stroke: {
default: '#323232',
focus: '#faff69',
},
},
format: {
Expand Down Expand Up @@ -2078,6 +2079,7 @@ const theme = {
},
stroke: {
default: 'rgba(0, 0, 0, 0)',
focus: '#faff69',
},
},
},
Expand Down
5 changes: 5 additions & 0 deletions src/theme/tokens/variables.light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,7 @@ const theme = {
},
stroke: {
default: '#e6e7e9',
focus: '#437eef',
},
},
format: {
Expand Down Expand Up @@ -2060,6 +2061,10 @@ const theme = {
active: 'rgb(100% 13.725% 13.725% / 0.3)',
disabled: '#ffffff',
},
stroke: {
default: 'rgba(0, 0, 0, 0)',
focus: '#437eef',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dustinhealy I missed this one. For tokens, we have to update them in the Figma file. The process will be revised soon to avoid confusion, but just for your interest, as it seems these values were hard typed, so lost on the latest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know - thanks Helder. In the meantime, feel free to let me know if I need to make any changes / open a new PR to account for this or anything else.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dustinhealy Not really, did a quick tweak for now #810 (started migrating out from styled components). Once I get the semantic versions for the tokens to reduce the file sizes and make it simpler, I'll tag you. Thanks!

},
},
},
},
Expand Down
Loading