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
5 changes: 5 additions & 0 deletions .changeset/early-books-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@radix-ui/react-select': patch
---

resolved incorrect accessibility from Select
51 changes: 41 additions & 10 deletions packages/react/select/src/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const SELECTION_KEYS = [' ', 'Enter'];

const SELECT_NAME = 'Select';

type ItemData = { value: string; disabled: boolean; textValue: string };
type ItemData = { value: string; disabled: boolean; textValue: string; id: string };
const [Collection, useCollection, createCollectionScope] = createCollection<
SelectItemElement,
ItemData
Expand Down Expand Up @@ -497,6 +497,8 @@ type SelectContentContextValue = {
position?: SelectContentProps['position'];
isPositioned?: boolean;
searchRef?: React.RefObject<string>;
focusedItem?: SelectItemElement | null;
onItemFocus?: (node: SelectItemElement | null) => void;
};

const [SelectContentProvider, useSelectContentContext] =
Expand Down Expand Up @@ -565,6 +567,7 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
const [selectedItemText, setSelectedItemText] = React.useState<SelectItemTextElement | null>(
null
);
const [focusedItem, setFocusedItem] = React.useState<SelectItemElement | null>(null);
const getItems = useCollection(__scopeSelect);
const [isPositioned, setIsPositioned] = React.useState(false);
const firstValidItemFoundRef = React.useRef(false);
Expand Down Expand Up @@ -592,10 +595,14 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
if (candidate === firstItem && viewport) viewport.scrollTop = 0;
if (candidate === lastItem && viewport) viewport.scrollTop = viewport.scrollHeight;
candidate?.focus();
if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;

if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) {
setFocusedItem(candidate as SelectItemElement);
return;
}
}
},
[getItems, viewport]
[getItems, viewport, setFocusedItem]
);

const focusSelectedItem = React.useCallback(
Expand Down Expand Up @@ -669,7 +676,10 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
* Imperative focus during keydown is risky so we prevent React's batching updates
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
*/
setTimeout(() => (nextItem.ref.current as HTMLElement).focus());
setTimeout(() => {
(nextItem.ref.current as HTMLElement).focus();
setFocusedItem(nextItem.ref.current as SelectItemElement);
});
}
});

Expand All @@ -684,7 +694,10 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
},
[context.value]
);
const handleItemLeave = React.useCallback(() => content?.focus(), [content]);
const handleItemLeave = React.useCallback(() => {
content?.focus();
setFocusedItem(null);
}, [content, setFocusedItem]);
const itemTextRefCallback = React.useCallback(
(node: SelectItemTextElement | null, value: string, disabled: boolean) => {
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
Expand All @@ -696,6 +709,10 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
[context.value]
);

const handleItemFocus = React.useCallback((node: SelectItemElement | null) => {
setFocusedItem(node);
}, []);

const SelectPosition = position === 'popper' ? SelectPopperPosition : SelectItemAlignedPosition;

// Silently ignore props that are not supported by `SelectItemAlignedPosition`
Expand Down Expand Up @@ -730,6 +747,8 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
position={position}
isPositioned={isPositioned}
searchRef={searchRef}
focusedItem={focusedItem}
onItemFocus={handleItemFocus}
>
<RemoveScroll as={Slot} allowPinchZoom>
<FocusScope
Expand Down Expand Up @@ -759,6 +778,7 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
<SelectPosition
role="listbox"
id={context.contentId}
aria-activedescendant={focusedItem?.id}
data-state={context.open ? 'open' : 'closed'}
dir={context.dir}
onContextMenu={(event) => event.preventDefault()}
Expand Down Expand Up @@ -1261,10 +1281,13 @@ const SelectItem = React.forwardRef<SelectItemElement, SelectItemProps>(
const isSelected = context.value === value;
const [textValue, setTextValue] = React.useState(textValueProp ?? '');
const [isFocused, setIsFocused] = React.useState(false);
const composedRefs = useComposedRefs(forwardedRef, (node) =>
contentContext.itemRefCallback?.(node, value, disabled)
);
const itemRef = React.useRef<SelectItemElement | null>(null);
const composedRefs = useComposedRefs(forwardedRef, (node) => {
itemRef.current = node;
contentContext.itemRefCallback?.(node, value, disabled);
});
const textId = useId();
const optionId = useId();
const pointerTypeRef = React.useRef<React.PointerEvent['pointerType']>('touch');

const handleSelect = () => {
Expand All @@ -1274,6 +1297,13 @@ const SelectItem = React.forwardRef<SelectItemElement, SelectItemProps>(
}
};

React.useEffect(() => {
if (isFocused && itemRef.current) {
contentContext.onItemFocus?.(itemRef.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused, contentContext.onItemFocus]);

if (value === '') {
throw new Error(
'A <Select.Item /> must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder.'
Expand All @@ -1296,13 +1326,14 @@ const SelectItem = React.forwardRef<SelectItemElement, SelectItemProps>(
value={value}
disabled={disabled}
textValue={textValue}
id={optionId}
>
<Primitive.div
id={optionId}
role="option"
aria-labelledby={textId}
data-highlighted={isFocused ? '' : undefined}
// `isFocused` caveat fixes stuttering in VoiceOver
aria-selected={isSelected && isFocused}
aria-selected={isSelected}
data-state={isSelected ? 'checked' : 'unchecked'}
aria-disabled={disabled || undefined}
data-disabled={disabled ? '' : undefined}
Expand Down