Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/__private_stories__/list-disclosure-story.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box paddingY={24}>
<ResponsiveLayout>
<Stack space={32}>
{/* Example 1: Radio rows that expand additional content */}
<Stack space={16}>
<Title3 as="h1">Radio rows with expandable content</Title3>

<RadioGroup
name="fruit"
value={fruit}
onChange={(value) => setFruit(value as 'banana' | 'apple')}
>
<RowList>
<Row
asset={<IconShopRegular />}
title="Banana"
description="Yellow"
radioValue="banana"
radio={{
controlDisclosure: {
expanded: fruit === 'banana',
'aria-live': 'assertive',
},
}}
/>

<Row
asset={<IconShopRegular />}
title="Apple"
description="Green"
radioValue="apple"
radio={{
controlDisclosure: {
expanded: fruit === 'apple',
'aria-live': 'assertive',
},
}}
/>
</RowList>
</RadioGroup>

{fruit === 'banana' && (
<Box id="panel-banana" paddingLeft={16}>
<Stack space={8}>
<Title3 as="h1">Additional options for BANANA</Title3>
<Text3 light>
Example content for Banana. Use this section to validate that the
radio row triggers the correct ARIA announcements and expansion
behaviour.
</Text3>
</Stack>
</Box>
)}

{fruit === 'apple' && (
<Box id="panel-apple" paddingLeft={16}>
<Stack space={8}>
<Title3 as="h1">Additional options for APPLE</Title3>
<Text3 light>
Example content for Apple. Expands when the corresponding radio row is
selected.
</Text3>
</Stack>
</Box>
)}
</Stack>
{/* Example 2: Switch / Checkbox rows that expand */}
<Stack space={16}>
<Title3 as="h2">Switch and checkbox rows with expandable content</Title3>

<NegativeBox>
<RowList>
<Row
title="Call forwarding A"
description="Additional options appear when enabled."
switch={{
name: 'switch-a',
value: switchA,
onChange: setSwitchA,
controlDisclosure: {
expanded: switchA,
'aria-live': 'assertive',
onLabelWhenExpanded: 'Options available below.',
},
}}
/>

<Row
title="Call forwarding B"
description="Another example of expandable content."
checkbox={{
name: 'checkbox-b',
value: checkboxB,
onChange: setCheckboxB,
controlDisclosure: {
expanded: checkboxB,
'aria-live': 'assertive',
onLabelWhenExpanded: 'Options available below.',
},
}}
/>
</RowList>
</NegativeBox>

{switchA && (
<Box id="switch-a-panel" paddingLeft={16}>
<Stack space={8}>
<Title3 as="h1">Extra options for A</Title3>
<Text3 light>
This section becomes visible when the switch is active. Used to
validate accessibility announcements for switch-based disclosure.
</Text3>
</Stack>
</Box>
)}

{checkboxB && (
<Box id="switch-b-panel" paddingLeft={16}>
<Stack space={8}>
<Title3 as="h1">Extra options for B</Title3>
<Text3 light>
Example content for the checkbox row. Helpful to test both visual and
screen-reader behaviour.
</Text3>
</Stack>
</Box>
)}
</Stack>
</Stack>
</ResponsiveLayout>
</Box>
);
};

ControlDisclosureExample.storyName = 'ControlDisclosure';
149 changes: 99 additions & 50 deletions src/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -309,6 +322,7 @@ interface RadioRowContentProps extends CommonProps {
trackingEvent?: TrackingEvent | ReadonlyArray<TrackingEvent>;

radioValue: string;
radio?: ControlProps | undefined;
}

interface IconButtonRowContentProps extends CommonProps {
Expand Down Expand Up @@ -436,6 +450,17 @@ const RowContent = React.forwardRef<TouchableElement, RowContentProps>((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,
Expand Down Expand Up @@ -566,67 +591,91 @@ const RowContent = React.forwardRef<TouchableElement, RowContentProps>((props, r

return isInteractive
? renderRowWithDoubleInteraction(
<Control
disabled={disabled}
name={name}
checked={isChecked}
aria-label={ariaLabel}
aria-labelledby={titleId}
onChange={toggle}
render={({controlElement}) => (
<div className={styles.dualActionRight}>{controlElement}</div>
)}
/>
<div
aria-live={controlDisclosure?.['aria-live'] ?? 'off'}
aria-atomic={controlDisclosure?.['aria-live'] !== 'off'}
aria-busy={rowIsBusy || undefined}
>
<Control
disabled={disabled || rowIsBusy}
name={name}
checked={isChecked}
aria-label={expandedAriaLabel}
aria-labelledby={titleId}
onChange={toggle}
render={({controlElement}) => (
<div className={styles.dualActionRight}>{controlElement}</div>
)}
/>
</div>
)
: renderRowWithSingleControl(
<Control
disabled={disabled}
name={name}
checked={isChecked}
aria-label={ariaLabel}
aria-labelledby={titleId}
onChange={toggle}
render={({controlElement, labelId}) => (
<Box paddingX={16} role={role}>
{renderContent({
labelId,
control: <Stack space="around">{controlElement}</Stack>,
})}
</Box>
)}
/>,
<div
aria-live={controlDisclosure?.['aria-live'] ?? 'off'}
aria-atomic={controlDisclosure?.['aria-live'] !== 'off'}
aria-busy={rowIsBusy || undefined}
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using || undefined to conditionally render the attribute is unnecessary. The aria-busy attribute should be explicitly set to 'true' or 'false' as a string, or omitted entirely. Use aria-busy={rowIsBusy ? 'true' : undefined} or aria-busy={rowIsBusy ? true : undefined} instead.

Suggested change
aria-busy={rowIsBusy || undefined}
aria-busy={rowIsBusy ? 'true' : undefined}

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i thought is a type Booleanish = boolean | 'true' | 'false'; so booleans are also valid, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so

>
<Control
disabled={disabled || rowIsBusy}
name={name}
checked={isChecked}
aria-label={expandedAriaLabel}
aria-labelledby={titleId}
onChange={toggle}
render={({controlElement, labelId}) => (
<Box paddingX={16} role={role}>
{renderContent({
labelId,
control: <Stack space="around">{controlElement}</Stack>,
})}
</Box>
)}
/>
</div>,
true
);
}

if (props.radioValue) {
return isInteractive
? renderRowWithDoubleInteraction(
<RadioButton
value={props.radioValue}
aria-label={ariaLabel}
aria-labelledby={titleId}
render={({controlElement}) => (
<Stack space="around">
<Box paddingX={16}>{controlElement}</Box>
</Stack>
)}
/>
<div
aria-live={controlDisclosure?.['aria-live'] ?? 'off'}
aria-atomic={controlDisclosure?.['aria-live'] !== 'off'}
aria-busy={controlDisclosure?.['aria-busy'] || undefined}
>
<RadioButton
value={props.radioValue}
aria-label={expandedAriaLabel}
aria-labelledby={titleId}
render={({controlElement}) => (
<Stack space="around">
<Box paddingX={16}>{controlElement}</Box>
</Stack>
)}
/>
</div>
)
: renderRowWithSingleControl(
<RadioButton
value={props.radioValue}
aria-label={ariaLabel}
aria-labelledby={titleId}
render={({controlElement}) => (
<Box paddingX={16} role={role}>
{renderContent({
labelId: titleId,
control: <Stack space="around">{controlElement}</Stack>,
})}
</Box>
)}
/>,
<div
aria-live={controlDisclosure?.['aria-live'] ?? 'off'}
aria-atomic={controlDisclosure?.['aria-live'] !== 'off'}
aria-busy={controlDisclosure?.['aria-busy'] || undefined}
>
<RadioButton
value={props.radioValue}
aria-label={expandedAriaLabel}
aria-labelledby={titleId}
render={({controlElement}) => (
<Box paddingX={16} role={role}>
{renderContent({
labelId: titleId,
control: <Stack space="around">{controlElement}</Stack>,
})}
</Box>
)}
/>
</div>,
true
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/switch-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type PropsRender = {
children?: undefined;
'aria-labelledby'?: string;
'aria-label'?: string;
'aria-controls'?: string;
'aria-expanded'?: boolean;
dataAttributes?: DataAttributes;
};

Expand All @@ -48,6 +50,8 @@ type PropsChildren = {
render?: undefined;
'aria-labelledby'?: string;
'aria-label'?: string;
'aria-controls'?: string;
'aria-expanded'?: boolean;
dataAttributes?: DataAttributes;
};

Expand Down Expand Up @@ -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 ? (
Expand Down
Loading
Loading