Skip to content

Commit aea1bc2

Browse files
committed
Add comprehensive ARIA attributes for accessibility
1 parent afd0fd7 commit aea1bc2

File tree

5 files changed

+140
-12
lines changed

5 files changed

+140
-12
lines changed

src/core/utils/RovingFocusGroup/fragments/RovingFocusGroup.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import { RovingFocusGroupContext } from '../context/RovingFocusGroupContext';
66
/**
77
* Props for the RovingFocusGroup component
88
* @property {React.ReactNode} children - Child components (should include RovingFocusItem components)
9+
* @property {string} [aria-label] - Accessible label for this specific group
10+
* @property {string} [aria-labelledby] - ID of an element that labels this specific group
911
*/
1012
type RovingFocusGroupProps = {
1113
children: React.ReactNode;
14+
'aria-label'?: string;
15+
'aria-labelledby'?: string;
1216
} & React.HTMLAttributes<HTMLDivElement>;
1317

1418
/**
@@ -19,12 +23,17 @@ type RovingFocusGroupProps = {
1923
* Each group maintains its own list of focusable items and tracks which item has focus.
2024
*
2125
* @example
22-
* <RovingFocusGroup className="flex gap-2">
26+
* <RovingFocusGroup className="flex gap-2" aria-label="Navigation section">
2327
* <RovingFocusItem><Button>Item 1</Button></RovingFocusItem>
2428
* <RovingFocusItem><Button>Item 2</Button></RovingFocusItem>
2529
* </RovingFocusGroup>
2630
*/
27-
const RovingFocusGroup = ({ children, ...props }: RovingFocusGroupProps) => {
31+
const RovingFocusGroup = ({
32+
children,
33+
'aria-label': ariaLabel,
34+
'aria-labelledby': ariaLabelledBy,
35+
...props
36+
}: RovingFocusGroupProps) => {
2837
const groupRef = useRef<HTMLDivElement>(null);
2938
const [focusItems, setFocusItems] = useState<string[]>([]);
3039
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
@@ -55,7 +64,14 @@ const RovingFocusGroup = ({ children, ...props }: RovingFocusGroupProps) => {
5564
};
5665

5766
return <RovingFocusGroupContext.Provider value={sendValues}>
58-
<Primitive.div id={groupId} ref={groupRef} {...props}>
67+
<Primitive.div
68+
id={groupId}
69+
ref={groupRef}
70+
role="group"
71+
aria-label={ariaLabel}
72+
aria-labelledby={ariaLabelledBy}
73+
{...props}
74+
>
5975
{children}
6076
</Primitive.div>
6177
</RovingFocusGroupContext.Provider>;

src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import { RovingFocusRootContext } from '../context/RovingFocusRootContext';
88
/**
99
* Props for the RovingFocusItem component
1010
* @property {React.ReactNode} children - Child component that will receive focus (usually a button or other interactive element)
11+
* @property {string} [aria-label] - Accessible label for this item
12+
* @property {string} [aria-labelledby] - ID of an element that labels this item
1113
*/
1214
type RovingFocusItemProps = {
1315
children: React.ReactNode;
16+
'aria-label'?: string;
17+
'aria-labelledby'?: string;
1418
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
1519

1620
/**
@@ -22,15 +26,20 @@ type RovingFocusItemProps = {
2226
* Automatically respects the disabled state of child elements.
2327
*
2428
* @example
25-
* <RovingFocusItem>
29+
* <RovingFocusItem aria-label="Navigation option">
2630
* <Button>Focusable Item</Button>
2731
* </RovingFocusItem>
2832
*
2933
* <RovingFocusItem>
3034
* <Button disabled>Disabled Item (skipped during navigation)</Button>
3135
* </RovingFocusItem>
3236
*/
33-
const RovingFocusItem = forwardRef<HTMLButtonElement, RovingFocusItemProps>(({ children, ...props }, ref) => {
37+
const RovingFocusItem = forwardRef<HTMLButtonElement, RovingFocusItemProps>(({
38+
children,
39+
'aria-label': ariaLabel,
40+
'aria-labelledby': ariaLabelledBy,
41+
...props
42+
}, ref) => {
3443
const id = useId();
3544
const { focusedItemId, setFocusedItemId, addFocusItem, focusItems, groupRef } = useContext(RovingFocusGroupContext);
3645
const { direction, loop } = useContext(RovingFocusRootContext);
@@ -40,6 +49,9 @@ const RovingFocusItem = forwardRef<HTMLButtonElement, RovingFocusItemProps>(({ c
4049
const child = childrenArray[0] as React.ReactElement;
4150
const isDisabled = child?.props?.disabled === true;
4251

52+
// Is this item currently selected
53+
const isSelected = focusedItemId === id;
54+
4355
// Register this item with the parent group
4456
useEffect(() => {
4557
// we check if the item is in the focusItems array, if not we add it
@@ -226,11 +238,16 @@ const RovingFocusItem = forwardRef<HTMLButtonElement, RovingFocusItemProps>(({ c
226238
return <Primitive.button
227239
asChild
228240
onFocus={handleFocus}
229-
tabIndex={!isDisabled && focusedItemId === id ? 0 : -1}
241+
tabIndex={!isDisabled && isSelected ? 0 : -1}
230242
ref={ref}
231243
id={id}
232244
onKeyDown={handleKeyDown}
233245
data-child-disabled={isDisabled}
246+
role="option"
247+
aria-selected={isSelected}
248+
aria-disabled={isDisabled}
249+
aria-label={ariaLabel}
250+
aria-labelledby={ariaLabelledBy}
234251
{...props}
235252
>
236253
{children}

src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ import { RovingFocusRootContext } from '../context/RovingFocusRootContext';
99
* @property {React.ReactNode} children - Child components (should include RovingFocusGroup)
1010
* @property {'horizontal'|'vertical'} [direction='horizontal'] - Direction of keyboard navigation
1111
* @property {boolean} [loop=true] - Whether focus should loop from last to first item and vice versa
12+
* @property {string} [aria-label] - Accessible label for the roving focus group
13+
* @property {string} [aria-labelledby] - ID of an element that labels the roving focus group
1214
*/
1315
type RovingFocusRootProps = {
1416
children: React.ReactNode;
1517
direction?: 'horizontal' | 'vertical';
1618
loop?: boolean;
19+
'aria-label'?: string;
20+
'aria-labelledby'?: string;
1721
} & React.HTMLAttributes<HTMLDivElement>;
1822

1923
/**
@@ -23,21 +27,34 @@ type RovingFocusRootProps = {
2327
* Manages overall direction (horizontal/vertical) and loop behavior.
2428
*
2529
* @example
26-
* <RovingFocusRoot direction="horizontal" loop={true}>
30+
* <RovingFocusRoot direction="horizontal" loop={true} aria-label="Main navigation">
2731
* <RovingFocusGroup>
2832
* <RovingFocusItem><Button>Item 1</Button></RovingFocusItem>
2933
* <RovingFocusItem><Button>Item 2</Button></RovingFocusItem>
3034
* </RovingFocusGroup>
3135
* </RovingFocusRoot>
3236
*/
33-
const RovingFocusRoot = ({ children, direction = 'horizontal', loop = true, ...props }: RovingFocusRootProps) => {
37+
const RovingFocusRoot = ({
38+
children,
39+
direction = 'horizontal',
40+
loop = true,
41+
'aria-label': ariaLabel,
42+
'aria-labelledby': ariaLabelledBy,
43+
...props
44+
}: RovingFocusRootProps) => {
3445
const sendValues = {
3546
direction,
3647
loop
3748
};
3849

3950
return <RovingFocusRootContext.Provider value={sendValues}>
40-
<Primitive.div {...props}>
51+
<Primitive.div
52+
role="listbox"
53+
aria-orientation={direction}
54+
aria-label={ariaLabel}
55+
aria-labelledby={ariaLabelledBy}
56+
{...props}
57+
>
4158
{children}
4259
</Primitive.div>
4360
</RovingFocusRootContext.Provider>;

src/core/utils/RovingFocusGroup/index.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,21 @@ import RovingFocusItem from './fragments/RovingFocusItem';
1616
* - Optional looping when reaching the end of a group
1717
* - Prevents default scrolling behavior when using arrow keys
1818
* - Automatically respects disabled state of child elements
19+
* - Comprehensive ARIA roles and attributes for screen readers
1920
*
2021
* Accessibility benefits:
2122
* - Follows WAI-ARIA best practices for keyboard navigation
2223
* - Improves navigation for keyboard and screen reader users
2324
* - Maintains a single tab stop within a group of related elements
2425
* - Properly identifies and skips disabled elements during navigation
26+
* - Uses appropriate ARIA roles (listbox/option pattern)
27+
* - Provides aria-selected state for the currently focused item
28+
* - Supports proper labeling with aria-label and aria-labelledby
2529
*
2630
* @example
27-
* <RovingFocusGroup.Root direction="horizontal" loop={true}>
28-
* <RovingFocusGroup.Group className="flex gap-2">
29-
* <RovingFocusGroup.Item>
31+
* <RovingFocusGroup.Root direction="horizontal" loop={true} aria-label="Main Menu">
32+
* <RovingFocusGroup.Group className="flex gap-2" aria-label="Navigation Section">
33+
* <RovingFocusGroup.Item aria-label="First option">
3034
* <Button>Option 1</Button>
3135
* </RovingFocusGroup.Item>
3236
* <RovingFocusGroup.Item>

src/core/utils/RovingFocusGroup/stories/RovingFocusGroup.stories.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,77 @@ export const DisabledItems = {
254254
</SandboxEditor>
255255
)
256256
};
257+
258+
// Story demonstrating the ARIA roles and attributes for screen reader accessibility
259+
export const AccessibilityDemo = {
260+
render: () => (
261+
<SandboxEditor className="space-y-8 bg-gray-50">
262+
<div className="p-4 bg-gray-50 rounded-md border border-gray-300">
263+
<h2 className="text-lg font-semibold mb-2">Screen Reader Accessibility</h2>
264+
<p className="mb-1">This example demonstrates ARIA roles and attributes for screen reader accessibility.</p>
265+
<p className="mb-1"><strong>Features:</strong> Proper ARIA roles, labels, and states for screen readers.</p>
266+
<p className="text-sm text-gray-600">The component uses listbox/option pattern with aria-selected state.</p>
267+
</div>
268+
269+
<div className="space-y-6">
270+
<div>
271+
<h3 className="text-md font-medium mb-2">ARIA Labeled Navigation Example</h3>
272+
<RovingFocusGroup.Root
273+
direction="horizontal"
274+
loop={true}
275+
aria-label="Main navigation menu"
276+
className="rounded-md border border-blue-500 p-4"
277+
>
278+
<RovingFocusGroup.Group
279+
className="flex gap-3"
280+
aria-label="Primary actions"
281+
>
282+
<RovingFocusGroup.Item aria-label="Dashboard view">
283+
<Button>Dashboard</Button>
284+
</RovingFocusGroup.Item>
285+
<RovingFocusGroup.Item aria-label="User profile settings">
286+
<Button>Profile</Button>
287+
</RovingFocusGroup.Item>
288+
<RovingFocusGroup.Item aria-label="System settings">
289+
<Button>Settings</Button>
290+
</RovingFocusGroup.Item>
291+
<RovingFocusGroup.Item>
292+
<Button disabled className="opacity-50">Admin (Disabled)</Button>
293+
</RovingFocusGroup.Item>
294+
</RovingFocusGroup.Group>
295+
</RovingFocusGroup.Root>
296+
</div>
297+
298+
<div>
299+
<h3 className="text-md font-medium mb-2">ARIA Attributes Explained</h3>
300+
<div className="grid grid-cols-2 gap-4 text-sm border border-gray-200 p-4 rounded-md">
301+
<div className="font-semibold">Root Component</div>
302+
<div>role="listbox", aria-orientation, aria-label</div>
303+
304+
<div className="font-semibold">Group Component</div>
305+
<div>role="group", aria-label</div>
306+
307+
<div className="font-semibold">Item Component</div>
308+
<div>role="option", aria-selected, aria-disabled</div>
309+
310+
<div className="font-semibold">Navigation</div>
311+
<div>Arrow keys, Home/End, with proper ARIA states</div>
312+
</div>
313+
</div>
314+
</div>
315+
316+
<div className="p-4 bg-gray-50 rounded-md border border-gray-300">
317+
<h3 className="text-md font-medium mb-2">Screen Reader Testing Instructions</h3>
318+
<p className="text-sm text-gray-600">Use VoiceOver (Mac) or NVDA/JAWS (Windows) to test the component.</p>
319+
<p className="text-sm text-gray-600 mt-1">Tab to the group, then use arrow keys to navigate between options.</p>
320+
<p className="text-sm text-gray-600 mt-1">The screen reader should announce:</p>
321+
<ul className="list-disc pl-5 text-sm text-gray-600 mt-1">
322+
<li>When you enter the navigation menu</li>
323+
<li>The name of each option as you navigate</li>
324+
<li>The selected state of the focused option</li>
325+
<li>When an option is disabled</li>
326+
</ul>
327+
</div>
328+
</SandboxEditor>
329+
)
330+
};

0 commit comments

Comments
 (0)