diff --git a/examples/bpk-component-segmented-control/examples.tsx b/examples/bpk-component-segmented-control/examples.tsx index da0d191a7d..8f55b37563 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, { + useSegmentedControlPanels, +} from '../../packages/bpk-component-segmented-control'; import { SEGMENT_TYPES } from '../../packages/bpk-component-segmented-control/src/BpkSegmentedControl'; +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'; @@ -276,6 +281,89 @@ const VisualExample = () => ( ); +// Example demonstrating the recommended hook pattern for managing tabs and panels. +// The hook automatically handles ID generation and ARIA relationships. +const WithHookControlledPanelsExample = () => { + 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 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 Panel +
+ )} + {selectedIndex === 1 && ( +
+ Flexible dates Panel +
+ )} +
+ ); +}; + export { SimpleDefault, SimpleCanvasContrast, @@ -292,4 +380,6 @@ export { ComplexCanvasDefault, ComplexSurfaceDefaultNoShadow, VisualExample, + WithHookControlledPanelsExample, + WithConditionalPanelsExample, }; diff --git a/examples/bpk-component-segmented-control/stories.tsx b/examples/bpk-component-segmented-control/stories.tsx index 59da23551a..040267347a 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, + WithHookControlledPanelsExample, + WithConditionalPanelsExample, } from './examples'; export default { @@ -57,6 +59,8 @@ export const ComplexThreeSegmentsCanvasContrast = ComplexCanvasContrast; export const ComplexThreeSegmentsCanvasDefault = ComplexCanvasDefault; export const ComplexThreeSegmentsSurfaceDefaultNoShadow = ComplexSurfaceDefaultNoShadow; +export const WithHookControlledPanels = WithHookControlledPanelsExample; +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..00a915683b 100644 --- a/packages/bpk-component-segmented-control/README.md +++ b/packages/bpk-component-segmented-control/README.md @@ -1,24 +1,93 @@ -# bpk-segmented-control +# bpk-component-segmented-control + +> Backpack segmented control component. ## Installation + Check the main [Readme](https://github.com/skyscanner/backpack#usage) for a complete installation guide. ## Usage -```js -import BpkSegmentedControl from '@skyscanner/backpack-web/bpk-component-segmented-control'; + +### Basic Usage + +```tsx +import BpkSegmentedControl, { + SEGMENT_TYPES, +} from '@skyscanner/backpack-web/bpk-component-segmented-control'; export default () => ( {}} - 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 (Recommended) + +When using the segmented control to switch between content panels, use the `useSegmentedControlPanels` hook for automatic ID generation and proper ARIA relationships. + +```tsx +import { useState } from 'react'; +import BpkSegmentedControl, { + useSegmentedControlPanels, + SEGMENT_TYPES, +} from '@skyscanner/backpack-web/bpk-component-segmented-control'; + +const TabbedContent = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + const buttonContents = ['Flights', 'Hotels', 'Car hire']; + + const { controlProps, getPanelProps } = useSegmentedControlPanels( + buttonContents, + selectedIndex, + ); + + return ( +
+ +
+

Search for flights to your destination.

+
+
+

Find the perfect place to stay.

+
+
+

Rent a car for your trip.

+
+
+ ); +}; + +export default TabbedContent; +``` + +## Accessibility + +The `BpkSegmentedControl` component implements the [ARIA tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) with full keyboard navigation support. + +### Keyboard Navigation + +- **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) + +### Activation Modes + +The component supports two activation modes: + +- **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 -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). + +Check out the full list of props on Skyscanner's [design system documentation website]( https://www.skyscanner.design/latest/components/section-list/web-tP8t6vq8). diff --git a/packages/bpk-component-segmented-control/index.ts b/packages/bpk-component-segmented-control/index.ts index bac79e5415..8244dad88f 100644 --- a/packages/bpk-component-segmented-control/index.ts +++ b/packages/bpk-component-segmented-control/index.ts @@ -17,8 +17,11 @@ */ import BpkSegmentedControl, { + useSegmentedControlPanels, type Props as BpkSegmentControlProps, + type TabPanelProps, } from './src/BpkSegmentedControl'; -export type { BpkSegmentControlProps }; +export type { BpkSegmentControlProps, TabPanelProps }; +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 499319e0e2..a82b613fc3 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl-test.tsx @@ -15,13 +15,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, fireEvent, screen } 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 BpkSegmentedControl, { SEGMENT_TYPES } from './BpkSegmentedControl'; +import BpkSegmentedControl, { + 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: () => mockIsRtl(), +})); + const defaultProps = { buttonContents: ['one', 'two'], onItemClick: mockOnItemClick, @@ -33,6 +50,7 @@ const defaultProps = { describe('BpkSegmentedControl', () => { beforeEach(() => { mockOnItemClick.mockClear(); + mockIsRtl.mockReturnValue(false); }); it('should render ReactNode contents correctly', () => { @@ -64,13 +82,141 @@ describe('BpkSegmentedControl', () => { expect(mockOnItemClick).toHaveBeenCalledWith(0); }); - it('should update the selected button when a 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); + }); + + 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', + ); + }); + + 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'); + }); - expect(screen.getByText('one')).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByText('two')).toHaveAttribute('aria-pressed', 'false'); + 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'); + }); + }); + }); + + 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'); + }); + }); + + 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', () => { @@ -101,26 +247,396 @@ describe('BpkSegmentedControl', () => { expect(selectedButton).toBeInTheDocument(); }); - it('should render with role="group" on the outer div', () => { - render(); - const group = screen.getByRole('group'); - expect(group).toBeInTheDocument(); + 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 buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + + it('should move to previous tab on ArrowLeft and change selection', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{ArrowLeft}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); + }); + + it('should wrap from last tab to first on ArrowRight', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); + }); + + it('should wrap from first tab to last on ArrowLeft', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowLeft}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + + it('should move focus to next button on arrow key', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + const buttonTwo = getByText('two'); + expect(document.activeElement).toBe(buttonTwo); + }); + + it('should jump to first tab on Home key', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonTwo = getByText('two'); + await user.click(buttonTwo); + await user.keyboard('{Home}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(0); + }); + }); + + it('should jump to last tab on End key', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{End}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + + it('should have automatic mode as default activation mode', () => { + // This test verifies that without specifying activationMode, arrow keys activate tabs + const { getByText } = render( + , + ); + + const buttonOne = getByText('one'); + fireEvent.click(buttonOne); + fireEvent.keyDown(buttonOne, { key: 'ArrowRight' }); + + expect(mockOnItemClick).toHaveBeenCalled(); + }); + }); + + describe('Keyboard navigation - Manual Mode', () => { + it('should move focus on ArrowRight but not change selection', async () => { + const user = userEvent.setup(); + mockOnItemClick.mockClear(); + + const { getByText } = render( + , + ); + + 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(); + }); + + it('should activate tab on Space key in manual mode', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + await user.keyboard(' '); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + + it('should activate tab on Enter key in manual mode', async () => { + const user = userEvent.setup(); + const { getByText } = render( + , + ); + + const buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + + it('should move focus on ArrowLeft but not change selection', async () => { + const user = userEvent.setup(); + mockOnItemClick.mockClear(); + + const { getByText } = render( + , + ); + + 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(); + }); }); - it('should set the accessible label on the group when label prop is provided', () => { - render( - , - ); - const group = screen.getByRole('group'); - expect(group).toHaveAttribute('aria-label', 'Segmented control label'); + 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 buttonOne = getByText('one'); + await user.click(buttonOne); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => { + expect(mockOnItemClick).toHaveBeenCalledWith(1); + }); + }); + + it('should reverse ArrowLeft direction when isRTL is true', async () => { + const user = userEvent.setup(); + mockIsRtl.mockReturnValue(true); + + 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); + }); + }); + }); + + 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(); + }); + }); }); - it('should not set aria-label when label prop is not provided', () => { - render(); - const group = screen.getByRole('group'); - expect(group).not.toHaveAttribute('aria-label'); + 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, + ); + }); + + it('should return controlProps with correct structure', () => { + const buttonContents = ['one', 'two']; + const { result } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); + + 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); + }); + + it('should return getPanelProps function that generates correct props', () => { + const buttonContents = ['one', 'two']; + const { result } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); + + const panelProps0 = result.current.getPanelProps(0); + const panelProps1 = result.current.getPanelProps(1); + + expect(panelProps0).toHaveProperty('id'); + expect(panelProps0).toHaveProperty('role', 'tabpanel'); + expect(panelProps0).toHaveProperty('aria-labelledby'); + expect(panelProps0).toHaveProperty('hidden', false); + expect(panelProps0).toHaveProperty('tabIndex', 0); + + expect(panelProps1.hidden).toBe(true); + }); + + it('should update panel hidden state when selectedIndex changes', () => { + const buttonContents = ['one', 'two']; + const { rerender, result } = renderHook( + ({ selectedIndex }) => + useSegmentedControlPanels(buttonContents, selectedIndex), + { initialProps: { selectedIndex: 0 } }, + ); + + expect(result.current.getPanelProps(0).hidden).toBe(false); + expect(result.current.getPanelProps(1).hidden).toBe(true); + + rerender({ selectedIndex: 1 }); + + expect(result.current.getPanelProps(0).hidden).toBe(true); + expect(result.current.getPanelProps(1).hidden).toBe(false); + }); + + it('should link tab to panel with correct aria-labelledby', () => { + const buttonContents = ['one', 'two']; + const { result } = renderHook(() => + useSegmentedControlPanels(buttonContents, 0), + ); + + const baseId = result.current.controlProps.id; + const panelProps0 = result.current.getPanelProps(0); + + expect(panelProps0['aria-labelledby']).toBe(`${baseId}-tab-0`); + expect(panelProps0.id).toBe(`${baseId}-panel-0`); + }); }); }); diff --git a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx index deaee7dd07..8c97428d71 100644 --- a/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx +++ b/packages/bpk-component-segmented-control/src/BpkSegmentedControl.tsx @@ -16,10 +16,10 @@ * limitations under the License. */ -import type { ReactNode } from 'react'; -import { useState } from 'react'; +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'; @@ -33,36 +33,220 @@ 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; +}; + +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. + * + * 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. + * @returns {TabPanelProps} An object containing the necessary props for a tab panel. + */ +const getTabPanelProps = ( + baseId: string, + index: number, + selectedIndex: number, +): TabPanelProps => ({ + id: getPanelId(baseId, index), + role: 'tabpanel', + 'aria-labelledby': getTabId(baseId, index), + hidden: index !== selectedIndex, + 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. + * + * @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 + */ +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[]; /** * Accessible label for the segmented control group. */ label?: string; + /** + * ID used to link the segmented control with its tab panels for accessibility. + * Created using controlProps from useSegmentedControlPanels hook. + */ + id?: 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, selectedIndex, shadow = false, type = SEGMENT_TYPES.CanvasDefault, }: Props) => { + const buttonRefs = useRef>([]); + const panelIds = Array.from({ length: buttonContents.length }, (_, i) => + providedId ? getPanelId(providedId, i) : undefined, + ); + + // 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; + + const nextItem = () => { + newIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + }; + + const previousItem = () => { + newIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; + }; + + switch (event.key) { + case 'ArrowRight': + if (isRTL()) { + previousItem(); + } else { + nextItem(); + } + break; + case 'ArrowLeft': + if (isRTL()) { + nextItem(); + } else { + previousItem(); + } + 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,8 +255,7 @@ const BpkSegmentedControl = ({ return (
{buttonContents.map((content, index) => { const isSelected = index === selectedButton; @@ -87,14 +270,24 @@ const BpkSegmentedControl = ({ `bpk-segmented-control--${type}-selected-shadow`, ); + const buttonTabId = providedId + ? getTabId(providedId, index) + : undefined; + const tabIndexValue = getTabIndex(providedId, isSelected); + return ( diff --git a/packages/bpk-component-segmented-control/src/accessibility-test.tsx b/packages/bpk-component-segmented-control/src/accessibility-test.tsx index 2c57cc60d6..f399b080a9 100644 --- a/packages/bpk-component-segmented-control/src/accessibility-test.tsx +++ b/packages/bpk-component-segmented-control/src/accessibility-test.tsx @@ -19,17 +19,55 @@ 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'] +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}/> - ) + {}} + selectedIndex={1} + />, + ); const results = await axe(container); expect(results).toHaveNoViolations(); - }) -}) + }); +});