Skip to content

Commit 7934881

Browse files
mbhagat-c-eightfoldypatadia-eightfoldrmotwani-c-eightfold
authored
fix: tab: added a prop to enable standard key navigation for tabs component (#953)
* fix: tab: added a prop to enable standard key navigation for tabs component * fix: tabs: updated tabs component story for enabling standard key navigation for tabs * fix: the tab navigation using arrow keys * fix: tabs:removed console statements * fix: removed console log * fixed indentations * fix: variable name for disable tab indexes * fix: added filter count on accordion title for collapsible section * fix: added test cases to enable standard key navigation for tab component * fix: updated the tests for tabs component to enable std key navigation * fix: updated test cases for Tabs file * fix: updated test snapshots for Tabs component * fix: updated test snapshots for Tabs component * fix: duplicate children issue --------- Co-authored-by: ypatadia-eightfold <[email protected]> Co-authored-by: Rishab Motwani <[email protected]>
1 parent b190566 commit 7934881

File tree

10 files changed

+1055
-14
lines changed

10 files changed

+1055
-14
lines changed

src/components/Dialog/BaseDialog/BaseDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ export const BaseDialog: FC<BaseDialogProps> = React.forwardRef(
155155
<span
156156
id={labelId}
157157
{...(header && {
158-
role: "heading",
159-
"aria-level": headingLevel
158+
role: 'heading',
159+
'aria-level': headingLevel,
160160
})}
161161
>
162162
{headerButtonProps && (

src/components/Dialog/Dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const Dialog: FC<DialogProps> = React.forwardRef(
4242
headerButtonProps,
4343
headerClassNames,
4444
headerIcon,
45-
headingLevel=1,
45+
headingLevel = 1,
4646
height,
4747
locale = enUS,
4848
okButtonProps,

src/components/Tabs/Tab/Tab.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import React, { FC, Ref } from 'react';
3+
import React, { FC, Ref, useEffect, useRef } from 'react';
44
import { mergeClasses } from '../../../shared/utilities';
55
import { TabIconAlign, TabProps, TabSize, TabVariant } from '../Tabs.types';
66
import { useTabs } from '../Tabs.context';
@@ -12,6 +12,7 @@ import { Badge } from '../../Badge';
1212
import { Loader } from '../../Loader';
1313
import { useCanvasDirection } from '../../../hooks/useCanvasDirection';
1414

15+
import { useMergedRefs } from '../../../hooks/useMergedRefs';
1516
import styles from '../tabs.module.scss';
1617

1718
export const Tab: FC<TabProps> = React.forwardRef(
@@ -25,11 +26,15 @@ export const Tab: FC<TabProps> = React.forwardRef(
2526
label,
2627
loading,
2728
value,
29+
ariaControls,
30+
index = 0,
2831
...rest
2932
},
3033
ref: Ref<HTMLButtonElement>
3134
) => {
3235
const htmlDir: string = useCanvasDirection();
36+
const tabRef = useRef(null);
37+
const combinedRef = useMergedRefs(ref, tabRef);
3338

3439
const {
3540
alignIcon,
@@ -38,6 +43,10 @@ export const Tab: FC<TabProps> = React.forwardRef(
3843
currentActiveTab,
3944
size,
4045
variant,
46+
enableArrowNav,
47+
handleKeyDown,
48+
registerTab,
49+
focusedTabIndex,
4150
theme,
4251
} = useTabs();
4352

@@ -63,6 +72,12 @@ export const Tab: FC<TabProps> = React.forwardRef(
6372
[TabSize.XSmall, IconSize.Small],
6473
]);
6574

75+
useEffect(() => {
76+
if (registerTab) {
77+
registerTab(tabRef.current, index);
78+
}
79+
}, [registerTab, index]);
80+
6681
const getIcon = (): JSX.Element =>
6782
// TODO: Once sizes are implemented for other variants, use the mapping.
6883
// For now, a ternary determines if mapping vs using the default icon size (medium).
@@ -106,16 +121,41 @@ export const Tab: FC<TabProps> = React.forwardRef(
106121
const getLoader = (): JSX.Element =>
107122
loading && <Loader classNames={styles.loader} />;
108123

124+
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
125+
if (enableArrowNav && index !== undefined) {
126+
handleKeyDown?.(e, index);
127+
}
128+
};
129+
130+
const currentActiveTabIndex =
131+
parseInt(currentActiveTab.match(/\d+/)?.[0] || '0', 10) - 1;
132+
133+
const getTabIndex = () => {
134+
if (
135+
currentActiveTabIndex !== undefined &&
136+
currentActiveTabIndex !== null &&
137+
index === currentActiveTabIndex
138+
) {
139+
return 0;
140+
}
141+
return -1;
142+
};
143+
109144
return (
110145
<button
111146
{...rest}
112-
ref={ref}
147+
aria-controls={ariaControls}
148+
ref={combinedRef}
113149
className={tabClassNames}
114150
aria-label={ariaLabel}
115151
aria-selected={isActive}
116152
role="tab"
117153
disabled={disabled}
118154
onClick={(e) => onTabClick(value, e)}
155+
onKeyDown={handleTabKeyDown}
156+
tabIndex={getTabIndex()}
157+
data-index={index}
158+
data-value={value}
119159
>
120160
{alignIcon === TabIconAlign.Start && getIcon()}
121161
{getLabel()}

src/components/Tabs/Tabs.context.tsx

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { createContext, useEffect, useState } from 'react';
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useEffect,
5+
useState,
6+
useRef,
7+
} from 'react';
28
import {
39
TabsContextProps,
410
ITabsContext,
@@ -29,17 +35,188 @@ const TabsProvider = ({
2935
themeContainerId,
3036
statgrouptheme,
3137
value,
38+
disabledTabIndexes = [],
39+
enableArrowNav = true,
3240
variant = TabVariant.default,
3341
}: TabsContextProps) => {
34-
const [currentActiveTab, setCurrentActiveTab] = useState<TabValue>(value);
42+
const [currentActiveTab, setCurrentActiveTab] = useState(value);
43+
const [focusedTabIndex, setFocusedTabIndex] = useState<number | null>(null);
44+
const tabsRef = useRef<HTMLElement[]>([]);
45+
const tabListRef = useRef<HTMLElement | null>(null);
46+
const tabsValuesRef = useRef<TabValue[]>([]);
3547

3648
useEffect(() => {
3749
setCurrentActiveTab(value);
3850
}, [value]);
3951

40-
const onTabClick = (value: TabValue, e: SelectTabEvent) => {
41-
onChange(value, e);
42-
};
52+
const registerTab = useCallback(
53+
(tabElement: HTMLElement | null, index: number) => {
54+
if (tabElement) {
55+
tabsRef.current[index] = tabElement;
56+
const tabValue = tabElement.getAttribute('data-value');
57+
if (tabValue) {
58+
tabsValuesRef.current[index] = tabValue;
59+
}
60+
}
61+
},
62+
[]
63+
);
64+
65+
const registerTablist = useCallback((tabListElement: HTMLElement | null) => {
66+
tabListRef.current = tabListElement;
67+
}, []);
68+
69+
const onTabClick = useCallback(
70+
(value: TabValue, e: SelectTabEvent) => {
71+
if (!readOnly) {
72+
setCurrentActiveTab(value);
73+
onChange(value, e);
74+
}
75+
},
76+
[onChange, readOnly]
77+
);
78+
79+
const moveFocusToNextTab = useCallback(() => {
80+
const tabValues = tabsValuesRef.current.filter(Boolean);
81+
if (tabValues.length === 0) return;
82+
83+
let currentIndex =
84+
focusedTabIndex !== null
85+
? focusedTabIndex
86+
: tabValues.indexOf(currentActiveTab);
87+
if (currentIndex === -1) currentIndex = 0;
88+
89+
let nextIndex = currentIndex;
90+
do {
91+
nextIndex = (nextIndex + 1) % tabValues.length;
92+
if (nextIndex === currentIndex) break;
93+
} while (disabledTabIndexes.includes(nextIndex));
94+
95+
const nextTab = tabsRef.current[nextIndex];
96+
if (nextTab && !disabledTabIndexes.includes(nextIndex)) {
97+
nextTab.focus();
98+
setFocusedTabIndex(nextIndex);
99+
}
100+
}, [focusedTabIndex, currentActiveTab, disabledTabIndexes]);
101+
102+
const moveFocusToPreviousTab = useCallback(() => {
103+
const tabValues = tabsValuesRef.current.filter(Boolean);
104+
if (tabValues.length == 0) return;
105+
106+
let currentIndex =
107+
focusedTabIndex !== null
108+
? focusedTabIndex
109+
: tabValues.indexOf(currentActiveTab);
110+
if (currentIndex === -1) currentIndex = 0;
111+
112+
let prevIndex = currentIndex;
113+
do {
114+
prevIndex = (prevIndex - 1 + tabValues.length) % tabValues.length;
115+
if (prevIndex === currentIndex) break;
116+
} while (disabledTabIndexes.includes(prevIndex));
117+
118+
const prevTab = tabsRef.current[prevIndex];
119+
if (prevTab && !disabledTabIndexes.includes(prevIndex)) {
120+
prevTab.focus();
121+
setFocusedTabIndex(prevIndex);
122+
}
123+
}, [focusedTabIndex, currentActiveTab, disabledTabIndexes]);
124+
125+
useEffect(() => {
126+
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
127+
if (enableArrowNav) return;
128+
if (event.key == 'Tab') {
129+
const activeElement = document.activeElement;
130+
const tabList = tabListRef.current;
131+
132+
if (
133+
tabList &&
134+
tabList.contains(activeElement) &&
135+
activeElement?.getAttribute('role') === 'tab'
136+
) {
137+
event.preventDefault();
138+
if (event.shiftKey) {
139+
moveFocusToPreviousTab();
140+
} else {
141+
moveFocusToNextTab();
142+
}
143+
}
144+
}
145+
};
146+
document.addEventListener('keydown', handleKeyDown);
147+
return () => {
148+
document.removeEventListener('keydown', handleKeyDown);
149+
};
150+
}, [moveFocusToNextTab, moveFocusToPreviousTab]);
151+
152+
const handleKeyDown = useCallback(
153+
(event: React.KeyboardEvent, tabIndex: number) => {
154+
if (!enableArrowNav || readOnly) return;
155+
156+
if (event.key === 'Tab') {
157+
return;
158+
}
159+
160+
const enableTabIndexes = tabsRef.current
161+
.map((_, index) => index)
162+
.filter((index) => !disabledTabIndexes.includes(index));
163+
164+
const currentEnabledIndex = enableTabIndexes.indexOf(tabIndex);
165+
166+
if (currentEnabledIndex === -1) return;
167+
168+
let nextFocusIndex: number | null = null;
169+
170+
switch (event.key) {
171+
case 'ArrowLeft':
172+
nextFocusIndex =
173+
currentEnabledIndex === 0
174+
? enableTabIndexes[enableTabIndexes.length - 1]
175+
: enableTabIndexes[currentEnabledIndex - 1];
176+
event.preventDefault();
177+
break;
178+
case 'ArrowRight':
179+
nextFocusIndex =
180+
currentEnabledIndex === enableTabIndexes.length - 1
181+
? enableTabIndexes[0]
182+
: enableTabIndexes[currentEnabledIndex + 1];
183+
event.preventDefault();
184+
break;
185+
case 'Home':
186+
nextFocusIndex = enableTabIndexes[0];
187+
event.preventDefault();
188+
break;
189+
case 'End':
190+
nextFocusIndex = enableTabIndexes[enableTabIndexes.length - 1];
191+
event.preventDefault();
192+
break;
193+
case 'Enter':
194+
const currentTab = tabsRef.current[tabIndex];
195+
if (currentTab) {
196+
const tabValue = currentTab.getAttribute('data-value');
197+
if (tabValue) {
198+
setCurrentActiveTab(tabValue);
199+
onChange?.(tabValue, {
200+
currentTarget: currentTab,
201+
} as SelectTabEvent);
202+
}
203+
}
204+
event.preventDefault();
205+
return;
206+
default:
207+
return;
208+
}
209+
210+
if (nextFocusIndex !== null) {
211+
const nextTab = tabsRef.current[nextFocusIndex];
212+
if (nextTab) {
213+
nextTab.focus();
214+
setFocusedTabIndex(nextFocusIndex);
215+
}
216+
}
217+
},
218+
[enableArrowNav, disabledTabIndexes, readOnly, onChange]
219+
);
43220

44221
return (
45222
<TabsContext.Provider
@@ -59,6 +236,12 @@ const TabsProvider = ({
59236
theme,
60237
themeContainerId,
61238
variant,
239+
registerTab,
240+
registerTablist,
241+
handleKeyDown,
242+
enableArrowNav,
243+
disabledTabIndexes,
244+
focusedTabIndex,
62245
}}
63246
>
64247
{children}

src/components/Tabs/Tabs.stories.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ const tabs = [1, 2, 3, 4].map((i) => ({
7373
value: `tab${i}`,
7474
label: `Tab ${i}`,
7575
ariaLabel: `Tab ${i}`,
76+
id: `tab-${i}`,
7677
...(i === 4 ? { disabled: true } : {}),
7778
}));
7879

7980
const badgeTabs = [1, 2, 3, 4].map((i) => ({
8081
value: `tab${i}`,
8182
label: `Tab ${i}`,
8283
ariaLabel: `Tab ${i}`,
84+
id: `tab-${i}`,
8385
badgeContent: i,
8486
...(i === 4 ? { disabled: true } : {}),
8587
}));
@@ -88,6 +90,7 @@ const iconTabs = [1, 2, 3, 4].map((i) => ({
8890
value: `tab${i}`,
8991
icon: IconName.mdiCardsHeart,
9092
ariaLabel: `Tab ${i}`,
93+
id: `tab-${i}`,
9194
...(i === 4 ? { disabled: true } : {}),
9295
}));
9396

@@ -96,16 +99,22 @@ const iconLabelTabs = [1, 2, 3, 4].map((i) => ({
9699
icon: IconName.mdiCardsHeart,
97100
label: `Tab ${i}`,
98101
ariaLabel: `Tab ${i}`,
102+
id: `tab-${i}`,
99103
...(i === 4 ? { disabled: true } : {}),
100104
}));
101105

102106
const scrollableTabs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ({
103107
value: `tab${i}`,
104108
label: `Tab ${i}`,
105109
ariaLabel: `Tab ${i}`,
110+
id: `tab-${i}`,
106111
...(i === 4 ? { disabled: true } : {}),
107112
}));
108113

114+
const disabledTabIndexes = tabs
115+
.map((tab, index) => (tab.disabled ? index : -1))
116+
.filter((index) => index !== -1);
117+
109118
const Tabs_Story: ComponentStory<typeof Tabs> = (args) => {
110119
const [activeTabs, setActiveTabs] = useState({ defaultTab: 'tab1' });
111120
return (
@@ -123,6 +132,7 @@ const Tabs_Story: ComponentStory<typeof Tabs> = (args) => {
123132
{...args}
124133
onChange={(tab) => setActiveTabs({ ...activeTabs, defaultTab: tab })}
125134
value={activeTabs.defaultTab}
135+
disabledTabIndexes={disabledTabIndexes}
126136
/>
127137
</div>
128138
);
@@ -170,6 +180,7 @@ const tabsArgs: Object = {
170180
variant: TabVariant.default,
171181
size: TabSize.Medium,
172182
underlined: false,
183+
enableArrowNav: false,
173184
children: tabs.map((tab) => <Tab key={tab.value} {...tab} />),
174185
style: {},
175186
};

0 commit comments

Comments
 (0)