1212
1313import { announce } from '@react-aria/live-announcer' ;
1414import { AriaButtonProps } from '@react-types/button' ;
15- import { AriaComboBoxProps } from '@react-types/combobox' ;
15+ import { AriaComboBoxProps , SelectionMode } from '@react-types/combobox' ;
1616import { ariaHideOutside } from '@react-aria/overlays' ;
1717import { AriaListBoxOptions , getItemId , listData } from '@react-aria/listbox' ;
1818import { BaseEvent , DOMAttributes , KeyboardDelegate , LayoutDelegate , PressEvent , RefObject , RouterOptions , ValidationResult } from '@react-types/shared' ;
19- import { chain , getActiveElement , getEventTarget , getOwnerDocument , isAppleDevice , mergeProps , nodeContains , useEvent , useFormReset , useLabels , useRouter , useUpdateEffect } from '@react-aria/utils' ;
19+ import { chain , getActiveElement , getEventTarget , getOwnerDocument , isAppleDevice , mergeProps , nodeContains , useEvent , useFormReset , useId , useLabels , useRouter , useUpdateEffect } from '@react-aria/utils' ;
2020import { ComboBoxState } from '@react-stately/combobox' ;
2121import { dispatchVirtualFocus } from '@react-aria/focus' ;
22- import { FocusEvent , InputHTMLAttributes , KeyboardEvent , TouchEvent , useEffect , useMemo , useRef } from 'react' ;
22+ import { FocusEvent , InputHTMLAttributes , KeyboardEvent , TouchEvent , useEffect , useMemo , useRef , useState } from 'react' ;
2323import { getChildNodes , getItemCount } from '@react-stately/collections' ;
2424// @ts -ignore
2525import intlMessages from '../intl/*.json' ;
@@ -29,7 +29,7 @@ import {useLocalizedStringFormatter} from '@react-aria/i18n';
2929import { useMenuTrigger } from '@react-aria/menu' ;
3030import { useTextField } from '@react-aria/textfield' ;
3131
32- export interface AriaComboBoxOptions < T > extends Omit < AriaComboBoxProps < T > , 'children' > {
32+ export interface AriaComboBoxOptions < T , M extends SelectionMode = 'single' > extends Omit < AriaComboBoxProps < T , M > , 'children' > {
3333 /** The ref for the input element. */
3434 inputRef : RefObject < HTMLInputElement | null > ,
3535 /** The ref for the list box popover. */
@@ -57,6 +57,8 @@ export interface ComboBoxAria<T> extends ValidationResult {
5757 listBoxProps : AriaListBoxOptions < T > ,
5858 /** Props for the optional trigger button, to be passed to `useButton`. */
5959 buttonProps : AriaButtonProps ,
60+ /** Props for the element representing the selected value. */
61+ valueProps : DOMAttributes ,
6062 /** Props for the combo box description element, if any. */
6163 descriptionProps : DOMAttributes ,
6264 /** Props for the combo box error message element, if any. */
@@ -69,7 +71,7 @@ export interface ComboBoxAria<T> extends ValidationResult {
6971 * @param props - Props for the combo box.
7072 * @param state - State for the select, as returned by `useComboBoxState`.
7173 */
72- export function useComboBox < T > ( props : AriaComboBoxOptions < T > , state : ComboBoxState < T > ) : ComboBoxAria < T > {
74+ export function useComboBox < T , M extends SelectionMode = 'single' > ( props : AriaComboBoxOptions < T , M > , state : ComboBoxState < T , M > ) : ComboBoxAria < T > {
7375 let {
7476 buttonRef,
7577 popoverRef,
@@ -158,7 +160,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
158160 break ;
159161 case 'Escape' :
160162 if (
161- state . selectedKey !== null ||
163+ ! state . selectionManager . isEmpty ||
162164 state . inputValue === '' ||
163165 props . allowsCustomValue
164166 ) {
@@ -206,6 +208,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
206208 state . setFocused ( true ) ;
207209 } ;
208210
211+ let valueId = useValueId ( [ state . selectedItems , state . selectionManager . selectionMode ] ) ;
209212 let { isInvalid, validationErrors, validationDetails} = state . displayValidation ;
210213 let { labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField ( {
211214 ...props ,
@@ -217,10 +220,11 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
217220 onFocus,
218221 autoComplete : 'off' ,
219222 validate : undefined ,
220- [ privateValidationStateProp ] : state
223+ [ privateValidationStateProp ] : state ,
224+ 'aria-describedby' : [ valueId , props [ 'aria-describedby' ] ] . filter ( Boolean ) . join ( ' ' ) || undefined
221225 } , inputRef ) ;
222226
223- useFormReset ( inputRef , state . defaultSelectedKey , state . setSelectedKey ) ;
227+ useFormReset ( inputRef , state . defaultValue , state . setValue ) ;
224228
225229 // Press handlers for the ComboBox button
226230 let onPress = ( e : PressEvent ) => {
@@ -332,6 +336,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
332336 } ) ;
333337
334338 // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically.
339+ // TODO: do we need to do this for multi-select?
335340 let lastSelectedKey = useRef ( state . selectedKey ) ;
336341 useEffect ( ( ) => {
337342 if ( isAppleDevice ( ) && state . isFocused && state . selectedItem && state . selectedKey !== lastSelectedKey . current ) {
@@ -392,10 +397,39 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
392397 linkBehavior : 'selection' as const ,
393398 [ 'UNSTABLE_itemBehavior' ] : 'action'
394399 } ) ,
400+ valueProps : {
401+ id : valueId
402+ } ,
395403 descriptionProps,
396404 errorMessageProps,
397405 isInvalid,
398406 validationErrors,
399407 validationDetails
400408 } ;
401409}
410+
411+ // This is a modified version of useSlotId that uses useEffect instead of useLayoutEffect.
412+ // Triggering re-renders from useLayoutEffect breaks useComboBoxState's useEffect logic in React 18.
413+ // These re-renders preempt async state updates in the useEffect, which ends up running multiple times
414+ // prior to the state being updated. This results in onSelectionChange being called multiple times.
415+ // TODO: refactor useComboBoxState to avoid this.
416+ function useValueId ( depArray : ReadonlyArray < any > = [ ] ) : string | undefined {
417+ let id = useId ( ) ;
418+ let [ exists , setExists ] = useState ( true ) ;
419+ let [ lastDeps , setLastDeps ] = useState ( depArray ) ;
420+
421+ // If the deps changed, set exists to true so we can test whether the element exists.
422+ if ( lastDeps . some ( ( v , i ) => ! Object . is ( v , depArray [ i ] ) ) ) {
423+ setExists ( true ) ;
424+ setLastDeps ( depArray ) ;
425+ }
426+
427+ useEffect ( ( ) => {
428+ if ( exists && ! document . getElementById ( id ) ) {
429+ // eslint-disable-next-line react-hooks/set-state-in-effect
430+ setExists ( false ) ;
431+ }
432+ } , [ id , exists , lastDeps ] ) ;
433+
434+ return exists ? id : undefined ;
435+ }
0 commit comments