diff --git a/src/__private_stories__/list-disclosure-story.tsx b/src/__private_stories__/list-disclosure-story.tsx new file mode 100644 index 0000000000..8c2ee84cb1 --- /dev/null +++ b/src/__private_stories__/list-disclosure-story.tsx @@ -0,0 +1,165 @@ +import * as React from 'react'; +import { + Box, + Stack, + Row, + RowList, + RadioGroup, + NegativeBox, + ResponsiveLayout, + Title3, + Text3, + IconShopRegular, +} from '..'; + +export default { + title: 'Private/Lists/ControlDisclosure', + parameters: { + fullScreen: true, + }, +}; + +export const ControlDisclosureExample: StoryComponent = () => { + // Example state for the radio rows + const [fruit, setFruit] = React.useState<'banana' | 'apple'>('banana'); + + // Example state for switch + checkbox rows + const [switchA, setSwitchA] = React.useState(false); + const [checkboxB, setCheckboxB] = React.useState(false); + + return ( + + + + {/* Example 1: Radio rows that expand additional content */} + + Radio rows with expandable content + + setFruit(value as 'banana' | 'apple')} + > + + } + title="Banana" + description="Yellow" + radioValue="banana" + radio={{ + controlDisclosure: { + expanded: fruit === 'banana', + 'aria-live': 'assertive', + }, + }} + /> + + } + title="Apple" + description="Green" + radioValue="apple" + radio={{ + controlDisclosure: { + expanded: fruit === 'apple', + 'aria-live': 'assertive', + }, + }} + /> + + + + {fruit === 'banana' && ( + + + Additional options for BANANA + + Example content for Banana. Use this section to validate that the + radio row triggers the correct ARIA announcements and expansion + behaviour. + + + + )} + + {fruit === 'apple' && ( + + + Additional options for APPLE + + Example content for Apple. Expands when the corresponding radio row is + selected. + + + + )} + + {/* Example 2: Switch / Checkbox rows that expand */} + + Switch and checkbox rows with expandable content + + + + + + + + + + {switchA && ( + + + Extra options for A + + This section becomes visible when the switch is active. Used to + validate accessibility announcements for switch-based disclosure. + + + + )} + + {checkboxB && ( + + + Extra options for B + + Example content for the checkbox row. Helpful to test both visual and + screen-reader behaviour. + + + + )} + + + + + ); +}; + +ControlDisclosureExample.storyName = 'ControlDisclosure'; diff --git a/src/list.tsx b/src/list.tsx index 87db41260d..549395faf2 100644 --- a/src/list.tsx +++ b/src/list.tsx @@ -28,6 +28,7 @@ import {applyCssVars} from './utils/css'; import {IconButton, ToggleIconButton} from './icon-button'; import ScreenReaderOnly from './screen-reader-only'; import {useTheme} from './hooks'; +import * as tokens from './text-tokens'; import type {IconButtonProps, ToggleIconButtonProps} from './icon-button'; import type {TouchableElement, TouchableProps} from './touchable'; @@ -279,11 +280,23 @@ export const Content = ({ ); }; +type ControlDisclosure = { + /* is the related content expanded */ + expanded: boolean; + /* announcement channel when changing expanded */ + 'aria-live'?: 'off' | 'polite' | 'assertive'; + /* blocks UI and sets aria-busy */ + 'aria-busy'?: boolean; + /* message of the SR when expanded = true */ + onLabelWhenExpanded?: string; +}; + type ControlProps = { name?: string; value?: boolean; defaultValue?: boolean; onChange?: (checked: boolean) => void; + controlDisclosure?: ControlDisclosure; }; interface BasicRowContentProps extends CommonProps { @@ -309,6 +322,7 @@ interface RadioRowContentProps extends CommonProps { trackingEvent?: TrackingEvent | ReadonlyArray; radioValue: string; + radio?: ControlProps | undefined; } interface IconButtonRowContentProps extends CommonProps { @@ -436,6 +450,17 @@ const RowContent = React.forwardRef((props, r const hasControl = hasControlProps(props); const isInteractive = !!props.onPress || !!props.href || !!props.to; const hasChevron = hasControl ? false : withChevron ?? isInteractive; + const {texts, t} = useTheme(); + const optionsBelowText = + texts.optionsAvailableBelowAnnouncement || t(tokens.optionsAvailableBelowAnnouncement); + const controlDisclosure = + props.switch?.controlDisclosure || + props.checkbox?.controlDisclosure || + props.radio?.controlDisclosure; + const announcementSuffix = controlDisclosure?.onLabelWhenExpanded ?? optionsBelowText; + const expandedAriaLabel = + ariaLabel ?? (controlDisclosure?.expanded ? `${title} ${announcementSuffix}` : ariaLabel); + const rowIsBusy = !!controlDisclosure?.['aria-busy']; const interactiveProps = { href: props.href, @@ -566,35 +591,47 @@ const RowContent = React.forwardRef((props, r return isInteractive ? renderRowWithDoubleInteraction( - ( -
{controlElement}
- )} - /> +
+ ( +
{controlElement}
+ )} + /> +
) : renderRowWithSingleControl( - ( - - {renderContent({ - labelId, - control: {controlElement}, - })} - - )} - />, +
+ ( + + {renderContent({ + labelId, + control: {controlElement}, + })} + + )} + /> +
, true ); } @@ -602,31 +639,43 @@ const RowContent = React.forwardRef((props, r if (props.radioValue) { return isInteractive ? renderRowWithDoubleInteraction( - ( - - {controlElement} - - )} - /> +
+ ( + + {controlElement} + + )} + /> +
) : renderRowWithSingleControl( - ( - - {renderContent({ - labelId: titleId, - control: {controlElement}, - })} - - )} - />, +
+ ( + + {renderContent({ + labelId: titleId, + control: {controlElement}, + })} + + )} + /> +
, true ); } diff --git a/src/switch-component.tsx b/src/switch-component.tsx index 6c3c3bbdcc..d3535249b8 100644 --- a/src/switch-component.tsx +++ b/src/switch-component.tsx @@ -35,6 +35,8 @@ type PropsRender = { children?: undefined; 'aria-labelledby'?: string; 'aria-label'?: string; + 'aria-controls'?: string; + 'aria-expanded'?: boolean; dataAttributes?: DataAttributes; }; @@ -48,6 +50,8 @@ type PropsChildren = { render?: undefined; 'aria-labelledby'?: string; 'aria-label'?: string; + 'aria-controls'?: string; + 'aria-expanded'?: boolean; dataAttributes?: DataAttributes; }; @@ -159,6 +163,8 @@ const Switch = (props: PropsRender | PropsChildren): JSX.Element => { aria-disabled={disabled} aria-label={props['aria-label']} aria-labelledby={props['aria-label'] ? undefined : labelId} + aria-controls={props['aria-controls']} + aria-expanded={props['aria-expanded']} {...getPrefixedDataAttributes(props.dataAttributes, 'Switch')} > {props.render ? ( diff --git a/src/text-tokens.tsx b/src/text-tokens.tsx index 97017c21e2..9c44091352 100644 --- a/src/text-tokens.tsx +++ b/src/text-tokens.tsx @@ -80,6 +80,7 @@ export type Dictionary = { ratingVeryGoodLabel: string; ratingQuantitativeLabel: string; skipLinkNavLabel: string; + optionsAvailableBelowAnnouncement: string; }; export type TextToken = Record; @@ -658,3 +659,10 @@ export const skipLinkNavLabel: TextToken = { de: 'Direkt zum Inhalt', pt: 'Acesso rápido', }; + +export const optionsAvailableBelowAnnouncement: TextToken = { + es: 'Opciones disponibles a continuación', + en: 'Options available below', + de: 'Optionen unten verfügbar', + pt: 'Opções disponíveis a seguir', +};