From 80426a64310904d69870c511fc85eeae83ca7595 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Tue, 9 Dec 2025 15:36:38 +0000 Subject: [PATCH 01/11] Improve BpkSegmentedControl's a11y by following the tabs interaction pattern --- .../examples.tsx | 136 ++++++++++++-- .../stories.tsx | 4 + .../bpk-component-segmented-control/README.md | 159 +++++++++++++++- .../bpk-component-segmented-control/index.ts | 5 +- .../src/BpkSegmentedControl-test.tsx | 170 ++++++++++++++++-- .../src/BpkSegmentedControl.tsx | 123 +++++++++++-- 6 files changed, 547 insertions(+), 50 deletions(-) diff --git a/examples/bpk-component-segmented-control/examples.tsx b/examples/bpk-component-segmented-control/examples.tsx index da0d191a7d..a73002d66b 100644 --- a/examples/bpk-component-segmented-control/examples.tsx +++ b/examples/bpk-component-segmented-control/examples.tsx @@ -16,13 +16,18 @@ * limitations under the License. */ +import { useState } from 'react'; + import { canvasContrastDay, surfaceContrastDay, } from '@skyscanner/bpk-foundations-web/tokens/base.es6'; -import BpkSegmentedControl from '../../packages/bpk-component-segmented-control'; +import BpkSegmentedControl, { + getTabPanelProps, +} from '../../packages/bpk-component-segmented-control'; import { SEGMENT_TYPES } from '../../packages/bpk-component-segmented-control/src/BpkSegmentedControl'; +import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; import { cssModules } from '../../packages/bpk-react-utils'; // @ts-expect-error Untyped import. See `decisions/imports-ts-suppressions.md`. import { BpkDarkExampleWrapper } from '../bpk-storybook-utils'; @@ -45,7 +50,7 @@ const surfaceContrastWrapperStyle = { const SimpleDefault = () => ( {}} selectedIndex={0} type={SEGMENT_TYPES.CanvasDefault} @@ -56,7 +61,7 @@ const SimpleCanvasContrast = () => (
{}} selectedIndex={2} type={SEGMENT_TYPES.CanvasContrast} @@ -68,7 +73,7 @@ const SimpleSurfaceDefault = () => (
{}} selectedIndex={2} type={SEGMENT_TYPES.SurfaceDefault} @@ -85,7 +90,7 @@ const SimpleSurfaceContrast = () => ( 'Very Long Value3', 'Very Long Value4', ]} - label='Segmented control' + label="Segmented control" onItemClick={() => {}} selectedIndex={2} type={SEGMENT_TYPES.SurfaceContrast} @@ -123,7 +128,7 @@ const CustomSurfaceContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceContrast} @@ -135,7 +140,7 @@ const CustomSurfaceContrast = () => ( const CustomSurfaceDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -147,7 +152,7 @@ const CustomCanvasContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasContrast} @@ -159,7 +164,7 @@ const CustomCanvasContrast = () => ( const CustomCanvasDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasDefault} @@ -170,7 +175,7 @@ const CustomCanvasDefault = () => ( const CustomSurfaceDefaultNoShadow = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -210,7 +215,7 @@ const ComplexSurfaceContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceContrast} @@ -222,7 +227,7 @@ const ComplexSurfaceContrast = () => ( const ComplexSurfaceDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -234,7 +239,7 @@ const ComplexCanvasContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasContrast} @@ -246,7 +251,7 @@ const ComplexCanvasContrast = () => ( const ComplexCanvasDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasDefault} @@ -257,7 +262,7 @@ const ComplexCanvasDefault = () => ( const ComplexSurfaceDefaultNoShadow = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -276,6 +281,105 @@ const VisualExample = () => ( ); +// Example demonstrating accessible tabs with panels using getTabPanelProps helper. +// This pattern provides full WCAG compliance with proper keyboard navigation +// and ARIA relationships between tabs and their panels. +const WithTabPanelsExample = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const panelStyle = { + padding: '1rem', + border: '1px solid #ddd', + borderRadius: '0.5rem', + marginTop: '1rem', + }; + + return ( +
+ +
+ Search for flights to your destination. +
+
+ Find the perfect place to stay. +
+
+ Rent a car for your trip. +
+
+ ); +}; + +// Example using conditional rendering instead of the hidden attribute. +// Both approaches are valid - use whichever fits your use case better. +const WithConditionalPanelsExample = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const panelStyle = { + padding: '1rem', + border: '1px solid #ddd', + borderRadius: '0.5rem', + marginTop: '1rem', + }; + + return ( +
+ + {selectedIndex === 0 && ( +
+ Specific dates + Pick exact travel dates from the calendar. +
+ )} + {selectedIndex === 1 && ( +
+ Flexible dates + Choose a month or date range. +
+ )} +
+ ); +}; + export { SimpleDefault, SimpleCanvasContrast, @@ -292,4 +396,6 @@ export { ComplexCanvasDefault, ComplexSurfaceDefaultNoShadow, VisualExample, + WithTabPanelsExample, + WithConditionalPanelsExample, }; diff --git a/examples/bpk-component-segmented-control/stories.tsx b/examples/bpk-component-segmented-control/stories.tsx index 59da23551a..d097de6577 100644 --- a/examples/bpk-component-segmented-control/stories.tsx +++ b/examples/bpk-component-segmented-control/stories.tsx @@ -34,6 +34,8 @@ import { ComplexCanvasDefault, ComplexSurfaceDefaultNoShadow, VisualExample, + WithTabPanelsExample, + WithConditionalPanelsExample, } from './examples'; export default { @@ -57,6 +59,8 @@ export const ComplexThreeSegmentsCanvasContrast = ComplexCanvasContrast; export const ComplexThreeSegmentsCanvasDefault = ComplexCanvasDefault; export const ComplexThreeSegmentsSurfaceDefaultNoShadow = ComplexSurfaceDefaultNoShadow; +export const WithTabPanels = WithTabPanelsExample; +export const WithConditionalPanels = WithConditionalPanelsExample; export const VisualTest = VisualExample; export const VisualTestWithZoom = { render: VisualTest, diff --git a/packages/bpk-component-segmented-control/README.md b/packages/bpk-component-segmented-control/README.md index adc524be73..89e4227a6b 100644 --- a/packages/bpk-component-segmented-control/README.md +++ b/packages/bpk-component-segmented-control/README.md @@ -1,24 +1,167 @@ # bpk-segmented-control ## Installation + Check the main [Readme](https://github.com/skyscanner/backpack#usage) for a complete installation guide. ## Usage + +### Basic usage + ```js import BpkSegmentedControl from '@skyscanner/backpack-web/bpk-component-segmented-control'; export default () => ( {}} - selectedIndex={1} // button selected on load - type={SEGMENT_TYPES.SurfaceContrast} - shadow + buttonContents={['Option 1', 'Option 2', 'Option 3']} + label="Trip type" // Accessible name, this should be localised + onItemClick={(index) => console.log('Selected:', index)} + selectedIndex={1} // button selected on load + type={SEGMENT_TYPES.SurfaceContrast} + shadow /> -) +); +``` + +### With tab panels (recommended for accessibility) + +When using the segmented control to switch between content panels, use the `getTabPanelProps` helper to ensure proper ARIA relationships: + +```js +import { useState } from 'react'; +import BpkSegmentedControl, { + getTabPanelProps, +} from '@skyscanner/backpack-web/bpk-component-segmented-control'; + +export default () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + return ( +
+ +
+ Flights content +
+
+ Hotels content +
+
+ Car hire content +
+
+ ); +}; +``` + +### With conditional rendering + +You can also use conditional rendering instead of the `hidden` attribute. When doing so, manually add the panel accessibility attributes: + +```js +import { useState } from 'react'; +import BpkSegmentedControl from '@skyscanner/backpack-web/bpk-component-segmented-control'; + +export default () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + return ( +
+ + {selectedIndex === 0 && ( +
+ Specific dates content +
+ )} + {selectedIndex === 1 && ( +
+ Flexible dates content +
+ )} +
+ ); +}; ``` +## Accessibility + +### Keyboard navigation + +The component implements the [WAI-ARIA Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) with the following keyboard interactions: + +| Key | Action | +| ------------------ | ------------------------------------------ | +| `←` / `ArrowLeft` | Move focus to previous tab (wraps to last) | +| `→` / `ArrowRight` | Move focus to next tab (wraps to first) | +| `Home` | Move focus to first tab | +| `End` | Move focus to last tab | +| `Tab` | Move focus into/out of the tab list | + +Selection follows focus automatically (automatic activation mode). + +### ARIA attributes + +The component automatically applies: + +- `role="tablist"` on the container +- `role="tab"` on each button +- `aria-selected` to indicate the selected tab +- `aria-orientation="horizontal"` on the tablist +- `aria-controls` linking to panels (when `panelIds` prop provided) + +When using the `getTabPanelProps` helper, panels receive: + +- `role="tabpanel"` +- `aria-labelledby` linking back to the controlling tab +- `hidden` attribute for non-selected panels +- `tabIndex={0}` to allow focus when panel has no focusable content ## Props -Check out the full list of props on Skyscanner's [design system documentation website]( https://github.com/Skyscanner/backpack/blob/main/packages/bpk-component-segmented-control/README.md). + +| Property | PropType | Required | Default Value | +| -------------- | -------------------- | -------- | --------------------------- | +| buttonContents | arrayOf(node) | true | - | +| onItemClick | func | true | - | +| selectedIndex | number | true | - | +| id | string | false | auto-generated | +| label | string | false | - | +| panelIds | arrayOf(string) | false | - | +| shadow | bool | false | false | +| type | oneOf(SEGMENT_TYPES) | false | SEGMENT_TYPES.CanvasDefault | + +### getTabPanelProps + +Helper function to generate accessibility props for tab panels. + +```ts +getTabPanelProps(baseId: string, index: number, selectedIndex: number): TabPanelProps +``` + +| Parameter | Type | Description | +| ------------- | ------ | ------------------------------------------- | +| baseId | string | The same `id` passed to BpkSegmentedControl | +| index | number | The index of this panel (0-based) | +| selectedIndex | number | The currently selected tab index | + +Returns an object with: `id`, `role`, `aria-labelledby`, `hidden`, `tabIndex` diff --git a/packages/bpk-component-segmented-control/index.ts b/packages/bpk-component-segmented-control/index.ts index bac79e5415..8354283488 100644 --- a/packages/bpk-component-segmented-control/index.ts +++ b/packages/bpk-component-segmented-control/index.ts @@ -17,8 +17,11 @@ */ import BpkSegmentedControl, { + getTabPanelProps, type Props as BpkSegmentControlProps, + type TabPanelProps, } from './src/BpkSegmentedControl'; -export type { BpkSegmentControlProps }; +export type { BpkSegmentControlProps, TabPanelProps }; +export { getTabPanelProps }; export default BpkSegmentedControl; diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx index 499319e0e2..6a73a22651 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx @@ -18,7 +18,10 @@ import { render, fireEvent, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import BpkSegmentedControl, { SEGMENT_TYPES } from './BpkSegmentedControl'; +import BpkSegmentedControl, { + getTabPanelProps, + SEGMENT_TYPES, +} from './BpkSegmentedControl'; const mockOnItemClick = jest.fn(); @@ -69,8 +72,8 @@ describe('BpkSegmentedControl', () => { const buttonOne = getByText('one'); fireEvent.click(buttonOne); - expect(screen.getByText('one')).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByText('two')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'false'); }); it('should render with the correct type class', () => { @@ -101,26 +104,165 @@ describe('BpkSegmentedControl', () => { expect(selectedButton).toBeInTheDocument(); }); - it('should render with role="group" on the outer div', () => { + it('should render with role="tablist" on the outer div', () => { render(); - const group = screen.getByRole('group'); - expect(group).toBeInTheDocument(); + const tablist = screen.getByRole('tablist'); + expect(tablist).toBeInTheDocument(); }); - it('should set the accessible label on the group when label prop is provided', () => { + it('should set aria-orientation="horizontal" on the tablist', () => { + render(); + const tablist = screen.getByRole('tablist'); + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + it('should set the accessible label on the tablist when label prop is provided', () => { + render( + , + ); + const tablist = screen.getByRole('tablist'); + expect(tablist).toHaveAttribute('aria-label', 'Segmented control label'); + }); + + it('should not set aria-label when label prop is not provided', () => { + render(); + const tablist = screen.getByRole('tablist'); + expect(tablist).not.toHaveAttribute('aria-label'); + }); + + it('should generate tab IDs based on provided id prop', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('id', 'my-tabs-tab-0'); + expect(tabs[1]).toHaveAttribute('id', 'my-tabs-tab-1'); + }); + + it('should add aria-controls when panelIds prop is provided', () => { render( , ); - const group = screen.getByRole('group'); - expect(group).toHaveAttribute('aria-label', 'Segmented control label'); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('aria-controls', 'panel-0'); + expect(tabs[1]).toHaveAttribute('aria-controls', 'panel-1'); }); - it('should not set aria-label when label prop is not provided', () => { - render(); - const group = screen.getByRole('group'); - expect(group).not.toHaveAttribute('aria-label'); + it('should not add aria-controls when panelIds prop is not provided', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).not.toHaveAttribute('aria-controls'); + expect(tabs[1]).not.toHaveAttribute('aria-controls'); + }); + + describe('keyboard navigation', () => { + it('should move focus to next tab on ArrowRight', () => { + render(); + const tabs = screen.getAllByRole('tab'); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], { key: 'ArrowRight' }); + + expect(mockOnItemClick).toHaveBeenCalledWith(0); // wraps to first + expect(document.activeElement).toBe(tabs[0]); + }); + + it('should move focus to previous tab on ArrowLeft', () => { + render(); + const tabs = screen.getAllByRole('tab'); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], { key: 'ArrowLeft' }); + + expect(mockOnItemClick).toHaveBeenCalledWith(0); + expect(document.activeElement).toBe(tabs[0]); + }); + + it('should wrap to last tab when pressing ArrowLeft on first tab', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); + + expect(mockOnItemClick).toHaveBeenCalledWith(1); + expect(document.activeElement).toBe(tabs[1]); + }); + + it('should move focus to first tab on Home', () => { + render(); + const tabs = screen.getAllByRole('tab'); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], { key: 'Home' }); + + expect(mockOnItemClick).toHaveBeenCalledWith(0); + expect(document.activeElement).toBe(tabs[0]); + }); + + it('should move focus to last tab on End', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'End' }); + + expect(mockOnItemClick).toHaveBeenCalledWith(1); + expect(document.activeElement).toBe(tabs[1]); + }); + + it('should set tabIndex=0 only on selected tab', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('tabIndex', '-1'); + expect(tabs[1]).toHaveAttribute('tabIndex', '0'); + }); + }); +}); + +describe('getTabPanelProps', () => { + it('should return correct props for selected panel', () => { + const props = getTabPanelProps('my-tabs', 0, 0); + expect(props).toEqual({ + id: 'my-tabs-panel-0', + role: 'tabpanel', + 'aria-labelledby': 'my-tabs-tab-0', + hidden: false, + tabIndex: 0, + }); + }); + + it('should return hidden=true for non-selected panel', () => { + const props = getTabPanelProps('my-tabs', 1, 0); + expect(props).toEqual({ + id: 'my-tabs-panel-1', + role: 'tabpanel', + 'aria-labelledby': 'my-tabs-tab-1', + hidden: true, + tabIndex: 0, + }); + }); + + it('should generate correct IDs for different indices', () => { + const props0 = getTabPanelProps('tabs', 0, 0); + const props1 = getTabPanelProps('tabs', 1, 0); + const props2 = getTabPanelProps('tabs', 2, 2); + + expect(props0.id).toBe('tabs-panel-0'); + expect(props1.id).toBe('tabs-panel-1'); + expect(props2.id).toBe('tabs-panel-2'); + + expect(props0['aria-labelledby']).toBe('tabs-tab-0'); + expect(props1['aria-labelledby']).toBe('tabs-tab-1'); + expect(props2['aria-labelledby']).toBe('tabs-tab-2'); }); }); diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index deaee7dd07..44dd3cd7e0 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -16,8 +16,8 @@ * limitations under the License. */ -import type { ReactNode } from 'react'; -import { useState } from 'react'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useId, useRef, useState } from 'react'; import { cssModules } from '../../bpk-react-utils'; @@ -33,36 +33,126 @@ export const SEGMENT_TYPES = { }; export type SegmentTypes = (typeof SEGMENT_TYPES)[keyof typeof SEGMENT_TYPES]; +export type TabPanelProps = { + id: string; + role: 'tabpanel'; + 'aria-labelledby': string; + hidden: boolean; + tabIndex: 0; +}; + +/** + * Helper function to get accessibility props for tab panel elements. + * Use this to ensure proper ARIA relationships between tabs and their panels. + * @param {string} baseId - The base ID used to generate unique IDs for tabs and panels. + * @param {number} index - The index of the tab panel. + * @param {number} selectedIndex - The currently selected tab index. + * @returns {TabPanelProps} An object containing the necessary props for a tab panel. + */ +export const getTabPanelProps = ( + baseId: string, + index: number, + selectedIndex: number, +): TabPanelProps => ({ + id: `${baseId}-panel-${index}`, + role: 'tabpanel', + 'aria-labelledby': `${baseId}-tab-${index}`, + hidden: index !== selectedIndex, + tabIndex: 0, +}); + export type Props = { buttonContents: string[] | ReactNode[]; + /** + * Unique identifier for the segmented control. Used to generate tab and panel IDs + * for ARIA relationships. If not provided, a unique ID will be auto-generated. + */ + id?: string; /** * Accessible label for the segmented control group. */ label?: string; + /** + * Array of panel IDs that each tab controls. When provided, adds aria-controls + * to each tab button linking it to its corresponding panel. + * Should match the order and length of buttonContents. + */ + panelIds?: string[]; type?: SegmentTypes; - /* - * Index parameter to track which is clicked + /** + * Callback fired when a tab is selected. Receives the index of the selected tab. */ onItemClick: (id: number) => void; selectedIndex: number; shadow?: boolean; + activationMode?: 'automatic' | 'manual'; }; const BpkSegmentedControl = ({ + activationMode = 'automatic', buttonContents, + id: providedId, label, onItemClick, + panelIds, selectedIndex, shadow = false, type = SEGMENT_TYPES.CanvasDefault, }: Props) => { + const generatedId = useId(); + const id = providedId || generatedId; + const buttonRefs = useRef>([]); + + // TODO: Consider removing internal state - component is controlled via selectedIndex prop. + // Internal state may cause sync issues if selectedIndex changes externally. const [selectedButton, setSelectedButton] = useState(selectedIndex); - const handleButtonClick = (id: number) => { - if (id !== selectedButton) { - setSelectedButton(id); - onItemClick(id); + + const handleButtonClick = (index: number) => { + if (index !== selectedButton) { + setSelectedButton(index); + onItemClick(index); } }; + + const handleKeyDown = ( + event: KeyboardEvent, + currentIndex: number, + ) => { + const lastIndex = buttonContents.length - 1; + let newIndex = currentIndex; + + switch (event.key) { + case 'ArrowRight': + newIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + break; + case 'ArrowLeft': + newIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; + break; + case 'Home': + newIndex = 0; + break; + case 'End': + newIndex = lastIndex; + break; + case ' ': + case 'Enter': + if (activationMode === 'manual') { + setSelectedButton(currentIndex); + onItemClick(currentIndex); + } + return; + default: + return; + } + + event.preventDefault(); + if (activationMode === 'automatic') { + setSelectedButton(newIndex); + onItemClick(newIndex); + } + buttonRefs.current[newIndex]?.focus(); + }; + const containerStyling = getClassName( 'bpk-segmented-control-group', shadow && 'bpk-segmented-control-group-shadow', @@ -71,7 +161,8 @@ const BpkSegmentedControl = ({ return (
{buttonContents.map((content, index) => { @@ -87,14 +178,22 @@ const BpkSegmentedControl = ({ `bpk-segmented-control--${type}-selected-shadow`, ); + const tabId = `${id}-tab-${index}`; return ( From 1b840bc6dfcc75dfa867c4788a75ba11fc221dd5 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Wed, 10 Dec 2025 14:56:58 +0000 Subject: [PATCH 02/11] Add useSegmentedControlPanels hook for improved tab management and accessibility --- .../examples.tsx | 56 ++++++++- .../stories.tsx | 2 + .../bpk-component-segmented-control/README.md | 47 +++++-- .../bpk-component-segmented-control/index.ts | 3 +- .../src/BpkSegmentedControl-test.tsx | 115 +++++++++++++++--- .../src/BpkSegmentedControl.tsx | 87 ++++++++++--- .../src/accessibility-test.tsx | 15 ++- 7 files changed, 273 insertions(+), 52 deletions(-) diff --git a/examples/bpk-component-segmented-control/examples.tsx b/examples/bpk-component-segmented-control/examples.tsx index a73002d66b..1e3855ca8e 100644 --- a/examples/bpk-component-segmented-control/examples.tsx +++ b/examples/bpk-component-segmented-control/examples.tsx @@ -25,6 +25,7 @@ import { import BpkSegmentedControl, { getTabPanelProps, + useSegmentedControlPanels, } from '../../packages/bpk-component-segmented-control'; import { SEGMENT_TYPES } from '../../packages/bpk-component-segmented-control/src/BpkSegmentedControl'; import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; @@ -281,6 +282,54 @@ const VisualExample = () => ( ); +// Example demonstrating the recommended hook pattern for managing tabs and panels. +// The hook automatically handles ID generation and ARIA relationships. +const WithHookExample = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + const buttonContents = ['Flights', 'Hotels', 'Car hire']; + + const { controlProps, getPanelProps } = useSegmentedControlPanels( + buttonContents, + selectedIndex, + ); + + const panelStyle = { + padding: '1rem', + border: '1px solid #ddd', + borderRadius: '0.5rem', + marginTop: '1rem', + }; + + return ( +
+ +
+ Search for flights to your destination. +
+
+ Find the perfect place to stay. +
+
+ Rent a car for your trip. +
+
+ ); +}; + // Example demonstrating accessible tabs with panels using getTabPanelProps helper. // This pattern provides full WCAG compliance with proper keyboard navigation // and ARIA relationships between tabs and their panels. @@ -303,11 +352,6 @@ const WithTabPanelsExample = () => { onItemClick={setSelectedIndex} selectedIndex={selectedIndex} type={SEGMENT_TYPES.CanvasDefault} - panelIds={[ - 'travel-options-panel-0', - 'travel-options-panel-1', - 'travel-options-panel-2', - ]} />
{ onItemClick={setSelectedIndex} selectedIndex={selectedIndex} type={SEGMENT_TYPES.SurfaceDefault} - panelIds={['date-selection-panel-0', 'date-selection-panel-1']} /> {selectedIndex === 0 && (
( ); ``` -### With tab panels (recommended for accessibility) +### With tab panels using the hook (recommended) -When using the segmented control to switch between content panels, use the `getTabPanelProps` helper to ensure proper ARIA relationships: +The easiest way to use segmented controls with tab panels is the `useSegmentedControlPanels` hook. It automatically manages IDs and provides proper accessibility: + +```js +import { useState } from 'react'; +import BpkSegmentedControl, { + useSegmentedControlPanels, +} from '@skyscanner/backpack-web/bpk-component-segmented-control'; + +export default () => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const { controlProps, getPanelProps } = useSegmentedControlPanels( + ['Flights', 'Hotels', 'Car hire'], + selectedIndex, + ); + + return ( +
+ +
Flights content
+
Hotels content
+
Car hire content
+
+ ); +}; +``` + +### With tab panels using getTabPanelProps (alternative) + +For more control over ID generation, you can use the `getTabPanelProps` helper directly: ```js import { useState } from 'react'; @@ -44,7 +78,6 @@ export default () => { label="Travel options" onItemClick={setSelectedIndex} selectedIndex={selectedIndex} - panelIds={['my-tabs-panel-0', 'my-tabs-panel-1', 'my-tabs-panel-2']} />
Flights content @@ -79,7 +112,6 @@ export default () => { label="Date selection" onItemClick={setSelectedIndex} selectedIndex={selectedIndex} - panelIds={['my-tabs-panel-0', 'my-tabs-panel-1']} /> {selectedIndex === 0 && (
{ expect(mockOnItemClick).toHaveBeenCalledWith(0); }); - it('should update the selected button when a button is clicked', () => { + it('should call onItemClick when a different button is clicked', () => { const { getByText } = render(); const buttonOne = getByText('one'); fireEvent.click(buttonOne); + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); + + it('should reflect selectedIndex prop in aria-selected attribute', () => { + const { rerender } = render( + , + ); expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'true'); expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'false'); + + rerender(); + expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'true'); }); it('should render with the correct type class', () => { @@ -137,29 +149,22 @@ describe('BpkSegmentedControl', () => { expect(tabs[1]).toHaveAttribute('id', 'my-tabs-tab-1'); }); - it('should add aria-controls when panelIds prop is provided', () => { - render( - , - ); - const tabs = screen.getAllByRole('tab'); - expect(tabs[0]).toHaveAttribute('aria-controls', 'panel-0'); - expect(tabs[1]).toHaveAttribute('aria-controls', 'panel-1'); - }); - - it('should not add aria-controls when panelIds prop is not provided', () => { + it('should auto-generate aria-controls for panels', () => { render(); const tabs = screen.getAllByRole('tab'); - expect(tabs[0]).not.toHaveAttribute('aria-controls'); - expect(tabs[1]).not.toHaveAttribute('aria-controls'); + expect(tabs[0]).toHaveAttribute('aria-controls', 'my-tabs-panel-0'); + expect(tabs[1]).toHaveAttribute('aria-controls', 'my-tabs-panel-1'); }); describe('keyboard navigation', () => { it('should move focus to next tab on ArrowRight', () => { - render(); + render( + , + ); const tabs = screen.getAllByRole('tab'); tabs[1].focus(); fireEvent.keyDown(tabs[1], { key: 'ArrowRight' }); @@ -266,3 +271,75 @@ describe('getTabPanelProps', () => { expect(props2['aria-labelledby']).toBe('tabs-tab-2'); }); }); + +describe('useSegmentedControlPanels', () => { + it('should return controlProps with auto-generated id', () => { + const { result } = renderHook(() => + useSegmentedControlPanels(['One', 'Two'], 0), + ); + + expect(result.current.controlProps).toHaveProperty('id'); + expect(result.current.controlProps.id).toBeTruthy(); + expect(result.current.controlProps.buttonContents).toEqual(['One', 'Two']); + expect(result.current.controlProps.selectedIndex).toBe(0); + }); + + it('should return getPanelProps function that generates correct props', () => { + const { result } = renderHook(() => + useSegmentedControlPanels(['One', 'Two', 'Three'], 1), + ); + + const panel0Props = result.current.getPanelProps(0); + const panel1Props = result.current.getPanelProps(1); + const panel2Props = result.current.getPanelProps(2); + + expect(panel0Props.hidden).toBe(true); + expect(panel1Props.hidden).toBe(false); + expect(panel2Props.hidden).toBe(true); + + expect(panel0Props.role).toBe('tabpanel'); + expect(panel1Props.role).toBe('tabpanel'); + + expect(panel0Props['aria-labelledby']).toContain('-tab-0'); + expect(panel1Props['aria-labelledby']).toContain('-tab-1'); + expect(panel2Props['aria-labelledby']).toContain('-tab-2'); + + expect(panel0Props.id).toContain('-panel-0'); + expect(panel1Props.id).toContain('-panel-1'); + expect(panel2Props.id).toContain('-panel-2'); + }); + + it('should maintain stable IDs across re-renders with same inputs', () => { + const { rerender, result } = renderHook( + ({ contents, selected }) => useSegmentedControlPanels(contents, selected), + { + initialProps: { contents: ['A', 'B'], selected: 0 }, + }, + ); + + const firstId = result.current.controlProps.id; + const firstPanelId = result.current.getPanelProps(0).id; + + rerender({ contents: ['A', 'B'], selected: 0 }); + + expect(result.current.controlProps.id).toBe(firstId); + expect(result.current.getPanelProps(0).id).toBe(firstPanelId); + }); + + it('should update panel hidden state when selectedIndex changes', () => { + const { rerender, result } = renderHook( + ({ contents, selected }) => useSegmentedControlPanels(contents, selected), + { + initialProps: { contents: ['A', 'B', 'C'], selected: 0 }, + }, + ); + + expect(result.current.getPanelProps(0).hidden).toBe(false); + expect(result.current.getPanelProps(1).hidden).toBe(true); + + rerender({ contents: ['A', 'B', 'C'], selected: 1 }); + + expect(result.current.getPanelProps(0).hidden).toBe(true); + expect(result.current.getPanelProps(1).hidden).toBe(false); + }); +}); diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index 44dd3cd7e0..3915dc1a76 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -17,7 +17,7 @@ */ import type { KeyboardEvent, ReactNode } from 'react'; -import { useId, useRef, useState } from 'react'; +import { useId, useMemo, useRef, useState } from 'react'; import { cssModules } from '../../bpk-react-utils'; @@ -41,9 +41,17 @@ export type TabPanelProps = { tabIndex: 0; }; +const getPanelId = (baseId: string, index: number) => + `${baseId}-panel-${index}`; + /** * Helper function to get accessibility props for tab panel elements. * Use this to ensure proper ARIA relationships between tabs and their panels. + * + * Note: For a simpler API, consider using the useSegmentedControlPanels hook instead, + * which manages IDs automatically and reduces boilerplate. + * This function is kept for backward compatibility. + * * @param {string} baseId - The base ID used to generate unique IDs for tabs and panels. * @param {number} index - The index of the tab panel. * @param {number} selectedIndex - The currently selected tab index. @@ -54,30 +62,76 @@ export const getTabPanelProps = ( index: number, selectedIndex: number, ): TabPanelProps => ({ - id: `${baseId}-panel-${index}`, + id: getPanelId(baseId, index), role: 'tabpanel', 'aria-labelledby': `${baseId}-tab-${index}`, hidden: index !== selectedIndex, tabIndex: 0, }); +/** + * Custom hook to manage segmented control and its panels with automatic ID generation. + * Simplifies the API by eliminating the need to manually track IDs. + * + * @param {Array} buttonContents - Array of button content (strings or ReactNodes) + * @param {number} selectedIndex - Currently selected tab index + * @returns {Object} Object with controlProps (for BpkSegmentedControl) and getPanelProps function + * + * @example + * const { controlProps, getPanelProps } = useSegmentedControlPanels( + * ['Flights', 'Hotels', 'Car hire'], + * selectedIndex + * ); + * + * return ( + *
+ * + *
Flights content
+ *
Hotels content
+ *
Car hire content
+ *
+ * ); + */ +export const useSegmentedControlPanels = ( + buttonContents: string[] | ReactNode[], + selectedIndex: number, +) => { + const baseId = useId(); + + const controlProps = useMemo( + () => ({ + id: baseId, + buttonContents, + selectedIndex, + }), + [baseId, buttonContents, selectedIndex], + ); + + const getPanelProps = useMemo( + () => + (index: number): TabPanelProps => + getTabPanelProps(baseId, index, selectedIndex), + [baseId, selectedIndex], + ); + + return { controlProps, getPanelProps }; +}; + export type Props = { buttonContents: string[] | ReactNode[]; - /** - * Unique identifier for the segmented control. Used to generate tab and panel IDs - * for ARIA relationships. If not provided, a unique ID will be auto-generated. - */ - id?: string; /** * Accessible label for the segmented control group. */ label?: string; /** - * Array of panel IDs that each tab controls. When provided, adds aria-controls - * to each tab button linking it to its corresponding panel. - * Should match the order and length of buttonContents. + * Optional ID for the segmented control. If not provided, an ID will be auto-generated. + * Required when manually managing tab panels with getTabPanelProps. */ - panelIds?: string[]; + id?: string; type?: SegmentTypes; /** * Callback fired when a tab is selected. Receives the index of the selected tab. @@ -89,12 +143,11 @@ export type Props = { }; const BpkSegmentedControl = ({ - activationMode = 'automatic', + activationMode = 'manual', buttonContents, id: providedId, label, onItemClick, - panelIds, selectedIndex, shadow = false, type = SEGMENT_TYPES.CanvasDefault, @@ -103,6 +156,12 @@ const BpkSegmentedControl = ({ const id = providedId || generatedId; const buttonRefs = useRef>([]); + // Auto-generate panelIds for aria-controls attributes + const computedPanelIds = Array.from( + { length: buttonContents.length }, + (_, i) => getPanelId(id, i), + ); + // TODO: Consider removing internal state - component is controlled via selectedIndex prop. // Internal state may cause sync issues if selectedIndex changes externally. const [selectedButton, setSelectedButton] = useState(selectedIndex); @@ -193,7 +252,7 @@ const BpkSegmentedControl = ({ className={buttonStyling} tabIndex={isSelected ? 0 : -1} aria-selected={isSelected} - {...(panelIds?.[index] ? { 'aria-controls': panelIds[index] } : {})} + aria-controls={computedPanelIds[index]} > {content} diff --git a/packages/bpk-component-segmented-control/src/accessibility-test.tsx b/packages/bpk-component-segmented-control/src/accessibility-test.tsx index 2c57cc60d6..8525d74c89 100644 --- a/packages/bpk-component-segmented-control/src/accessibility-test.tsx +++ b/packages/bpk-component-segmented-control/src/accessibility-test.tsx @@ -21,15 +21,20 @@ import { axe } from 'jest-axe'; import BpkSegmentedControl from './BpkSegmentedControl'; -const buttonContents = ['specific dates', 'flexible dates'] +const buttonContents = ['specific dates', 'flexible dates']; describe('BpkSegmentedControl accessibility tests', () => { it('should not have programmatically-detectable accessibility issues', async () => { const { container } = render( - {}} selectedIndex={1}/> - ) + {}} + selectedIndex={1} + />, + ); const results = await axe(container); expect(results).toHaveNoViolations(); - }) -}) + }); +}); From 86ef9e88c83f94343cbbafabd774cd2534ea28e4 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Thu, 11 Dec 2025 13:00:57 +0000 Subject: [PATCH 03/11] Refactor BpkSegmentedControl examples and tests for improved accessibility and RTL support --- .../examples.tsx | 74 +---- .../stories.tsx | 6 +- .../bpk-component-segmented-control/index.ts | 3 +- .../src/BpkSegmentedControl-test.tsx | 260 ++++++++++++++---- .../src/BpkSegmentedControl.tsx | 60 ++-- 5 files changed, 250 insertions(+), 153 deletions(-) diff --git a/examples/bpk-component-segmented-control/examples.tsx b/examples/bpk-component-segmented-control/examples.tsx index 1e3855ca8e..9d35324e44 100644 --- a/examples/bpk-component-segmented-control/examples.tsx +++ b/examples/bpk-component-segmented-control/examples.tsx @@ -24,11 +24,10 @@ import { } from '@skyscanner/bpk-foundations-web/tokens/base.es6'; import BpkSegmentedControl, { - getTabPanelProps, useSegmentedControlPanels, } from '../../packages/bpk-component-segmented-control'; import { SEGMENT_TYPES } from '../../packages/bpk-component-segmented-control/src/BpkSegmentedControl'; -import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; +import BpkText from '../../packages/bpk-component-text'; import { cssModules } from '../../packages/bpk-react-utils'; // @ts-expect-error Untyped import. See `decisions/imports-ts-suppressions.md`. import { BpkDarkExampleWrapper } from '../bpk-storybook-utils'; @@ -284,7 +283,7 @@ const VisualExample = () => ( // Example demonstrating the recommended hook pattern for managing tabs and panels. // The hook automatically handles ID generation and ARIA relationships. -const WithHookExample = () => { +const WithHookControlledPanelsExample = () => { const [selectedIndex, setSelectedIndex] = useState(0); const buttonContents = ['Flights', 'Hotels', 'Car hire']; @@ -330,51 +329,6 @@ const WithHookExample = () => { ); }; -// Example demonstrating accessible tabs with panels using getTabPanelProps helper. -// This pattern provides full WCAG compliance with proper keyboard navigation -// and ARIA relationships between tabs and their panels. -const WithTabPanelsExample = () => { - const [selectedIndex, setSelectedIndex] = useState(0); - - const panelStyle = { - padding: '1rem', - border: '1px solid #ddd', - borderRadius: '0.5rem', - marginTop: '1rem', - }; - - return ( -
- -
- Search for flights to your destination. -
-
- Find the perfect place to stay. -
-
- Rent a car for your trip. -
-
- ); -}; - // Example using conditional rendering instead of the hidden attribute. // Both approaches are valid - use whichever fits your use case better. const WithConditionalPanelsExample = () => { @@ -390,7 +344,6 @@ const WithConditionalPanelsExample = () => { return (
{ type={SEGMENT_TYPES.SurfaceDefault} /> {selectedIndex === 0 && ( -
- Specific dates - Pick exact travel dates from the calendar. +
+ Specific dates Panel
)} {selectedIndex === 1 && ( -
- Flexible dates - Choose a month or date range. +
+ Flexible dates Panel
)}
@@ -439,7 +380,6 @@ export { ComplexCanvasDefault, ComplexSurfaceDefaultNoShadow, VisualExample, - WithHookExample, - WithTabPanelsExample, + WithHookControlledPanelsExample, WithConditionalPanelsExample, }; diff --git a/examples/bpk-component-segmented-control/stories.tsx b/examples/bpk-component-segmented-control/stories.tsx index 0dbb0312f4..040267347a 100644 --- a/examples/bpk-component-segmented-control/stories.tsx +++ b/examples/bpk-component-segmented-control/stories.tsx @@ -34,8 +34,7 @@ import { ComplexCanvasDefault, ComplexSurfaceDefaultNoShadow, VisualExample, - WithHookExample, - WithTabPanelsExample, + WithHookControlledPanelsExample, WithConditionalPanelsExample, } from './examples'; @@ -60,8 +59,7 @@ export const ComplexThreeSegmentsCanvasContrast = ComplexCanvasContrast; export const ComplexThreeSegmentsCanvasDefault = ComplexCanvasDefault; export const ComplexThreeSegmentsSurfaceDefaultNoShadow = ComplexSurfaceDefaultNoShadow; -export const WithHook = WithHookExample; -export const WithTabPanels = WithTabPanelsExample; +export const WithHookControlledPanels = WithHookControlledPanelsExample; export const WithConditionalPanels = WithConditionalPanelsExample; export const VisualTest = VisualExample; export const VisualTestWithZoom = { diff --git a/packages/bpk-component-segmented-control/index.ts b/packages/bpk-component-segmented-control/index.ts index e1989fff04..8244dad88f 100644 --- a/packages/bpk-component-segmented-control/index.ts +++ b/packages/bpk-component-segmented-control/index.ts @@ -17,12 +17,11 @@ */ import BpkSegmentedControl, { - getTabPanelProps, useSegmentedControlPanels, type Props as BpkSegmentControlProps, type TabPanelProps, } from './src/BpkSegmentedControl'; export type { BpkSegmentControlProps, TabPanelProps }; -export { getTabPanelProps, useSegmentedControlPanels }; +export { useSegmentedControlPanels }; export default BpkSegmentedControl; diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx index f1e90dfd44..872a254231 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx @@ -18,12 +18,19 @@ import { render, fireEvent, screen, renderHook } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { isRTL } from '../../bpk-react-utils'; + import BpkSegmentedControl, { - getTabPanelProps, useSegmentedControlPanels, SEGMENT_TYPES, } from './BpkSegmentedControl'; +// Mock the isRTL function +jest.mock('../../bpk-react-utils', () => ({ + ...jest.requireActual('../../bpk-react-utils'), + isRTL: jest.fn(() => false), +})); + const mockOnItemClick = jest.fn(); const defaultProps = { @@ -76,14 +83,14 @@ describe('BpkSegmentedControl', () => { expect(mockOnItemClick).toHaveBeenCalledWith(0); }); - it('should reflect selectedIndex prop in aria-selected attribute', () => { - const { rerender } = render( - , - ); + it('should set aria-selected based on selectedIndex prop on initial render', () => { + render(); expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'true'); expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'false'); + }); - rerender(); + it('should set aria-selected to true for the selected tab', () => { + render(); expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'false'); expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'true'); }); @@ -149,37 +156,130 @@ describe('BpkSegmentedControl', () => { expect(tabs[1]).toHaveAttribute('id', 'my-tabs-tab-1'); }); - it('should auto-generate aria-controls for panels', () => { + it('should not generate tab IDs when id prop is not provided', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).not.toHaveAttribute('id'); + expect(tabs[1]).not.toHaveAttribute('id'); + }); + + it('should auto-generate aria-controls for panels when id is provided', () => { render(); const tabs = screen.getAllByRole('tab'); expect(tabs[0]).toHaveAttribute('aria-controls', 'my-tabs-panel-0'); expect(tabs[1]).toHaveAttribute('aria-controls', 'my-tabs-panel-1'); }); + it('should not set aria-controls when id prop is not provided', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).not.toHaveAttribute('aria-controls'); + expect(tabs[1]).not.toHaveAttribute('aria-controls'); + }); + + describe('activationMode', () => { + it('should default to manual activation mode', () => { + render(); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); + + // In manual mode (default), arrow keys move focus but don't activate + expect(document.activeElement).toBe(tabs[1]); + expect(mockOnItemClick).not.toHaveBeenCalled(); + }); + + it('should activate tabs automatically in automatic mode', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); + + // In automatic mode, navigating should call onItemClick + expect(mockOnItemClick).toHaveBeenCalledWith(1); + expect(document.activeElement).toBe(tabs[1]); + }); + + it('should require Enter or Space to activate in manual mode', () => { + mockOnItemClick.mockClear(); + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); + + // Focus moves but tab is not activated yet (onItemClick not called) + expect(document.activeElement).toBe(tabs[1]); + expect(mockOnItemClick).not.toHaveBeenCalled(); + + // Now press Enter to activate + fireEvent.keyDown(tabs[1], { key: 'Enter' }); + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + + it('should activate tab with Space key in manual mode', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], { key: ' ' }); + + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + describe('keyboard navigation', () => { - it('should move focus to next tab on ArrowRight', () => { + beforeEach(() => { + (isRTL as jest.Mock).mockReturnValue(false); + }); + + it('should move focus to next tab on ArrowRight in manual mode', () => { render( , ); const tabs = screen.getAllByRole('tab'); tabs[1].focus(); fireEvent.keyDown(tabs[1], { key: 'ArrowRight' }); - expect(mockOnItemClick).toHaveBeenCalledWith(0); // wraps to first - expect(document.activeElement).toBe(tabs[0]); + // In manual mode, arrow keys only move focus, not activate + expect(mockOnItemClick).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(tabs[0]); // wraps to first }); - it('should move focus to previous tab on ArrowLeft', () => { - render(); + it('should move focus to previous tab on ArrowLeft in manual mode', () => { + render( + , + ); const tabs = screen.getAllByRole('tab'); tabs[1].focus(); fireEvent.keyDown(tabs[1], { key: 'ArrowLeft' }); - expect(mockOnItemClick).toHaveBeenCalledWith(0); + expect(mockOnItemClick).not.toHaveBeenCalled(); expect(document.activeElement).toBe(tabs[0]); }); @@ -189,23 +289,30 @@ describe('BpkSegmentedControl', () => { {...defaultProps} id="my-tabs" selectedIndex={0} + activationMode="manual" />, ); const tabs = screen.getAllByRole('tab'); tabs[0].focus(); fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); - expect(mockOnItemClick).toHaveBeenCalledWith(1); + expect(mockOnItemClick).not.toHaveBeenCalled(); expect(document.activeElement).toBe(tabs[1]); }); it('should move focus to first tab on Home', () => { - render(); + render( + , + ); const tabs = screen.getAllByRole('tab'); tabs[1].focus(); fireEvent.keyDown(tabs[1], { key: 'Home' }); - expect(mockOnItemClick).toHaveBeenCalledWith(0); + expect(mockOnItemClick).not.toHaveBeenCalled(); expect(document.activeElement).toBe(tabs[0]); }); @@ -215,13 +322,14 @@ describe('BpkSegmentedControl', () => { {...defaultProps} id="my-tabs" selectedIndex={0} + activationMode="manual" />, ); const tabs = screen.getAllByRole('tab'); tabs[0].focus(); fireEvent.keyDown(tabs[0], { key: 'End' }); - expect(mockOnItemClick).toHaveBeenCalledWith(1); + expect(mockOnItemClick).not.toHaveBeenCalled(); expect(document.activeElement).toBe(tabs[1]); }); @@ -232,43 +340,101 @@ describe('BpkSegmentedControl', () => { expect(tabs[1]).toHaveAttribute('tabIndex', '0'); }); }); -}); -describe('getTabPanelProps', () => { - it('should return correct props for selected panel', () => { - const props = getTabPanelProps('my-tabs', 0, 0); - expect(props).toEqual({ - id: 'my-tabs-panel-0', - role: 'tabpanel', - 'aria-labelledby': 'my-tabs-tab-0', - hidden: false, - tabIndex: 0, + describe('RTL keyboard navigation', () => { + beforeEach(() => { + (isRTL as jest.Mock).mockReturnValue(true); }); - }); - it('should return hidden=true for non-selected panel', () => { - const props = getTabPanelProps('my-tabs', 1, 0); - expect(props).toEqual({ - id: 'my-tabs-panel-1', - role: 'tabpanel', - 'aria-labelledby': 'my-tabs-tab-1', - hidden: true, - tabIndex: 0, + afterEach(() => { + (isRTL as jest.Mock).mockReturnValue(false); }); - }); - it('should generate correct IDs for different indices', () => { - const props0 = getTabPanelProps('tabs', 0, 0); - const props1 = getTabPanelProps('tabs', 1, 0); - const props2 = getTabPanelProps('tabs', 2, 2); + it('should move focus to previous tab on ArrowRight in RTL', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], { key: 'ArrowRight' }); - expect(props0.id).toBe('tabs-panel-0'); - expect(props1.id).toBe('tabs-panel-1'); - expect(props2.id).toBe('tabs-panel-2'); + expect(mockOnItemClick).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(tabs[0]); + }); - expect(props0['aria-labelledby']).toBe('tabs-tab-0'); - expect(props1['aria-labelledby']).toBe('tabs-tab-1'); - expect(props2['aria-labelledby']).toBe('tabs-tab-2'); + it('should move focus to next tab on ArrowLeft in RTL', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); + + expect(mockOnItemClick).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(tabs[1]); + }); + + it('should wrap from first to last on ArrowRight in RTL', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); + + expect(mockOnItemClick).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(tabs[1]); + }); + + it('should wrap from last to first on ArrowLeft in RTL', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], { key: 'ArrowLeft' }); + + expect(mockOnItemClick).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(tabs[0]); + }); + + it('should activate tabs automatically in RTL with automatic mode', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); + + // In RTL, ArrowLeft goes to next item + expect(mockOnItemClick).toHaveBeenCalledWith(1); + expect(document.activeElement).toBe(tabs[1]); + }); }); }); diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index 3915dc1a76..eafab351b0 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -19,7 +19,7 @@ import type { KeyboardEvent, ReactNode } from 'react'; import { useId, useMemo, useRef, useState } from 'react'; -import { cssModules } from '../../bpk-react-utils'; +import { cssModules, isRTL } from '../../bpk-react-utils'; import STYLES from './BpkSegmentedControl.module.scss'; @@ -44,6 +44,8 @@ export type TabPanelProps = { const getPanelId = (baseId: string, index: number) => `${baseId}-panel-${index}`; +const getTabId = (baseId: string, index: number) => `${baseId}-tab-${index}`; + /** * Helper function to get accessibility props for tab panel elements. * Use this to ensure proper ARIA relationships between tabs and their panels. @@ -57,14 +59,14 @@ const getPanelId = (baseId: string, index: number) => * @param {number} selectedIndex - The currently selected tab index. * @returns {TabPanelProps} An object containing the necessary props for a tab panel. */ -export const getTabPanelProps = ( +const getTabPanelProps = ( baseId: string, index: number, selectedIndex: number, ): TabPanelProps => ({ id: getPanelId(baseId, index), role: 'tabpanel', - 'aria-labelledby': `${baseId}-tab-${index}`, + 'aria-labelledby': getTabId(baseId, index), hidden: index !== selectedIndex, tabIndex: 0, }); @@ -76,25 +78,6 @@ export const getTabPanelProps = ( * @param {Array} buttonContents - Array of button content (strings or ReactNodes) * @param {number} selectedIndex - Currently selected tab index * @returns {Object} Object with controlProps (for BpkSegmentedControl) and getPanelProps function - * - * @example - * const { controlProps, getPanelProps } = useSegmentedControlPanels( - * ['Flights', 'Hotels', 'Car hire'], - * selectedIndex - * ); - * - * return ( - *
- * - *
Flights content
- *
Hotels content
- *
Car hire content
- *
- * ); */ export const useSegmentedControlPanels = ( buttonContents: string[] | ReactNode[], @@ -152,14 +135,9 @@ const BpkSegmentedControl = ({ shadow = false, type = SEGMENT_TYPES.CanvasDefault, }: Props) => { - const generatedId = useId(); - const id = providedId || generatedId; const buttonRefs = useRef>([]); - - // Auto-generate panelIds for aria-controls attributes - const computedPanelIds = Array.from( - { length: buttonContents.length }, - (_, i) => getPanelId(id, i), + const panelIds = Array.from({ length: buttonContents.length }, (_, i) => + providedId ? getPanelId(providedId, i) : undefined, ); // TODO: Consider removing internal state - component is controlled via selectedIndex prop. @@ -180,12 +158,28 @@ const BpkSegmentedControl = ({ const lastIndex = buttonContents.length - 1; let newIndex = currentIndex; + const nextItem = () => { + newIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + }; + + const previousItem = () => { + newIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; + }; + switch (event.key) { case 'ArrowRight': - newIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + if (isRTL()) { + previousItem(); + } else { + nextItem(); + } break; case 'ArrowLeft': - newIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; + if (isRTL()) { + nextItem(); + } else { + previousItem(); + } break; case 'Home': newIndex = 0; @@ -237,7 +231,7 @@ const BpkSegmentedControl = ({ `bpk-segmented-control--${type}-selected-shadow`, ); - const tabId = `${id}-tab-${index}`; + const tabId = providedId ? getTabId(providedId, index) : undefined; return ( From 8fb5d6f35331e0fc51970a27420df091a7beb1dd Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Thu, 11 Dec 2025 15:23:38 +0000 Subject: [PATCH 04/11] Enhance accessibility tests for BpkSegmentedControl and update props for better integration with useSegmentedControlPanels --- .../src/BpkSegmentedControl-test.tsx | 37 +++++++++++++------ .../src/BpkSegmentedControl.tsx | 6 +-- .../src/accessibility-test.tsx | 37 ++++++++++++++++++- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx index 872a254231..32c21a1071 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx @@ -25,7 +25,6 @@ import BpkSegmentedControl, { SEGMENT_TYPES, } from './BpkSegmentedControl'; -// Mock the isRTL function jest.mock('../../bpk-react-utils', () => ({ ...jest.requireActual('../../bpk-react-utils'), isRTL: jest.fn(() => false), @@ -85,12 +84,16 @@ describe('BpkSegmentedControl', () => { it('should set aria-selected based on selectedIndex prop on initial render', () => { render(); + expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'true'); expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'false'); }); - it('should set aria-selected to true for the selected tab', () => { - render(); + it('should set aria-selected to true when a different button is clicked', () => { + render(); + const buttonTwo = screen.getByText('two'); + fireEvent.click(buttonTwo); + expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'false'); expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'true'); }); @@ -126,12 +129,14 @@ describe('BpkSegmentedControl', () => { it('should render with role="tablist" on the outer div', () => { render(); const tablist = screen.getByRole('tablist'); + expect(tablist).toBeInTheDocument(); }); it('should set aria-orientation="horizontal" on the tablist', () => { render(); const tablist = screen.getByRole('tablist'); + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); }); @@ -140,18 +145,21 @@ describe('BpkSegmentedControl', () => { , ); const tablist = screen.getByRole('tablist'); + expect(tablist).toHaveAttribute('aria-label', 'Segmented control label'); }); it('should not set aria-label when label prop is not provided', () => { render(); const tablist = screen.getByRole('tablist'); + expect(tablist).not.toHaveAttribute('aria-label'); }); it('should generate tab IDs based on provided id prop', () => { render(); const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('id', 'my-tabs-tab-0'); expect(tabs[1]).toHaveAttribute('id', 'my-tabs-tab-1'); }); @@ -159,6 +167,7 @@ describe('BpkSegmentedControl', () => { it('should not generate tab IDs when id prop is not provided', () => { render(); const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).not.toHaveAttribute('id'); expect(tabs[1]).not.toHaveAttribute('id'); }); @@ -166,6 +175,7 @@ describe('BpkSegmentedControl', () => { it('should auto-generate aria-controls for panels when id is provided', () => { render(); const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('aria-controls', 'my-tabs-panel-0'); expect(tabs[1]).toHaveAttribute('aria-controls', 'my-tabs-panel-1'); }); @@ -173,37 +183,38 @@ describe('BpkSegmentedControl', () => { it('should not set aria-controls when id prop is not provided', () => { render(); const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).not.toHaveAttribute('aria-controls'); expect(tabs[1]).not.toHaveAttribute('aria-controls'); }); describe('activationMode', () => { - it('should default to manual activation mode', () => { + it('should default to automatic activation mode', () => { render(); const tabs = screen.getAllByRole('tab'); tabs[0].focus(); fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); - // In manual mode (default), arrow keys move focus but don't activate + // In automatic mode, navigating should call onItemClick expect(document.activeElement).toBe(tabs[1]); - expect(mockOnItemClick).not.toHaveBeenCalled(); + expect(mockOnItemClick).toHaveBeenCalledWith(1); }); - it('should activate tabs automatically in automatic mode', () => { + it('should not activate tabs automatically in manual mode', () => { render( , ); const tabs = screen.getAllByRole('tab'); tabs[0].focus(); fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); - // In automatic mode, navigating should call onItemClick - expect(mockOnItemClick).toHaveBeenCalledWith(1); + // In manual mode (default), arrow keys move focus but don't activate expect(document.activeElement).toBe(tabs[1]); + expect(mockOnItemClick).not.toHaveBeenCalled(); }); it('should require Enter or Space to activate in manual mode', () => { @@ -287,6 +298,7 @@ describe('BpkSegmentedControl', () => { render( { fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[1]); + expect(document.activeElement).toBe(tabs[2]); }); it('should move focus to first tab on Home', () => { @@ -336,6 +348,8 @@ describe('BpkSegmentedControl', () => { it('should set tabIndex=0 only on selected tab', () => { render(); const tabs = screen.getAllByRole('tab'); + + // default selectedIndex is 1 expect(tabs[0]).toHaveAttribute('tabIndex', '-1'); expect(tabs[1]).toHaveAttribute('tabIndex', '0'); }); @@ -465,6 +479,7 @@ describe('useSegmentedControlPanels', () => { expect(panel0Props.role).toBe('tabpanel'); expect(panel1Props.role).toBe('tabpanel'); + expect(panel2Props.role).toBe('tabpanel'); expect(panel0Props['aria-labelledby']).toContain('-tab-0'); expect(panel1Props['aria-labelledby']).toContain('-tab-1'); diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index eafab351b0..bf199eaf68 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -111,8 +111,8 @@ export type Props = { */ label?: string; /** - * Optional ID for the segmented control. If not provided, an ID will be auto-generated. - * Required when manually managing tab panels with getTabPanelProps. + * ID used to link the segmented control with its tab panels for accessibility. + * Created using controlProps from useSegmentedControlPanels hook. */ id?: string; type?: SegmentTypes; @@ -126,7 +126,7 @@ export type Props = { }; const BpkSegmentedControl = ({ - activationMode = 'manual', + activationMode = 'automatic', buttonContents, id: providedId, label, diff --git a/packages/bpk-component-segmented-control/src/accessibility-test.tsx b/packages/bpk-component-segmented-control/src/accessibility-test.tsx index 8525d74c89..f399b080a9 100644 --- a/packages/bpk-component-segmented-control/src/accessibility-test.tsx +++ b/packages/bpk-component-segmented-control/src/accessibility-test.tsx @@ -19,16 +19,49 @@ import { render } from '@testing-library/react'; import { axe } from 'jest-axe'; -import BpkSegmentedControl from './BpkSegmentedControl'; +import BpkSegmentedControl, { + useSegmentedControlPanels, +} from './BpkSegmentedControl'; const buttonContents = ['specific dates', 'flexible dates']; +// Test component that includes both tabs and panels for complete accessibility testing +const SegmentedControlWithPanels = () => { + const { controlProps, getPanelProps } = useSegmentedControlPanels( + buttonContents, + 0, + ); + + return ( +
+ {}} + /> +
+

Panel for specific dates

+
+
+

Panel for flexible dates

+
+
+ ); +}; + describe('BpkSegmentedControl accessibility tests', () => { it('should not have programmatically-detectable accessibility issues', async () => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should not have accessibility issues when used without panels', async () => { const { container } = render( {}} selectedIndex={1} />, From effcd8e288d3f40ed7bebb30c63dccc5a1362b43 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Thu, 11 Dec 2025 15:28:25 +0000 Subject: [PATCH 05/11] reset lint --- .../examples.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/bpk-component-segmented-control/examples.tsx b/examples/bpk-component-segmented-control/examples.tsx index 9d35324e44..ec40208982 100644 --- a/examples/bpk-component-segmented-control/examples.tsx +++ b/examples/bpk-component-segmented-control/examples.tsx @@ -50,7 +50,7 @@ const surfaceContrastWrapperStyle = { const SimpleDefault = () => ( {}} selectedIndex={0} type={SEGMENT_TYPES.CanvasDefault} @@ -61,7 +61,7 @@ const SimpleCanvasContrast = () => (
{}} selectedIndex={2} type={SEGMENT_TYPES.CanvasContrast} @@ -73,7 +73,7 @@ const SimpleSurfaceDefault = () => (
{}} selectedIndex={2} type={SEGMENT_TYPES.SurfaceDefault} @@ -90,7 +90,7 @@ const SimpleSurfaceContrast = () => ( 'Very Long Value3', 'Very Long Value4', ]} - label="Segmented control" + label='Segmented control' onItemClick={() => {}} selectedIndex={2} type={SEGMENT_TYPES.SurfaceContrast} @@ -128,7 +128,7 @@ const CustomSurfaceContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceContrast} @@ -140,7 +140,7 @@ const CustomSurfaceContrast = () => ( const CustomSurfaceDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -152,7 +152,7 @@ const CustomCanvasContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasContrast} @@ -164,7 +164,7 @@ const CustomCanvasContrast = () => ( const CustomCanvasDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasDefault} @@ -175,7 +175,7 @@ const CustomCanvasDefault = () => ( const CustomSurfaceDefaultNoShadow = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -215,7 +215,7 @@ const ComplexSurfaceContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceContrast} @@ -227,7 +227,7 @@ const ComplexSurfaceContrast = () => ( const ComplexSurfaceDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} @@ -239,7 +239,7 @@ const ComplexCanvasContrast = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasContrast} @@ -251,7 +251,7 @@ const ComplexCanvasContrast = () => ( const ComplexCanvasDefault = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.CanvasDefault} @@ -262,7 +262,7 @@ const ComplexCanvasDefault = () => ( const ComplexSurfaceDefaultNoShadow = () => ( {}} selectedIndex={1} type={SEGMENT_TYPES.SurfaceDefault} From 74f3831ce8b6111b7614c642b412732bd0fcdb86 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Thu, 11 Dec 2025 15:39:25 +0000 Subject: [PATCH 06/11] update readme --- .../bpk-component-segmented-control/README.md | 183 ++++-------------- 1 file changed, 38 insertions(+), 145 deletions(-) diff --git a/packages/bpk-component-segmented-control/README.md b/packages/bpk-component-segmented-control/README.md index 554a874962..d045a6dc4c 100644 --- a/packages/bpk-component-segmented-control/README.md +++ b/packages/bpk-component-segmented-control/README.md @@ -1,4 +1,6 @@ -# bpk-segmented-control +# bpk-component-segmented-control + +> Backpack segmented control component. ## Installation @@ -6,38 +8,41 @@ Check the main [Readme](https://github.com/skyscanner/backpack#usage) for a comp ## Usage -### Basic usage +### Basic Usage -```js -import BpkSegmentedControl from '@skyscanner/backpack-web/bpk-component-segmented-control'; +```tsx +import BpkSegmentedControl, { + SEGMENT_TYPES, +} from '@skyscanner/backpack-web/bpk-component-segmented-control'; export default () => ( console.log('Selected:', index)} - selectedIndex={1} // button selected on load - type={SEGMENT_TYPES.SurfaceContrast} - shadow + buttonContents={['Specific dates', 'Flexible dates']} + label="Date selection" // Accessible name, this should be localised + onItemClick={(index) => console.log(`Selected index: ${index}`)} + selectedIndex={0} + type={SEGMENT_TYPES.CanvasDefault} /> ); ``` -### With tab panels using the hook (recommended) +### With Tab Panels (Recommended) -The easiest way to use segmented controls with tab panels is the `useSegmentedControlPanels` hook. It automatically manages IDs and provides proper accessibility: +When using the segmented control to switch between content panels, use the `useSegmentedControlPanels` hook for automatic ID generation and proper ARIA relationships. -```js +```tsx import { useState } from 'react'; import BpkSegmentedControl, { useSegmentedControlPanels, + SEGMENT_TYPES, } from '@skyscanner/backpack-web/bpk-component-segmented-control'; -export default () => { +const TabbedContent = () => { const [selectedIndex, setSelectedIndex] = useState(0); + const buttonContents = ['Flights', 'Hotels', 'Car hire']; const { controlProps, getPanelProps } = useSegmentedControlPanels( - ['Flights', 'Hotels', 'Car hire'], + buttonContents, selectedIndex, ); @@ -49,152 +54,40 @@ export default () => { onItemClick={setSelectedIndex} type={SEGMENT_TYPES.CanvasDefault} /> -
Flights content
-
Hotels content
-
Car hire content
-
- ); -}; -``` - -### With tab panels using getTabPanelProps (alternative) - -For more control over ID generation, you can use the `getTabPanelProps` helper directly: - -```js -import { useState } from 'react'; -import BpkSegmentedControl, { - getTabPanelProps, -} from '@skyscanner/backpack-web/bpk-component-segmented-control'; - -export default () => { - const [selectedIndex, setSelectedIndex] = useState(0); - - return ( -
- -
- Flights content +
+

Search for flights to your destination.

-
- Hotels content +
+

Find the perfect place to stay.

-
- Car hire content +
+

Rent a car for your trip.

); }; -``` -### With conditional rendering - -You can also use conditional rendering instead of the `hidden` attribute. When doing so, manually add the panel accessibility attributes: - -```js -import { useState } from 'react'; -import BpkSegmentedControl from '@skyscanner/backpack-web/bpk-component-segmented-control'; - -export default () => { - const [selectedIndex, setSelectedIndex] = useState(0); - - return ( -
- - {selectedIndex === 0 && ( -
- Specific dates content -
- )} - {selectedIndex === 1 && ( -
- Flexible dates content -
- )} -
- ); -}; +export default TabbedContent; ``` ## Accessibility -### Keyboard navigation - -The component implements the [WAI-ARIA Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) with the following keyboard interactions: +The `BpkSegmentedControl` component implements the [ARIA tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) with full keyboard navigation support. -| Key | Action | -| ------------------ | ------------------------------------------ | -| `←` / `ArrowLeft` | Move focus to previous tab (wraps to last) | -| `→` / `ArrowRight` | Move focus to next tab (wraps to first) | -| `Home` | Move focus to first tab | -| `End` | Move focus to last tab | -| `Tab` | Move focus into/out of the tab list | +### Keyboard Navigation -Selection follows focus automatically (automatic activation mode). +- **Arrow Left/Right**: Move focus between tabs (respects RTL layouts) +- **Home**: Move focus to the first tab +- **End**: Move focus to the last tab +- **Enter/Space**: Activate the focused tab (in manual mode) -### ARIA attributes +### Activation Modes -The component automatically applies: +The component supports two activation modes: -- `role="tablist"` on the container -- `role="tab"` on each button -- `aria-selected` to indicate the selected tab -- `aria-orientation="horizontal"` on the tablist -- `aria-controls` linking to panels (auto-generated panel IDs) - -When using the `useSegmentedControlPanels` hook or `getTabPanelProps` helper, panels receive: - -- `role="tabpanel"` -- `aria-labelledby` linking back to the controlling tab -- `hidden` attribute for non-selected panels -- `tabIndex={0}` to allow focus when panel has no focusable content +- **Automatic (default)**: Tabs are activated automatically when focused via keyboard navigation. This provides a faster experience but may cause frequent content changes. +- **Manual**: Tabs must be explicitly activated using Enter or Space keys after focusing. This is recommended when tab panel content is computationally expensive or when rapid content changes could be disorienting. ## Props -| Property | PropType | Required | Default Value | -| -------------- | -------------------- | -------- | --------------------------- | -| buttonContents | arrayOf(node) | true | - | -| onItemClick | func | true | - | -| selectedIndex | number | true | - | -| activationMode | string | false | 'automatic' | -| id | string | false | auto-generated | -| label | string | false | - | -| shadow | bool | false | false | -| type | string | false | 'canvas-default' | -| type | oneOf(SEGMENT_TYPES) | false | SEGMENT_TYPES.CanvasDefault | - -### getTabPanelProps - -Helper function to generate accessibility props for tab panels. - -```ts -getTabPanelProps(baseId: string, index: number, selectedIndex: number): TabPanelProps -``` - -| Parameter | Type | Description | -| ------------- | ------ | ------------------------------------------- | -| baseId | string | The same `id` passed to BpkSegmentedControl | -| index | number | The index of this panel (0-based) | -| selectedIndex | number | The currently selected tab index | - -Returns an object with: `id`, `role`, `aria-labelledby`, `hidden`, `tabIndex` +Check out the full list of props on Skyscanner's [design system documentation website](https://www.skyscanner.design/latest/components/segmented-control/web). From 5a7b229d01f30bd95a296f5f8e72e1b52e130960 Mon Sep 17 00:00:00 2001 From: Noel Rajan <14931745+FireRedNinja@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:47:51 +0000 Subject: [PATCH 07/11] getPanelProps(buttonContents.indexOf(...)) to getPanelProps(index) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/bpk-component-segmented-control/examples.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/bpk-component-segmented-control/examples.tsx b/examples/bpk-component-segmented-control/examples.tsx index ec40208982..8f55b37563 100644 --- a/examples/bpk-component-segmented-control/examples.tsx +++ b/examples/bpk-component-segmented-control/examples.tsx @@ -308,19 +308,19 @@ const WithHookControlledPanelsExample = () => { type={SEGMENT_TYPES.CanvasDefault} />
Search for flights to your destination.
Find the perfect place to stay.
Rent a car for your trip. From df161fd3a125dc54aad1669ecda4f77f7a6d6dd4 Mon Sep 17 00:00:00 2001 From: Noel Rajan <14931745+FireRedNinja@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:00:31 +0000 Subject: [PATCH 08/11] Use index as key if id is not provided Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../bpk-component-segmented-control/src/BpkSegmentedControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index bf199eaf68..22c12cd37c 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -237,7 +237,7 @@ const BpkSegmentedControl = ({ ref={(el) => { buttonRefs.current[index] = el; }} - key={tabId} + key={tabId || index} id={tabId} type="button" role="tab" From 43a96434771eabbd12a1af7e82ecc29510770133 Mon Sep 17 00:00:00 2001 From: Noel Rajan <14931745+FireRedNinja@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:05:20 +0000 Subject: [PATCH 09/11] Fix Readme URL --- packages/bpk-component-segmented-control/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bpk-component-segmented-control/README.md b/packages/bpk-component-segmented-control/README.md index d045a6dc4c..00a915683b 100644 --- a/packages/bpk-component-segmented-control/README.md +++ b/packages/bpk-component-segmented-control/README.md @@ -90,4 +90,4 @@ The component supports two activation modes: ## Props -Check out the full list of props on Skyscanner's [design system documentation website](https://www.skyscanner.design/latest/components/segmented-control/web). +Check out the full list of props on Skyscanner's [design system documentation website]( https://www.skyscanner.design/latest/components/section-list/web-tP8t6vq8). From ec763c38d2bef8bfbe3a45e3dd195eac5e2ccf40 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Fri, 9 Jan 2026 15:35:34 +0000 Subject: [PATCH 10/11] make the default role undefined --- .../src/BpkSegmentedControl.tsx | 61 ++++++++++++++++--- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index 22c12cd37c..8c97428d71 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -71,6 +71,47 @@ const getTabPanelProps = ( tabIndex: 0, }); +const getContainerAriaProps = (providedId?: string, label?: string) => { + const props: Record = {}; + + if (providedId) { + props.role = 'tablist'; + props['aria-orientation'] = 'horizontal'; + } + + if (label) { + props['aria-label'] = label; + } + + return props; +}; + +const getButtonAriaProps = ( + providedId: string | undefined, + isSelected: boolean, + panelId: string | undefined, +) => { + if (!providedId) { + return {}; + } + + return { + role: 'tab', + 'aria-selected': isSelected, + 'aria-controls': panelId, + }; +}; + +const getTabIndex = ( + providedId: string | undefined, + isSelected: boolean, +): number | undefined => { + if (!providedId) { + return undefined; + } + return isSelected ? 0 : -1; +}; + /** * Custom hook to manage segmented control and its panels with automatic ID generation. * Simplifies the API by eliminating the need to manually track IDs. @@ -214,9 +255,7 @@ const BpkSegmentedControl = ({ return (
{buttonContents.map((content, index) => { const isSelected = index === selectedButton; @@ -231,22 +270,24 @@ const BpkSegmentedControl = ({ `bpk-segmented-control--${type}-selected-shadow`, ); - const tabId = providedId ? getTabId(providedId, index) : undefined; + const buttonTabId = providedId + ? getTabId(providedId, index) + : undefined; + const tabIndexValue = getTabIndex(providedId, isSelected); + return ( From b83835c8ffd3f90d233d64810e6c04e9d50b5313 Mon Sep 17 00:00:00 2001 From: Noel Rajan Date: Fri, 9 Jan 2026 17:04:51 +0000 Subject: [PATCH 11/11] add tests --- .../src/BpkSegmentedControl-test.tsx | 704 ++++++++++-------- 1 file changed, 410 insertions(+), 294 deletions(-) diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx index 32c21a1071..a82b613fc3 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx @@ -15,23 +15,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, fireEvent, screen, renderHook } from '@testing-library/react'; +import { + render, + fireEvent, + screen, + waitFor, + renderHook, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import { isRTL } from '../../bpk-react-utils'; - import BpkSegmentedControl, { - useSegmentedControlPanels, SEGMENT_TYPES, + useSegmentedControlPanels, } from './BpkSegmentedControl'; +const mockOnItemClick = jest.fn(); + +const mockIsRtl = jest.fn(() => false); + jest.mock('../../bpk-react-utils', () => ({ ...jest.requireActual('../../bpk-react-utils'), - isRTL: jest.fn(() => false), + isRTL: () => mockIsRtl(), })); -const mockOnItemClick = jest.fn(); - const defaultProps = { buttonContents: ['one', 'two'], onItemClick: mockOnItemClick, @@ -43,6 +50,7 @@ const defaultProps = { describe('BpkSegmentedControl', () => { beforeEach(() => { mockOnItemClick.mockClear(); + mockIsRtl.mockReturnValue(false); }); it('should render ReactNode contents correctly', () => { @@ -74,28 +82,141 @@ describe('BpkSegmentedControl', () => { expect(mockOnItemClick).toHaveBeenCalledWith(0); }); - it('should call onItemClick when a different button is clicked', () => { - const { getByText } = render(); - const buttonOne = getByText('one'); - fireEvent.click(buttonOne); + describe('ARIA attributes in button group mode (no id prop)', () => { + it('should update selection when a button is clicked', () => { + const { getByText } = render(); + const buttonOne = getByText('one'); + fireEvent.click(buttonOne); - expect(mockOnItemClick).toHaveBeenCalledWith(0); - }); + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); - it('should set aria-selected based on selectedIndex prop on initial render', () => { - render(); + it('should set the accessible label when label prop is provided', () => { + render( + , + ); + const container = screen.getByLabelText('Segmented control label'); + expect(container).toBeInTheDocument(); + expect(container).toHaveAttribute( + 'aria-label', + 'Segmented control label', + ); + }); - expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'false'); + it('should not set aria-label when label prop is not provided', () => { + const { container } = render( + , + ); + const mainContainer = container.firstChild as HTMLElement; + expect(mainContainer).not.toHaveAttribute('aria-label'); + }); + + it('should not have role="tablist" when no id prop is provided', () => { + const { container } = render(); + const mainContainer = container.firstChild as HTMLElement; + expect(mainContainer).not.toHaveAttribute('role', 'tablist'); + }); + + it('should not have tab-related ARIA attributes when no id prop is provided', () => { + render(); + const buttons = screen.getAllByRole('button'); + buttons.forEach((button) => { + expect(button).not.toHaveAttribute('aria-selected'); + expect(button).not.toHaveAttribute('aria-controls'); + expect(button).not.toHaveAttribute('role', 'tab'); + }); + }); }); - it('should set aria-selected to true when a different button is clicked', () => { - render(); - const buttonTwo = screen.getByText('two'); - fireEvent.click(buttonTwo); + describe('ARIA attributes in tabs mode (with id prop)', () => { + it('should render with role="tablist" when id prop is provided', () => { + render(); + const tablist = screen.getByRole('tablist'); + expect(tablist).toBeInTheDocument(); + }); + + it('should have aria-orientation="horizontal" when id prop is provided', () => { + render(); + const tablist = screen.getByRole('tablist'); + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + it('should have role="tab" on buttons when id prop is provided', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(2); + }); + + it('should have correct aria-selected state on tabs', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); + expect(tabs[1]).toHaveAttribute('aria-selected', 'false'); + }); + + it('should have aria-controls pointing to panel id', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('aria-controls', 'test-control-panel-0'); + expect(tabs[1]).toHaveAttribute('aria-controls', 'test-control-panel-1'); + }); + + it('should have correct tabIndex roving pattern', () => { + render( + , + ); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('tabindex', '0'); + expect(tabs[1]).toHaveAttribute('tabindex', '-1'); + }); + + it('should update tabIndex and aria-selected when selection changes', async () => { + const { getByText } = render( + , + ); + const buttonTwo = getByText('two'); + fireEvent.click(buttonTwo); + + await waitFor(() => { + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('tabindex', '-1'); + expect(tabs[1]).toHaveAttribute('tabindex', '0'); + expect(tabs[0]).toHaveAttribute('aria-selected', 'false'); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + }); + }); + + it('should not have aria-pressed in tabs mode', () => { + render(); + const tabs = screen.getAllByRole('tab'); + tabs.forEach((tab) => { + expect(tab).not.toHaveAttribute('aria-pressed'); + }); + }); - expect(screen.getByText('one')).toHaveAttribute('aria-selected', 'false'); - expect(screen.getByText('two')).toHaveAttribute('aria-selected', 'true'); + it('should have correct button ids when id prop is provided', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('id', 'my-control-tab-0'); + expect(tabs[1]).toHaveAttribute('id', 'my-control-tab-1'); + }); }); it('should render with the correct type class', () => { @@ -126,401 +247,396 @@ describe('BpkSegmentedControl', () => { expect(selectedButton).toBeInTheDocument(); }); - it('should render with role="tablist" on the outer div', () => { - render(); - const tablist = screen.getByRole('tablist'); - - expect(tablist).toBeInTheDocument(); - }); - - it('should set aria-orientation="horizontal" on the tablist', () => { - render(); - const tablist = screen.getByRole('tablist'); - - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); - }); - - it('should set the accessible label on the tablist when label prop is provided', () => { - render( - , - ); - const tablist = screen.getByRole('tablist'); - - expect(tablist).toHaveAttribute('aria-label', 'Segmented control label'); - }); - - it('should not set aria-label when label prop is not provided', () => { - render(); - const tablist = screen.getByRole('tablist'); - - expect(tablist).not.toHaveAttribute('aria-label'); - }); - - it('should generate tab IDs based on provided id prop', () => { - render(); - const tabs = screen.getAllByRole('tab'); - - expect(tabs[0]).toHaveAttribute('id', 'my-tabs-tab-0'); - expect(tabs[1]).toHaveAttribute('id', 'my-tabs-tab-1'); - }); - - it('should not generate tab IDs when id prop is not provided', () => { - render(); - const tabs = screen.getAllByRole('tab'); - - expect(tabs[0]).not.toHaveAttribute('id'); - expect(tabs[1]).not.toHaveAttribute('id'); - }); - - it('should auto-generate aria-controls for panels when id is provided', () => { - render(); - const tabs = screen.getAllByRole('tab'); - - expect(tabs[0]).toHaveAttribute('aria-controls', 'my-tabs-panel-0'); - expect(tabs[1]).toHaveAttribute('aria-controls', 'my-tabs-panel-1'); - }); - - it('should not set aria-controls when id prop is not provided', () => { - render(); - const tabs = screen.getAllByRole('tab'); - - expect(tabs[0]).not.toHaveAttribute('aria-controls'); - expect(tabs[1]).not.toHaveAttribute('aria-controls'); - }); - - describe('activationMode', () => { - it('should default to automatic activation mode', () => { - render(); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); - - // In automatic mode, navigating should call onItemClick - expect(document.activeElement).toBe(tabs[1]); - expect(mockOnItemClick).toHaveBeenCalledWith(1); - }); - - it('should not activate tabs automatically in manual mode', () => { - render( + describe('Keyboard navigation - Automatic Mode', () => { + it('should move to next tab on ArrowRight and change selection', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); - // In manual mode (default), arrow keys move focus but don't activate - expect(document.activeElement).toBe(tabs[1]); - expect(mockOnItemClick).not.toHaveBeenCalled(); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); }); - it('should require Enter or Space to activate in manual mode', () => { - mockOnItemClick.mockClear(); - render( + it('should move to previous tab on ArrowLeft and change selection', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); - // Focus moves but tab is not activated yet (onItemClick not called) - expect(document.activeElement).toBe(tabs[1]); - expect(mockOnItemClick).not.toHaveBeenCalled(); + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{ArrowLeft}'); - // Now press Enter to activate - fireEvent.keyDown(tabs[1], { key: 'Enter' }); - expect(mockOnItemClick).toHaveBeenCalledWith(1); + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); }); - it('should activate tab with Space key in manual mode', () => { - render( + it('should wrap from last tab to first on ArrowRight', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[1].focus(); - fireEvent.keyDown(tabs[1], { key: ' ' }); - expect(mockOnItemClick).toHaveBeenCalledWith(1); - }); - }); + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{ArrowRight}'); - describe('keyboard navigation', () => { - beforeEach(() => { - (isRTL as jest.Mock).mockReturnValue(false); + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); }); - it('should move focus to next tab on ArrowRight in manual mode', () => { - render( + it('should wrap from first tab to last on ArrowLeft', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[1].focus(); - fireEvent.keyDown(tabs[1], { key: 'ArrowRight' }); - // In manual mode, arrow keys only move focus, not activate - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[0]); // wraps to first + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowLeft}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); }); - it('should move focus to previous tab on ArrowLeft in manual mode', () => { - render( + it('should move focus to next button on arrow key', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[1].focus(); - fireEvent.keyDown(tabs[1], { key: 'ArrowLeft' }); - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[0]); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + const buttonTwo = getByText('two'); + expect(document.activeElement).toBe(buttonTwo); }); - it('should wrap to last tab when pressing ArrowLeft on first tab', () => { - render( + it('should jump to first tab on Home key', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[2]); + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{Home}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); }); - it('should move focus to first tab on Home', () => { - render( + it('should jump to last tab on End key', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[1].focus(); - fireEvent.keyDown(tabs[1], { key: 'Home' }); - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[0]); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{End}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); }); - it('should move focus to last tab on End', () => { - render( + it('should have automatic mode as default activation mode', () => { + // This test verifies that without specifying activationMode, arrow keys activate tabs + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'End' }); - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[1]); - }); - - it('should set tabIndex=0 only on selected tab', () => { - render(); - const tabs = screen.getAllByRole('tab'); + const buttonOne = getByText('one'); + fireEvent.click(buttonOne); + fireEvent.keyDown(buttonOne, { key: 'ArrowRight' }); - // default selectedIndex is 1 - expect(tabs[0]).toHaveAttribute('tabIndex', '-1'); - expect(tabs[1]).toHaveAttribute('tabIndex', '0'); + expect(mockOnItemClick).toHaveBeenCalled(); }); }); - describe('RTL keyboard navigation', () => { - beforeEach(() => { - (isRTL as jest.Mock).mockReturnValue(true); - }); - - afterEach(() => { - (isRTL as jest.Mock).mockReturnValue(false); - }); + describe('Keyboard navigation - Manual Mode', () => { + it('should move focus on ArrowRight but not change selection', async () => { + const user = userEvent.setup(); + mockOnItemClick.mockClear(); - it('should move focus to previous tab on ArrowRight in RTL', () => { - render( + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[1].focus(); - fireEvent.keyDown(tabs[1], { key: 'ArrowRight' }); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + const buttonTwo = getByText('two'); + expect(document.activeElement).toBe(buttonTwo); expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[0]); }); - it('should move focus to next tab on ArrowLeft in RTL', () => { - render( + it('should activate tab on Space key in manual mode', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[1]); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + await user.keyboard(' '); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); }); - it('should wrap from first to last on ArrowRight in RTL', () => { - render( + it('should activate tab on Enter key in manual mode', async () => { + const user = userEvent.setup(); + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowRight' }); - expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[1]); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); }); - it('should wrap from last to first on ArrowLeft in RTL', () => { - render( + it('should move focus on ArrowLeft but not change selection', async () => { + const user = userEvent.setup(); + mockOnItemClick.mockClear(); + + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[1].focus(); - fireEvent.keyDown(tabs[1], { key: 'ArrowLeft' }); + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{ArrowLeft}'); + + const buttonOne = getByText('one'); + expect(document.activeElement).toBe(buttonOne); expect(mockOnItemClick).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(tabs[0]); }); + }); - it('should activate tabs automatically in RTL with automatic mode', () => { - render( + describe('RTL keyboard navigation', () => { + it('should reverse arrow key direction when isRTL is true', async () => { + const user = userEvent.setup(); + mockIsRtl.mockReturnValue(true); + + const { getByText } = render( , ); - const tabs = screen.getAllByRole('tab'); - tabs[0].focus(); - fireEvent.keyDown(tabs[0], { key: 'ArrowLeft' }); - // In RTL, ArrowLeft goes to next item - expect(mockOnItemClick).toHaveBeenCalledWith(1); - expect(document.activeElement).toBe(tabs[1]); + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); }); - }); -}); -describe('useSegmentedControlPanels', () => { - it('should return controlProps with auto-generated id', () => { - const { result } = renderHook(() => - useSegmentedControlPanels(['One', 'Two'], 0), - ); + it('should reverse ArrowLeft direction when isRTL is true', async () => { + const user = userEvent.setup(); + mockIsRtl.mockReturnValue(true); - expect(result.current.controlProps).toHaveProperty('id'); - expect(result.current.controlProps.id).toBeTruthy(); - expect(result.current.controlProps.buttonContents).toEqual(['One', 'Two']); - expect(result.current.controlProps.selectedIndex).toBe(0); + const { getByText } = render( + , + ); + + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + // In RTL, ArrowLeft should go to next (wrap to first) + await user.keyboard('{ArrowLeft}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); + }); }); - it('should return getPanelProps function that generates correct props', () => { - const { result } = renderHook(() => - useSegmentedControlPanels(['One', 'Two', 'Three'], 1), - ); + describe('Segment types', () => { + const segmentTypeEntries = Object.entries(SEGMENT_TYPES); + + segmentTypeEntries.forEach(([name, type]) => { + it(`should render correctly with type="${type}"`, () => { + const { container } = render( + , + ); + + const button = container.querySelector( + `.bpk-segmented-control--${type}`, + ); + expect(button).toBeInTheDocument(); + }); + }); + }); + + describe('useSegmentedControlPanels hook', () => { + it('should generate unique ids', () => { + const buttonContents = ['one', 'two']; + const { result: result1 } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); + const { result: result2 } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); + + expect(result1.current.controlProps.id).not.toBe( + result2.current.controlProps.id, + ); + }); - const panel0Props = result.current.getPanelProps(0); - const panel1Props = result.current.getPanelProps(1); - const panel2Props = result.current.getPanelProps(2); + it('should return controlProps with correct structure', () => { + const buttonContents = ['one', 'two']; + const { result } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); - expect(panel0Props.hidden).toBe(true); - expect(panel1Props.hidden).toBe(false); - expect(panel2Props.hidden).toBe(true); + expect(result.current.controlProps).toHaveProperty('id'); + expect(result.current.controlProps).toHaveProperty('buttonContents'); + expect(result.current.controlProps).toHaveProperty('selectedIndex'); + expect(result.current.controlProps.buttonContents).toEqual( + buttonContents, + ); + expect(result.current.controlProps.selectedIndex).toBe(0); + }); - expect(panel0Props.role).toBe('tabpanel'); - expect(panel1Props.role).toBe('tabpanel'); - expect(panel2Props.role).toBe('tabpanel'); + it('should return getPanelProps function that generates correct props', () => { + const buttonContents = ['one', 'two']; + const { result } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); - expect(panel0Props['aria-labelledby']).toContain('-tab-0'); - expect(panel1Props['aria-labelledby']).toContain('-tab-1'); - expect(panel2Props['aria-labelledby']).toContain('-tab-2'); + const panelProps0 = result.current.getPanelProps(0); + const panelProps1 = result.current.getPanelProps(1); - expect(panel0Props.id).toContain('-panel-0'); - expect(panel1Props.id).toContain('-panel-1'); - expect(panel2Props.id).toContain('-panel-2'); - }); + expect(panelProps0).toHaveProperty('id'); + expect(panelProps0).toHaveProperty('role', 'tabpanel'); + expect(panelProps0).toHaveProperty('aria-labelledby'); + expect(panelProps0).toHaveProperty('hidden', false); + expect(panelProps0).toHaveProperty('tabIndex', 0); - it('should maintain stable IDs across re-renders with same inputs', () => { - const { rerender, result } = renderHook( - ({ contents, selected }) => useSegmentedControlPanels(contents, selected), - { - initialProps: { contents: ['A', 'B'], selected: 0 }, - }, - ); + expect(panelProps1.hidden).toBe(true); + }); - const firstId = result.current.controlProps.id; - const firstPanelId = result.current.getPanelProps(0).id; + it('should update panel hidden state when selectedIndex changes', () => { + const buttonContents = ['one', 'two']; + const { rerender, result } = renderHook( + ({ selectedIndex }) => + useSegmentedControlPanels(buttonContents, selectedIndex), + { initialProps: { selectedIndex: 0 } }, + ); - rerender({ contents: ['A', 'B'], selected: 0 }); + expect(result.current.getPanelProps(0).hidden).toBe(false); + expect(result.current.getPanelProps(1).hidden).toBe(true); - expect(result.current.controlProps.id).toBe(firstId); - expect(result.current.getPanelProps(0).id).toBe(firstPanelId); - }); + rerender({ selectedIndex: 1 }); - it('should update panel hidden state when selectedIndex changes', () => { - const { rerender, result } = renderHook( - ({ contents, selected }) => useSegmentedControlPanels(contents, selected), - { - initialProps: { contents: ['A', 'B', 'C'], selected: 0 }, - }, - ); + expect(result.current.getPanelProps(0).hidden).toBe(true); + expect(result.current.getPanelProps(1).hidden).toBe(false); + }); - expect(result.current.getPanelProps(0).hidden).toBe(false); - expect(result.current.getPanelProps(1).hidden).toBe(true); + it('should link tab to panel with correct aria-labelledby', () => { + const buttonContents = ['one', 'two']; + const { result } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); - rerender({ contents: ['A', 'B', 'C'], selected: 1 }); + const baseId = result.current.controlProps.id; + const panelProps0 = result.current.getPanelProps(0); - expect(result.current.getPanelProps(0).hidden).toBe(true); - expect(result.current.getPanelProps(1).hidden).toBe(false); + expect(panelProps0['aria-labelledby']).toBe(`${baseId}-tab-0`); + expect(panelProps0.id).toBe(`${baseId}-panel-0`); + }); }); });