Skip to content

Commit ae72908

Browse files
fix(tabs): honor hash-linked nav tabs (#12303)
Assisted-by: Cursor Co-authored-by: Rebecca Alpert <ralpert@redhat.com>
1 parent 4d1e99c commit ae72908

3 files changed

Lines changed: 282 additions & 25 deletions

File tree

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

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,25 @@ const variantStyle = {
140140
secondary: styles.modifiers.secondary
141141
};
142142

143+
const getHashFromHref = (href?: string) => {
144+
const hashIndex = href?.indexOf('#') ?? -1;
145+
146+
return hashIndex >= 0 ? href.slice(hashIndex) : undefined;
147+
};
148+
149+
const getTabHashActiveKey = ({ children, component, isNav }: Pick<TabsProps, 'children' | 'component' | 'isNav'>) => {
150+
if (!canUseDOM || !(component === TabsComponent.nav || isNav) || !window.location.hash) {
151+
return undefined;
152+
}
153+
154+
return Children.toArray(children)
155+
.filter((child): child is TabElement => isValidElement(child))
156+
.filter(({ props }) => !props.isHidden)
157+
.find(
158+
({ props }) => !props.isDisabled && !props.isAriaDisabled && getHashFromHref(props.href) === window.location.hash
159+
)?.props.eventKey;
160+
};
161+
143162
interface TabsState {
144163
/** Used to signal if the scroll buttons should be used */
145164
enableScrollButtons: boolean;
@@ -152,7 +171,8 @@ interface TabsState {
152171
disableBackScrollButton: boolean;
153172
disableForwardScrollButton: boolean;
154173
shownKeys: (string | number)[];
155-
uncontrolledActiveKey: number | string;
174+
uncontrolledActiveKey: number | string | undefined;
175+
initialActiveKey: number | string | undefined;
156176
uncontrolledIsExpandedLocal: boolean;
157177
overflowingTabCount: number;
158178
isInitializingAccent: boolean;
@@ -167,14 +187,17 @@ class Tabs extends Component<TabsProps, TabsState> {
167187
private direction = 'ltr';
168188
constructor(props: TabsProps) {
169189
super(props);
190+
const hashActiveKey = getTabHashActiveKey(props);
191+
170192
this.state = {
171193
enableScrollButtons: false,
172194
showScrollButtons: false,
173195
renderScrollButtons: false,
174196
disableBackScrollButton: true,
175197
disableForwardScrollButton: true,
176-
shownKeys: this.props.defaultActiveKey !== undefined ? [this.props.defaultActiveKey] : [this.props.activeKey], // only for mountOnEnter case
177-
uncontrolledActiveKey: this.props.defaultActiveKey,
198+
shownKeys: [hashActiveKey ?? this.props.defaultActiveKey ?? this.props.activeKey],
199+
uncontrolledActiveKey: hashActiveKey ?? this.props.defaultActiveKey,
200+
initialActiveKey: this.props.defaultActiveKey === undefined ? hashActiveKey : undefined,
178201
uncontrolledIsExpandedLocal: this.props.defaultIsExpanded,
179202
overflowingTabCount: 0,
180203
isInitializingAccent: true,
@@ -221,14 +244,26 @@ class Tabs extends Component<TabsProps, TabsState> {
221244
eventKey: number | string,
222245
tabContentRef: React.RefObject<any>
223246
) {
224-
const { shownKeys } = this.state;
225-
const { onSelect, defaultActiveKey } = this.props;
247+
const { shownKeys, initialActiveKey } = this.state;
248+
const { onSelect, defaultActiveKey, component, isNav } = this.props;
249+
250+
if (event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.hasAttribute('href')) {
251+
event.preventDefault();
252+
253+
if (canUseDOM && (component === TabsComponent.nav || isNav)) {
254+
window.history.pushState(null, '', event.currentTarget.href);
255+
}
256+
}
257+
226258
// if defaultActiveKey Tabs are uncontrolled, set new active key internally
227259
if (defaultActiveKey !== undefined) {
228260
this.setState({
229261
uncontrolledActiveKey: eventKey
230262
});
231263
} else {
264+
if (initialActiveKey !== undefined) {
265+
this.setState({ initialActiveKey: undefined });
266+
}
232267
onSelect(event, eventKey);
233268
}
234269

@@ -401,8 +436,9 @@ class Tabs extends Component<TabsProps, TabsState> {
401436
componentDidUpdate(prevProps: TabsProps, prevState: TabsState) {
402437
this.direction = getLanguageDirection(this.tabList.current);
403438
const { activeKey, mountOnEnter, isOverflowHorizontal, children, defaultActiveKey } = this.props;
404-
const { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey } = this.state;
439+
const { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey, initialActiveKey } = this.state;
405440
const isOnCloseUpdate = !!prevProps.onClose !== !!this.props.onClose;
441+
406442
if (
407443
(defaultActiveKey !== undefined && prevState.uncontrolledActiveKey !== uncontrolledActiveKey) ||
408444
(defaultActiveKey === undefined && prevProps.activeKey !== activeKey) ||
@@ -417,6 +453,10 @@ class Tabs extends Component<TabsProps, TabsState> {
417453
});
418454
}
419455

456+
if (defaultActiveKey === undefined && prevProps.activeKey !== activeKey && initialActiveKey !== undefined) {
457+
this.setState({ initialActiveKey: undefined });
458+
}
459+
420460
if (
421461
prevProps.children &&
422462
children &&
@@ -517,6 +557,7 @@ class Tabs extends Component<TabsProps, TabsState> {
517557
disableForwardScrollButton,
518558
shownKeys,
519559
uncontrolledActiveKey,
560+
initialActiveKey,
520561
uncontrolledIsExpandedLocal,
521562
overflowingTabCount,
522563
isInitializingAccent,
@@ -533,7 +574,7 @@ class Tabs extends Component<TabsProps, TabsState> {
533574

534575
const defaultComponent = isNav && !component ? 'nav' : 'div';
535576
const Component: any = component !== undefined ? component : defaultComponent;
536-
const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : activeKey;
577+
const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : (initialActiveKey ?? activeKey);
537578

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

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

Lines changed: 217 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,222 @@ 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 select the nav tab that matches the current URL hash when isNav is enabled', () => {
151+
render(
152+
<Tabs activeKey={0} onSelect={() => undefined} isNav>
153+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
154+
Tab item 1
155+
</Tab>
156+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
157+
Tab item 2
158+
</Tab>
159+
</Tabs>
160+
);
161+
162+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true');
163+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'false');
164+
});
165+
166+
test('should fall back to activeKey when no tab href matches the current hash', () => {
167+
window.location.hash = '#/items/unknown';
168+
169+
render(
170+
<Tabs activeKey={0} onSelect={() => undefined} component="nav">
171+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
172+
Tab item 1
173+
</Tab>
174+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
175+
Tab item 2
176+
</Tab>
177+
</Tabs>
178+
);
179+
180+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
181+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
182+
});
183+
184+
test('should fall back to activeKey when the URL hash is empty', () => {
185+
window.location.hash = '';
186+
187+
render(
188+
<Tabs activeKey={0} onSelect={() => undefined} component="nav">
189+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
190+
Tab item 1
191+
</Tab>
192+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
193+
Tab item 2
194+
</Tab>
195+
</Tabs>
196+
);
197+
198+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
199+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
200+
});
201+
202+
test('should respect later controlled selections after the initial hash match', async () => {
203+
const user = userEvent.setup();
204+
const ControlledTabs = () => {
205+
const [activeKey, setActiveKey] = useState<string | number>(0);
206+
207+
return (
208+
<Tabs activeKey={activeKey} onSelect={(_event, eventKey) => setActiveKey(eventKey)} component="nav">
209+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
210+
Tab item 1
211+
</Tab>
212+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
213+
Tab item 2
214+
</Tab>
215+
<Tab eventKey={2} title={<TabTitleText>Tab item 3</TabTitleText>} href="#/items/3">
216+
Tab item 3
217+
</Tab>
218+
</Tabs>
219+
);
220+
};
221+
222+
render(<ControlledTabs />);
223+
224+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true');
225+
226+
await user.click(screen.getByRole('tab', { name: 'Tab item 3' }));
227+
228+
expect(screen.getByRole('tab', { name: 'Tab item 3' })).toHaveAttribute('aria-selected', 'true');
229+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
230+
});
231+
232+
test('should use the URL hash to initialize uncontrolled nav tabs', async () => {
233+
const user = userEvent.setup();
234+
235+
render(
236+
<Tabs defaultActiveKey={0} isNav>
237+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
238+
Tab item 1
239+
</Tab>
240+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
241+
Tab item 2
242+
</Tab>
243+
<Tab eventKey={2} title={<TabTitleText>Tab item 3</TabTitleText>} href="#/items/3">
244+
Tab item 3
245+
</Tab>
246+
</Tabs>
247+
);
248+
249+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true');
250+
251+
await user.click(screen.getByRole('tab', { name: 'Tab item 1' }));
252+
253+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
254+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
255+
});
256+
257+
test('should ignore hidden, disabled and aria-disabled tabs when matching the current hash', () => {
258+
render(
259+
<Tabs activeKey={0} onSelect={() => undefined} component="nav">
260+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
261+
Tab item 1
262+
</Tab>
263+
<Tab eventKey={1} title={<TabTitleText>Hidden tab</TabTitleText>} href="#/items/2" isHidden>
264+
Hidden tab
265+
</Tab>
266+
<Tab eventKey={2} title={<TabTitleText>Disabled tab</TabTitleText>} href="#/items/2" isDisabled>
267+
Disabled tab
268+
</Tab>
269+
<Tab eventKey={3} title={<TabTitleText>Aria disabled tab</TabTitleText>} href="#/items/2" isAriaDisabled>
270+
Aria disabled tab
271+
</Tab>
272+
</Tabs>
273+
);
274+
275+
expect(screen.queryByRole('tab', { name: 'Hidden tab' })).not.toBeInTheDocument();
276+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
277+
expect(screen.getByRole('tab', { name: 'Disabled tab' })).toHaveAttribute('aria-selected', 'false');
278+
expect(screen.getByRole('tab', { name: 'Aria disabled tab' })).toHaveAttribute('aria-selected', 'false');
279+
});
280+
281+
test('should ignore the current URL hash when nav behavior is not enabled', () => {
282+
render(
283+
<Tabs activeKey={0} onSelect={() => undefined}>
284+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
285+
Tab item 1
286+
</Tab>
287+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
288+
Tab item 2
289+
</Tab>
290+
</Tabs>
291+
);
292+
293+
expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true');
294+
expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false');
295+
});
296+
297+
test('should prevent default anchor navigation when selecting a tab with an href', async () => {
298+
const user = userEvent.setup();
299+
window.location.hash = '';
300+
301+
render(
302+
<Tabs activeKey={0} onSelect={() => undefined}>
303+
<Tab eventKey={0} title={<TabTitleText>Tab item 1</TabTitleText>} href="#/items/1">
304+
Tab item 1
305+
</Tab>
306+
<Tab eventKey={1} title={<TabTitleText>Tab item 2</TabTitleText>} href="#/items/2">
307+
Tab item 2
308+
</Tab>
309+
</Tabs>
310+
);
311+
312+
await user.click(screen.getByRole('tab', { name: 'Tab item 2' }));
313+
314+
expect(window.location.hash).toBe('');
315+
});
316+
317+
test('should update the URL hash when selecting a nav tab with an href', async () => {
318+
const user = userEvent.setup();
319+
window.location.hash = '';
320+
321+
render(
322+
<Tabs activeKey={0} onSelect={() => undefined} isNav>
323+
<Tab eventKey={0} title={<TabTitleText>Users</TabTitleText>} href="#users">
324+
Users
325+
</Tab>
326+
<Tab eventKey={1} title={<TabTitleText>Containers</TabTitleText>} href="#containers">
327+
Containers
328+
</Tab>
329+
</Tabs>
330+
);
331+
332+
await user.click(screen.getByRole('tab', { name: 'Containers' }));
333+
334+
expect(window.location.hash).toBe('#containers');
335+
});
336+
});
337+
122338
test(`Renders with class ${styles.modifiers.initializingAccent} when uncontrolled expandable component initially mounts`, async () => {
123339
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
124340

0 commit comments

Comments
 (0)