diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 1c997f8c095..a31fc66772e 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -155,6 +155,7 @@ interface TabsState { isInitializingAccent: boolean; currentLinkAccentLength: string; currentLinkAccentStart: string; + currentUrlHash: string; } class Tabs extends Component { @@ -164,20 +165,24 @@ class Tabs extends Component { 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) { @@ -193,6 +198,36 @@ class Tabs extends Component { scrollTimeout: NodeJS.Timeout = null; + static getCurrentUrlHash = () => (canUseDOM ? window.location.hash : ''); + + static getActiveKeyFromCurrentUrl = ( + props: Pick, + 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; + }; + static defaultProps: PickOptional = { activeKey: 0, onSelect: () => undefined as any, @@ -373,7 +408,23 @@ class Tabs extends Component { 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); @@ -387,6 +438,10 @@ class Tabs extends Component { } componentWillUnmount() { + if (canUseDOM) { + window.removeEventListener('hashchange', this.handleHashChange, false); + } + if (!this.props.isVertical) { if (canUseDOM) { window.removeEventListener('resize', this.handleResize, false); @@ -398,20 +453,24 @@ class Tabs extends Component { 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) }); } @@ -463,7 +522,10 @@ class Tabs extends Component { // 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 }; } @@ -471,8 +533,8 @@ class Tabs extends Component { const { className, children, - activeKey, - defaultActiveKey, + activeKey: _activeKey, + defaultActiveKey: _defaultActiveKey, id, isAddButtonDisabled, isFilled, @@ -506,13 +568,14 @@ class Tabs extends Component { isOverflowHorizontal: isOverflowHorizontal, ...props } = this.props; + void _activeKey; + void _defaultActiveKey; const { showScrollButtons, renderScrollButtons, disableBackScrollButton, disableForwardScrollButton, shownKeys, - uncontrolledActiveKey, uncontrolledIsExpandedLocal, overflowingTabCount, isInitializingAccent, @@ -530,7 +593,7 @@ class Tabs extends Component { 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 */ diff --git a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx index 5fd3a88b11c..18855f6d186 100644 --- a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx +++ b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx @@ -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'; @@ -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'); @@ -78,6 +78,72 @@ const renderSeparateTabs = (props?: Pick { + const [activeTabKey, setActiveTabKey] = useState(0); + + return ( + setActiveTabKey(tabIndex)} + component="nav" + aria-label="Tabs in the nav element example" + > + {navTabs.map(({ eventKey, title, href, ariaLabel, ...tabProps }) => ( + {title}} + href={href} + aria-label={ariaLabel} + {...tabProps} + > + {title} + + ))} + + ); +}; + +afterEach(() => { + window.location.hash = ''; +}); + test(`Renders with classes ${styles.tabs} and ${styles.modifiers.animateCurrent} by default`, () => { render( @@ -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(); + + 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(); + + 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'); +}); diff --git a/packages/react-core/src/components/Tabs/examples/Tabs.md b/packages/react-core/src/components/Tabs/examples/Tabs.md index 094482a76b4..98106c8a1c4 100644 --- a/packages/react-core/src/components/Tabs/examples/Tabs.md +++ b/packages/react-core/src/components/Tabs/examples/Tabs.md @@ -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. diff --git a/packages/react-core/src/components/Tabs/examples/TabsNavInitialHash.tsx b/packages/react-core/src/components/Tabs/examples/TabsNavInitialHash.tsx new file mode 100644 index 00000000000..f01fa7f6c12 --- /dev/null +++ b/packages/react-core/src/components/Tabs/examples/TabsNavInitialHash.tsx @@ -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(tabs[0].eventKey); + + const handleTabClick = ( + _event: React.MouseEvent | React.KeyboardEvent | MouseEvent, + tabIndex: string | number + ) => { + setActiveTabKey(tabIndex); + }; + + return ( + + {tabs.map(({ eventKey, title, href, ariaLabel, ...tabProps }) => ( + {title}} + href={href} + aria-label={ariaLabel} + {...tabProps} + > + {title} + + ))} + + ); +};