diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 1c997f8c095..4107cda8d2e 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -136,6 +136,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; @@ -148,7 +167,8 @@ interface TabsState { disableBackScrollButton: boolean; disableForwardScrollButton: boolean; shownKeys: (string | number)[]; - uncontrolledActiveKey: number | string; + uncontrolledActiveKey: number | string | undefined; + initialActiveKey: number | string | undefined; uncontrolledIsExpandedLocal: boolean; ouiaStateId: string; overflowingTabCount: number; @@ -164,14 +184,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, ouiaStateId: getDefaultOUIAId(Tabs.displayName), overflowingTabCount: 0, @@ -219,14 +245,18 @@ class Tabs extends Component { eventKey: number | string, tabContentRef: React.RefObject ) { - const { shownKeys } = this.state; + const { shownKeys, initialActiveKey } = this.state; const { onSelect, defaultActiveKey } = this.props; + // 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); } @@ -399,8 +429,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) || @@ -415,6 +446,10 @@ class Tabs extends Component { }); } + if (defaultActiveKey === undefined && prevProps.activeKey !== activeKey && initialActiveKey !== undefined) { + this.setState({ initialActiveKey: undefined }); + } + if ( prevProps.children && children && @@ -513,6 +548,7 @@ class Tabs extends Component { disableForwardScrollButton, shownKeys, uncontrolledActiveKey, + initialActiveKey, uncontrolledIsExpandedLocal, overflowingTabCount, isInitializingAccent, @@ -530,7 +566,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 = 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 5fd3a88b11c..ea45cd373be 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,130 @@ 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 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(`Renders with class ${styles.modifiers.initializingAccent} when uncontrolled expandable component initially mounts`, async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });