Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 79 additions & 16 deletions packages/react-core/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ interface TabsState {
isInitializingAccent: boolean;
currentLinkAccentLength: string;
currentLinkAccentStart: string;
currentUrlHash: string;
}

class Tabs extends Component<TabsProps, TabsState> {
Expand All @@ -164,20 +165,24 @@ class Tabs extends Component<TabsProps, TabsState> {
private direction = 'ltr';
constructor(props: TabsProps) {
super(props);
const currentUrlHash = Tabs.getCurrentUrlHash();
const initialActiveKey = Tabs.getActiveKeyFromProps(props, props.defaultActiveKey, currentUrlHash);

this.state = {
enableScrollButtons: false,
showScrollButtons: false,
renderScrollButtons: false,
disableBackScrollButton: true,
disableForwardScrollButton: true,
shownKeys: this.props.defaultActiveKey !== undefined ? [this.props.defaultActiveKey] : [this.props.activeKey], // only for mountOnEnter case
shownKeys: initialActiveKey !== undefined ? [initialActiveKey] : [], // only for mountOnEnter case
uncontrolledActiveKey: this.props.defaultActiveKey,
uncontrolledIsExpandedLocal: this.props.defaultIsExpanded,
ouiaStateId: getDefaultOUIAId(Tabs.displayName),
overflowingTabCount: 0,
isInitializingAccent: true,
currentLinkAccentLength: linkAccentLength.value,
currentLinkAccentStart: linkAccentStart.value
currentLinkAccentStart: linkAccentStart.value,
currentUrlHash
};

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

scrollTimeout: NodeJS.Timeout = null;

static getCurrentUrlHash = () => (canUseDOM ? window.location.hash : '');

static getActiveKeyFromCurrentUrl = (
props: Pick<TabsProps, 'children' | 'component' | 'isNav'>,
currentUrlHash?: string
) => {
if ((!props.isNav && props.component !== TabsComponent.nav) || !currentUrlHash) {
return undefined;
}

return Children.toArray(props.children)
.filter((child): child is TabElement => isValidElement(child))
.filter(({ props }) => !props.isHidden)
.find(({ props }) => !props.isDisabled && !props.isAriaDisabled && props.href === currentUrlHash)?.props.eventKey;
};

static getActiveKeyFromProps = (
props: TabsProps,
uncontrolledActiveKey: TabsState['uncontrolledActiveKey'],
currentUrlHash?: string
) => {
const activeKeyFromCurrentUrl = Tabs.getActiveKeyFromCurrentUrl(props, currentUrlHash);

if (activeKeyFromCurrentUrl !== undefined) {
return activeKeyFromCurrentUrl;
}

return props.defaultActiveKey !== undefined ? uncontrolledActiveKey : props.activeKey;
};
Comment on lines +217 to +229
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep activeKey authoritative in controlled mode.

Lines 224-228 make the hash match win over props.activeKey, and Lines 411-420 update that hash internally without notifying the owner. A controlled <Tabs activeKey={...}> can now render a different selected tab than the parent state on initial load or after hashchange, which breaks separate-content patterns and any parent UI derived from activeKey. Either scope hash resolution to uncontrolled tabs, or add a callback path that keeps the owner in sync before the hash becomes authoritative.

Also applies to: 411-420

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-core/src/components/Tabs/Tabs.tsx` around lines 217 - 229,
Tabs currently lets the URL hash override props.activeKey even in controlled
mode: change Tabs.getActiveKeyFromProps to only consider
Tabs.getActiveKeyFromCurrentUrl when the component is uncontrolled (i.e.,
props.activeKey is undefined), so controlled consumers remain authoritative;
likewise, in the internal routine that writes the hash (the code that updates
the URL hash without notifying the parent), either skip updating the URL when
props.activeKey is provided or invoke the owner callback (e.g.,
onSelect/onChange) so the parent can sync before the hash becomes authoritative.


static defaultProps: PickOptional<TabsProps> = {
activeKey: 0,
onSelect: () => undefined as any,
Expand Down Expand Up @@ -373,7 +408,23 @@ class Tabs extends Component<TabsProps, TabsState> {
this.setAccentStyles();
};

handleHashChange = () => {
const currentUrlHash = Tabs.getCurrentUrlHash();

if (currentUrlHash !== this.state.currentUrlHash) {
this.setState({ currentUrlHash });
}
};

getLocalActiveKey = (props = this.props, state = this.state) =>
Tabs.getActiveKeyFromProps(props, state.uncontrolledActiveKey, state.currentUrlHash);

componentDidMount() {
if (canUseDOM) {
window.addEventListener('hashchange', this.handleHashChange, false);
this.handleHashChange();
}

if (!this.props.isVertical) {
if (canUseDOM) {
window.addEventListener('resize', this.handleResize, false);
Expand All @@ -387,6 +438,10 @@ class Tabs extends Component<TabsProps, TabsState> {
}

componentWillUnmount() {
if (canUseDOM) {
window.removeEventListener('hashchange', this.handleHashChange, false);
}

if (!this.props.isVertical) {
if (canUseDOM) {
window.removeEventListener('resize', this.handleResize, false);
Expand All @@ -398,20 +453,24 @@ class Tabs extends Component<TabsProps, TabsState> {

componentDidUpdate(prevProps: TabsProps, prevState: TabsState) {
this.direction = getLanguageDirection(this.tabList.current);
const { activeKey, mountOnEnter, isOverflowHorizontal, children, defaultActiveKey } = this.props;
const { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey } = this.state;
const { mountOnEnter, isOverflowHorizontal, children } = this.props;
const { shownKeys, overflowingTabCount, enableScrollButtons } = this.state;
const isOnCloseUpdate = !!prevProps.onClose !== !!this.props.onClose;
if (
(defaultActiveKey !== undefined && prevState.uncontrolledActiveKey !== uncontrolledActiveKey) ||
(defaultActiveKey === undefined && prevProps.activeKey !== activeKey) ||
isOnCloseUpdate
) {
const previousLocalActiveKey = this.getLocalActiveKey(prevProps, prevState);
const localActiveKey = this.getLocalActiveKey();

if (previousLocalActiveKey !== localActiveKey || isOnCloseUpdate) {
this.setAccentStyles(isOnCloseUpdate);
}

if (prevProps.activeKey !== activeKey && mountOnEnter && shownKeys.indexOf(activeKey) < 0) {
if (
mountOnEnter &&
previousLocalActiveKey !== localActiveKey &&
localActiveKey !== undefined &&
shownKeys.indexOf(localActiveKey) < 0
) {
this.setState({
shownKeys: shownKeys.concat(activeKey)
shownKeys: shownKeys.concat(localActiveKey)
});
}

Expand Down Expand Up @@ -463,16 +522,19 @@ class Tabs extends Component<TabsProps, TabsState> {
// otherwise update state derived from nextProps.defaultActiveKey
return {
uncontrolledActiveKey: nextProps.defaultActiveKey,
shownKeys: nextProps.defaultActiveKey !== undefined ? [nextProps.defaultActiveKey] : [nextProps.activeKey] // only for mountOnEnter case
shownKeys: (() => {
const activeKey = Tabs.getActiveKeyFromProps(nextProps, nextProps.defaultActiveKey, prevState.currentUrlHash);
return activeKey !== undefined ? [activeKey] : [];
})() // only for mountOnEnter case
};
}

render() {
const {
className,
children,
activeKey,
defaultActiveKey,
activeKey: _activeKey,
defaultActiveKey: _defaultActiveKey,
id,
isAddButtonDisabled,
isFilled,
Expand Down Expand Up @@ -506,13 +568,14 @@ class Tabs extends Component<TabsProps, TabsState> {
isOverflowHorizontal: isOverflowHorizontal,
...props
} = this.props;
void _activeKey;
void _defaultActiveKey;
const {
showScrollButtons,
renderScrollButtons,
disableBackScrollButton,
disableForwardScrollButton,
shownKeys,
uncontrolledActiveKey,
uncontrolledIsExpandedLocal,
overflowingTabCount,
isInitializingAccent,
Expand All @@ -530,7 +593,7 @@ class Tabs extends Component<TabsProps, TabsState> {
const uniqueId = id || getUniqueId();
const defaultComponent = isNav && !component ? 'nav' : 'div';
const Component: any = component !== undefined ? component : defaultComponent;
const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : activeKey;
const localActiveKey = this.getLocalActiveKey();

const isExpandedLocal = defaultIsExpanded !== undefined ? uncontrolledIsExpandedLocal : isExpanded;
/* Uncontrolled expandable tabs */
Expand Down
89 changes: 88 additions & 1 deletion packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createRef, useState } from 'react';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tabs, TabsProps } from '../Tabs';
Expand All @@ -7,7 +8,6 @@ import { TabTitleText } from '../TabTitleText';
import { TabTitleIcon } from '../TabTitleIcon';
import { TabContent } from '../TabContent';
import { TabContentBody } from '../TabContentBody';
import { createRef } from 'react';

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

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

const navTabs = [
{
eventKey: 0,
title: 'Users',
href: '#users',
ariaLabel: 'Nav element content users'
},
{
eventKey: 1,
title: 'Containers',
href: '#containers'
},
{
eventKey: 2,
title: 'Database',
href: '#database'
},
{
eventKey: 3,
title: 'Disabled',
href: '#disabled',
isDisabled: true
},
{
eventKey: 4,
title: 'ARIA Disabled',
href: '#aria-disabled',
isAriaDisabled: true
},
{
eventKey: 6,
title: 'Network',
href: '#network'
}
] as const;

const ControlledNavTabs = () => {
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);

return (
<Tabs
activeKey={activeTabKey}
onSelect={(_event, tabIndex) => setActiveTabKey(tabIndex)}
component="nav"
aria-label="Tabs in the nav element example"
>
{navTabs.map(({ eventKey, title, href, ariaLabel, ...tabProps }) => (
<Tab
key={eventKey}
eventKey={eventKey}
title={<TabTitleText>{title}</TabTitleText>}
href={href}
aria-label={ariaLabel}
{...tabProps}
>
{title}
</Tab>
))}
</Tabs>
);
};

afterEach(() => {
window.location.hash = '';
});

test(`Renders with classes ${styles.tabs} and ${styles.modifiers.animateCurrent} by default`, () => {
render(
<Tabs role="region">
Expand Down Expand Up @@ -742,3 +808,24 @@ test(`should render with custom inline style and accent position inline style`,

expect(screen.getByRole('region')).toHaveStyle(`background-color: #12345;--pf-v6-c-tabs--link-accent--start: 0px;`);
});

test('selects the nav tab that matches the initial URL fragment', () => {
window.location.hash = '#database';

render(<ControlledNavTabs />);

expect(screen.getByRole('tab', { name: 'Database' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Nav element content users' })).toHaveAttribute('aria-selected', 'false');
});

test('updates the selected nav tab when the URL fragment changes', () => {
render(<ControlledNavTabs />);

act(() => {
window.location.hash = '#network';
window.dispatchEvent(new Event('hashchange'));
});

expect(screen.getByRole('tab', { name: 'Network' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Nav element content users' })).toHaveAttribute('aria-selected', 'false');
});
8 changes: 8 additions & 0 deletions packages/react-core/src/components/Tabs/examples/Tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ Nav tabs should use the `href` property to link the tab to the URL of another pa

```

### Tabs linked to nav elements with initial hash selection

Use this example to verify that a direct load with a hash fragment selects the matching tab.

```ts file="./TabsNavInitialHash.tsx"

```

### Subtabs linked to nav elements

Subtabs can also link to nav elements.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { Tabs, Tab, TabsComponent, TabTitleText } from '@patternfly/react-core';

const tabs = [
{
eventKey: 0,
title: 'Users',
href: '#users',
ariaLabel: 'Nav element content users'
},
{
eventKey: 1,
title: 'Containers',
href: '#containers'
},
{
eventKey: 2,
title: 'Database',
href: '#database'
},
{
eventKey: 3,
title: 'Disabled',
href: '#disabled',
isDisabled: true
},
{
eventKey: 4,
title: 'ARIA Disabled',
href: '#aria-disabled',
isAriaDisabled: true
},
{
eventKey: 6,
title: 'Network',
href: '#network'
}
];

export const TabsNavInitialHash: React.FunctionComponent = () => {
const [activeTabKey, setActiveTabKey] = useState<string | number>(tabs[0].eventKey);

const handleTabClick = (
_event: React.MouseEvent<any> | React.KeyboardEvent | MouseEvent,
tabIndex: string | number
) => {
setActiveTabKey(tabIndex);
};

return (
<Tabs
activeKey={activeTabKey}
onSelect={handleTabClick}
component={TabsComponent.nav}
aria-label="Tabs in the nav element example"
>
{tabs.map(({ eventKey, title, href, ariaLabel, ...tabProps }) => (
<Tab
key={eventKey}
eventKey={eventKey}
title={<TabTitleText>{title}</TabTitleText>}
href={href}
aria-label={ariaLabel}
{...tabProps}
>
{title}
</Tab>
))}
</Tabs>
);
};
Loading