diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 19b861bcaf5ff..2b4fa6fbde0d6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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. */ @@ -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. */ diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 025e6ca249751..9b8e7888b258d 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -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 + @@ -550,6 +551,7 @@ function BaseSelectionList({ <> {!shouldHeaderBeInsideList && header} ({ 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 ( ({ 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} > ({ const {isKeyboardShown} = useKeyboardState(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const triggerScrollEvent = useScrollEventEmitter(); - const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle; const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); @@ -358,6 +358,7 @@ function BaseSelectionListWithSections({ renderListEmptyContent() ) : ( undefined; + +// eslint-disable-next-line import/prefer-default-export +export {getListboxRole}; diff --git a/src/components/SelectionList/utils/getListboxRole/index.web.ts b/src/components/SelectionList/utils/getListboxRole/index.web.ts new file mode 100644 index 0000000000000..f20364a6872ea --- /dev/null +++ b/src/components/SelectionList/utils/getListboxRole/index.web.ts @@ -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}; diff --git a/src/components/SelectionList/utils/getListboxRole/types.ts b/src/components/SelectionList/utils/getListboxRole/types.ts new file mode 100644 index 0000000000000..f78e7a8494844 --- /dev/null +++ b/src/components/SelectionList/utils/getListboxRole/types.ts @@ -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}; diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 87c5961003be6..71481960c11ac 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -81,8 +81,12 @@ function BaseListItem({ 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}; @@ -117,6 +121,7 @@ function BaseListItem({ // eslint-disable-next-line react/jsx-props-no-spreading {...accessibleProps} accessibilityState={accessibilityState} + role={effectiveRole} isNested hoverDimmingValue={1} pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} @@ -134,11 +139,10 @@ function BaseListItem({ onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} wrapperStyle={pressableWrapperStyle} - testID={testID} + testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`} > ({ renderScrollComponent={renderScrollComponent} removeClippedSubviews={removeClippedSubviews} ref={listRef} + role={getListboxRole(canSelectMultiple)} sections={slicedSections} stickySectionHeadersEnabled={false} renderSectionHeader={(arg) => (