Skip to content

Commit e52db7b

Browse files
authored
fix(ui-react): Whitespace in Tab IDs (#5378)
* fix: whitespace in tabs, validate value & defaultValue types * chore: added changeset * fix: creates const idValue to avoid changing inputted values * fix: removed unused import, type-checked before .replace * fix: called functions in test * fix: updated ComposedTabsExample.tsx
1 parent ba17d38 commit e52db7b

File tree

8 files changed

+87
-10
lines changed

8 files changed

+87
-10
lines changed

.changeset/tall-olives-beam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/ui-react': patch
3+
---
4+
5+
fixes invalid tab IDs

docs/src/pages/[platform]/components/tabs/examples/ComposedTabsExample.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ export const ComposedTabsExample = () => {
1111
</Tabs.Item>
1212
</Tabs.List>
1313
<Tabs.Panel value="Tab 1">Tab 1 content</Tabs.Panel>
14-
<Tabs.Panel value="Tab 2">Tab 1 content</Tabs.Panel>
14+
<Tabs.Panel value="Tab 2">Tab 2 content</Tabs.Panel>
1515
<Tabs.Panel value="Tab 3" isDisabled>
16-
Tab 1 content
16+
Tab 3 content
1717
</Tabs.Panel>
1818
</Tabs.Container>
1919
);

packages/react/src/primitives/Tabs/TabsContainer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { primitiveWithForwardRef } from '../utils/primitiveWithForwardRef';
77
import { BaseTabsProps, TabsProps } from './types';
88
import { View } from '../View';
99
import { TabsContext } from './TabsContext';
10+
import { useStableId } from '../utils/useStableId';
1011

1112
const TabsContainerPrimitive: Primitive<TabsProps, 'div'> = (
1213
{
@@ -20,6 +21,7 @@ const TabsContainerPrimitive: Primitive<TabsProps, 'div'> = (
2021
}: BaseTabsProps,
2122
ref
2223
) => {
24+
const groupId = useStableId(); // groupId is used to ensure uniqueness between Tab Groups in IDs
2325
const isControlled = controlledValue !== undefined;
2426
const [localValue, setLocalValue] = React.useState(() =>
2527
isControlled ? controlledValue : defaultValue
@@ -44,8 +46,9 @@ const TabsContainerPrimitive: Primitive<TabsProps, 'div'> = (
4446
activeTab,
4547
isLazy,
4648
setActiveTab,
49+
groupId,
4750
};
48-
}, [activeTab, setActiveTab, isLazy]);
51+
}, [activeTab, setActiveTab, isLazy, groupId]);
4952

5053
return (
5154
<TabsContext.Provider value={_value}>

packages/react/src/primitives/Tabs/TabsContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import * as React from 'react';
33
export interface TabsContextInterface {
44
activeTab: string;
55
isLazy?: boolean;
6+
groupId: string;
67
setActiveTab: (tab: string) => void;
78
}
89

910
export const TabsContext = React.createContext<TabsContextInterface>({
11+
groupId: '',
1012
activeTab: '',
1113
setActiveTab: () => {},
1214
});

packages/react/src/primitives/Tabs/TabsItem.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,32 @@ import { View } from '../View';
1212
import { primitiveWithForwardRef } from '../utils/primitiveWithForwardRef';
1313
import { BaseTabsItemProps, TabsItemProps } from './types';
1414
import { TabsContext } from './TabsContext';
15+
import { WHITESPACE_VALUE } from './constants';
1516

1617
const TabsItemPrimitive: Primitive<TabsItemProps, 'button'> = (
1718
{ className, value, children, onClick, as = 'button', role = 'tab', ...rest },
1819
ref
1920
) => {
20-
const { activeTab, setActiveTab } = React.useContext(TabsContext);
21+
const { activeTab, setActiveTab, groupId } = React.useContext(TabsContext);
22+
let idValue = value;
23+
if (typeof idValue === 'string') {
24+
idValue = idValue.replace(' ', WHITESPACE_VALUE);
25+
}
2126
const isActive = activeTab === value;
2227
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
2328
if (isTypedFunction(onClick)) {
2429
onClick?.(e);
2530
}
2631
setActiveTab(value);
2732
};
28-
2933
return (
3034
<View
3135
{...rest}
3236
role={role}
3337
as={as}
34-
id={`${value}-tab`}
38+
id={`${groupId}-tab-${idValue}`}
3539
aria-selected={isActive}
36-
aria-controls={`${value}-panel`}
40+
aria-controls={`${groupId}-panel-${idValue}`}
3741
tabIndex={!isActive ? -1 : undefined}
3842
className={classNames(
3943
ComponentClassName.TabsItem,

packages/react/src/primitives/Tabs/TabsPanel.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,27 @@ import { View } from '../View';
88
import { BaseTabsPanelProps, TabsPanelProps } from './types';
99
import { primitiveWithForwardRef } from '../utils/primitiveWithForwardRef';
1010
import { TabsContext } from './TabsContext';
11+
import { WHITESPACE_VALUE } from './constants';
1112

1213
const TabPanelPrimitive: Primitive<TabsPanelProps, 'div'> = (
1314
{ className, value, children, role = 'tabpanel', ...rest },
1415
ref
1516
) => {
16-
const { activeTab, isLazy } = React.useContext(TabsContext);
17+
const { activeTab, isLazy, groupId } = React.useContext(TabsContext);
1718

1819
if (isLazy && activeTab !== value) return null;
1920

21+
let idValue = value;
22+
if (typeof idValue === 'string') {
23+
idValue = idValue.replace(' ', WHITESPACE_VALUE);
24+
}
25+
2026
return (
2127
<View
2228
{...rest}
2329
role={role}
24-
id={`${value}-panel`}
25-
aria-labelledby={`${value}-tab`}
30+
id={`${groupId}-panel-${idValue}`}
31+
aria-labelledby={`${groupId}-tab-${idValue}`}
2632
className={classNames(
2733
ComponentClassName.TabsPanel,
2834
classNameModifierByFlag(

packages/react/src/primitives/Tabs/__tests__/Tabs.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,61 @@ describe('Tabs', () => {
8686
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
8787
});
8888

89+
it('creates unique IDs for two tabs with same value in different tab groups"', async () => {
90+
render(
91+
<Tabs.Container>
92+
<Tabs.List>
93+
<Tabs.Item value="Tab 1">Tab 1</Tabs.Item>
94+
</Tabs.List>
95+
</Tabs.Container>
96+
);
97+
render(
98+
<Tabs.Container>
99+
<Tabs.List>
100+
<Tabs.Item value="Tab 1">Tab 1</Tabs.Item>
101+
</Tabs.List>
102+
</Tabs.Container>
103+
);
104+
const tabs = await screen.findAllByRole('tab');
105+
expect(tabs[0].id === tabs[1].id).toBeFalsy();
106+
});
107+
108+
it('creates unique ids for each tab with a unique value', async () => {
109+
render(
110+
<Tabs.Container testId="tabsTest">
111+
<Tabs.List>
112+
<Tabs.Item value="1">Tab 1</Tabs.Item>
113+
<Tabs.Item value="2">Tab 2</Tabs.Item>
114+
<Tabs.Item value="3">Tab 3</Tabs.Item>
115+
</Tabs.List>
116+
<Tabs.Panel value="1">Tab 1</Tabs.Panel>
117+
<Tabs.Panel value="2">Tab 2</Tabs.Panel>
118+
<Tabs.Panel value="3">Tab 3</Tabs.Panel>
119+
</Tabs.Container>
120+
);
121+
const tabs = await screen.findAllByRole('tab');
122+
expect(tabs[0].id === tabs[1].id).toBeFalsy();
123+
expect(tabs[0].id === tabs[2].id).toBeFalsy();
124+
});
125+
126+
it('creates the same ids tabs with the same value in the same group', async () => {
127+
render(
128+
<Tabs.Container testId="tabsTest">
129+
<Tabs.List>
130+
<Tabs.Item value="1">Tab 1</Tabs.Item>
131+
<Tabs.Item value="1">Tab 2</Tabs.Item>
132+
<Tabs.Item value="1">Tab 3</Tabs.Item>
133+
</Tabs.List>
134+
<Tabs.Panel value="1">Tab 1</Tabs.Panel>
135+
<Tabs.Panel value="1">Tab 2</Tabs.Panel>
136+
<Tabs.Panel value="1">Tab 3</Tabs.Panel>
137+
</Tabs.Container>
138+
);
139+
const tabs = await screen.findAllByRole('tab');
140+
expect(tabs[0].id === tabs[1].id).toBeTruthy();
141+
expect(tabs[0].id === tabs[2].id).toBeTruthy();
142+
});
143+
89144
describe('TabItem', () => {
90145
it('can render custom classnames', async () => {
91146
render(
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/* WHITESPACE_VALUE is used to fill whitespace present in user-inputed `value` when creating id for TabsItem and TabsPanel */
2+
export const WHITESPACE_VALUE = '-';

0 commit comments

Comments
 (0)