Skip to content

Commit 521d5d1

Browse files
fix(Tabs): select nav tab from initial hash
1 parent f3613a6 commit 521d5d1

File tree

4 files changed

+246
-17
lines changed

4 files changed

+246
-17
lines changed

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

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ interface TabsState {
155155
isInitializingAccent: boolean;
156156
currentLinkAccentLength: string;
157157
currentLinkAccentStart: string;
158+
currentUrlHash: string;
158159
}
159160

160161
class Tabs extends Component<TabsProps, TabsState> {
@@ -164,20 +165,24 @@ class Tabs extends Component<TabsProps, TabsState> {
164165
private direction = 'ltr';
165166
constructor(props: TabsProps) {
166167
super(props);
168+
const currentUrlHash = Tabs.getCurrentUrlHash();
169+
const initialActiveKey = Tabs.getActiveKeyFromProps(props, props.defaultActiveKey, currentUrlHash);
170+
167171
this.state = {
168172
enableScrollButtons: false,
169173
showScrollButtons: false,
170174
renderScrollButtons: false,
171175
disableBackScrollButton: true,
172176
disableForwardScrollButton: true,
173-
shownKeys: this.props.defaultActiveKey !== undefined ? [this.props.defaultActiveKey] : [this.props.activeKey], // only for mountOnEnter case
177+
shownKeys: initialActiveKey !== undefined ? [initialActiveKey] : [], // only for mountOnEnter case
174178
uncontrolledActiveKey: this.props.defaultActiveKey,
175179
uncontrolledIsExpandedLocal: this.props.defaultIsExpanded,
176180
ouiaStateId: getDefaultOUIAId(Tabs.displayName),
177181
overflowingTabCount: 0,
178182
isInitializingAccent: true,
179183
currentLinkAccentLength: linkAccentLength.value,
180-
currentLinkAccentStart: linkAccentStart.value
184+
currentLinkAccentStart: linkAccentStart.value,
185+
currentUrlHash
181186
};
182187

183188
if (this.props.isVertical && this.props.expandable !== undefined) {
@@ -193,6 +198,36 @@ class Tabs extends Component<TabsProps, TabsState> {
193198

194199
scrollTimeout: NodeJS.Timeout = null;
195200

201+
static getCurrentUrlHash = () => (canUseDOM ? window.location.hash : '');
202+
203+
static getActiveKeyFromCurrentUrl = (
204+
props: Pick<TabsProps, 'children' | 'component' | 'isNav'>,
205+
currentUrlHash?: string
206+
) => {
207+
if ((!props.isNav && props.component !== TabsComponent.nav) || !currentUrlHash) {
208+
return undefined;
209+
}
210+
211+
return Children.toArray(props.children)
212+
.filter((child): child is TabElement => isValidElement(child))
213+
.filter(({ props }) => !props.isHidden)
214+
.find(({ props }) => !props.isDisabled && !props.isAriaDisabled && props.href === currentUrlHash)?.props.eventKey;
215+
};
216+
217+
static getActiveKeyFromProps = (
218+
props: TabsProps,
219+
uncontrolledActiveKey: TabsState['uncontrolledActiveKey'],
220+
currentUrlHash?: string
221+
) => {
222+
const activeKeyFromCurrentUrl = Tabs.getActiveKeyFromCurrentUrl(props, currentUrlHash);
223+
224+
if (activeKeyFromCurrentUrl !== undefined) {
225+
return activeKeyFromCurrentUrl;
226+
}
227+
228+
return props.defaultActiveKey !== undefined ? uncontrolledActiveKey : props.activeKey;
229+
};
230+
196231
static defaultProps: PickOptional<TabsProps> = {
197232
activeKey: 0,
198233
onSelect: () => undefined as any,
@@ -373,7 +408,23 @@ class Tabs extends Component<TabsProps, TabsState> {
373408
this.setAccentStyles();
374409
};
375410

411+
handleHashChange = () => {
412+
const currentUrlHash = Tabs.getCurrentUrlHash();
413+
414+
if (currentUrlHash !== this.state.currentUrlHash) {
415+
this.setState({ currentUrlHash });
416+
}
417+
};
418+
419+
getLocalActiveKey = (props = this.props, state = this.state) =>
420+
Tabs.getActiveKeyFromProps(props, state.uncontrolledActiveKey, state.currentUrlHash);
421+
376422
componentDidMount() {
423+
if (canUseDOM) {
424+
window.addEventListener('hashchange', this.handleHashChange, false);
425+
this.handleHashChange();
426+
}
427+
377428
if (!this.props.isVertical) {
378429
if (canUseDOM) {
379430
window.addEventListener('resize', this.handleResize, false);
@@ -387,6 +438,10 @@ class Tabs extends Component<TabsProps, TabsState> {
387438
}
388439

389440
componentWillUnmount() {
441+
if (canUseDOM) {
442+
window.removeEventListener('hashchange', this.handleHashChange, false);
443+
}
444+
390445
if (!this.props.isVertical) {
391446
if (canUseDOM) {
392447
window.removeEventListener('resize', this.handleResize, false);
@@ -398,20 +453,24 @@ class Tabs extends Component<TabsProps, TabsState> {
398453

399454
componentDidUpdate(prevProps: TabsProps, prevState: TabsState) {
400455
this.direction = getLanguageDirection(this.tabList.current);
401-
const { activeKey, mountOnEnter, isOverflowHorizontal, children, defaultActiveKey } = this.props;
402-
const { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey } = this.state;
456+
const { mountOnEnter, isOverflowHorizontal, children } = this.props;
457+
const { shownKeys, overflowingTabCount, enableScrollButtons } = this.state;
403458
const isOnCloseUpdate = !!prevProps.onClose !== !!this.props.onClose;
404-
if (
405-
(defaultActiveKey !== undefined && prevState.uncontrolledActiveKey !== uncontrolledActiveKey) ||
406-
(defaultActiveKey === undefined && prevProps.activeKey !== activeKey) ||
407-
isOnCloseUpdate
408-
) {
459+
const previousLocalActiveKey = this.getLocalActiveKey(prevProps, prevState);
460+
const localActiveKey = this.getLocalActiveKey();
461+
462+
if (previousLocalActiveKey !== localActiveKey || isOnCloseUpdate) {
409463
this.setAccentStyles(isOnCloseUpdate);
410464
}
411465

412-
if (prevProps.activeKey !== activeKey && mountOnEnter && shownKeys.indexOf(activeKey) < 0) {
466+
if (
467+
mountOnEnter &&
468+
previousLocalActiveKey !== localActiveKey &&
469+
localActiveKey !== undefined &&
470+
shownKeys.indexOf(localActiveKey) < 0
471+
) {
413472
this.setState({
414-
shownKeys: shownKeys.concat(activeKey)
473+
shownKeys: shownKeys.concat(localActiveKey)
415474
});
416475
}
417476

@@ -463,16 +522,19 @@ class Tabs extends Component<TabsProps, TabsState> {
463522
// otherwise update state derived from nextProps.defaultActiveKey
464523
return {
465524
uncontrolledActiveKey: nextProps.defaultActiveKey,
466-
shownKeys: nextProps.defaultActiveKey !== undefined ? [nextProps.defaultActiveKey] : [nextProps.activeKey] // only for mountOnEnter case
525+
shownKeys: (() => {
526+
const activeKey = Tabs.getActiveKeyFromProps(nextProps, nextProps.defaultActiveKey, prevState.currentUrlHash);
527+
return activeKey !== undefined ? [activeKey] : [];
528+
})() // only for mountOnEnter case
467529
};
468530
}
469531

470532
render() {
471533
const {
472534
className,
473535
children,
474-
activeKey,
475-
defaultActiveKey,
536+
activeKey: _activeKey,
537+
defaultActiveKey: _defaultActiveKey,
476538
id,
477539
isAddButtonDisabled,
478540
isFilled,
@@ -506,13 +568,14 @@ class Tabs extends Component<TabsProps, TabsState> {
506568
isOverflowHorizontal: isOverflowHorizontal,
507569
...props
508570
} = this.props;
571+
void _activeKey;
572+
void _defaultActiveKey;
509573
const {
510574
showScrollButtons,
511575
renderScrollButtons,
512576
disableBackScrollButton,
513577
disableForwardScrollButton,
514578
shownKeys,
515-
uncontrolledActiveKey,
516579
uncontrolledIsExpandedLocal,
517580
overflowingTabCount,
518581
isInitializingAccent,
@@ -530,7 +593,7 @@ class Tabs extends Component<TabsProps, TabsState> {
530593
const uniqueId = id || getUniqueId();
531594
const defaultComponent = isNav && !component ? 'nav' : 'div';
532595
const Component: any = component !== undefined ? component : defaultComponent;
533-
const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : activeKey;
596+
const localActiveKey = this.getLocalActiveKey();
534597

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

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createRef, useState } from 'react';
12
import { render, screen, act } from '@testing-library/react';
23
import userEvent from '@testing-library/user-event';
34
import { Tabs, TabsProps } from '../Tabs';
@@ -7,7 +8,6 @@ import { TabTitleText } from '../TabTitleText';
78
import { TabTitleIcon } from '../TabTitleIcon';
89
import { TabContent } from '../TabContent';
910
import { TabContentBody } from '../TabContentBody';
10-
import { createRef } from 'react';
1111

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

@@ -78,6 +78,72 @@ const renderSeparateTabs = (props?: Pick<TabsProps, 'activeKey' | 'defaultActive
7878
);
7979
};
8080

81+
const navTabs = [
82+
{
83+
eventKey: 0,
84+
title: 'Users',
85+
href: '#users',
86+
ariaLabel: 'Nav element content users'
87+
},
88+
{
89+
eventKey: 1,
90+
title: 'Containers',
91+
href: '#containers'
92+
},
93+
{
94+
eventKey: 2,
95+
title: 'Database',
96+
href: '#database'
97+
},
98+
{
99+
eventKey: 3,
100+
title: 'Disabled',
101+
href: '#disabled',
102+
isDisabled: true
103+
},
104+
{
105+
eventKey: 4,
106+
title: 'ARIA Disabled',
107+
href: '#aria-disabled',
108+
isAriaDisabled: true
109+
},
110+
{
111+
eventKey: 6,
112+
title: 'Network',
113+
href: '#network'
114+
}
115+
] as const;
116+
117+
const ControlledNavTabs = () => {
118+
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);
119+
120+
return (
121+
<Tabs
122+
activeKey={activeTabKey}
123+
onSelect={(_event, tabIndex) => setActiveTabKey(tabIndex)}
124+
component="nav"
125+
aria-label="Tabs in the nav element example"
126+
>
127+
{navTabs.map(({ eventKey, title, href, ariaLabel, ...tabProps }) => (
128+
<Tab
129+
key={eventKey}
130+
eventKey={eventKey}
131+
title={<TabTitleText>{title}</TabTitleText>}
132+
href={href}
133+
aria-label={ariaLabel}
134+
{...tabProps}
135+
>
136+
{title}
137+
</Tab>
138+
))}
139+
</Tabs>
140+
);
141+
};
142+
143+
afterEach(() => {
144+
window.location.hash = '';
145+
});
146+
81147
test(`Renders with classes ${styles.tabs} and ${styles.modifiers.animateCurrent} by default`, () => {
82148
render(
83149
<Tabs role="region">
@@ -742,3 +808,24 @@ test(`should render with custom inline style and accent position inline style`,
742808

743809
expect(screen.getByRole('region')).toHaveStyle(`background-color: #12345;--pf-v6-c-tabs--link-accent--start: 0px;`);
744810
});
811+
812+
test('selects the nav tab that matches the initial URL fragment', () => {
813+
window.location.hash = '#database';
814+
815+
render(<ControlledNavTabs />);
816+
817+
expect(screen.getByRole('tab', { name: 'Database' })).toHaveAttribute('aria-selected', 'true');
818+
expect(screen.getByRole('tab', { name: 'Nav element content users' })).toHaveAttribute('aria-selected', 'false');
819+
});
820+
821+
test('updates the selected nav tab when the URL fragment changes', () => {
822+
render(<ControlledNavTabs />);
823+
824+
act(() => {
825+
window.location.hash = '#network';
826+
window.dispatchEvent(new Event('hashchange'));
827+
});
828+
829+
expect(screen.getByRole('tab', { name: 'Network' })).toHaveAttribute('aria-selected', 'true');
830+
expect(screen.getByRole('tab', { name: 'Nav element content users' })).toHaveAttribute('aria-selected', 'false');
831+
});

packages/react-core/src/components/Tabs/examples/Tabs.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ Nav tabs should use the `href` property to link the tab to the URL of another pa
181181

182182
```
183183

184+
### Tabs linked to nav elements with initial hash selection
185+
186+
Use this example to verify that a direct load with a hash fragment selects the matching tab.
187+
188+
```ts file="./TabsNavInitialHash.tsx"
189+
190+
```
191+
184192
### Subtabs linked to nav elements
185193

186194
Subtabs can also link to nav elements.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useState } from 'react';
2+
import { Tabs, Tab, TabsComponent, TabTitleText } from '@patternfly/react-core';
3+
4+
const tabs = [
5+
{
6+
eventKey: 0,
7+
title: 'Users',
8+
href: '#users',
9+
ariaLabel: 'Nav element content users'
10+
},
11+
{
12+
eventKey: 1,
13+
title: 'Containers',
14+
href: '#containers'
15+
},
16+
{
17+
eventKey: 2,
18+
title: 'Database',
19+
href: '#database'
20+
},
21+
{
22+
eventKey: 3,
23+
title: 'Disabled',
24+
href: '#disabled',
25+
isDisabled: true
26+
},
27+
{
28+
eventKey: 4,
29+
title: 'ARIA Disabled',
30+
href: '#aria-disabled',
31+
isAriaDisabled: true
32+
},
33+
{
34+
eventKey: 6,
35+
title: 'Network',
36+
href: '#network'
37+
}
38+
];
39+
40+
export const TabsNavInitialHash: React.FunctionComponent = () => {
41+
const [activeTabKey, setActiveTabKey] = useState<string | number>(tabs[0].eventKey);
42+
43+
const handleTabClick = (
44+
_event: React.MouseEvent<any> | React.KeyboardEvent | MouseEvent,
45+
tabIndex: string | number
46+
) => {
47+
setActiveTabKey(tabIndex);
48+
};
49+
50+
return (
51+
<Tabs
52+
activeKey={activeTabKey}
53+
onSelect={handleTabClick}
54+
component={TabsComponent.nav}
55+
aria-label="Tabs in the nav element example"
56+
>
57+
{tabs.map(({ eventKey, title, href, ariaLabel, ...tabProps }) => (
58+
<Tab
59+
key={eventKey}
60+
eventKey={eventKey}
61+
title={<TabTitleText>{title}</TabTitleText>}
62+
href={href}
63+
aria-label={ariaLabel}
64+
{...tabProps}
65+
>
66+
{title}
67+
</Tab>
68+
))}
69+
</Tabs>
70+
);
71+
};

0 commit comments

Comments
 (0)