Skip to content

Commit 9122887

Browse files
fix(tabs): preserve initial hash selection
1 parent 7e3c952 commit 9122887

File tree

2 files changed

+140
-7
lines changed

2 files changed

+140
-7
lines changed

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ interface TabsState {
167167
disableBackScrollButton: boolean;
168168
disableForwardScrollButton: boolean;
169169
shownKeys: (string | number)[];
170-
uncontrolledActiveKey: number | string;
170+
uncontrolledActiveKey: number | string | undefined;
171+
initialActiveKey: number | string | undefined;
171172
uncontrolledIsExpandedLocal: boolean;
172173
ouiaStateId: string;
173174
overflowingTabCount: number;
@@ -195,7 +196,8 @@ class Tabs extends Component<TabsProps, TabsState> {
195196
this.props.defaultActiveKey !== undefined
196197
? [hashActiveKey ?? this.props.defaultActiveKey]
197198
: [hashActiveKey ?? this.props.activeKey], // only for mountOnEnter case
198-
uncontrolledActiveKey: this.props.defaultActiveKey,
199+
uncontrolledActiveKey: hashActiveKey ?? this.props.defaultActiveKey,
200+
initialActiveKey: this.props.defaultActiveKey === undefined ? hashActiveKey : undefined,
199201
uncontrolledIsExpandedLocal: this.props.defaultIsExpanded,
200202
ouiaStateId: getDefaultOUIAId(Tabs.displayName),
201203
overflowingTabCount: 0,
@@ -243,7 +245,7 @@ class Tabs extends Component<TabsProps, TabsState> {
243245
eventKey: number | string,
244246
tabContentRef: React.RefObject<any>
245247
) {
246-
const { shownKeys } = this.state;
248+
const { shownKeys, initialActiveKey } = this.state;
247249
const { onSelect, defaultActiveKey } = this.props;
248250

249251
// if defaultActiveKey Tabs are uncontrolled, set new active key internally
@@ -252,6 +254,9 @@ class Tabs extends Component<TabsProps, TabsState> {
252254
uncontrolledActiveKey: eventKey
253255
});
254256
} else {
257+
if (initialActiveKey !== undefined) {
258+
this.setState({ initialActiveKey: undefined });
259+
}
255260
onSelect(event, eventKey);
256261
}
257262

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

430435
if (
@@ -441,6 +446,10 @@ class Tabs extends Component<TabsProps, TabsState> {
441446
});
442447
}
443448

449+
if (defaultActiveKey === undefined && prevProps.activeKey !== activeKey && initialActiveKey !== undefined) {
450+
this.setState({ initialActiveKey: undefined });
451+
}
452+
444453
if (
445454
prevProps.children &&
446455
children &&
@@ -539,6 +548,7 @@ class Tabs extends Component<TabsProps, TabsState> {
539548
disableForwardScrollButton,
540549
shownKeys,
541550
uncontrolledActiveKey,
551+
initialActiveKey,
542552
uncontrolledIsExpandedLocal,
543553
overflowingTabCount,
544554
isInitializingAccent,
@@ -556,8 +566,7 @@ class Tabs extends Component<TabsProps, TabsState> {
556566
const uniqueId = id || getUniqueId();
557567
const defaultComponent = isNav && !component ? 'nav' : 'div';
558568
const Component: any = component !== undefined ? component : defaultComponent;
559-
const hashActiveKey = getTabHashActiveKey({ children, component, isNav });
560-
const localActiveKey = hashActiveKey ?? (defaultActiveKey !== undefined ? uncontrolledActiveKey : activeKey);
569+
const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : (initialActiveKey ?? activeKey);
561570

562571
const isExpandedLocal = defaultIsExpanded !== undefined ? uncontrolledIsExpandedLocal : isExpanded;
563572
/* 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)