diff --git a/src/bottom-navigation/__tests__/bottom-navigation.test.tsx b/src/bottom-navigation/__tests__/bottom-navigation.test.tsx index 4709b19ca4..ec7f49d630 100644 --- a/src/bottom-navigation/__tests__/bottom-navigation.test.tsx +++ b/src/bottom-navigation/__tests__/bottom-navigation.test.tsx @@ -26,4 +26,34 @@ describe('BottomNavigation', () => { const moreTab = getByText('More').closest('[role="tab"]'); expect(moreTab).toHaveAttribute('aria-selected', 'true'); }); + + it('labels each tabpanel with the id of the tab that controls it', () => { + const items = Array.from({ length: 6 }).map((_, idx) => ( + + {`panel ${idx}`} + + )); + + const { getByText, getAllByRole } = render( + {items} + ); + + const panels = getAllByRole('tabpanel', { hidden: true }); + const tabIds = getAllByRole('tab').map((tab) => tab.id); + + // Items 0-3 each have their own tab; item 4 (and beyond) is only reachable + // via the "More" overflow tab, so its panel must be labelled by that tab. + panels.slice(0, 4).forEach((panel, idx) => { + expect(panel).toHaveAttribute('aria-labelledby', tabIds[idx]); + }); + const moreTabId = getByText('More').closest('[role="tab"]')?.id; + expect(panels[4]).toHaveAttribute('aria-labelledby', moreTabId); + + // Every referenced id must resolve to an element that actually exists. + panels.forEach((panel) => { + const labelledBy = panel.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + expect(document.getElementById(labelledBy as string)).not.toBeNull(); + }); + }); }); diff --git a/src/bottom-navigation/bottom-navigation.tsx b/src/bottom-navigation/bottom-navigation.tsx index 8a94a40e0b..0fa1517ce4 100644 --- a/src/bottom-navigation/bottom-navigation.tsx +++ b/src/bottom-navigation/bottom-navigation.tsx @@ -57,6 +57,13 @@ const BottomNavigation = ({ onChange({ activeKey }); } + const navItems = React.Children.toArray(children); + + const getSelectorId = (idx) => + navItems.length > MAX_SELECTORS && idx >= MAX_SELECTORS - 1 + ? 'bottom-navigation-tab-overflow' + : `bottom-navigation-tab-${idx}`; + const renderSelectors = (navItems) => navItems.map((navItem: React.ReactElement, idx) => { const isActive = activeKey === idx && !displayOverflow; @@ -69,6 +76,7 @@ const BottomNavigation = ({ isActive ? scrollToTop(idx) : handleNavItemChange(idx); }} overrides={navItem.props.overrides} + id={`bottom-navigation-tab-${idx}`} key={idx} /> ); @@ -81,12 +89,11 @@ const BottomNavigation = ({ onChange={() => setDisplayOverflow(true)} isActive={displayOverflow || activeKey >= MAX_SELECTORS - 1} overrides={{ Title: overrides.OverflowTitle, Selector: overrides.OverflowSelector }} + id="bottom-navigation-tab-overflow" key={'more'} /> ); - const navItems = React.Children.toArray(children); - return ( {navItems.map((navItem: React.ReactElement, idx) => { @@ -98,7 +105,7 @@ const BottomNavigation = ({ ref={(element) => NavItemPanelRefs.current.push(element)} overrides={navItem.props.overrides} key={idx} - aria-labelledby={`tabs-${navItem.props.title}-tabpanel-${idx}`} + aria-labelledby={getSelectorId(idx)} > {navItem.props.children} diff --git a/src/bottom-navigation/panel.tsx b/src/bottom-navigation/panel.tsx index aabe376d18..367433bef5 100644 --- a/src/bottom-navigation/panel.tsx +++ b/src/bottom-navigation/panel.tsx @@ -10,18 +10,21 @@ import { StyledPanel } from './styled-components'; import { getOverrides } from '../helpers/overrides'; import type { PanelProps } from './types'; -export const Panel = React.forwardRef(({ isActive, overrides = {}, children }: PanelProps, ref) => { - const [Panel, PanelProps] = getOverrides(overrides.Panel, StyledPanel); - return ( - - ); -}); +export const Panel = React.forwardRef( + ({ isActive, overrides = {}, children, 'aria-labelledby': ariaLabelledBy }: PanelProps, ref) => { + const [Panel, PanelProps] = getOverrides(overrides.Panel, StyledPanel); + return ( + + ); + } +); Panel.displayName = 'Panel'; diff --git a/src/bottom-navigation/selector.tsx b/src/bottom-navigation/selector.tsx index 75461621af..8abaa71ace 100644 --- a/src/bottom-navigation/selector.tsx +++ b/src/bottom-navigation/selector.tsx @@ -11,14 +11,21 @@ import { useStyletron } from '../styles/index'; import { getOverrides } from '../helpers/overrides'; import type { SelectorProps } from './types'; -export const Selector = ({ title, icon, isActive, onChange, overrides = {} }: SelectorProps) => { +export const Selector = ({ + title, + icon, + isActive, + onChange, + overrides = {}, + id, +}: SelectorProps) => { const [Title, TitleProps] = getOverrides(overrides.Title, StyledTitle); const [Selector, SelectorProps] = getOverrides(overrides.Selector, StyledSelector); const [, theme] = useStyletron(); const Icon = icon; return ( - + void;