Skip to content

Commit 26eff58

Browse files
authored
RovingFocusGroup + Refactor Tabs component to use RovingFocusGroup (#876)
1 parent d1d4fdd commit 26eff58

File tree

11 files changed

+883
-58
lines changed

11 files changed

+883
-58
lines changed

src/components/tools/SandboxEditor/SandboxEditor.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import React, { PropsWithChildren, useEffect, useState } from 'react';
1+
import React, { forwardRef, PropsWithChildren, useEffect, useState } from 'react';
22
import Button from '~/components/ui/Button/Button';
33
import Separator from '~/components/ui/Separator/Separator';
44
import Heading from '~/components/ui/Heading/Heading';
55
import Text from '~/components/ui/Text/Text';
66

7+
import RovingFocusGroup from '~/core/utils/RovingFocusGroup';
8+
79
import colors from '~/colors';
810

911
const SunIcon = () => {
@@ -22,11 +24,11 @@ type ColorSelectProps = {color:typeof colors[keyof typeof colors], colorName: st
2224

2325
const ColorSelect = ({ color, colorName, changeAccentColor }: ColorSelectProps) => {
2426
const dimensions = 32;
25-
return <button
26-
onClick={() => changeAccentColor(colorName)}
27+
return <span
2728
aria-label={`Change accent color to ${colorName}`}
28-
className='cursor-pointer rounded-full hover:border-gray-700 border'
29-
style={{ width: dimensions, height: dimensions, backgroundColor: color.light['900'] }}></button>;
29+
className='cursor-pointer rounded-full inline-flex'
30+
style={{ width: dimensions, height: dimensions, backgroundColor: color.light['900'] }}
31+
></span>;
3032
};
3133

3234
type SandboxProps = {className?: string | ''} & PropsWithChildren
@@ -54,7 +56,7 @@ const SandboxEditor = ({ children, className } : SandboxProps) => {
5456
<RadUILogo/>
5557
</div>
5658
<Separator orientation='vertical' />
57-
<Button description="Click this button" variant="outline" onClick={toggleDarkMode}>{isDarkMode ? <SunIcon/> : <MoonIcon/>}</Button>
59+
<Button description="Click this button" variant="solid" onClick={toggleDarkMode}>{isDarkMode ? <SunIcon/> : <MoonIcon/>}</Button>
5860
</div>
5961
<Separator />
6062
<div>
@@ -66,14 +68,25 @@ const SandboxEditor = ({ children, className } : SandboxProps) => {
6668
</Text>
6769
</div>
6870
<Separator />
69-
<div className='flex space-x-2 my-1'>
70-
{Object.keys(colors).map((color, idx) => {
71-
const colorName = color as AvailableColors;
72-
return <ColorSelect changeAccentColor={() => setColorName(colorName)} colorName={color} color={colors[colorName]} key={idx} />;
73-
}
74-
)}
75-
76-
</div>
71+
<RovingFocusGroup.Root >
72+
<RovingFocusGroup.Group className='flex space-x-1 my-1'>
73+
{Object.keys(colors).map((color, idx) => {
74+
const colorName = color as AvailableColors;
75+
return <RovingFocusGroup.Item
76+
key={idx}
77+
className='cursor-pointer rounded-full inline-block w-8 h-8 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
78+
onFocus={() => setColorName(colorName)}
79+
onClick={() => setColorName(colorName)}
80+
>
81+
<button>
82+
<ColorSelect colorName={color} color={colors[colorName]}/>
83+
</button>
84+
</RovingFocusGroup.Item>;
85+
}
86+
)}
87+
</RovingFocusGroup.Group>
88+
89+
</RovingFocusGroup.Root>
7790
</div>
7891
</div>
7992
<Separator/>

src/components/ui/Tabs/fragments/TabList.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { clsx } from 'clsx';
44
import { TabProps } from '../types';
55
import TabsRootContext from '../context/TabsRootContext';
66

7+
import RovingFocusGroup from '~/core/utils/RovingFocusGroup';
8+
79
const COMPONENT_NAME = 'TabList';
810

911
// Define the Tab type if it's not imported
@@ -20,9 +22,9 @@ export type TabListProps = {
2022

2123
const TabList = ({ className = '', children }: TabListProps) => {
2224
const { rootClass } = useContext(TabsRootContext);
23-
return <div role="tablist" aria-orientation='horizontal' aria-label="todo" className={clsx(`${rootClass}-list`, className)}>
25+
return <RovingFocusGroup.Group role="tablist" aria-orientation='horizontal' aria-label="todo" className={clsx(`${rootClass}-list`, className)}>
2426
{children}
25-
</div>;
27+
</RovingFocusGroup.Group>;
2628
};
2729

2830
TabList.displayName = COMPONENT_NAME;

src/components/ui/Tabs/fragments/TabRoot.tsx

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import React, { useState, useRef } from 'react';
33
import { customClassSwitcher } from '~/core';
44
import { clsx } from 'clsx';
55
import TabsRootContext from '../context/TabsRootContext';
6-
import { getAllBatchElements, getNextBatchItem, getPrevBatchItem } from '~/core/batches';
6+
7+
import RovingFocusGroup from '~/core/utils/RovingFocusGroup';
78

89
const COMPONENT_NAME = 'Tabs';
910

@@ -14,33 +15,19 @@ const TabRoot = ({ children, defaultTab = '', customRootClass, tabs = [], classN
1415

1516
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0].value || '');
1617

17-
const nextTab = () => {
18-
const batches = getAllBatchElements(tabRef?.current);
19-
const nextItem = getNextBatchItem(batches);
20-
nextItem.focus();
21-
};
22-
23-
const previousTab = () => {
24-
const batches = getAllBatchElements(tabRef?.current);
25-
const prevItem = getPrevBatchItem(batches);
26-
prevItem.focus();
27-
};
28-
2918
const contextValues = {
3019
rootClass,
3120
activeTab,
3221
setActiveTab,
33-
nextTab,
34-
previousTab,
3522
tabs
3623
};
3724

3825
return (
39-
<TabsRootContext.Provider
40-
value={contextValues}>
41-
<div ref={tabRef} className={clsx(rootClass, className)} data-accent-color={color} {...props} >
26+
<TabsRootContext.Provider value={contextValues}>
27+
<RovingFocusGroup.Root direction="horizontal" loop ref={tabRef} className={clsx(rootClass, className)} data-accent-color={color} {...props}>
4228
{children}
43-
</div>
29+
</RovingFocusGroup.Root>
30+
4431
</TabsRootContext.Provider>
4532
);
4633
};

src/components/ui/Tabs/fragments/TabTrigger.tsx

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { TabProps } from '../types';
55

66
import TabsRootContext from '../context/TabsRootContext';
77

8+
import RovingFocusGroup from '~/core/utils/RovingFocusGroup';
9+
810
export type TabTriggerProps = {
911
tab: TabProps;
1012
setActiveTab: React.Dispatch<Tab>;
@@ -17,24 +19,11 @@ export type TabTriggerProps = {
1719

1820
const TabTrigger = ({ tab, className = '', ...props }: TabTriggerProps) => {
1921
// use context
20-
const { previousTab, nextTab, activeTab, setActiveTab, rootClass } = useContext(TabsRootContext);
22+
const { activeTab, setActiveTab, rootClass } = useContext(TabsRootContext);
2123
const ref = useRef<HTMLButtonElement>(null);
2224

2325
const isActive = activeTab === tab.value;
2426

25-
const handleClick = (tab: TabProps) => {
26-
setActiveTab(tab.value);
27-
};
28-
29-
const handleKeyDownEvent = (e: React.KeyboardEvent) => {
30-
if (e.key === 'ArrowLeft') {
31-
previousTab();
32-
}
33-
if (e.key === 'ArrowRight') {
34-
nextTab();
35-
}
36-
};
37-
3827
const handleFocus = (tab: TabProps) => {
3928
if (ref.current) {
4029
ref.current?.focus();
@@ -47,17 +36,15 @@ const TabTrigger = ({ tab, className = '', ...props }: TabTriggerProps) => {
4736
};
4837

4938
return (
50-
<button
51-
ref={ref}
52-
role="tab" className={clsx(`${rootClass}-trigger`, `${isActive ? 'active' : ''}`, className)} {...props} onKeyDown={handleKeyDownEvent}
53-
onClick={() => handleClick(tab)}
39+
<RovingFocusGroup.Item
5440
onFocus={() => handleFocus(tab)}
55-
tabIndex={isActive ? 0 : -1}
56-
data-rad-ui-batch-element
57-
5841
>
59-
{tab.label}
60-
</button>
42+
<button
43+
ref={ref}
44+
className={clsx(`${rootClass}-trigger`, `${isActive ? 'active' : ''}`, className)} role="tab"{...props}>
45+
{tab.label}
46+
</button>
47+
</RovingFocusGroup.Item>
6148
);
6249
};
6350

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { createContext, RefObject } from 'react';
2+
3+
/**
4+
* Type definition for the RovingFocusGroup context values
5+
* @property {string|null} focusedItemId - ID of the currently focused item
6+
* @property {Function} setFocusedItemId - Function to update the focused item ID
7+
* @property {string[]} focusItems - Array of all item IDs in the focus group
8+
* @property {Function} setFocusItems - Function to update the array of focus items
9+
* @property {Function} addFocusItem - Function to add a new item to the focus group
10+
* @property {RefObject<HTMLDivElement>|null} groupRef - Reference to the group container element
11+
*/
12+
export type RovingFocusGroupContextTypes = {
13+
focusedItemId: string | null;
14+
setFocusedItemId: (id: string | null) => void;
15+
focusItems: string[];
16+
setFocusItems: React.Dispatch<React.SetStateAction<string[]>>;
17+
addFocusItem: (id: string) => void;
18+
groupRef: RefObject<HTMLDivElement> | null;
19+
}
20+
21+
/**
22+
* Context that manages the state for a single RovingFocusGroup
23+
* Tracks which items are in the group and which one is currently focused
24+
* Used by child RovingFocusItem components to coordinate focus management
25+
*/
26+
export const RovingFocusGroupContext = createContext<RovingFocusGroupContextTypes>({
27+
focusedItemId: null,
28+
setFocusedItemId: () => {},
29+
focusItems: [],
30+
setFocusItems: () => {},
31+
addFocusItem: () => {},
32+
groupRef: null
33+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createContext } from 'react';
2+
3+
/**
4+
* Type definition for the RovingFocusRoot context values
5+
* @property {('horizontal'|'vertical')} direction - The navigation direction for arrow keys
6+
* @property {boolean} loop - Whether focus should loop from last to first item and vice versa
7+
*/
8+
export type RovingFocusRootContextTypes = {
9+
direction: 'horizontal' | 'vertical';
10+
loop: boolean;
11+
}
12+
13+
/**
14+
* Context that manages the root-level configuration for the RovingFocusGroup
15+
* Provides direction and loop behavior settings to all nested groups and items
16+
*/
17+
export const RovingFocusRootContext = createContext<RovingFocusRootContextTypes>({
18+
direction: 'horizontal',
19+
loop: true
20+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React, { useEffect, useState, useId, useRef } from 'react';
2+
import Primitive from '~/core/primitives/Primitive';
3+
4+
import { RovingFocusGroupContext } from '../context/RovingFocusGroupContext';
5+
6+
/**
7+
* Props for the RovingFocusGroup component
8+
* @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
11+
*/
12+
type RovingFocusGroupProps = {
13+
children: React.ReactNode;
14+
'aria-label'?: string;
15+
'aria-labelledby'?: string;
16+
} & React.HTMLAttributes<HTMLDivElement>;
17+
18+
/**
19+
* Group component for the roving focus pattern
20+
*
21+
* Manages the state of focusable items within a group and handles focus tracking.
22+
* Multiple groups can exist within a single RovingFocusRoot.
23+
* Each group maintains its own list of focusable items and tracks which item has focus.
24+
*
25+
* @example
26+
* <RovingFocusGroup className="flex gap-2" aria-label="Navigation section">
27+
* <RovingFocusItem><Button>Item 1</Button></RovingFocusItem>
28+
* <RovingFocusItem><Button>Item 2</Button></RovingFocusItem>
29+
* </RovingFocusGroup>
30+
*/
31+
const RovingFocusGroup = ({
32+
children,
33+
'aria-label': ariaLabel,
34+
'aria-labelledby': ariaLabelledBy,
35+
...props
36+
}: RovingFocusGroupProps) => {
37+
const groupRef = useRef<HTMLDivElement>(null);
38+
const [focusItems, setFocusItems] = useState<string[]>([]);
39+
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
40+
const groupId = useId();
41+
42+
/**
43+
* Adds a new focusable item to the group
44+
* @param id - Unique identifier for the item
45+
*/
46+
const addFocusItem = (id: string) => {
47+
setFocusItems((prev) => [...prev, id]);
48+
};
49+
50+
// Set initial focus to the first item when items are added
51+
useEffect(() => {
52+
if (!focusedItemId && focusItems.length > 0) {
53+
setFocusedItemId(focusItems[0]);
54+
}
55+
}, [focusItems, focusedItemId]);
56+
57+
const sendValues = {
58+
focusedItemId,
59+
setFocusedItemId,
60+
focusItems,
61+
setFocusItems,
62+
addFocusItem,
63+
groupRef
64+
};
65+
66+
return <RovingFocusGroupContext.Provider value={sendValues}>
67+
<Primitive.div
68+
id={groupId}
69+
ref={groupRef}
70+
role="group"
71+
aria-label={ariaLabel}
72+
aria-labelledby={ariaLabelledBy}
73+
{...props}
74+
>
75+
{children}
76+
</Primitive.div>
77+
</RovingFocusGroupContext.Provider>;
78+
};
79+
80+
export default RovingFocusGroup;

0 commit comments

Comments
 (0)