diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 495636d8999..66346156f20 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -140,6 +140,25 @@ const variantStyle = { secondary: styles.modifiers.secondary }; +const getHashFromHref = (href?: string) => { + const hashIndex = href?.indexOf('#') ?? -1; + + return hashIndex >= 0 ? href.slice(hashIndex) : undefined; +}; + +const getTabHashActiveKey = ({ children, component, isNav }: Pick) => { + if (!canUseDOM || !(component === TabsComponent.nav || isNav) || !window.location.hash) { + return undefined; + } + + return Children.toArray(children) + .filter((child): child is TabElement => isValidElement(child)) + .filter(({ props }) => !props.isHidden) + .find( + ({ props }) => !props.isDisabled && !props.isAriaDisabled && getHashFromHref(props.href) === window.location.hash + )?.props.eventKey; +}; + interface TabsState { /** Used to signal if the scroll buttons should be used */ enableScrollButtons: boolean; @@ -152,7 +171,8 @@ interface TabsState { disableBackScrollButton: boolean; disableForwardScrollButton: boolean; shownKeys: (string | number)[]; - uncontrolledActiveKey: number | string; + uncontrolledActiveKey: number | string | undefined; + initialActiveKey: number | string | undefined; uncontrolledIsExpandedLocal: boolean; overflowingTabCount: number; isInitializingAccent: boolean; @@ -167,14 +187,20 @@ class Tabs extends Component { private direction = 'ltr'; constructor(props: TabsProps) { super(props); + const hashActiveKey = getTabHashActiveKey(props); + 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 - uncontrolledActiveKey: this.props.defaultActiveKey, + shownKeys: + this.props.defaultActiveKey !== undefined + ? [hashActiveKey ?? this.props.defaultActiveKey] + : [hashActiveKey ?? this.props.activeKey], // only for mountOnEnter case + uncontrolledActiveKey: hashActiveKey ?? this.props.defaultActiveKey, + initialActiveKey: this.props.defaultActiveKey === undefined ? hashActiveKey : undefined, uncontrolledIsExpandedLocal: this.props.defaultIsExpanded, overflowingTabCount: 0, isInitializingAccent: true, @@ -221,14 +247,26 @@ class Tabs extends Component { eventKey: number | string, tabContentRef: React.RefObject ) { - const { shownKeys } = this.state; - const { onSelect, defaultActiveKey } = this.props; + const { shownKeys, initialActiveKey } = this.state; + const { onSelect, defaultActiveKey, component, isNav } = this.props; + + if (event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.hasAttribute('href')) { + event.preventDefault(); + + if (canUseDOM && (component === TabsComponent.nav || isNav)) { + window.history.pushState(null, '', event.currentTarget.href); + } + } + // if defaultActiveKey Tabs are uncontrolled, set new active key internally if (defaultActiveKey !== undefined) { this.setState({ uncontrolledActiveKey: eventKey }); } else { + if (initialActiveKey !== undefined) { + this.setState({ initialActiveKey: undefined }); + } onSelect(event, eventKey); } @@ -401,8 +439,9 @@ 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 { shownKeys, overflowingTabCount, enableScrollButtons, uncontrolledActiveKey, initialActiveKey } = this.state; const isOnCloseUpdate = !!prevProps.onClose !== !!this.props.onClose; + if ( (defaultActiveKey !== undefined && prevState.uncontrolledActiveKey !== uncontrolledActiveKey) || (defaultActiveKey === undefined && prevProps.activeKey !== activeKey) || @@ -417,6 +456,10 @@ class Tabs extends Component { }); } + if (defaultActiveKey === undefined && prevProps.activeKey !== activeKey && initialActiveKey !== undefined) { + this.setState({ initialActiveKey: undefined }); + } + if ( prevProps.children && children && @@ -517,6 +560,7 @@ class Tabs extends Component { disableForwardScrollButton, shownKeys, uncontrolledActiveKey, + initialActiveKey, uncontrolledIsExpandedLocal, overflowingTabCount, isInitializingAccent, @@ -533,7 +577,7 @@ class Tabs extends Component { const defaultComponent = isNav && !component ? 'nav' : 'div'; const Component: any = component !== undefined ? component : defaultComponent; - const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : activeKey; + const localActiveKey = defaultActiveKey !== undefined ? uncontrolledActiveKey : (initialActiveKey ?? activeKey); 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 fd574570793..3aa1d847bd4 100644 --- a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx +++ b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx @@ -7,7 +7,7 @@ import { TabTitleText } from '../TabTitleText'; import { TabTitleIcon } from '../TabTitleIcon'; import { TabContent } from '../TabContent'; import { TabContentBody } from '../TabContentBody'; -import { createRef } from 'react'; +import { createRef, useState } from 'react'; jest.mock('../../../helpers/GenerateId/GenerateId'); @@ -119,6 +119,222 @@ test(`Does not render with class ${styles.modifiers.initializingAccent} when com jest.useRealTimers(); }); +describe('hash-based nav selection', () => { + beforeEach(() => { + window.location.hash = '#/items/2'; + }); + + afterEach(() => { + window.location.hash = ''; + }); + + test('should select the nav tab that matches the current URL hash on initial render', () => { + render( + undefined} component="nav"> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + Tab item 3} href="#/items/3"> + Tab item 3 + + + ); + + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should select the nav tab that matches the current URL hash when isNav is enabled', () => { + render( + undefined} isNav> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + + ); + + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should fall back to activeKey when no tab href matches the current hash', () => { + window.location.hash = '#/items/unknown'; + + render( + undefined} component="nav"> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + + ); + + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should fall back to activeKey when the URL hash is empty', () => { + window.location.hash = ''; + + render( + undefined} component="nav"> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + + ); + + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should respect later controlled selections after the initial hash match', async () => { + const user = userEvent.setup(); + const ControlledTabs = () => { + const [activeKey, setActiveKey] = useState(0); + + return ( + setActiveKey(eventKey)} component="nav"> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + Tab item 3} href="#/items/3"> + Tab item 3 + + + ); + }; + + render(); + + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true'); + + await user.click(screen.getByRole('tab', { name: 'Tab item 3' })); + + expect(screen.getByRole('tab', { name: 'Tab item 3' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should use the URL hash to initialize uncontrolled nav tabs', async () => { + const user = userEvent.setup(); + + render( + + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + Tab item 3} href="#/items/3"> + Tab item 3 + + + ); + + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'true'); + + await user.click(screen.getByRole('tab', { name: 'Tab item 1' })); + + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should ignore hidden, disabled and aria-disabled tabs when matching the current hash', () => { + render( + undefined} component="nav"> + Tab item 1} href="#/items/1"> + Tab item 1 + + Hidden tab} href="#/items/2" isHidden> + Hidden tab + + Disabled tab} href="#/items/2" isDisabled> + Disabled tab + + Aria disabled tab} href="#/items/2" isAriaDisabled> + Aria disabled tab + + + ); + + expect(screen.queryByRole('tab', { name: 'Hidden tab' })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Disabled tab' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByRole('tab', { name: 'Aria disabled tab' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should ignore the current URL hash when nav behavior is not enabled', () => { + render( + undefined}> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + + ); + + expect(screen.getByRole('tab', { name: 'Tab item 1' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tab item 2' })).toHaveAttribute('aria-selected', 'false'); + }); + + test('should prevent default anchor navigation when selecting a tab with an href', async () => { + const user = userEvent.setup(); + window.location.hash = ''; + + render( + undefined}> + Tab item 1} href="#/items/1"> + Tab item 1 + + Tab item 2} href="#/items/2"> + Tab item 2 + + + ); + + await user.click(screen.getByRole('tab', { name: 'Tab item 2' })); + + expect(window.location.hash).toBe(''); + }); + + test('should update the URL hash when selecting a nav tab with an href', async () => { + const user = userEvent.setup(); + window.location.hash = ''; + + render( + undefined} isNav> + Users} href="#users"> + Users + + Containers} href="#containers"> + Containers + + + ); + + await user.click(screen.getByRole('tab', { name: 'Containers' })); + + expect(window.location.hash).toBe('#containers'); + }); +}); + test(`Renders with class ${styles.modifiers.initializingAccent} when uncontrolled expandable component initially mounts`, async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); diff --git a/packages/react-core/src/components/Tabs/__tests__/__snapshots__/Tabs.test.tsx.snap b/packages/react-core/src/components/Tabs/__tests__/__snapshots__/Tabs.test.tsx.snap index 9d0c6efebc6..864927b376c 100644 --- a/packages/react-core/src/components/Tabs/__tests__/__snapshots__/Tabs.test.tsx.snap +++ b/packages/react-core/src/components/Tabs/__tests__/__snapshots__/Tabs.test.tsx.snap @@ -4,7 +4,7 @@ exports[`should not render tablist aria-label or aria-labelledby when neither is