Skip to content
Merged
4 changes: 4 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5813,6 +5813,8 @@ const CONST = {
LINK: 'link',
/** Use to identify a list of items. */
LIST: 'list',
/** Use for a list of selectable options (single or multi-select). */
LISTBOX: 'listbox',
/** Use for individual items within a list. */
LISTITEM: 'listitem',
/** Use for a list of choices or options. */
Expand All @@ -5821,6 +5823,8 @@ const CONST = {
MENUBAR: 'menubar',
/** Use for items within a menu. */
MENUITEM: 'menuitem',
/** Use for selectable options within a listbox. */
OPTION: 'option',
/** Use when no specific role is needed. */
NONE: 'none',
/** Use for elements that don't require a specific role. */
Expand Down
2 changes: 2 additions & 0 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import useSearchFocusSync from './hooks/useSearchFocusSync';
import useSelectedItemFocusSync from './hooks/useSelectedItemFocusSync';
import ListItemRenderer from './ListItem/ListItemRenderer';
import type {ButtonOrCheckBoxRoles, DataDetailsType, ListItem, SelectionListProps} from './types';
import {getListboxRole} from './utils/getListboxRole';

const ANIMATED_HIGHLIGHT_DURATION =
CONST.ANIMATED_HIGHLIGHT_ENTRY_DELAY +
Expand Down Expand Up @@ -550,6 +551,7 @@ function BaseSelectionList<TItem extends ListItem>({
<>
{!shouldHeaderBeInsideList && header}
<FlashList
role={getListboxRole(canSelectMultiple)}
data={data}
renderItem={renderItem}
ref={listRef}
Expand Down
13 changes: 8 additions & 5 deletions src/components/SelectionList/ListItem/BaseListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,12 @@ function BaseListItem<TItem extends ListItem>({

const shouldShowHiddenCheckmark = shouldShowRBRIndicator && !shouldShowCheckmark && !!item.canShowSeveralIndicators;

// For single-select lists, use role="option" with aria-selected so screen readers announce "selected"/"not selected".
// For multi-select (checkbox/radio), keep existing role and state.
const isSelectableOption = !canSelectMultiple && accessibilityRole !== CONST.ROLE.CHECKBOX && accessibilityRole !== CONST.ROLE.RADIO;
const effectiveRole = isSelectableOption ? CONST.ROLE.OPTION : accessibilityRole;
const accessibilityState =
accessibilityRole === CONST.ROLE.CHECKBOX || accessibilityRole === CONST.ROLE.RADIO ? {checked: !!item.isSelected, selected: !!isFocused} : {selected: !!isFocused};
accessibilityRole === CONST.ROLE.CHECKBOX || accessibilityRole === CONST.ROLE.RADIO ? {checked: !!item.isSelected, selected: !!isFocused} : {selected: !!item.isSelected};

return (
<OfflineWithFeedback
Expand Down Expand Up @@ -139,13 +143,12 @@ function BaseListItem<TItem extends ListItem>({
onMouseLeave={handleMouseLeave}
tabIndex={accessible === false ? -1 : item.tabIndex}
wrapperStyle={pressableWrapperStyle}
testID={testID}
testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`}
accessible={accessible}
role={accessible === false ? CONST.ROLE.PRESENTATION : accessibilityRole}
role={accessible === false ? CONST.ROLE.PRESENTATION : effectiveRole}
>
<View
testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`}
accessibilityState={{selected: !!isFocused}}
testID={testID}
style={[
wrapperStyle,
isFocused &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSy
import useSelectedItemFocusSync from '@components/SelectionList/hooks/useSelectedItemFocusSync';
import ListItemRenderer from '@components/SelectionList/ListItem/ListItemRenderer';
import type {ButtonOrCheckBoxRoles} from '@components/SelectionList/types';
import {getListboxRole} from '@components/SelectionList/utils/getListboxRole';
import Text from '@components/Text';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useActiveElementRole from '@hooks/useActiveElementRole';
Expand Down Expand Up @@ -88,7 +89,6 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
const {isKeyboardShown} = useKeyboardState();
const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
const triggerScrollEvent = useScrollEventEmitter();

const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle;

const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey);
Expand Down Expand Up @@ -358,6 +358,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
renderListEmptyContent()
) : (
<FlashList
role={getListboxRole(canSelectMultiple)}
data={flattenedData}
renderItem={renderItem}
ref={listRef}
Expand Down
6 changes: 6 additions & 0 deletions src/components/SelectionList/utils/getListboxRole/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type {GetListboxRole} from './types';

const getListboxRole: GetListboxRole = () => undefined;

// eslint-disable-next-line import/prefer-default-export
Copy link
Contributor

Choose a reason for hiding this comment

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

NAB: Why do we eslint disable instead of just using default exports?

export {getListboxRole};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type {Role} from 'react-native';
import CONST from '@src/CONST';
import type {GetListboxRole} from './types';

const getListboxRole: GetListboxRole = (canSelectMultiple) => (!canSelectMultiple ? (CONST.ROLE.LISTBOX as Role) : undefined);

// eslint-disable-next-line import/prefer-default-export
export {getListboxRole};
6 changes: 6 additions & 0 deletions src/components/SelectionList/utils/getListboxRole/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type {Role} from 'react-native';

type GetListboxRole = (canSelectMultiple: boolean) => Role | undefined;

// eslint-disable-next-line import/prefer-default-export
export type {GetListboxRole};
12 changes: 8 additions & 4 deletions src/components/SelectionListWithSections/BaseListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,12 @@ function BaseListItem<TItem extends ListItem>({
const defaultAccessibilityLabel = item.text === item.alternateText ? (item.text ?? '') : [item.text, item.alternateText].filter(Boolean).join(', ');
const accessibilityLabel = item.accessibilityLabel ?? defaultAccessibilityLabel;

// For single-select lists, use role="option" with aria-selected so screen readers announce "selected"/"not selected".
// For multi-select (checkbox/radio), keep existing role and state.
const isSelectableOption = !canSelectMultiple && accessibilityRole !== CONST.ROLE.CHECKBOX && accessibilityRole !== CONST.ROLE.RADIO;
const effectiveRole = isSelectableOption ? CONST.ROLE.OPTION : accessibilityRole;
const accessibilityState =
accessibilityRole === CONST.ROLE.CHECKBOX || accessibilityRole === CONST.ROLE.RADIO ? {checked: !!item.isSelected, selected: !!isFocused} : {selected: !!isFocused};
accessibilityRole === CONST.ROLE.CHECKBOX || accessibilityRole === CONST.ROLE.RADIO ? {checked: !!item.isSelected, selected: !!isFocused} : {selected: !!item.isSelected};

const accessibleProps = accessible === false ? {accessible: false as const, role: undefined} : {accessibilityLabel, role: accessibilityRole};

Expand Down Expand Up @@ -117,6 +121,7 @@ function BaseListItem<TItem extends ListItem>({
// eslint-disable-next-line react/jsx-props-no-spreading
{...accessibleProps}
accessibilityState={accessibilityState}
role={effectiveRole}
isNested
hoverDimmingValue={1}
pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue}
Expand All @@ -134,11 +139,10 @@ function BaseListItem<TItem extends ListItem>({
onMouseLeave={handleMouseLeave}
tabIndex={item.tabIndex}
wrapperStyle={pressableWrapperStyle}
testID={testID}
testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`}
>
<View
testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`}
accessibilityState={{selected: !!isFocused}}
testID={testID}
style={[
wrapperStyle,
isFocused &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import FixedFooter from '@components/FixedFooter';
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
import {PressableWithFeedback} from '@components/Pressable';
import SectionList from '@components/SectionList';
import {getListboxRole} from '@components/SelectionList/utils/getListboxRole';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useActiveElementRole from '@hooks/useActiveElementRole';
Expand Down Expand Up @@ -1044,6 +1045,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
renderScrollComponent={renderScrollComponent}
removeClippedSubviews={removeClippedSubviews}
ref={listRef}
role={getListboxRole(canSelectMultiple)}
sections={slicedSections}
stickySectionHeadersEnabled={false}
renderSectionHeader={(arg) => (
Expand Down
Loading