Skip to content

Commit c6d6d1c

Browse files
tarunvashishthrebeccaalpert
authored andcommitted
fix(tabs): preserve initial hash selection
1 parent 17b55f9 commit c6d6d1c

2 files changed

Lines changed: 140 additions & 7 deletions

File tree

packages/react-core/src/components/Tabs/Tabs.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ interface TabsState {
171171
disableBackScrollButton: boolean;
172172
disableForwardScrollButton: boolean;
173173
shownKeys: (string | number)[];
174-
uncontrolledActiveKey: number | string;
174+
uncontrolledActiveKey: number | string | undefined;
175+
initialActiveKey: number | string | undefined;
175176
uncontrolledIsExpandedLocal: boolean;
176177
overflowingTabCount: number;
177178
isInitializingAccent: boolean;
@@ -198,7 +199,8 @@ class Tabs extends Component<TabsProps, TabsState> {
198199
this.props.defaultActiveKey !== undefined
199200
? [hashActiveKey ?? this.props.defaultActiveKey]
200201
: [hashActiveKey ?? this.props.activeKey], // only for mountOnEnter case
201-
uncontrolledActiveKey: this.props.defaultActiveKey,
202+
uncontrolledActiveKey: hashActiveKey ?? this.props.defaultActiveKey,
203+
initialActiveKey: this.props.defaultActiveKey === undefined ? hashActiveKey : undefined,
202204
uncontrolledIsExpandedLocal: this.props.defaultIsExpanded,
203205
overflowingTabCount: 0,
204206
isInitializingAccent: true,
@@ -245,7 +247,7 @@ class Tabs extends Component<TabsProps, TabsState> {
245247
eventKey: number | string,
246248
tabContentRef: React.RefObject<any>
247249
) {
248-
const { shownKeys } = this.state;
250+
const { shownKeys, initialActiveKey } = this.state;
249251
const { onSelect, defaultActiveKey } = this.props;
250252

251253
// if defaultActiveKey Tabs are uncontrolled, set new active key internally
@@ -254,6 +256,9 @@ class Tabs extends Component<TabsProps, TabsState> {
254256
uncontrolledActiveKey: eventKey
255257
});
256258
} else {
259+
if (initialActiveKey !== undefined) {
260+
this.setState({ initialActiveKey: undefined });
261+
}
257262
onSelect(event, eventKey);
258263
}
259264

@@ -426,7 +431,7 @@ class Tabs extends Component<TabsProps, TabsState> {
426431
componentDidUpdate(prevProps: TabsProps, prevState: TabsState) {
427432
this.direction = getLanguageDirection(this.tabList.current);
428433
const { activeKey, mountOnEnter, isOverflowHorizontal, children, defaultActiveKey } = this.props;
429-
const { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey } = this.state;
434+
const { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey, initialActiveKey } = this.state;
430435
const isOnCloseUpdate = !!prevProps.onClose !== !!this.props.onClose;
431436

432437
if (
@@ -443,6 +448,10 @@ class Tabs extends Component<TabsProps, TabsState> {
443448
});
444449
}
445450

451+
if (defaultActiveKey === undefined && prevProps.activeKey !== activeKey && initialActiveKey !== undefined) {
452+
this.setState({ initialActiveKey: undefined });
453+
}
454+
446455
if (
447456
prevProps.children &&
448457
children &&
@@ -543,6 +552,7 @@ class Tabs extends Component<TabsProps, TabsState> {
543552
disableForwardScrollButton,
544553
shownKeys,
545554
uncontrolledActiveKey,
555+
initialActiveKey,
546556
uncontrolledIsExpandedLocal,
547557
overflowingTabCount,
548558
isInitializingAccent,
@@ -559,8 +569,7 @@ class Tabs extends Component<TabsProps, TabsState> {
559569

560570
const defaultComponent = isNav && !component ? 'nav' : 'div';
561571
const Component: any = component !== undefined ? component : defaultComponent;
562-
const hashActiveKey = getTabHashActiveKey({ children, component, isNav });
563-
const localActiveKey = hashActiveKey ?? (defaultActiveKey !== undefined ? uncontrolledActiveKey : activeKey);
572+
const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : (initialActiveKey ?? activeKey);
564573

565574
const isExpandedLocal = defaultIsExpanded !== undefined ? uncontrolledIsExpandedLocal : isExpanded;
566575
/* Uncontrolled expandable tabs */

packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { TabTitleText } from '../TabTitleText';
77
import { TabTitleIcon } from '../TabTitleIcon';
88
import { TabContent } from '../TabContent';
99
import { TabContentBody } from '../TabContentBody';
10-
import { createRef } from 'react';
10+
import { createRef, useState } from 'react';
1111

1212
jest.mock('../../../helpers/GenerateId/GenerateId');
1313

@@ -119,6 +119,130 @@ test(`Does not render with class ${styles.modifiers.initializingAccent} when com
119119
jest.useRealTimers();
120120
});
121121

122+
describe('hash-based nav selection', () => {
123+
beforeEach(() => {
124+
window.location.hash = '#/items/2';
125+
});
126+
127+
afterEach(() => {
128+
window.location.hash = '';
129+
});
130+
131+
test('should select the nav tab that matches the current URL hash on initial render', () => {
132+
render(
133+
<Tabs activeKey={0} onSelect={() => undefined} component="nav">
134+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
135+
Tab item 1
136+
</Tab>
137+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
138+
Tab item 2
139+
</Tab>
140+
<Tab eventKey={2} title={<TabTitleText>Tab item 3</TabTitleText>} href="#/items/3">
141+
Tab item 3
142+
</Tab>
143+
</Tabs>
144+
);
145+
146+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true');
147+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'false');
148+
});
149+
150+
test('should respect later controlled selections after the initial hash match', async () => {
151+
const user = userEvent.setup();
152+
const ControlledTabs = () => {
153+
const [activeKey, setActiveKey] = useState<string | number>(0);
154+
155+
return (
156+
<Tabs activeKey={activeKey} onSelect={(_event, eventKey) => setActiveKey(eventKey)} component="nav">
157+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
158+
Tab item 1
159+
</Tab>
160+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
161+
Tab item 2
162+
</Tab>
163+
<Tab eventKey={2} title={<TabTitleText>Tab item 3</TabTitleText>} href="#/items/3">
164+
Tab item 3
165+
</Tab>
166+
</Tabs>
167+
);
168+
};
169+
170+
render(<ControlledTabs />);
171+
172+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true');
173+
174+
await user.click(screen.getByRole('tab', { name: 'Tab item 3' }));
175+
176+
expect(screen.getByRole('tab', { name: 'Tab item 3' })).toHaveAttribute('aria-selected', 'true');
177+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
178+
});
179+
180+
test('should use the URL hash to initialize uncontrolled nav tabs', async () => {
181+
const user = userEvent.setup();
182+
183+
render(
184+
<Tabs defaultActiveKey={0} isNav>
185+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
186+
Tab item 1
187+
</Tab>
188+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
189+
Tab item 2
190+
</Tab>
191+
<Tab eventKey={2} title={<TabTitleText>Tab item 3</TabTitleText>} href="#/items/3">
192+
Tab item 3
193+
</Tab>
194+
</Tabs>
195+
);
196+
197+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true');
198+
199+
await user.click(screen.getByRole('tab', { name: 'Tab item 1' }));
200+
201+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
202+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
203+
});
204+
205+
test('should ignore hidden, disabled and aria-disabled tabs when matching the current hash', () => {
206+
render(
207+
<Tabs activeKey={0} onSelect={() => undefined} component="nav">
208+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
209+
Tab item 1
210+
</Tab>
211+
<Tab eventKey={1} title={<TabTitleText>Hidden tab</TabTitleText>} href="#/items/2" isHidden>
212+
Hidden tab
213+
</Tab>
214+
<Tab eventKey={2} title={<TabTitleText>Disabled tab</TabTitleText>} href="#/items/2" isDisabled>
215+
Disabled tab
216+
</Tab>
217+
<Tab eventKey={3} title={<TabTitleText>Aria disabled tab</TabTitleText>} href="#/items/2" isAriaDisabled>
218+
Aria disabled tab
219+
</Tab>
220+
</Tabs>
221+
);
222+
223+
expect(screen.queryByRole('tab', { name: 'Hidden tab' })).not.toBeInTheDocument();
224+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
225+
expect(screen.getByRole('tab', { name: 'Disabled tab' })).toHaveAttribute('aria-selected', 'false');
226+
expect(screen.getByRole('tab', { name: 'Aria disabled tab' })).toHaveAttribute('aria-selected', 'false');
227+
});
228+
229+
test('should ignore the current URL hash when nav behavior is not enabled', () => {
230+
render(
231+
<Tabs activeKey={0} onSelect={() => undefined}>
232+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
233+
Tab item 1
234+
</Tab>
235+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
236+
Tab item 2
237+
</Tab>
238+
</Tabs>
239+
);
240+
241+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
242+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
243+
});
244+
});
245+
122246
test(`Renders with class ${styles.modifiers.initializingAccent} when uncontrolled expandable component initially mounts`, async () => {
123247
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
124248

0 commit comments

Comments
 (0)