From c33ac962e5f7343f8325782c57ae261ff0199096 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 9 Oct 2024 14:22:55 -0700 Subject: [PATCH 01/42] scaffolding, copied Combobox and renamed to Autocomplete --- .../src/Autocomplete.tsx | 234 ++++++++++++++++++ packages/react-aria-components/src/index.ts | 2 + .../stories/Autocomplete.stories.tsx | 185 ++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 packages/react-aria-components/src/Autocomplete.tsx create mode 100644 packages/react-aria-components/stories/Autocomplete.stories.tsx diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx new file mode 100644 index 00000000000..15f66bfd2f5 --- /dev/null +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -0,0 +1,234 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import {AriaComboBoxProps, useComboBox, useFilter} from 'react-aria'; +import {ButtonContext} from './Button'; +import {Collection, ComboBoxState, Node, useComboBoxState} from 'react-stately'; +import {CollectionBuilder} from '@react-aria/collections'; +import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; +import {FieldErrorContext} from './FieldError'; +import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; +import {FormContext} from './Form'; +import {forwardRefType, RefObject} from '@react-types/shared'; +import {GroupContext} from './Group'; +import {InputContext} from './Input'; +import {LabelContext} from './Label'; +import {ListBoxContext, ListStateContext} from './ListBox'; +import {OverlayTriggerStateContext} from './Dialog'; +import {PopoverContext} from './Popover'; +import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef, useState} from 'react'; +import {TextContext} from './Text'; + +export interface AutocompleteRenderProps { + // /** + // * Whether the combobox is currently open. + // * @selector [data-open] + // */ + // isOpen: boolean, + /** + * Whether the autocomplete is disabled. + * @selector [data-disabled] + */ + isDisabled: boolean, + /** + * Whether the autocomplete is invalid. + * @selector [data-invalid] + */ + isInvalid: boolean, + /** + * Whether the autocomplete is required. + * @selector [data-required] + */ + isRequired: boolean +} + +// TODO get rid of any other combobox specific props here +export interface AutocompleteProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { + /** The filter function used to determine if a option should be included in the autocomplete list. */ + defaultFilter?: (textValue: string, inputValue: string) => boolean, + /** + * Whether the text or key of the selected item is submitted as part of an HTML form. + * When `allowsCustomValue` is `true`, this option does not apply and the text is always submitted. + * @default 'key' + */ + formValue?: 'text' | 'key', + // /** Whether the combo box allows the menu to be open when the collection is empty. */ + // allowsEmptyCollection?: boolean +} + +export const AutocompleteContext = createContext, HTMLDivElement>>(null); +export const ComboBoxStateContext = createContext | null>(null); + +function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, AutocompleteContext); + let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; + let content = useMemo(() => ( + + {typeof children === 'function' + ? children({ + isOpen: false, + isDisabled, + isInvalid, + isRequired, + defaultChildren: null + }) + : children} + + ), [children, isDisabled, isInvalid, isRequired, props.items, props.defaultItems]); + + return ( + + {collection => } + + ); +} + +interface AutocompleteInnerProps { + props: AutocompleteProps, + collection: Collection>, + autocompleteRef: RefObject +} + +function AutocompleteInner({props, collection, autocompleteRef: ref}: AutocompleteInnerProps) { + let { + name, + formValue = 'key', + allowsCustomValue + } = props; + if (allowsCustomValue) { + formValue = 'text'; + } + + let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; + let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; + let {contains} = useFilter({sensitivity: 'base'}); + let state = useComboBoxState({ + defaultFilter: props.defaultFilter || contains, + ...props, + // If props.items isn't provided, rely on collection filtering (aka listbox.items is provided or defaultItems provided to Combobox) + items: props.items, + children: undefined, + collection, + validationBehavior + }); + + let buttonRef = useRef(null); + let inputRef = useRef(null); + let listBoxRef = useRef(null); + let popoverRef = useRef(null); + let [labelRef, label] = useSlot(); + + // TODO: replace with useAutocomplete + let { + buttonProps, + inputProps, + listBoxProps, + labelProps, + descriptionProps, + errorMessageProps, + ...validation + } = useComboBox({ + ...removeDataAttributes(props), + label, + inputRef, + buttonRef, + listBoxRef, + popoverRef, + name: formValue === 'text' ? name : undefined, + validationBehavior + }, state); + + + // TODO: comment these out when you get Autocomplete working in the story + // Make menu width match input + button + let [menuWidth, setMenuWidth] = useState(null); + let onResize = useCallback(() => { + if (inputRef.current) { + let buttonRect = buttonRef.current?.getBoundingClientRect(); + let inputRect = inputRef.current.getBoundingClientRect(); + let minX = buttonRect ? Math.min(buttonRect.left, inputRect.left) : inputRect.left; + let maxX = buttonRect ? Math.max(buttonRect.right, inputRect.right) : inputRect.right; + setMenuWidth((maxX - minX) + 'px'); + } + }, [buttonRef, inputRef, setMenuWidth]); + + useResizeObserver({ + ref: inputRef, + onResize: onResize + }); + + // Only expose a subset of state to renderProps function to avoid infinite render loop + let renderPropsState = useMemo(() => ({ + isOpen: state.isOpen, + isDisabled: props.isDisabled || false, + isInvalid: validation.isInvalid || false, + isRequired: props.isRequired || false + }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired]); + + let renderProps = useRenderProps({ + ...props, + values: renderPropsState, + // TODO rename + defaultClassName: 'react-aria-ComboBox' + }); + + let DOMProps = filterDOMProps(props); + delete DOMProps.id; + + return ( + +
+ {name && formValue === 'key' && } + + ); +} + +/** + * A autocomplete combines a text input with a listbox, allowing users to filter a list of options to items matching a query. + */ +const _Autocomplete = /*#__PURE__*/ (forwardRef as forwardRefType)(Autocomplete); +export {_Autocomplete as Autocomplete}; diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index e074844b5fc..4b35a4beac5 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -16,6 +16,8 @@ import 'client-only'; export {CheckboxContext, ColorAreaContext, ColorFieldContext, ColorSliderContext, ColorWheelContext, HeadingContext} from './RSPContexts'; +// TODO: export the respective contexts here +export {Autocomplete} from './Autocomplete'; export {Breadcrumbs, BreadcrumbsContext, Breadcrumb} from './Breadcrumbs'; export {Button, ButtonContext} from './Button'; export {Calendar, CalendarGrid, CalendarGridHeader, CalendarGridBody, CalendarHeaderCell, CalendarCell, RangeCalendar, CalendarContext, RangeCalendarContext, CalendarStateContext, RangeCalendarStateContext} from './Calendar'; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx new file mode 100644 index 00000000000..61a109dd2bb --- /dev/null +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Autocomplete, Button, Input, Label, ListBox, Popover} from 'react-aria-components'; +import {MyListBoxItem} from './utils'; +import React from 'react'; +import styles from '../example/index.css'; +import {useAsyncList} from 'react-stately'; + +export default { + title: 'React Aria Components' +}; + +export const AutocompleteExample = () => ( + + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+); + +interface AutocompleteItem { + id: string, + name: string +} + +let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; +export const AutocompleteRenderPropsStatic = () => ( + + {({isOpen}) => ( + <> + +
+ + +
+ + + Foo + Bar + Baz + + + + )} +
+); + +export const AutocompleteRenderPropsDefaultItems = () => ( + + {({isOpen}) => ( + <> + +
+ + +
+ + + {(item: AutocompleteItem) => {item.name}} + + + + )} +
+); + +export const AutocompleteRenderPropsItems = { + render: () => ( + + {({isOpen}) => ( + <> + +
+ + +
+ + + {(item: AutocompleteItem) => {item.name}} + + + + )} +
+ ), + parameters: { + description: { + data: 'Note this won\'t filter the items in the listbox because it is fully controlled' + } + } +}; + +export const AutocompleteRenderPropsListBoxDynamic = () => ( + + {({isOpen}) => ( + <> + +
+ + +
+ + + {item => {item.name}} + + + + )} +
+); + +export const AutocompleteAsyncLoadingExample = () => { + let list = useAsyncList({ + async load({filterText}) { + let json = await new Promise(resolve => { + setTimeout(() => { + resolve(filterText ? items.filter(item => { + let name = item.name.toLowerCase(); + for (let filterChar of filterText.toLowerCase()) { + if (!name.includes(filterChar)) { + return false; + } + name = name.replace(filterChar, ''); + } + return true; + }) : items); + }, 300); + }) as AutocompleteItem[]; + + return { + items: json + }; + } + }); + + return ( + + +
+ + +
+ + + className={styles.menu}> + {item => {item.name}} + + +
+ ); +}; From a185473ce6265352e353476af18475fe18405c31 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 9 Oct 2024 15:51:59 -0700 Subject: [PATCH 02/42] get listbox rendering by default without open state --- .../@react-aria/autocomplete/package.json | 9 + .../@react-aria/autocomplete/src/index.ts | 3 + .../autocomplete/src/useAutocomplete.ts | 403 +++++++++++++++++ .../@react-stately/autocomplete/README.md | 3 + packages/@react-stately/autocomplete/index.ts | 13 + .../@react-stately/autocomplete/package.json | 42 ++ .../@react-stately/autocomplete/src/index.ts | 16 + .../autocomplete/src/useAutocompleteState.ts | 419 ++++++++++++++++++ packages/react-aria-components/package.json | 3 + .../src/Autocomplete.tsx | 71 +-- .../stories/Autocomplete.stories.tsx | 40 +- yarn.lock | 30 ++ 12 files changed, 998 insertions(+), 54 deletions(-) create mode 100644 packages/@react-aria/autocomplete/src/useAutocomplete.ts create mode 100644 packages/@react-stately/autocomplete/README.md create mode 100644 packages/@react-stately/autocomplete/index.ts create mode 100644 packages/@react-stately/autocomplete/package.json create mode 100644 packages/@react-stately/autocomplete/src/index.ts create mode 100644 packages/@react-stately/autocomplete/src/useAutocompleteState.ts diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index 486b78e6773..b9a12561339 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -23,12 +23,21 @@ }, "dependencies": { "@react-aria/combobox": "^3.10.4", + "@react-aria/i18n": "^3.12.3", "@react-aria/listbox": "^3.13.4", + "@react-aria/live-announcer": "^3.4.0", + "@react-aria/menu": "^3.15.4", + "@react-aria/overlays": "^3.23.3", "@react-aria/searchfield": "^3.7.9", + "@react-aria/selection": "^3.20.0", + "@react-aria/textfield": "^3.14.9", "@react-aria/utils": "^3.25.3", + "@react-stately/collections": "^3.11.0", "@react-stately/combobox": "^3.10.0", + "@react-stately/form": "^3.0.6", "@react-types/autocomplete": "3.0.0-alpha.26", "@react-types/button": "^3.10.0", + "@react-types/combobox": "^3.13.0", "@react-types/shared": "^3.25.0", "@swc/helpers": "^0.5.0" }, diff --git a/packages/@react-aria/autocomplete/src/index.ts b/packages/@react-aria/autocomplete/src/index.ts index 0764e4ee829..f14a652df0e 100644 --- a/packages/@react-aria/autocomplete/src/index.ts +++ b/packages/@react-aria/autocomplete/src/index.ts @@ -10,5 +10,8 @@ * governing permissions and limitations under the License. */ export {useSearchAutocomplete} from './useSearchAutocomplete'; +export {useAutocomplete} from './useAutocomplete'; + +// TODO: export types for the hook when done export type {AriaSearchAutocompleteOptions, SearchAutocompleteAria} from './useSearchAutocomplete'; export type {AriaSearchAutocompleteProps} from '@react-types/autocomplete'; diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts new file mode 100644 index 00000000000..188c397feeb --- /dev/null +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -0,0 +1,403 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {announce} from '@react-aria/live-announcer'; +import {AriaButtonProps} from '@react-types/button'; +import {AriaComboBoxProps} from '@react-types/combobox'; +// import {ariaHideOutside} from '@react-aria/overlays'; +import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; +import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; +import {chain, isAppleDevice, mergeProps, useLabels, useRouter} from '@react-aria/utils'; +import {ComboBoxState} from '@react-stately/combobox'; +import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; +import {getChildNodes, getItemCount} from '@react-stately/collections'; +// TODO: port over intl message +// @ts-ignore +import intlMessages from '../../combobox/intl/*.json'; +import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; +import {privateValidationStateProp} from '@react-stately/form'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useMenuTrigger} from '@react-aria/menu'; +import {useTextField} from '@react-aria/textfield'; + +export interface AriaComboBoxOptions extends Omit, 'children'> { + /** The ref for the input element. */ + inputRef: RefObject, + /** The ref for the list box popover. */ + popoverRef: RefObject, + /** The ref for the list box. */ + listBoxRef: RefObject, + /** The ref for the optional list box popup trigger button. */ + buttonRef?: RefObject, + /** An optional keyboard delegate implementation, to override the default. */ + keyboardDelegate?: KeyboardDelegate, + /** + * A delegate object that provides layout information for items in the collection. + * By default this uses the DOM, but this can be overridden to implement things like + * virtualized scrolling. + */ + layoutDelegate?: LayoutDelegate +} + +export interface AutocompleteAria extends ValidationResult { + /** Props for the label element. */ + labelProps: DOMAttributes, + /** Props for the combo box input element. */ + inputProps: InputHTMLAttributes, + /** Props for the list box, to be passed to [useListBox](useListBox.html). */ + listBoxProps: AriaListBoxOptions, + /** Props for the optional trigger button, to be passed to [useButton](useButton.html). */ + buttonProps: AriaButtonProps, + /** Props for the combo box description element, if any. */ + descriptionProps: DOMAttributes, + /** Props for the combo box error message element, if any. */ + errorMessageProps: DOMAttributes +} + +/** + * Provides the behavior and accessibility implementation for a combo box component. + * A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query. + * @param props - Props for the combo box. + * @param state - State for the select, as returned by `useComboBoxState`. + */ +export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBoxState): AutocompleteAria { + let { + buttonRef, + // popoverRef, + inputRef, + listBoxRef, + keyboardDelegate, + layoutDelegate, + // completionMode = 'suggest', + shouldFocusWrap, + isReadOnly, + isDisabled + } = props; + + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/combobox'); + // TODO: we will only need the menu props for the id for listData (might need a replacement aria-labelledby and autofocus?) + let {menuProps} = useMenuTrigger( + { + type: 'listbox', + isDisabled: isDisabled || isReadOnly + }, + state, + buttonRef + ); + + // Set listbox id so it can be used when calling getItemId later + listData.set(state, {id: menuProps.id}); + + // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). + // When virtualized, the layout object will be passed in as a prop and override this. + let {collection} = state; + let {disabledKeys} = state.selectionManager; + let delegate = useMemo(() => ( + keyboardDelegate || new ListKeyboardDelegate({ + collection, + disabledKeys, + ref: listBoxRef, + layoutDelegate + }) + ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, listBoxRef]); + + // Use useSelectableCollection to get the keyboard handlers to apply to the textfield + let {collectionProps} = useSelectableCollection({ + selectionManager: state.selectionManager, + keyboardDelegate: delegate, + disallowTypeAhead: true, + disallowEmptySelection: true, + shouldFocusWrap, + ref: inputRef, + // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component + isVirtualized: true + }); + + let router = useRouter(); + + // For textfield specific keydown operations + let onKeyDown = (e: BaseEvent>) => { + if (e.nativeEvent.isComposing) { + return; + } + switch (e.key) { + case 'Enter': + case 'Tab': + // // Prevent form submission if menu is open since we may be selecting a option + // if (state.isOpen && e.key === 'Enter') { + // e.preventDefault(); + // } + // TODO: Prevent form submission at all times? + e.preventDefault(); + + + // If the focused item is a link, trigger opening it. Items that are links are not selectable. + if (state.isOpen && state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { + if (e.key === 'Enter') { + let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); + if (item instanceof HTMLAnchorElement) { + let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); + router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); + } + } + + // TODO: for now keep close and commit? But perhaps this should always just be commit? Maybe need to replace useComboboxState since the open state tracking may prove problematic + state.close(); + } else { + state.commit(); + } + break; + case 'Escape': + if ( + state.selectedKey !== null || + state.inputValue === '' || + props.allowsCustomValue + ) { + e.continuePropagation(); + } + + // TODO: right now hitting escape multiple times will not clear the input field, perhaps only do that if the user provides a searchfiled to the autocomplete + state.revert(); + break; + // TODO: replace up and down with setFocusStrategy eventually, but may need to rip out the open state stuff first + case 'ArrowDown': + state.selectionManager.setFocused(true); + // state.open('first', 'manual'); + // state.setFocusStrategy('first') + + break; + case 'ArrowUp': + state.selectionManager.setFocused(true); + // state.open('last', 'manual'); + // state.setFocusStrategy('last'); + break; + case 'ArrowLeft': + case 'ArrowRight': + state.selectionManager.setFocusedKey(null); + break; + } + }; + + let onBlur = (e: FocusEvent) => { + // TODO: no more button or popover + // let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; + // let blurIntoPopover = popoverRef.current?.contains(e.relatedTarget); + // // Ignore blur if focused moved to the button(if exists) or into the popover. + // if (blurFromButton || blurIntoPopover) { + // return; + // } + + if (props.onBlur) { + props.onBlur(e); + } + + state.setFocused(false); + }; + + let onFocus = (e: FocusEvent) => { + if (state.isFocused) { + return; + } + + if (props.onFocus) { + props.onFocus(e); + } + + state.setFocused(true); + }; + + let {isInvalid, validationErrors, validationDetails} = state.displayValidation; + let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({ + ...props, + onChange: state.setInputValue, + // TODO: no longer need isOpen logic since it is always technically open + // onKeyDown: !isReadOnly ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, + onKeyDown: !isReadOnly ? chain(collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, + onBlur, + value: state.inputValue, + onFocus, + autoComplete: 'off', + validate: undefined, + [privateValidationStateProp]: state + }, inputRef); + + // Press handlers for the ComboBox button + // let onPress = (e: PressEvent) => { + // if (e.pointerType === 'touch') { + // // Focus the input field in case it isn't focused yet + // inputRef.current.focus(); + // state.toggle(null, 'manual'); + // } + // }; + + // let onPressStart = (e: PressEvent) => { + // if (e.pointerType !== 'touch') { + // inputRef.current.focus(); + // state.toggle((e.pointerType === 'keyboard' || e.pointerType === 'virtual') ? 'first' : null, 'manual'); + // } + // }; + + // let triggerLabelProps = useLabels({ + // id: menuTriggerProps.id, + // 'aria-label': stringFormatter.format('buttonLabel'), + // 'aria-labelledby': props['aria-labelledby'] || labelProps.id + // }); + + let listBoxProps = useLabels({ + id: menuProps.id, + 'aria-label': stringFormatter.format('listboxLabel'), + 'aria-labelledby': props['aria-labelledby'] || labelProps.id + }); + + // If a touch happens on direct center of ComboBox input, might be virtual click from iPad so open ComboBox menu + let lastEventTime = useRef(0); + let onTouchEnd = (e: TouchEvent) => { + if (isDisabled || isReadOnly) { + return; + } + + // Sometimes VoiceOver on iOS fires two touchend events in quick succession. Ignore the second one. + if (e.timeStamp - lastEventTime.current < 500) { + e.preventDefault(); + inputRef.current.focus(); + return; + } + + let rect = (e.target as Element).getBoundingClientRect(); + let touch = e.changedTouches[0]; + + let centerX = Math.ceil(rect.left + .5 * rect.width); + let centerY = Math.ceil(rect.top + .5 * rect.height); + + if (touch.clientX === centerX && touch.clientY === centerY) { + e.preventDefault(); + inputRef.current.focus(); + // TODO: don't need this because it is technically always open + // state.toggle(null, 'manual'); + + lastEventTime.current = e.timeStamp; + } + }; + + // VoiceOver has issues with announcing aria-activedescendant properly on change + // (especially on iOS). We use a live region announcer to announce focus changes + // manually. In addition, section titles are announced when navigating into a new section. + let focusedItem = state.selectionManager.focusedKey != null && state.isOpen + ? state.collection.getItem(state.selectionManager.focusedKey) + : undefined; + let sectionKey = focusedItem?.parentKey ?? null; + let itemKey = state.selectionManager.focusedKey ?? null; + let lastSection = useRef(sectionKey); + let lastItem = useRef(itemKey); + useEffect(() => { + if (isAppleDevice() && focusedItem != null && itemKey !== lastItem.current) { + let isSelected = state.selectionManager.isSelected(itemKey); + let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; + let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; + + let announcement = stringFormatter.format('focusAnnouncement', { + isGroupChange: section && sectionKey !== lastSection.current, + groupTitle: sectionTitle, + groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, + optionText: focusedItem['aria-label'] || focusedItem.textValue || '', + isSelected + }); + + announce(announcement); + } + + lastSection.current = sectionKey; + lastItem.current = itemKey; + }); + + // Announce the number of available suggestions when it changes + let optionCount = getItemCount(state.collection); + let lastSize = useRef(optionCount); + // let lastOpen = useRef(state.isOpen); + // TODO: test this behavior below + useEffect(() => { + // Only announce the number of options available when the menu opens if there is no + // focused item, otherwise screen readers will typically read e.g. "1 of 6". + // The exception is VoiceOver since this isn't included in the message above. + let didOpenWithoutFocusedItem = + // state.isOpen !== lastOpen.current && + (state.selectionManager.focusedKey == null || isAppleDevice()); + + // if (state.isOpen && (didOpenWithoutFocusedItem || optionCount !== lastSize.current)) { + if ((didOpenWithoutFocusedItem || optionCount !== lastSize.current)) { + let announcement = stringFormatter.format('countAnnouncement', {optionCount}); + announce(announcement); + } + + lastSize.current = optionCount; + // lastOpen.current = state.isOpen; + }); + + // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. + let lastSelectedKey = useRef(state.selectedKey); + useEffect(() => { + + if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) { + let optionText = state.selectedItem['aria-label'] || state.selectedItem.textValue || ''; + let announcement = stringFormatter.format('selectedAnnouncement', {optionText}); + announce(announcement); + } + + lastSelectedKey.current = state.selectedKey; + }); + + // TODO: may need a replacement for this? Actually I don't think we do since we just hide everything outside the popover which will be external to this component + // useEffect(() => { + // if (state.isOpen) { + // return ariaHideOutside([inputRef.current, popoverRef.current]); + // } + // }, [state.isOpen, inputRef, popoverRef]); + + return { + labelProps, + // TODO get rid of + buttonProps: { + // ...menuTriggerProps, + // ...triggerLabelProps, + // excludeFromTabOrder: true, + // preventFocusOnPress: true, + // onPress, + // onPressStart, + // isDisabled: isDisabled || isReadOnly + }, + inputProps: mergeProps(inputProps, { + // role: 'combobox', + // 'aria-expanded': menuTriggerProps['aria-expanded'], + 'aria-controls': state.isOpen ? menuProps.id : undefined, + // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) + 'aria-autocomplete': 'list', + 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, + onTouchEnd, + // This disable's iOS's autocorrect suggestions, since the combo box provides its own suggestions. + autoCorrect: 'off', + // This disable's the macOS Safari spell check auto corrections. + spellCheck: 'false' + }), + listBoxProps: mergeProps(menuProps, listBoxProps, { + autoFocus: state.focusStrategy, + shouldUseVirtualFocus: true, + shouldSelectOnPressUp: true, + shouldFocusOnHover: true, + linkBehavior: 'selection' as const + }), + descriptionProps, + errorMessageProps, + isInvalid, + validationErrors, + validationDetails + }; +} diff --git a/packages/@react-stately/autocomplete/README.md b/packages/@react-stately/autocomplete/README.md new file mode 100644 index 00000000000..815a5ccee2d --- /dev/null +++ b/packages/@react-stately/autocomplete/README.md @@ -0,0 +1,3 @@ +# @react-stately/autocomplete + +This package is part of [react-spectrum](https://github.com/adobe-private/react-spectrum-v3). See the repo for more details. diff --git a/packages/@react-stately/autocomplete/index.ts b/packages/@react-stately/autocomplete/index.ts new file mode 100644 index 00000000000..dc59658a5da --- /dev/null +++ b/packages/@react-stately/autocomplete/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-stately/autocomplete/package.json b/packages/@react-stately/autocomplete/package.json new file mode 100644 index 00000000000..296a5b74736 --- /dev/null +++ b/packages/@react-stately/autocomplete/package.json @@ -0,0 +1,42 @@ +{ + "name": "@react-stately/autocomplete", + "version": "3.0.0-alpha.1", + "private": true, + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/import.mjs", + "require": "./dist/main.js" + }, + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@react-stately/collections": "^3.11.0", + "@react-stately/form": "^3.0.6", + "@react-stately/list": "^3.11.0", + "@react-stately/overlays": "^3.6.11", + "@react-stately/select": "^3.6.8", + "@react-stately/utils": "^3.10.4", + "@react-types/combobox": "^3.13.0", + "@react-types/shared": "^3.25.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-stately/autocomplete/src/index.ts b/packages/@react-stately/autocomplete/src/index.ts new file mode 100644 index 00000000000..f0d3f97e660 --- /dev/null +++ b/packages/@react-stately/autocomplete/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export {useAutocompleteState} from './useAutocompleteState'; + +// TODO export the types +// export type {ComboBoxStateOptions, ComboBoxState} from './useComboBoxState'; diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts new file mode 100644 index 00000000000..0efc07c2832 --- /dev/null +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -0,0 +1,419 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Collection, CollectionStateBase, FocusStrategy, Node} from '@react-types/shared'; +import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox'; +import {FormValidationState, useFormValidationState} from '@react-stately/form'; +import {getChildNodes} from '@react-stately/collections'; +import {ListCollection, useSingleSelectListState} from '@react-stately/list'; +import {SelectState} from '@react-stately/select'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useControlledState} from '@react-stately/utils'; +import {useOverlayTriggerState} from '@react-stately/overlays'; + +export interface AutocompleteState extends SelectState, FormValidationState{ + /** The current value of the combo box input. */ + inputValue: string, + /** Sets the value of the combo box input. */ + setInputValue(value: string): void, + /** Selects the currently focused item and updates the input value. */ + commit(): void, + /** Controls which item will be auto focused when the menu opens. */ + readonly focusStrategy: FocusStrategy | null, + // /** Opens the menu. */ + // open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, + /** Toggles the menu. */ + toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, + /** Resets the input value to the previously selected item's text if any and closes the menu. */ + revert(): void +} + +type FilterFn = (textValue: string, inputValue: string) => boolean; + +export interface AutocompleteStateOptions extends Omit, 'children'>, CollectionStateBase { + /** The filter function used to determine if a option should be included in the combo box list. */ + defaultFilter?: FilterFn, + /** Whether the combo box allows the menu to be open when the collection is empty. */ + allowsEmptyCollection?: boolean, + /** Whether the combo box menu should close on blur. */ + shouldCloseOnBlur?: boolean +} + +/** + * Provides state management for a combo box component. Handles building a collection + * of items from props and manages the option selection state of the combo box. In addition, it tracks the input value, + * focus state, and other properties of the combo box. + */ +export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState { + let { + defaultFilter, + menuTrigger = 'input', + allowsEmptyCollection = false, + allowsCustomValue, + shouldCloseOnBlur = true + } = props; + + let [showAllItems, setShowAllItems] = useState(false); + let [isFocused, setFocusedState] = useState(false); + let [focusStrategy, setFocusStrategy] = useState(null); + + let onSelectionChange = (key) => { + if (props.onSelectionChange) { + props.onSelectionChange(key); + } + + // If key is the same, reset the inputValue and close the menu + // (scenario: user clicks on already selected option) + if (key === selectedKey) { + resetInputValue(); + // closeMenu(); + } + }; + + let {collection, + selectionManager, + selectedKey, + setSelectedKey, + selectedItem, + disabledKeys + } = useSingleSelectListState({ + ...props, + onSelectionChange, + items: props.items ?? props.defaultItems + }); + let defaultInputValue: string | null | undefined = props.defaultInputValue; + if (defaultInputValue == null) { + if (selectedKey == null) { + defaultInputValue = ''; + } else { + defaultInputValue = collection.getItem(selectedKey)?.textValue ?? ''; + } + } + + let [inputValue, setInputValue] = useControlledState( + props.inputValue, + defaultInputValue!, + props.onInputChange + ); + + // Preserve original collection so we can show all items on demand + // TODO I think we can get rid of original collection + // let originalCollection = collection; + let filteredCollection = useMemo(() => ( + // No default filter if items are controlled. + props.items != null || !defaultFilter + ? collection + : filterCollection(collection, inputValue, defaultFilter) + ), [collection, inputValue, defaultFilter, props.items]); + // let [lastCollection, setLastCollection] = useState(filteredCollection); + + // TODO remove + // Track what action is attempting to open the menu + // let menuOpenTrigger = useRef('focus'); + // let onOpenChange = (open: boolean) => { + // if (props.onOpenChange) { + // props.onOpenChange(open, open ? menuOpenTrigger.current : undefined); + // } + + // selectionManager.setFocused(open); + // if (!open) { + // selectionManager.setFocusedKey(null); + // } + // }; + + // let triggerState = useOverlayTriggerState({...props, onOpenChange, isOpen: undefined, defaultOpen: undefined}); + // let open = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => { + // let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); + // // Prevent open operations from triggering if there is nothing to display + // // Also prevent open operations from triggering if items are uncontrolled but defaultItems is empty, even if displayAllItems is true. + // // This is to prevent comboboxes with empty defaultItems from opening but allow controlled items comboboxes to open even if the inital list is empty (assumption is user will provide swap the empty list with a base list via onOpenChange returning `menuTrigger` manual) + // if (allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) { + // if (displayAllItems && !triggerState.isOpen && props.items === undefined) { + // // Show all items if menu is manually opened. Only care about this if items are undefined + // setShowAllItems(true); + // } + + // menuOpenTrigger.current = trigger; + // setFocusStrategy(focusStrategy); + // triggerState.open(); + // } + // }; + + // let toggle = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => { + // let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); + // // If the menu is closed and there is nothing to display, early return so toggle isn't called to prevent extraneous onOpenChange + // if (!(allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) && !triggerState.isOpen) { + // return; + // } + + // if (displayAllItems && !triggerState.isOpen && props.items === undefined) { + // // Show all items if menu is toggled open. Only care about this if items are undefined + // setShowAllItems(true); + // } + + // // Only update the menuOpenTrigger if menu is currently closed + // if (!triggerState.isOpen) { + // menuOpenTrigger.current = trigger; + // } + + // toggleMenu(focusStrategy); + // }; + + // let updateLastCollection = useCallback(() => { + // setLastCollection(showAllItems ? originalCollection : filteredCollection); + // }, [showAllItems, originalCollection, filteredCollection]); + + // If menu is going to close, save the current collection so we can freeze the displayed collection when the + // user clicks outside the popover to close the menu. Prevents the menu contents from updating as the menu closes. + // let toggleMenu = useCallback((focusStrategy: FocusStrategy | null = null) => { + // if (triggerState.isOpen) { + // updateLastCollection(); + // } + + // setFocusStrategy(focusStrategy); + // triggerState.toggle(); + // }, [triggerState, updateLastCollection]); + + // let closeMenu = useCallback(() => { + // if (triggerState.isOpen) { + // updateLastCollection(); + // triggerState.close(); + // } + // }, [triggerState, updateLastCollection]); + + let [lastValue, setLastValue] = useState(inputValue); + let resetInputValue = () => { + let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; + setLastValue(itemText); + setInputValue(itemText); + }; + + let lastSelectedKey = useRef(props.selectedKey ?? props.defaultSelectedKey ?? null); + let lastSelectedKeyText = useRef( + selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '' + ); + // intentional omit dependency array, want this to happen on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + // Open and close menu automatically when the input value changes if the input is focused, + // and there are items in the collection or allowEmptyCollection is true. + // if ( + // isFocused && + // (filteredCollection.size > 0 || allowsEmptyCollection) && + // !triggerState.isOpen && + // inputValue !== lastValue && + // menuTrigger !== 'manual' + // ) { + // open(null, 'input'); + // } + + // // Close the menu if the collection is empty. Don't close menu if filtered collection size is 0 + // // but we are currently showing all items via button press + // if ( + // !showAllItems && + // !allowsEmptyCollection && + // triggerState.isOpen && + // filteredCollection.size === 0 + // ) { + // closeMenu(); + // } + + // // Close when an item is selected. + // if ( + // selectedKey != null && + // selectedKey !== lastSelectedKey.current + // ) { + // closeMenu(); + // } + + // Clear focused key when input value changes and display filtered collection again. + if (inputValue !== lastValue) { + selectionManager.setFocusedKey(null); + setShowAllItems(false); + + // Set selectedKey to null when the user clears the input. + // If controlled, this is the application developer's responsibility. + if (inputValue === '' && (props.inputValue === undefined || props.selectedKey === undefined)) { + setSelectedKey(null); + } + } + + // If the selectedKey changed, update the input value. + // Do nothing if both inputValue and selectedKey are controlled. + // In this case, it's the user's responsibility to update inputValue in onSelectionChange. + if ( + selectedKey !== lastSelectedKey.current && + (props.inputValue === undefined || props.selectedKey === undefined) + ) { + resetInputValue(); + } else if (lastValue !== inputValue) { + setLastValue(inputValue); + } + + // Update the inputValue if the selected item's text changes from its last tracked value. + // This is to handle cases where a selectedKey is specified but the items aren't available (async loading) or the selected item's text value updates. + // Only reset if the user isn't currently within the field so we don't erroneously modify user input. + // If inputValue is controlled, it is the user's responsibility to update the inputValue when items change. + let selectedItemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; + if (!isFocused && selectedKey != null && props.inputValue === undefined && selectedKey === lastSelectedKey.current) { + if (lastSelectedKeyText.current !== selectedItemText) { + setLastValue(selectedItemText); + setInputValue(selectedItemText); + } + } + + lastSelectedKey.current = selectedKey; + lastSelectedKeyText.current = selectedItemText; + }); + + let validation = useFormValidationState({ + ...props, + value: useMemo(() => ({inputValue, selectedKey}), [inputValue, selectedKey]) + }); + + // Revert input value and close menu + let revert = () => { + if (allowsCustomValue && selectedKey == null) { + commitCustomValue(); + } else { + commitSelection(); + } + }; + + let commitCustomValue = () => { + lastSelectedKey.current = null; + setSelectedKey(null); + // closeMenu(); + }; + + let commitSelection = () => { + // If multiple things are controlled, call onSelectionChange + if (props.selectedKey !== undefined && props.inputValue !== undefined) { + props.onSelectionChange?.(selectedKey); + + // Stop menu from reopening from useEffect + let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; + setLastValue(itemText); + // closeMenu(); + } else { + // If only a single aspect of combobox is controlled, reset input value and close menu for the user + resetInputValue(); + // closeMenu(); + } + }; + + const commitValue = () => { + if (allowsCustomValue) { + const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; + (inputValue === itemText) ? commitSelection() : commitCustomValue(); + } else { + // Reset inputValue and close menu + commitSelection(); + } + }; + + let commit = () => { + // if (triggerState.isOpen && selectionManager.focusedKey != null) { + // // Reset inputValue and close menu here if the selected key is already the focused key. Otherwise + // // fire onSelectionChange to allow the application to control the closing. + // if (selectedKey === selectionManager.focusedKey) { + // commitSelection(); + // } else { + // setSelectedKey(selectionManager.focusedKey); + // } + // } else { + // commitValue(); + // } + + // Reset inputValue and close menu here if the selected key is already the focused key. Otherwise + // fire onSelectionChange to allow the application to control the closing. + if (selectedKey === selectionManager.focusedKey) { + commitSelection(); + } else { + setSelectedKey(selectionManager.focusedKey); + } + }; + + let valueOnFocus = useRef(inputValue); + let setFocused = (isFocused: boolean) => { + if (isFocused) { + valueOnFocus.current = inputValue; + // if (menuTrigger === 'focus' && !props.isReadOnly) { + // open(null, 'focus'); + // } + } else { + // if (shouldCloseOnBlur) { + commitValue(); + // } + + if (inputValue !== valueOnFocus.current) { + validation.commitValidation(); + } + } + + setFocusedState(isFocused); + }; + + // let displayedCollection = useMemo(() => { + // // if (triggerState.isOpen) { + // // if (showAllItems) { + // // return originalCollection; + // // } else { + // return filteredCollection; + // // } + // // } else { + // // return lastCollection; + // // } + // }, [triggerState.isOpen, originalCollection, filteredCollection, showAllItems, lastCollection]); + + return { + ...validation, + // ...triggerState, + focusStrategy, + // toggle, + // open, + // close: commitValue, + selectionManager, + selectedKey, + setSelectedKey, + disabledKeys, + isFocused, + setFocused, + selectedItem, + collection: filteredCollection, + inputValue, + setInputValue, + commit, + revert + }; +} + +function filterCollection(collection: Collection>, inputValue: string, filter: FilterFn): Collection> { + return new ListCollection(filterNodes(collection, collection, inputValue, filter)); +} + +function filterNodes(collection: Collection>, nodes: Iterable>, inputValue: string, filter: FilterFn): Iterable> { + let filteredNode: Node[] = []; + for (let node of nodes) { + if (node.type === 'section' && node.hasChildNodes) { + let filtered = filterNodes(collection, getChildNodes(node, collection), inputValue, filter); + if ([...filtered].some(node => node.type === 'item')) { + filteredNode.push({...node, childNodes: filtered}); + } + } else if (node.type === 'item' && filter(node.textValue, inputValue)) { + filteredNode.push({...node}); + } else if (node.type !== 'item') { + filteredNode.push({...node}); + } + } + return filteredNode; +} diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 4caab2ba6db..6945f230488 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -40,6 +40,7 @@ "@internationalized/date": "^3.5.6", "@internationalized/string": "^3.2.4", "@react-aria/accordion": "3.0.0-alpha.34", + "@react-aria/autocomplete": "3.0.0-alpha.34", "@react-aria/collections": "3.0.0-alpha.5", "@react-aria/color": "^3.0.0", "@react-aria/disclosure": "3.0.0-alpha.0", @@ -52,7 +53,9 @@ "@react-aria/tree": "3.0.0-beta.0", "@react-aria/utils": "^3.25.3", "@react-aria/virtualizer": "^4.0.3", + "@react-stately/autocomplete": "3.0.0-alpha.1", "@react-stately/color": "^3.8.0", + "@react-stately/combobox": "^3.10.0", "@react-stately/disclosure": "3.0.0-alpha.0", "@react-stately/layout": "^4.0.3", "@react-stately/menu": "^3.8.3", diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 15f66bfd2f5..8c4b5f8cea0 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -9,9 +9,9 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {AriaComboBoxProps, useComboBox, useFilter} from 'react-aria'; +import {AriaComboBoxProps, useFilter} from 'react-aria'; import {ButtonContext} from './Button'; -import {Collection, ComboBoxState, Node, useComboBoxState} from 'react-stately'; +import {Collection, ComboBoxState, Node} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; @@ -26,6 +26,8 @@ import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext} from './Popover'; import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef, useState} from 'react'; import {TextContext} from './Text'; +import { useAutocomplete } from '@react-aria/autocomplete'; +import { useAutocompleteState } from '@react-stately/autocomplete'; export interface AutocompleteRenderProps { // /** @@ -59,7 +61,7 @@ export interface AutocompleteProps extends Omit({props, collection, autocompleteRef let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let {contains} = useFilter({sensitivity: 'base'}); - let state = useComboBoxState({ + let state = useAutocompleteState({ defaultFilter: props.defaultFilter || contains, ...props, // If props.items isn't provided, rely on collection filtering (aka listbox.items is provided or defaultItems provided to Combobox) @@ -119,6 +121,7 @@ function AutocompleteInner({props, collection, autocompleteRef collection, validationBehavior }); + // console.log('state', state) let buttonRef = useRef(null); let inputRef = useRef(null); @@ -135,7 +138,7 @@ function AutocompleteInner({props, collection, autocompleteRef descriptionProps, errorMessageProps, ...validation - } = useComboBox({ + } = useAutocomplete({ ...removeDataAttributes(props), label, inputRef, @@ -149,25 +152,25 @@ function AutocompleteInner({props, collection, autocompleteRef // TODO: comment these out when you get Autocomplete working in the story // Make menu width match input + button - let [menuWidth, setMenuWidth] = useState(null); - let onResize = useCallback(() => { - if (inputRef.current) { - let buttonRect = buttonRef.current?.getBoundingClientRect(); - let inputRect = inputRef.current.getBoundingClientRect(); - let minX = buttonRect ? Math.min(buttonRect.left, inputRect.left) : inputRect.left; - let maxX = buttonRect ? Math.max(buttonRect.right, inputRect.right) : inputRect.right; - setMenuWidth((maxX - minX) + 'px'); - } - }, [buttonRef, inputRef, setMenuWidth]); - - useResizeObserver({ - ref: inputRef, - onResize: onResize - }); + // let [menuWidth, setMenuWidth] = useState(null); + // let onResize = useCallback(() => { + // if (inputRef.current) { + // let buttonRect = buttonRef.current?.getBoundingClientRect(); + // let inputRect = inputRef.current.getBoundingClientRect(); + // let minX = buttonRect ? Math.min(buttonRect.left, inputRect.left) : inputRect.left; + // let maxX = buttonRect ? Math.max(buttonRect.right, inputRect.right) : inputRect.right; + // setMenuWidth((maxX - minX) + 'px'); + // } + // }, [buttonRef, inputRef, setMenuWidth]); + + // useResizeObserver({ + // ref: inputRef, + // onResize: onResize + // }); // Only expose a subset of state to renderProps function to avoid infinite render loop let renderPropsState = useMemo(() => ({ - isOpen: state.isOpen, + // isOpen: state.isOpen, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, isRequired: props.isRequired || false @@ -188,19 +191,19 @@ function AutocompleteInner({props, collection, autocompleteRef values={[ [ComboBoxStateContext, state], [LabelContext, {...labelProps, ref: labelRef}], - [ButtonContext, {...buttonProps, ref: buttonRef, isPressed: state.isOpen}], + // [ButtonContext, {...buttonProps, ref: buttonRef, isPressed: state.isOpen}], [InputContext, {...inputProps, ref: inputRef}], - // TODO: get rid of the popover stuff - [OverlayTriggerStateContext, state], - [PopoverContext, { - ref: popoverRef, - triggerRef: inputRef, - scrollRef: listBoxRef, - placement: 'bottom start', - isNonModal: true, - trigger: 'ComboBox', - style: {'--trigger-width': menuWidth} as React.CSSProperties - }], + // TODO: get rid of the popover stuff and trigger + // [OverlayTriggerStateContext, state], + // [PopoverContext, { + // ref: popoverRef, + // triggerRef: inputRef, + // scrollRef: listBoxRef, + // placement: 'bottom start', + // isNonModal: true, + // trigger: 'ComboBox', + // // style: {'--trigger-width': menuWidth} as React.CSSProperties + // }], [ListBoxContext, {...listBoxProps, ref: listBoxRef}], [ListStateContext, state], [TextContext, { @@ -218,7 +221,7 @@ function AutocompleteInner({props, collection, autocompleteRef ref={ref} slot={props.slot || undefined} data-focused={state.isFocused || undefined} - data-open={state.isOpen || undefined} + // data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} data-required={props.isRequired || undefined} /> diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 61a109dd2bb..64ebf6a2e1c 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -29,16 +29,16 @@ export const AutocompleteExample = () => (
- - - Foo - Bar - Baz - Google - - + {/* */} + + Foo + Bar + Baz + Google + + {/* */} ); @@ -59,13 +59,13 @@ export const AutocompleteRenderPropsStatic = () => ( - + {/* */} Foo Bar Baz - + {/* */} )} @@ -82,11 +82,11 @@ export const AutocompleteRenderPropsDefaultItems = () => ( - + {/* */} {(item: AutocompleteItem) => {item.name}} - + {/* */} )} @@ -104,11 +104,11 @@ export const AutocompleteRenderPropsItems = { - + {/* */} {(item: AutocompleteItem) => {item.name}} - + {/* */} )} @@ -131,11 +131,11 @@ export const AutocompleteRenderPropsListBoxDynamic = () => ( - + {/* */} {item => {item.name}} - + {/* */} )} @@ -174,12 +174,12 @@ export const AutocompleteAsyncLoadingExample = () => { - + {/* */} className={styles.menu}> {item => {item.name}} - + {/* */} ); }; diff --git a/yarn.lock b/yarn.lock index 87e1973efcf..315161ad2b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5603,12 +5603,21 @@ __metadata: resolution: "@react-aria/autocomplete@workspace:packages/@react-aria/autocomplete" dependencies: "@react-aria/combobox": "npm:^3.10.4" + "@react-aria/i18n": "npm:^3.12.3" "@react-aria/listbox": "npm:^3.13.4" + "@react-aria/live-announcer": "npm:^3.4.0" + "@react-aria/menu": "npm:^3.15.4" + "@react-aria/overlays": "npm:^3.23.3" "@react-aria/searchfield": "npm:^3.7.9" + "@react-aria/selection": "npm:^3.20.0" + "@react-aria/textfield": "npm:^3.14.9" "@react-aria/utils": "npm:^3.25.3" + "@react-stately/collections": "npm:^3.11.0" "@react-stately/combobox": "npm:^3.10.0" + "@react-stately/form": "npm:^3.0.6" "@react-types/autocomplete": "npm:3.0.0-alpha.26" "@react-types/button": "npm:^3.10.0" + "@react-types/combobox": "npm:^3.13.0" "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: @@ -8074,6 +8083,24 @@ __metadata: languageName: unknown linkType: soft +"@react-stately/autocomplete@npm:3.0.0-alpha.1, @react-stately/autocomplete@workspace:packages/@react-stately/autocomplete": + version: 0.0.0-use.local + resolution: "@react-stately/autocomplete@workspace:packages/@react-stately/autocomplete" + dependencies: + "@react-stately/collections": "npm:^3.11.0" + "@react-stately/form": "npm:^3.0.6" + "@react-stately/list": "npm:^3.11.0" + "@react-stately/overlays": "npm:^3.6.11" + "@react-stately/select": "npm:^3.6.8" + "@react-stately/utils": "npm:^3.10.4" + "@react-types/combobox": "npm:^3.13.0" + "@react-types/shared": "npm:^3.25.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + languageName: unknown + linkType: soft + "@react-stately/calendar@npm:^3.5.5, @react-stately/calendar@workspace:packages/@react-stately/calendar": version: 0.0.0-use.local resolution: "@react-stately/calendar@workspace:packages/@react-stately/calendar" @@ -28758,6 +28785,7 @@ __metadata: "@internationalized/date": "npm:^3.5.6" "@internationalized/string": "npm:^3.2.4" "@react-aria/accordion": "npm:3.0.0-alpha.34" + "@react-aria/autocomplete": "npm:3.0.0-alpha.34" "@react-aria/collections": "npm:3.0.0-alpha.5" "@react-aria/color": "npm:^3.0.0" "@react-aria/disclosure": "npm:3.0.0-alpha.0" @@ -28770,7 +28798,9 @@ __metadata: "@react-aria/tree": "npm:3.0.0-beta.0" "@react-aria/utils": "npm:^3.25.3" "@react-aria/virtualizer": "npm:^4.0.3" + "@react-stately/autocomplete": "npm:3.0.0-alpha.1" "@react-stately/color": "npm:^3.8.0" + "@react-stately/combobox": "npm:^3.10.0" "@react-stately/disclosure": "npm:3.0.0-alpha.0" "@react-stately/layout": "npm:^4.0.3" "@react-stately/menu": "npm:^3.8.3" From e6fa7c163ad777cb548ed3b7a241accb093430da Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 9 Oct 2024 17:18:20 -0700 Subject: [PATCH 03/42] update intl file and more clean up to get rid of combobox stuff --- .../@react-aria/autocomplete/intl/ar-AE.json | 6 + .../@react-aria/autocomplete/intl/bg-BG.json | 6 + .../@react-aria/autocomplete/intl/cs-CZ.json | 6 + .../@react-aria/autocomplete/intl/da-DK.json | 6 + .../@react-aria/autocomplete/intl/de-DE.json | 6 + .../@react-aria/autocomplete/intl/el-GR.json | 6 + .../@react-aria/autocomplete/intl/en-US.json | 6 + .../@react-aria/autocomplete/intl/es-ES.json | 6 + .../@react-aria/autocomplete/intl/et-EE.json | 6 + .../@react-aria/autocomplete/intl/fi-FI.json | 6 + .../@react-aria/autocomplete/intl/fr-FR.json | 6 + .../@react-aria/autocomplete/intl/he-IL.json | 6 + .../@react-aria/autocomplete/intl/hr-HR.json | 6 + .../@react-aria/autocomplete/intl/hu-HU.json | 6 + .../@react-aria/autocomplete/intl/it-IT.json | 6 + .../@react-aria/autocomplete/intl/ja-JP.json | 6 + .../@react-aria/autocomplete/intl/ko-KR.json | 6 + .../@react-aria/autocomplete/intl/lt-LT.json | 6 + .../@react-aria/autocomplete/intl/lv-LV.json | 6 + .../@react-aria/autocomplete/intl/nb-NO.json | 6 + .../@react-aria/autocomplete/intl/nl-NL.json | 6 + .../@react-aria/autocomplete/intl/pl-PL.json | 6 + .../@react-aria/autocomplete/intl/pt-BR.json | 6 + .../@react-aria/autocomplete/intl/pt-PT.json | 6 + .../@react-aria/autocomplete/intl/ro-RO.json | 6 + .../@react-aria/autocomplete/intl/ru-RU.json | 6 + .../@react-aria/autocomplete/intl/sk-SK.json | 6 + .../@react-aria/autocomplete/intl/sl-SI.json | 6 + .../@react-aria/autocomplete/intl/sr-SP.json | 6 + .../@react-aria/autocomplete/intl/sv-SE.json | 6 + .../@react-aria/autocomplete/intl/tr-TR.json | 6 + .../@react-aria/autocomplete/intl/uk-UA.json | 6 + .../@react-aria/autocomplete/intl/zh-CN.json | 6 + .../@react-aria/autocomplete/intl/zh-TW.json | 6 + .../@react-aria/autocomplete/package.json | 1 + .../@react-aria/autocomplete/src/index.ts | 2 +- .../autocomplete/src/useAutocomplete.ts | 163 +++---------- .../@react-stately/autocomplete/src/index.ts | 2 +- .../autocomplete/src/useAutocompleteState.ts | 227 ++++-------------- .../src/Autocomplete.tsx | 73 +----- 40 files changed, 302 insertions(+), 370 deletions(-) create mode 100644 packages/@react-aria/autocomplete/intl/ar-AE.json create mode 100644 packages/@react-aria/autocomplete/intl/bg-BG.json create mode 100644 packages/@react-aria/autocomplete/intl/cs-CZ.json create mode 100644 packages/@react-aria/autocomplete/intl/da-DK.json create mode 100644 packages/@react-aria/autocomplete/intl/de-DE.json create mode 100644 packages/@react-aria/autocomplete/intl/el-GR.json create mode 100644 packages/@react-aria/autocomplete/intl/en-US.json create mode 100644 packages/@react-aria/autocomplete/intl/es-ES.json create mode 100644 packages/@react-aria/autocomplete/intl/et-EE.json create mode 100644 packages/@react-aria/autocomplete/intl/fi-FI.json create mode 100644 packages/@react-aria/autocomplete/intl/fr-FR.json create mode 100644 packages/@react-aria/autocomplete/intl/he-IL.json create mode 100644 packages/@react-aria/autocomplete/intl/hr-HR.json create mode 100644 packages/@react-aria/autocomplete/intl/hu-HU.json create mode 100644 packages/@react-aria/autocomplete/intl/it-IT.json create mode 100644 packages/@react-aria/autocomplete/intl/ja-JP.json create mode 100644 packages/@react-aria/autocomplete/intl/ko-KR.json create mode 100644 packages/@react-aria/autocomplete/intl/lt-LT.json create mode 100644 packages/@react-aria/autocomplete/intl/lv-LV.json create mode 100644 packages/@react-aria/autocomplete/intl/nb-NO.json create mode 100644 packages/@react-aria/autocomplete/intl/nl-NL.json create mode 100644 packages/@react-aria/autocomplete/intl/pl-PL.json create mode 100644 packages/@react-aria/autocomplete/intl/pt-BR.json create mode 100644 packages/@react-aria/autocomplete/intl/pt-PT.json create mode 100644 packages/@react-aria/autocomplete/intl/ro-RO.json create mode 100644 packages/@react-aria/autocomplete/intl/ru-RU.json create mode 100644 packages/@react-aria/autocomplete/intl/sk-SK.json create mode 100644 packages/@react-aria/autocomplete/intl/sl-SI.json create mode 100644 packages/@react-aria/autocomplete/intl/sr-SP.json create mode 100644 packages/@react-aria/autocomplete/intl/sv-SE.json create mode 100644 packages/@react-aria/autocomplete/intl/tr-TR.json create mode 100644 packages/@react-aria/autocomplete/intl/uk-UA.json create mode 100644 packages/@react-aria/autocomplete/intl/zh-CN.json create mode 100644 packages/@react-aria/autocomplete/intl/zh-TW.json diff --git a/packages/@react-aria/autocomplete/intl/ar-AE.json b/packages/@react-aria/autocomplete/intl/ar-AE.json new file mode 100644 index 00000000000..fc0949be5ba --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/ar-AE.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# خيار} other {# خيارات}} متاحة.", + "focusAnnouncement": "{isGroupChange, select, true {المجموعة المدخلة {groupTitle}, مع {groupCount, plural, one {# خيار} other {# خيارات}}. } other {}}{optionText}{isSelected, select, true {, محدد} other {}}", + "listboxLabel": "مقترحات", + "selectedAnnouncement": "{optionText}، محدد" +} diff --git a/packages/@react-aria/autocomplete/intl/bg-BG.json b/packages/@react-aria/autocomplete/intl/bg-BG.json new file mode 100644 index 00000000000..a213204e41c --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/bg-BG.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# опция} other {# опции}} на разположение.", + "focusAnnouncement": "{isGroupChange, select, true {Въведена група {groupTitle}, с {groupCount, plural, one {# опция} other {# опции}}. } other {}}{optionText}{isSelected, select, true {, избрани} other {}}", + "listboxLabel": "Предложения", + "selectedAnnouncement": "{optionText}, избрани" +} diff --git a/packages/@react-aria/autocomplete/intl/cs-CZ.json b/packages/@react-aria/autocomplete/intl/cs-CZ.json new file mode 100644 index 00000000000..41152807f7b --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/cs-CZ.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "K dispozici {optionCount, plural, one {je # možnost} other {jsou/je # možnosti/-í}}.", + "focusAnnouncement": "{isGroupChange, select, true {Zadaná skupina „{groupTitle}“ {groupCount, plural, one {s # možností} other {se # možnostmi}}. } other {}}{optionText}{isSelected, select, true { (vybráno)} other {}}", + "listboxLabel": "Návrhy", + "selectedAnnouncement": "{optionText}, vybráno" +} diff --git a/packages/@react-aria/autocomplete/intl/da-DK.json b/packages/@react-aria/autocomplete/intl/da-DK.json new file mode 100644 index 00000000000..b8be8c29595 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/da-DK.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# mulighed tilgængelig} other {# muligheder tilgængelige}}.", + "focusAnnouncement": "{isGroupChange, select, true {Angivet gruppe {groupTitle}, med {groupCount, plural, one {# mulighed} other {# muligheder}}. } other {}}{optionText}{isSelected, select, true {, valgt} other {}}", + "listboxLabel": "Forslag", + "selectedAnnouncement": "{optionText}, valgt" +} diff --git a/packages/@react-aria/autocomplete/intl/de-DE.json b/packages/@react-aria/autocomplete/intl/de-DE.json new file mode 100644 index 00000000000..f1746b14927 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/de-DE.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# Option} other {# Optionen}} verfügbar.", + "focusAnnouncement": "{isGroupChange, select, true {Eingetretene Gruppe {groupTitle}, mit {groupCount, plural, one {# Option} other {# Optionen}}. } other {}}{optionText}{isSelected, select, true {, ausgewählt} other {}}", + "listboxLabel": "Empfehlungen", + "selectedAnnouncement": "{optionText}, ausgewählt" +} diff --git a/packages/@react-aria/autocomplete/intl/el-GR.json b/packages/@react-aria/autocomplete/intl/el-GR.json new file mode 100644 index 00000000000..f1fb8f0c7e0 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/el-GR.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# επιλογή} other {# επιλογές }} διαθέσιμες.", + "focusAnnouncement": "{isGroupChange, select, true {Εισαγμένη ομάδα {groupTitle}, με {groupCount, plural, one {# επιλογή} other {# επιλογές}}. } other {}}{optionText}{isSelected, select, true {, επιλεγμένο} other {}}", + "listboxLabel": "Προτάσεις", + "selectedAnnouncement": "{optionText}, επιλέχθηκε" +} diff --git a/packages/@react-aria/autocomplete/intl/en-US.json b/packages/@react-aria/autocomplete/intl/en-US.json new file mode 100644 index 00000000000..0d751d72110 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/en-US.json @@ -0,0 +1,6 @@ +{ + "focusAnnouncement": "{isGroupChange, select, true {Entered group {groupTitle}, with {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, selected} other {}}", + "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} available.", + "selectedAnnouncement": "{optionText}, selected", + "listboxLabel": "Suggestions" +} diff --git a/packages/@react-aria/autocomplete/intl/es-ES.json b/packages/@react-aria/autocomplete/intl/es-ES.json new file mode 100644 index 00000000000..16fee43d61c --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/es-ES.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# opción} other {# opciones}} disponible(s).", + "focusAnnouncement": "{isGroupChange, select, true {Se ha unido al grupo {groupTitle}, con {groupCount, plural, one {# opción} other {# opciones}}. } other {}}{optionText}{isSelected, select, true {, seleccionado} other {}}", + "listboxLabel": "Sugerencias", + "selectedAnnouncement": "{optionText}, seleccionado" +} diff --git a/packages/@react-aria/autocomplete/intl/et-EE.json b/packages/@react-aria/autocomplete/intl/et-EE.json new file mode 100644 index 00000000000..ff91c38570f --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/et-EE.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# valik} other {# valikud}} saadaval.", + "focusAnnouncement": "{isGroupChange, select, true {Sisestatud rühm {groupTitle}, valikuga {groupCount, plural, one {# valik} other {# valikud}}. } other {}}{optionText}{isSelected, select, true {, valitud} other {}}", + "listboxLabel": "Soovitused", + "selectedAnnouncement": "{optionText}, valitud" +} diff --git a/packages/@react-aria/autocomplete/intl/fi-FI.json b/packages/@react-aria/autocomplete/intl/fi-FI.json new file mode 100644 index 00000000000..90504c138c4 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/fi-FI.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# vaihtoehto} other {# vaihtoehdot}} saatavilla.", + "focusAnnouncement": "{isGroupChange, select, true {Mentiin ryhmään {groupTitle}, {groupCount, plural, one {# vaihtoehdon} other {# vaihtoehdon}} kanssa.} other {}}{optionText}{isSelected, select, true {, valittu} other {}}", + "listboxLabel": "Ehdotukset", + "selectedAnnouncement": "{optionText}, valittu" +} diff --git a/packages/@react-aria/autocomplete/intl/fr-FR.json b/packages/@react-aria/autocomplete/intl/fr-FR.json new file mode 100644 index 00000000000..2737cd1f5f3 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/fr-FR.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} disponible(s).", + "focusAnnouncement": "{isGroupChange, select, true {Groupe {groupTitle} rejoint, avec {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, sélectionné(s)} other {}}", + "listboxLabel": "Suggestions", + "selectedAnnouncement": "{optionText}, sélectionné" +} diff --git a/packages/@react-aria/autocomplete/intl/he-IL.json b/packages/@react-aria/autocomplete/intl/he-IL.json new file mode 100644 index 00000000000..01db0ac0d7d --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/he-IL.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {אפשרות #} other {# אפשרויות}} במצב זמין.", + "focusAnnouncement": "{isGroupChange, select, true {נכנס לקבוצה {groupTitle}, עם {groupCount, plural, one {אפשרות #} other {# אפשרויות}}. } other {}}{optionText}{isSelected, select, true {, נבחר} other {}}", + "listboxLabel": "הצעות", + "selectedAnnouncement": "{optionText}, נבחר" +} diff --git a/packages/@react-aria/autocomplete/intl/hr-HR.json b/packages/@react-aria/autocomplete/intl/hr-HR.json new file mode 100644 index 00000000000..28d2f0f4cd0 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/hr-HR.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "Dostupno još: {optionCount, plural, one {# opcija} other {# opcije/a}}.", + "focusAnnouncement": "{isGroupChange, select, true {Unesena skupina {groupTitle}, s {groupCount, plural, one {# opcijom} other {# opcije/a}}. } other {}}{optionText}{isSelected, select, true {, odabranih} other {}}", + "listboxLabel": "Prijedlozi", + "selectedAnnouncement": "{optionText}, odabrano" +} diff --git a/packages/@react-aria/autocomplete/intl/hu-HU.json b/packages/@react-aria/autocomplete/intl/hu-HU.json new file mode 100644 index 00000000000..59ff457671a --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/hu-HU.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# lehetőség} other {# lehetőség}} áll rendelkezésre.", + "focusAnnouncement": "{isGroupChange, select, true {Belépett a(z) {groupTitle} csoportba, amely {groupCount, plural, one {# lehetőséget} other {# lehetőséget}} tartalmaz. } other {}}{optionText}{isSelected, select, true {, kijelölve} other {}}", + "listboxLabel": "Javaslatok", + "selectedAnnouncement": "{optionText}, kijelölve" +} diff --git a/packages/@react-aria/autocomplete/intl/it-IT.json b/packages/@react-aria/autocomplete/intl/it-IT.json new file mode 100644 index 00000000000..21a8ac5fbaa --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/it-IT.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# opzione disponibile} other {# opzioni disponibili}}.", + "focusAnnouncement": "{isGroupChange, select, true {Ingresso nel gruppo {groupTitle}, con {groupCount, plural, one {# opzione} other {# opzioni}}. } other {}}{optionText}{isSelected, select, true {, selezionato} other {}}", + "listboxLabel": "Suggerimenti", + "selectedAnnouncement": "{optionText}, selezionato" +} diff --git a/packages/@react-aria/autocomplete/intl/ja-JP.json b/packages/@react-aria/autocomplete/intl/ja-JP.json new file mode 100644 index 00000000000..7fc42638944 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/ja-JP.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# 個のオプション} other {# 個のオプション}}を利用できます。", + "focusAnnouncement": "{isGroupChange, select, true {入力されたグループ {groupTitle}、{groupCount, plural, one {# 個のオプション} other {# 個のオプション}}を含む。} other {}}{optionText}{isSelected, select, true {、選択済み} other {}}", + "listboxLabel": "候補", + "selectedAnnouncement": "{optionText}、選択済み" +} diff --git a/packages/@react-aria/autocomplete/intl/ko-KR.json b/packages/@react-aria/autocomplete/intl/ko-KR.json new file mode 100644 index 00000000000..c1c5a976819 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/ko-KR.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {#개 옵션} other {#개 옵션}}을 사용할 수 있습니다.", + "focusAnnouncement": "{isGroupChange, select, true {입력한 그룹 {groupTitle}, {groupCount, plural, one {#개 옵션} other {#개 옵션}}. } other {}}{optionText}{isSelected, select, true {, 선택됨} other {}}", + "listboxLabel": "제안", + "selectedAnnouncement": "{optionText}, 선택됨" +} diff --git a/packages/@react-aria/autocomplete/intl/lt-LT.json b/packages/@react-aria/autocomplete/intl/lt-LT.json new file mode 100644 index 00000000000..bfeee55cef9 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/lt-LT.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "Yra {optionCount, plural, one {# parinktis} other {# parinktys (-ių)}}.", + "focusAnnouncement": "{isGroupChange, select, true {Įvesta grupė {groupTitle}, su {groupCount, plural, one {# parinktimi} other {# parinktimis (-ių)}}. } other {}}{optionText}{isSelected, select, true {, pasirinkta} other {}}", + "listboxLabel": "Pasiūlymai", + "selectedAnnouncement": "{optionText}, pasirinkta" +} diff --git a/packages/@react-aria/autocomplete/intl/lv-LV.json b/packages/@react-aria/autocomplete/intl/lv-LV.json new file mode 100644 index 00000000000..ab9559f1d04 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/lv-LV.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "Pieejamo opciju skaits: {optionCount, plural, one {# opcija} other {# opcijas}}.", + "focusAnnouncement": "{isGroupChange, select, true {Ievadīta grupa {groupTitle}, ar {groupCount, plural, one {# opciju} other {# opcijām}}. } other {}}{optionText}{isSelected, select, true {, atlasīta} other {}}", + "listboxLabel": "Ieteikumi", + "selectedAnnouncement": "{optionText}, atlasīta" +} diff --git a/packages/@react-aria/autocomplete/intl/nb-NO.json b/packages/@react-aria/autocomplete/intl/nb-NO.json new file mode 100644 index 00000000000..5827af48745 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/nb-NO.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# alternativ} other {# alternativer}} finnes.", + "focusAnnouncement": "{isGroupChange, select, true {Angitt gruppe {groupTitle}, med {groupCount, plural, one {# alternativ} other {# alternativer}}. } other {}}{optionText}{isSelected, select, true {, valgt} other {}}", + "listboxLabel": "Forslag", + "selectedAnnouncement": "{optionText}, valgt" +} diff --git a/packages/@react-aria/autocomplete/intl/nl-NL.json b/packages/@react-aria/autocomplete/intl/nl-NL.json new file mode 100644 index 00000000000..bfe2e10883e --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/nl-NL.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# optie} other {# opties}} beschikbaar.", + "focusAnnouncement": "{isGroupChange, select, true {Groep {groupTitle} ingevoerd met {groupCount, plural, one {# optie} other {# opties}}. } other {}}{optionText}{isSelected, select, true {, geselecteerd} other {}}", + "listboxLabel": "Suggesties", + "selectedAnnouncement": "{optionText}, geselecteerd" +} diff --git a/packages/@react-aria/autocomplete/intl/pl-PL.json b/packages/@react-aria/autocomplete/intl/pl-PL.json new file mode 100644 index 00000000000..1bde3c435e0 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/pl-PL.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "dostępna/dostępne(-nych) {optionCount, plural, one {# opcja} other {# opcje(-i)}}.", + "focusAnnouncement": "{isGroupChange, select, true {Dołączono do grupy {groupTitle}, z {groupCount, plural, one {# opcją} other {# opcjami}}. } other {}}{optionText}{isSelected, select, true {, wybrano} other {}}", + "listboxLabel": "Sugestie", + "selectedAnnouncement": "{optionText}, wybrano" +} diff --git a/packages/@react-aria/autocomplete/intl/pt-BR.json b/packages/@react-aria/autocomplete/intl/pt-BR.json new file mode 100644 index 00000000000..4330af7c0ec --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/pt-BR.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# opção} other {# opções}} disponível.", + "focusAnnouncement": "{isGroupChange, select, true {Grupo inserido {groupTitle}, com {groupCount, plural, one {# opção} other {# opções}}. } other {}}{optionText}{isSelected, select, true {, selecionado} other {}}", + "listboxLabel": "Sugestões", + "selectedAnnouncement": "{optionText}, selecionado" +} diff --git a/packages/@react-aria/autocomplete/intl/pt-PT.json b/packages/@react-aria/autocomplete/intl/pt-PT.json new file mode 100644 index 00000000000..d44b9324dd9 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/pt-PT.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# opção} other {# opções}} disponível.", + "focusAnnouncement": "{isGroupChange, select, true {Grupo introduzido {groupTitle}, com {groupCount, plural, one {# opção} other {# opções}}. } other {}}{optionText}{isSelected, select, true {, selecionado} other {}}", + "listboxLabel": "Sugestões", + "selectedAnnouncement": "{optionText}, selecionado" +} diff --git a/packages/@react-aria/autocomplete/intl/ro-RO.json b/packages/@react-aria/autocomplete/intl/ro-RO.json new file mode 100644 index 00000000000..81799479638 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/ro-RO.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# opțiune} other {# opțiuni}} disponibile.", + "focusAnnouncement": "{isGroupChange, select, true {Grup {groupTitle} introdus, cu {groupCount, plural, one {# opțiune} other {# opțiuni}}. } other {}}{optionText}{isSelected, select, true {, selectat} other {}}", + "listboxLabel": "Sugestii", + "selectedAnnouncement": "{optionText}, selectat" +} diff --git a/packages/@react-aria/autocomplete/intl/ru-RU.json b/packages/@react-aria/autocomplete/intl/ru-RU.json new file mode 100644 index 00000000000..768de8157c6 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/ru-RU.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# параметр} other {# параметров}} доступно.", + "focusAnnouncement": "{isGroupChange, select, true {Введенная группа {groupTitle}, с {groupCount, plural, one {# параметром} other {# параметрами}}. } other {}}{optionText}{isSelected, select, true {, выбранными} other {}}", + "listboxLabel": "Предложения", + "selectedAnnouncement": "{optionText}, выбрано" +} diff --git a/packages/@react-aria/autocomplete/intl/sk-SK.json b/packages/@react-aria/autocomplete/intl/sk-SK.json new file mode 100644 index 00000000000..9e2c5121279 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/sk-SK.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# možnosť} other {# možnosti/-í}} k dispozícii.", + "focusAnnouncement": "{isGroupChange, select, true {Zadaná skupina {groupTitle}, s {groupCount, plural, one {# možnosťou} other {# možnosťami}}. } other {}}{optionText}{isSelected, select, true {, vybraté} other {}}", + "listboxLabel": "Návrhy", + "selectedAnnouncement": "{optionText}, vybraté" +} diff --git a/packages/@react-aria/autocomplete/intl/sl-SI.json b/packages/@react-aria/autocomplete/intl/sl-SI.json new file mode 100644 index 00000000000..3b8c4edf79b --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/sl-SI.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "Na voljo je {optionCount, plural, one {# opcija} other {# opcije}}.", + "focusAnnouncement": "{isGroupChange, select, true {Vnesena skupina {groupTitle}, z {groupCount, plural, one {# opcija} other {# opcije}}. } other {}}{optionText}{isSelected, select, true {, izbrano} other {}}", + "listboxLabel": "Predlogi", + "selectedAnnouncement": "{optionText}, izbrano" +} diff --git a/packages/@react-aria/autocomplete/intl/sr-SP.json b/packages/@react-aria/autocomplete/intl/sr-SP.json new file mode 100644 index 00000000000..ad39438b523 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/sr-SP.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "Dostupno još: {optionCount, plural, one {# opcija} other {# opcije/a}}.", + "focusAnnouncement": "{isGroupChange, select, true {Unesena grupa {groupTitle}, s {groupCount, plural, one {# opcijom} other {# optione/a}}. } other {}}{optionText}{isSelected, select, true {, izabranih} other {}}", + "listboxLabel": "Predlozi", + "selectedAnnouncement": "{optionText}, izabrano" +} diff --git a/packages/@react-aria/autocomplete/intl/sv-SE.json b/packages/@react-aria/autocomplete/intl/sv-SE.json new file mode 100644 index 00000000000..579fdf61df1 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/sv-SE.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# alternativ} other {# alternativ}} tillgängliga.", + "focusAnnouncement": "{isGroupChange, select, true {Ingick i gruppen {groupTitle} med {groupCount, plural, one {# alternativ} other {# alternativ}}. } other {}}{optionText}{isSelected, select, true {, valda} other {}}", + "listboxLabel": "Förslag", + "selectedAnnouncement": "{optionText}, valda" +} diff --git a/packages/@react-aria/autocomplete/intl/tr-TR.json b/packages/@react-aria/autocomplete/intl/tr-TR.json new file mode 100644 index 00000000000..f2be96a361f --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/tr-TR.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# seçenek} other {# seçenekler}} kullanılabilir.", + "focusAnnouncement": "{isGroupChange, select, true {Girilen grup {groupTitle}, ile {groupCount, plural, one {# seçenek} other {# seçenekler}}. } other {}}{optionText}{isSelected, select, true {, seçildi} other {}}", + "listboxLabel": "Öneriler", + "selectedAnnouncement": "{optionText}, seçildi" +} diff --git a/packages/@react-aria/autocomplete/intl/uk-UA.json b/packages/@react-aria/autocomplete/intl/uk-UA.json new file mode 100644 index 00000000000..e4dbc439ea7 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/uk-UA.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# параметр} other {# параметри(-ів)}} доступно.", + "focusAnnouncement": "{isGroupChange, select, true {Введена група {groupTitle}, з {groupCount, plural, one {# параметр} other {# параметри(-ів)}}. } other {}}{optionText}{isSelected, select, true {, вибрано} other {}}", + "listboxLabel": "Пропозиції", + "selectedAnnouncement": "{optionText}, вибрано" +} diff --git a/packages/@react-aria/autocomplete/intl/zh-CN.json b/packages/@react-aria/autocomplete/intl/zh-CN.json new file mode 100644 index 00000000000..5cf6e21add6 --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/zh-CN.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "有 {optionCount, plural, one {# 个选项} other {# 个选项}}可用。", + "focusAnnouncement": "{isGroupChange, select, true {进入了 {groupTitle} 组,其中有 {groupCount, plural, one {# 个选项} other {# 个选项}}. } other {}}{optionText}{isSelected, select, true {, 已选择} other {}}", + "listboxLabel": "建议", + "selectedAnnouncement": "{optionText}, 已选择" +} diff --git a/packages/@react-aria/autocomplete/intl/zh-TW.json b/packages/@react-aria/autocomplete/intl/zh-TW.json new file mode 100644 index 00000000000..be3f207abfb --- /dev/null +++ b/packages/@react-aria/autocomplete/intl/zh-TW.json @@ -0,0 +1,6 @@ +{ + "countAnnouncement": "{optionCount, plural, one {# 選項} other {# 選項}} 可用。", + "focusAnnouncement": "{isGroupChange, select, true {輸入的群組 {groupTitle}, 有 {groupCount, plural, one {# 選項} other {# 選項}}. } other {}}{optionText}{isSelected, select, true {, 已選取} other {}}", + "listboxLabel": "建議", + "selectedAnnouncement": "{optionText}, 已選取" +} diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index b9a12561339..26539457c26 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -32,6 +32,7 @@ "@react-aria/selection": "^3.20.0", "@react-aria/textfield": "^3.14.9", "@react-aria/utils": "^3.25.3", + "@react-stately/autocomplete": "3.0.0-alpha.1", "@react-stately/collections": "^3.11.0", "@react-stately/combobox": "^3.10.0", "@react-stately/form": "^3.0.6", diff --git a/packages/@react-aria/autocomplete/src/index.ts b/packages/@react-aria/autocomplete/src/index.ts index f14a652df0e..b257386b8c4 100644 --- a/packages/@react-aria/autocomplete/src/index.ts +++ b/packages/@react-aria/autocomplete/src/index.ts @@ -12,6 +12,6 @@ export {useSearchAutocomplete} from './useSearchAutocomplete'; export {useAutocomplete} from './useAutocomplete'; -// TODO: export types for the hook when done export type {AriaSearchAutocompleteOptions, SearchAutocompleteAria} from './useSearchAutocomplete'; export type {AriaSearchAutocompleteProps} from '@react-types/autocomplete'; +export type {AriaAutocompleteProps, AriaAutocompleteOptions, AutocompleteAria} from './useAutocomplete'; diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 188c397feeb..1e0e3e9b6c8 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -11,33 +11,30 @@ */ import {announce} from '@react-aria/live-announcer'; -import {AriaButtonProps} from '@react-types/button'; -import {AriaComboBoxProps} from '@react-types/combobox'; -// import {ariaHideOutside} from '@react-aria/overlays'; +import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, KeyboardDelegate, LayoutDelegate, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; -import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, isAppleDevice, mergeProps, useLabels, useRouter} from '@react-aria/utils'; -import {ComboBoxState} from '@react-stately/combobox'; -import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; +import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; +import {chain, isAppleDevice, mergeProps, useId, useLabels, useRouter} from '@react-aria/utils'; +import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; import {getChildNodes, getItemCount} from '@react-stately/collections'; -// TODO: port over intl message // @ts-ignore -import intlMessages from '../../combobox/intl/*.json'; +import intlMessages from '../intl/*.json'; import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; import {privateValidationStateProp} from '@react-stately/form'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useMenuTrigger} from '@react-aria/menu'; import {useTextField} from '@react-aria/textfield'; -export interface AriaComboBoxOptions extends Omit, 'children'> { + +export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps { + /** Whether keyboard navigation is circular. */ + shouldFocusWrap?: boolean +} + +export interface AriaAutocompleteOptions extends Omit, 'children'>, DOMProps, InputDOMProps, AriaLabelingProps { /** The ref for the input element. */ inputRef: RefObject, - /** The ref for the list box popover. */ - popoverRef: RefObject, /** The ref for the list box. */ listBoxRef: RefObject, - /** The ref for the optional list box popup trigger button. */ - buttonRef?: RefObject, /** An optional keyboard delegate implementation, to override the default. */ keyboardDelegate?: KeyboardDelegate, /** @@ -47,55 +44,43 @@ export interface AriaComboBoxOptions extends Omit, 'chil */ layoutDelegate?: LayoutDelegate } - export interface AutocompleteAria extends ValidationResult { /** Props for the label element. */ labelProps: DOMAttributes, - /** Props for the combo box input element. */ + /** Props for the autocomplete input element. */ inputProps: InputHTMLAttributes, + // TODO change this menu props /** Props for the list box, to be passed to [useListBox](useListBox.html). */ listBoxProps: AriaListBoxOptions, - /** Props for the optional trigger button, to be passed to [useButton](useButton.html). */ - buttonProps: AriaButtonProps, - /** Props for the combo box description element, if any. */ + /** Props for the autocomplete description element, if any. */ descriptionProps: DOMAttributes, - /** Props for the combo box error message element, if any. */ + /** Props for the autocomplete error message element, if any. */ errorMessageProps: DOMAttributes } /** - * Provides the behavior and accessibility implementation for a combo box component. - * A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query. - * @param props - Props for the combo box. - * @param state - State for the select, as returned by `useComboBoxState`. + * Provides the behavior and accessibility implementation for a autocomplete component. + * A autocomplete combines a text input with a listbox, allowing users to filter a list of options to items matching a query. + * @param props - Props for the autocomplete. + * @param state - State for the autocomplete, as returned by `useAutocompleteState`. */ -export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBoxState): AutocompleteAria { +export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { - buttonRef, - // popoverRef, inputRef, listBoxRef, keyboardDelegate, layoutDelegate, - // completionMode = 'suggest', shouldFocusWrap, isReadOnly, isDisabled } = props; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/combobox'); - // TODO: we will only need the menu props for the id for listData (might need a replacement aria-labelledby and autofocus?) - let {menuProps} = useMenuTrigger( - { - type: 'listbox', - isDisabled: isDisabled || isReadOnly - }, - state, - buttonRef - ); + // TODO: we will only need the menu props for the id for listData (might need a replacement for aria-labelledby and autofocus?) + let menuId = useId(); // Set listbox id so it can be used when calling getItemId later - listData.set(state, {id: menuProps.id}); + listData.set(state, {id: menuId}); // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). // When virtualized, the layout object will be passed in as a prop and override this. @@ -119,6 +104,7 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo shouldFocusWrap, ref: inputRef, // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component + // TODO: If we are using menu, then maybe we get rid of this? isVirtualized: true }); @@ -132,16 +118,11 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo switch (e.key) { case 'Enter': case 'Tab': - // // Prevent form submission if menu is open since we may be selecting a option - // if (state.isOpen && e.key === 'Enter') { - // e.preventDefault(); - // } // TODO: Prevent form submission at all times? e.preventDefault(); - // If the focused item is a link, trigger opening it. Items that are links are not selectable. - if (state.isOpen && state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { + if (state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { if (e.key === 'Enter') { let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); if (item instanceof HTMLAnchorElement) { @@ -150,8 +131,8 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo } } - // TODO: for now keep close and commit? But perhaps this should always just be commit? Maybe need to replace useComboboxState since the open state tracking may prove problematic - state.close(); + // TODO: previously used to call state.close here which would toggle selection for a link and set the input value to that link's input text + // I think that doens't make sense really so opting to do nothing here. } else { state.commit(); } @@ -168,17 +149,9 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo // TODO: right now hitting escape multiple times will not clear the input field, perhaps only do that if the user provides a searchfiled to the autocomplete state.revert(); break; - // TODO: replace up and down with setFocusStrategy eventually, but may need to rip out the open state stuff first case 'ArrowDown': - state.selectionManager.setFocused(true); - // state.open('first', 'manual'); - // state.setFocusStrategy('first') - - break; case 'ArrowUp': state.selectionManager.setFocused(true); - // state.open('last', 'manual'); - // state.setFocusStrategy('last'); break; case 'ArrowLeft': case 'ArrowRight': @@ -188,14 +161,6 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo }; let onBlur = (e: FocusEvent) => { - // TODO: no more button or popover - // let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; - // let blurIntoPopover = popoverRef.current?.contains(e.relatedTarget); - // // Ignore blur if focused moved to the button(if exists) or into the popover. - // if (blurFromButton || blurIntoPopover) { - // return; - // } - if (props.onBlur) { props.onBlur(e); } @@ -219,8 +184,6 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({ ...props, onChange: state.setInputValue, - // TODO: no longer need isOpen logic since it is always technically open - // onKeyDown: !isReadOnly ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, onKeyDown: !isReadOnly ? chain(collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, onBlur, value: state.inputValue, @@ -230,30 +193,8 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo [privateValidationStateProp]: state }, inputRef); - // Press handlers for the ComboBox button - // let onPress = (e: PressEvent) => { - // if (e.pointerType === 'touch') { - // // Focus the input field in case it isn't focused yet - // inputRef.current.focus(); - // state.toggle(null, 'manual'); - // } - // }; - - // let onPressStart = (e: PressEvent) => { - // if (e.pointerType !== 'touch') { - // inputRef.current.focus(); - // state.toggle((e.pointerType === 'keyboard' || e.pointerType === 'virtual') ? 'first' : null, 'manual'); - // } - // }; - - // let triggerLabelProps = useLabels({ - // id: menuTriggerProps.id, - // 'aria-label': stringFormatter.format('buttonLabel'), - // 'aria-labelledby': props['aria-labelledby'] || labelProps.id - // }); - let listBoxProps = useLabels({ - id: menuProps.id, + id: menuId, 'aria-label': stringFormatter.format('listboxLabel'), 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); @@ -281,8 +222,6 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo if (touch.clientX === centerX && touch.clientY === centerY) { e.preventDefault(); inputRef.current.focus(); - // TODO: don't need this because it is technically always open - // state.toggle(null, 'manual'); lastEventTime.current = e.timeStamp; } @@ -291,7 +230,7 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo // VoiceOver has issues with announcing aria-activedescendant properly on change // (especially on iOS). We use a live region announcer to announce focus changes // manually. In addition, section titles are announced when navigating into a new section. - let focusedItem = state.selectionManager.focusedKey != null && state.isOpen + let focusedItem = state.selectionManager.focusedKey != null ? state.collection.getItem(state.selectionManager.focusedKey) : undefined; let sectionKey = focusedItem?.parentKey ?? null; @@ -322,25 +261,23 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo // Announce the number of available suggestions when it changes let optionCount = getItemCount(state.collection); let lastSize = useRef(optionCount); - // let lastOpen = useRef(state.isOpen); - // TODO: test this behavior below + let [announced, setAnnounced] = useState(false); + + // TODO: test this behavior below, now that there isn't a open state this should just announce for the first render in which the field is focused? useEffect(() => { - // Only announce the number of options available when the menu opens if there is no + // Only announce the number of options available when the autocomplete first renders if there is no // focused item, otherwise screen readers will typically read e.g. "1 of 6". // The exception is VoiceOver since this isn't included in the message above. - let didOpenWithoutFocusedItem = - // state.isOpen !== lastOpen.current && - (state.selectionManager.focusedKey == null || isAppleDevice()); + let didRenderWithoutFocusedItem = !announced && (state.selectionManager.focusedKey == null || isAppleDevice()); - // if (state.isOpen && (didOpenWithoutFocusedItem || optionCount !== lastSize.current)) { - if ((didOpenWithoutFocusedItem || optionCount !== lastSize.current)) { + if ((didRenderWithoutFocusedItem || optionCount !== lastSize.current)) { let announcement = stringFormatter.format('countAnnouncement', {optionCount}); announce(announcement); + setAnnounced(true); } lastSize.current = optionCount; - // lastOpen.current = state.isOpen; - }); + }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. let lastSelectedKey = useRef(state.selectedKey); @@ -355,29 +292,10 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo lastSelectedKey.current = state.selectedKey; }); - // TODO: may need a replacement for this? Actually I don't think we do since we just hide everything outside the popover which will be external to this component - // useEffect(() => { - // if (state.isOpen) { - // return ariaHideOutside([inputRef.current, popoverRef.current]); - // } - // }, [state.isOpen, inputRef, popoverRef]); - return { labelProps, - // TODO get rid of - buttonProps: { - // ...menuTriggerProps, - // ...triggerLabelProps, - // excludeFromTabOrder: true, - // preventFocusOnPress: true, - // onPress, - // onPressStart, - // isDisabled: isDisabled || isReadOnly - }, inputProps: mergeProps(inputProps, { - // role: 'combobox', - // 'aria-expanded': menuTriggerProps['aria-expanded'], - 'aria-controls': state.isOpen ? menuProps.id : undefined, + 'aria-controls': menuId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, @@ -387,8 +305,7 @@ export function useAutocomplete(props: AriaComboBoxOptions, state: ComboBo // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false' }), - listBoxProps: mergeProps(menuProps, listBoxProps, { - autoFocus: state.focusStrategy, + listBoxProps: mergeProps(listBoxProps, { shouldUseVirtualFocus: true, shouldSelectOnPressUp: true, shouldFocusOnHover: true, diff --git a/packages/@react-stately/autocomplete/src/index.ts b/packages/@react-stately/autocomplete/src/index.ts index f0d3f97e660..0d35f371a84 100644 --- a/packages/@react-stately/autocomplete/src/index.ts +++ b/packages/@react-stately/autocomplete/src/index.ts @@ -13,4 +13,4 @@ export {useAutocompleteState} from './useAutocompleteState'; // TODO export the types -// export type {ComboBoxStateOptions, ComboBoxState} from './useComboBoxState'; +export type {AutocompleteProps, AutocompleteStateOptions, AutocompleteState} from './useAutocompleteState'; diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 0efc07c2832..1a4031175fa 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -10,72 +10,79 @@ * governing permissions and limitations under the License. */ -import {Collection, CollectionStateBase, FocusStrategy, Node} from '@react-types/shared'; -import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox'; +import {Collection, CollectionBase, CollectionStateBase, FocusableProps, HelpTextProps, InputBase, Key, LabelableProps, Node, SingleSelection, TextInputBase, Validation} from '@react-types/shared'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getChildNodes} from '@react-stately/collections'; import {ListCollection, useSingleSelectListState} from '@react-stately/list'; import {SelectState} from '@react-stately/select'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useControlledState} from '@react-stately/utils'; -import {useOverlayTriggerState} from '@react-stately/overlays'; +import {useEffect, useMemo, useRef, useState} from 'react'; -export interface AutocompleteState extends SelectState, FormValidationState{ +export interface AutocompleteState extends Omit, 'focusStrategy' | 'open' | 'close' | 'toggle' | 'isOpen' | 'setOpen'>, FormValidationState{ /** The current value of the combo box input. */ inputValue: string, /** Sets the value of the combo box input. */ setInputValue(value: string): void, /** Selects the currently focused item and updates the input value. */ commit(): void, - /** Controls which item will be auto focused when the menu opens. */ - readonly focusStrategy: FocusStrategy | null, - // /** Opens the menu. */ - // open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, - /** Toggles the menu. */ - toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, - /** Resets the input value to the previously selected item's text if any and closes the menu. */ + /** Resets the input value to the previously selected item's text if any. */ revert(): void } type FilterFn = (textValue: string, inputValue: string) => boolean; -export interface AutocompleteStateOptions extends Omit, 'children'>, CollectionStateBase { - /** The filter function used to determine if a option should be included in the combo box list. */ - defaultFilter?: FilterFn, - /** Whether the combo box allows the menu to be open when the collection is empty. */ - allowsEmptyCollection?: boolean, - /** Whether the combo box menu should close on blur. */ - shouldCloseOnBlur?: boolean +// TODO the below interface and props are pretty much copied from combobox props but without onOpenChange and any other open related ones. See if we need to remove anymore +interface AutocompleteValidationValue { + /** The selected key in the ComboBox. */ + selectedKey: Key | null, + /** The value of the ComboBox input. */ + inputValue: string +} + +export interface AutocompleteProps extends CollectionBase, Omit, InputBase, TextInputBase, Validation, FocusableProps, LabelableProps, HelpTextProps { + /** The list of autocomplete items (uncontrolled). */ + defaultItems?: Iterable, + /** The list of autocomplete items (controlled). */ + items?: Iterable, + /** Handler that is called when the selection changes. */ + onSelectionChange?: (key: Key | null) => void, + /** The value of the autocomplete input (controlled). */ + inputValue?: string, + /** The default value of the autocomplete input (uncontrolled). */ + defaultInputValue?: string, + /** Handler that is called when the autocomplete input value changes. */ + onInputChange?: (value: string) => void, + /** Whether the autocomplete allows a non-item matching input value to be set. */ + allowsCustomValue?: boolean +} + +export interface AutocompleteStateOptions extends Omit, 'children'>, CollectionStateBase { + /** The filter function used to determine if a option should be included in the autocomplete list. */ + defaultFilter?: FilterFn } /** - * Provides state management for a combo box component. Handles building a collection - * of items from props and manages the option selection state of the combo box. In addition, it tracks the input value, - * focus state, and other properties of the combo box. + * Provides state management for a autocomplete component. Handles building a collection + * of items from props and manages the option selection state of the autocomplete component. In addition, it tracks the input value, + * focus state, and other properties of the autocomplete. */ export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState { let { defaultFilter, - menuTrigger = 'input', - allowsEmptyCollection = false, - allowsCustomValue, - shouldCloseOnBlur = true + allowsCustomValue } = props; - let [showAllItems, setShowAllItems] = useState(false); let [isFocused, setFocusedState] = useState(false); - let [focusStrategy, setFocusStrategy] = useState(null); let onSelectionChange = (key) => { if (props.onSelectionChange) { props.onSelectionChange(key); } - // If key is the same, reset the inputValue and close the menu + // If key is the same, reset the inputValue // (scenario: user clicks on already selected option) if (key === selectedKey) { resetInputValue(); - // closeMenu(); } }; @@ -105,91 +112,14 @@ export function useAutocompleteState(props: AutocompleteStateO props.onInputChange ); - // Preserve original collection so we can show all items on demand - // TODO I think we can get rid of original collection - // let originalCollection = collection; let filteredCollection = useMemo(() => ( // No default filter if items are controlled. props.items != null || !defaultFilter ? collection : filterCollection(collection, inputValue, defaultFilter) ), [collection, inputValue, defaultFilter, props.items]); - // let [lastCollection, setLastCollection] = useState(filteredCollection); - - // TODO remove - // Track what action is attempting to open the menu - // let menuOpenTrigger = useRef('focus'); - // let onOpenChange = (open: boolean) => { - // if (props.onOpenChange) { - // props.onOpenChange(open, open ? menuOpenTrigger.current : undefined); - // } - - // selectionManager.setFocused(open); - // if (!open) { - // selectionManager.setFocusedKey(null); - // } - // }; - - // let triggerState = useOverlayTriggerState({...props, onOpenChange, isOpen: undefined, defaultOpen: undefined}); - // let open = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => { - // let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); - // // Prevent open operations from triggering if there is nothing to display - // // Also prevent open operations from triggering if items are uncontrolled but defaultItems is empty, even if displayAllItems is true. - // // This is to prevent comboboxes with empty defaultItems from opening but allow controlled items comboboxes to open even if the inital list is empty (assumption is user will provide swap the empty list with a base list via onOpenChange returning `menuTrigger` manual) - // if (allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) { - // if (displayAllItems && !triggerState.isOpen && props.items === undefined) { - // // Show all items if menu is manually opened. Only care about this if items are undefined - // setShowAllItems(true); - // } - - // menuOpenTrigger.current = trigger; - // setFocusStrategy(focusStrategy); - // triggerState.open(); - // } - // }; - - // let toggle = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => { - // let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); - // // If the menu is closed and there is nothing to display, early return so toggle isn't called to prevent extraneous onOpenChange - // if (!(allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) && !triggerState.isOpen) { - // return; - // } - - // if (displayAllItems && !triggerState.isOpen && props.items === undefined) { - // // Show all items if menu is toggled open. Only care about this if items are undefined - // setShowAllItems(true); - // } - - // // Only update the menuOpenTrigger if menu is currently closed - // if (!triggerState.isOpen) { - // menuOpenTrigger.current = trigger; - // } - - // toggleMenu(focusStrategy); - // }; - - // let updateLastCollection = useCallback(() => { - // setLastCollection(showAllItems ? originalCollection : filteredCollection); - // }, [showAllItems, originalCollection, filteredCollection]); - - // If menu is going to close, save the current collection so we can freeze the displayed collection when the - // user clicks outside the popover to close the menu. Prevents the menu contents from updating as the menu closes. - // let toggleMenu = useCallback((focusStrategy: FocusStrategy | null = null) => { - // if (triggerState.isOpen) { - // updateLastCollection(); - // } - - // setFocusStrategy(focusStrategy); - // triggerState.toggle(); - // }, [triggerState, updateLastCollection]); - - // let closeMenu = useCallback(() => { - // if (triggerState.isOpen) { - // updateLastCollection(); - // triggerState.close(); - // } - // }, [triggerState, updateLastCollection]); + // TODO: maybe revisit and see if we can simplify it more at all let [lastValue, setLastValue] = useState(inputValue); let resetInputValue = () => { let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; @@ -204,41 +134,9 @@ export function useAutocompleteState(props: AutocompleteStateO // intentional omit dependency array, want this to happen on every render // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { - // Open and close menu automatically when the input value changes if the input is focused, - // and there are items in the collection or allowEmptyCollection is true. - // if ( - // isFocused && - // (filteredCollection.size > 0 || allowsEmptyCollection) && - // !triggerState.isOpen && - // inputValue !== lastValue && - // menuTrigger !== 'manual' - // ) { - // open(null, 'input'); - // } - - // // Close the menu if the collection is empty. Don't close menu if filtered collection size is 0 - // // but we are currently showing all items via button press - // if ( - // !showAllItems && - // !allowsEmptyCollection && - // triggerState.isOpen && - // filteredCollection.size === 0 - // ) { - // closeMenu(); - // } - - // // Close when an item is selected. - // if ( - // selectedKey != null && - // selectedKey !== lastSelectedKey.current - // ) { - // closeMenu(); - // } - - // Clear focused key when input value changes and display filtered collection again. + // Clear focused key when input value changes. if (inputValue !== lastValue) { selectionManager.setFocusedKey(null); - setShowAllItems(false); // Set selectedKey to null when the user clears the input. // If controlled, this is the application developer's responsibility. @@ -280,7 +178,7 @@ export function useAutocompleteState(props: AutocompleteStateO value: useMemo(() => ({inputValue, selectedKey}), [inputValue, selectedKey]) }); - // Revert input value and close menu + // Revert input value let revert = () => { if (allowsCustomValue && selectedKey == null) { commitCustomValue(); @@ -292,22 +190,17 @@ export function useAutocompleteState(props: AutocompleteStateO let commitCustomValue = () => { lastSelectedKey.current = null; setSelectedKey(null); - // closeMenu(); }; let commitSelection = () => { // If multiple things are controlled, call onSelectionChange if (props.selectedKey !== undefined && props.inputValue !== undefined) { props.onSelectionChange?.(selectedKey); - - // Stop menu from reopening from useEffect let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; setLastValue(itemText); - // closeMenu(); } else { - // If only a single aspect of combobox is controlled, reset input value and close menu for the user + // If only a single aspect of autocomplete is controlled, reset input value resetInputValue(); - // closeMenu(); } }; @@ -316,25 +209,13 @@ export function useAutocompleteState(props: AutocompleteStateO const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; (inputValue === itemText) ? commitSelection() : commitCustomValue(); } else { - // Reset inputValue and close menu + // Reset inputValue commitSelection(); } }; let commit = () => { - // if (triggerState.isOpen && selectionManager.focusedKey != null) { - // // Reset inputValue and close menu here if the selected key is already the focused key. Otherwise - // // fire onSelectionChange to allow the application to control the closing. - // if (selectedKey === selectionManager.focusedKey) { - // commitSelection(); - // } else { - // setSelectedKey(selectionManager.focusedKey); - // } - // } else { - // commitValue(); - // } - - // Reset inputValue and close menu here if the selected key is already the focused key. Otherwise + // Reset inputValue if the selected key is already the focused key. Otherwise // fire onSelectionChange to allow the application to control the closing. if (selectedKey === selectionManager.focusedKey) { commitSelection(); @@ -347,13 +228,8 @@ export function useAutocompleteState(props: AutocompleteStateO let setFocused = (isFocused: boolean) => { if (isFocused) { valueOnFocus.current = inputValue; - // if (menuTrigger === 'focus' && !props.isReadOnly) { - // open(null, 'focus'); - // } } else { - // if (shouldCloseOnBlur) { commitValue(); - // } if (inputValue !== valueOnFocus.current) { validation.commitValidation(); @@ -363,25 +239,8 @@ export function useAutocompleteState(props: AutocompleteStateO setFocusedState(isFocused); }; - // let displayedCollection = useMemo(() => { - // // if (triggerState.isOpen) { - // // if (showAllItems) { - // // return originalCollection; - // // } else { - // return filteredCollection; - // // } - // // } else { - // // return lastCollection; - // // } - // }, [triggerState.isOpen, originalCollection, filteredCollection, showAllItems, lastCollection]); - return { ...validation, - // ...triggerState, - focusStrategy, - // toggle, - // open, - // close: commitValue, selectionManager, selectedKey, setSelectedKey, diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 8c4b5f8cea0..afa19b5d5a2 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -9,32 +9,25 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {AriaComboBoxProps, useFilter} from 'react-aria'; -import {ButtonContext} from './Button'; -import {Collection, ComboBoxState, Node} from 'react-stately'; + +import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete'; +import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; +import {Collection, Node} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps} from '@react-aria/utils'; import {FormContext} from './Form'; import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; import {ListBoxContext, ListStateContext} from './ListBox'; -import {OverlayTriggerStateContext} from './Dialog'; -import {PopoverContext} from './Popover'; -import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, useMemo, useRef} from 'react'; import {TextContext} from './Text'; -import { useAutocomplete } from '@react-aria/autocomplete'; -import { useAutocompleteState } from '@react-stately/autocomplete'; +import {useFilter} from 'react-aria'; export interface AutocompleteRenderProps { - // /** - // * Whether the combobox is currently open. - // * @selector [data-open] - // */ - // isOpen: boolean, /** * Whether the autocomplete is disabled. * @selector [data-disabled] @@ -52,8 +45,7 @@ export interface AutocompleteRenderProps { isRequired: boolean } -// TODO get rid of any other combobox specific props here -export interface AutocompleteProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { +export interface AutocompleteProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { /** The filter function used to determine if a option should be included in the autocomplete list. */ defaultFilter?: (textValue: string, inputValue: string) => boolean, /** @@ -62,21 +54,19 @@ export interface AutocompleteProps extends Omit, HTMLDivElement>>(null); -export const ComboBoxStateContext = createContext | null>(null); +export const AutocompleteStateContext = createContext | null>(null); function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, AutocompleteContext); let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; + // TODO will need to replace with menu (aka replicate Listbox's stuff in Menu) let content = useMemo(() => ( {typeof children === 'function' ? children({ - isOpen: false, isDisabled, isInvalid, isRequired, @@ -121,17 +111,13 @@ function AutocompleteInner({props, collection, autocompleteRef collection, validationBehavior }); - // console.log('state', state) - let buttonRef = useRef(null); let inputRef = useRef(null); let listBoxRef = useRef(null); - let popoverRef = useRef(null); let [labelRef, label] = useSlot(); // TODO: replace with useAutocomplete let { - buttonProps, inputProps, listBoxProps, labelProps, @@ -142,39 +128,17 @@ function AutocompleteInner({props, collection, autocompleteRef ...removeDataAttributes(props), label, inputRef, - buttonRef, listBoxRef, - popoverRef, name: formValue === 'text' ? name : undefined, validationBehavior }, state); - - // TODO: comment these out when you get Autocomplete working in the story - // Make menu width match input + button - // let [menuWidth, setMenuWidth] = useState(null); - // let onResize = useCallback(() => { - // if (inputRef.current) { - // let buttonRect = buttonRef.current?.getBoundingClientRect(); - // let inputRect = inputRef.current.getBoundingClientRect(); - // let minX = buttonRect ? Math.min(buttonRect.left, inputRect.left) : inputRect.left; - // let maxX = buttonRect ? Math.max(buttonRect.right, inputRect.right) : inputRect.right; - // setMenuWidth((maxX - minX) + 'px'); - // } - // }, [buttonRef, inputRef, setMenuWidth]); - - // useResizeObserver({ - // ref: inputRef, - // onResize: onResize - // }); - // Only expose a subset of state to renderProps function to avoid infinite render loop let renderPropsState = useMemo(() => ({ - // isOpen: state.isOpen, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, isRequired: props.isRequired || false - }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired]); + }), [props.isDisabled, validation.isInvalid, props.isRequired]); let renderProps = useRenderProps({ ...props, @@ -189,21 +153,9 @@ function AutocompleteInner({props, collection, autocompleteRef return ( ({props, collection, autocompleteRef ref={ref} slot={props.slot || undefined} data-focused={state.isFocused || undefined} - // data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} data-required={props.isRequired || undefined} /> From 61aab83b874f97d739fb0e944349fb0a985de4ac Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 9 Oct 2024 17:26:36 -0700 Subject: [PATCH 04/42] fix lint --- .../@react-stately/autocomplete/package.json | 1 - .../stories/Autocomplete.stories.tsx | 76 ++++++------------- 2 files changed, 23 insertions(+), 54 deletions(-) diff --git a/packages/@react-stately/autocomplete/package.json b/packages/@react-stately/autocomplete/package.json index 296a5b74736..00dd5e0e825 100644 --- a/packages/@react-stately/autocomplete/package.json +++ b/packages/@react-stately/autocomplete/package.json @@ -1,7 +1,6 @@ { "name": "@react-stately/autocomplete", "version": "3.0.0-alpha.1", - "private": true, "description": "Spectrum UI components in React", "license": "Apache-2.0", "main": "dist/main.js", diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 64ebf6a2e1c..5c613d6158a 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Autocomplete, Button, Input, Label, ListBox, Popover} from 'react-aria-components'; +import {Autocomplete, Input, Label, ListBox} from 'react-aria-components'; import {MyListBoxItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; @@ -25,11 +25,7 @@ export const AutocompleteExample = () => (
-
- {/* */} @@ -38,7 +34,6 @@ export const AutocompleteExample = () => ( Baz Google - {/* */} ); @@ -50,22 +45,17 @@ interface AutocompleteItem { let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; export const AutocompleteRenderPropsStatic = () => ( - {({isOpen}) => ( + {() => ( <>
-
- {/* */} - - Foo - Bar - Baz - - {/* */} + + Foo + Bar + Baz + )}
@@ -73,20 +63,15 @@ export const AutocompleteRenderPropsStatic = () => ( export const AutocompleteRenderPropsDefaultItems = () => ( - {({isOpen}) => ( + {() => ( <>
-
- {/* */} - - {(item: AutocompleteItem) => {item.name}} - - {/* */} + + {(item: AutocompleteItem) => {item.name}} + )}
@@ -95,20 +80,15 @@ export const AutocompleteRenderPropsDefaultItems = () => ( export const AutocompleteRenderPropsItems = { render: () => ( - {({isOpen}) => ( + {() => ( <>
-
- {/* */} - - {(item: AutocompleteItem) => {item.name}} - - {/* */} + + {(item: AutocompleteItem) => {item.name}} + )}
@@ -122,20 +102,15 @@ export const AutocompleteRenderPropsItems = { export const AutocompleteRenderPropsListBoxDynamic = () => ( - {({isOpen}) => ( + {() => ( <>
-
- {/* */} - - {item => {item.name}} - - {/* */} + + {item => {item.name}} + )}
@@ -170,16 +145,11 @@ export const AutocompleteAsyncLoadingExample = () => {
-
- {/* */} - - className={styles.menu}> - {item => {item.name}} - - {/* */} + + className={styles.menu}> + {item => {item.name}} + ); }; From 66fecc8eeb8daa7bf7ec47d18b4edd9f7d18f808 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 11 Oct 2024 14:46:47 -0700 Subject: [PATCH 05/42] rough working version of Menu instead of Listbox in autocomplete issues outlined in various comments, basically boils down to ideally using the wrapped collection components state --- .../autocomplete/src/useAutocomplete.ts | 59 ++++++++------ packages/@react-aria/menu/src/index.ts | 2 +- packages/@react-aria/menu/src/useMenu.ts | 18 ++++- packages/@react-aria/menu/src/useMenuItem.ts | 11 ++- .../autocomplete/src/useAutocompleteState.ts | 8 +- .../src/Autocomplete.tsx | 35 ++++---- packages/react-aria-components/src/Menu.tsx | 38 ++++++--- .../stories/Autocomplete.stories.tsx | 81 ++++++++++++------- yarn.lock | 1 + 9 files changed, 163 insertions(+), 90 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 1e0e3e9b6c8..f24223a1500 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -12,7 +12,7 @@ import {announce} from '@react-aria/live-announcer'; import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, KeyboardDelegate, LayoutDelegate, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; +import {AriaMenuOptions, menuData} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, isAppleDevice, mergeProps, useId, useLabels, useRouter} from '@react-aria/utils'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; @@ -24,17 +24,18 @@ import {privateValidationStateProp} from '@react-stately/form'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTextField} from '@react-aria/textfield'; - export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps { /** Whether keyboard navigation is circular. */ shouldFocusWrap?: boolean } +// TODO: all of this is menu specific but will need to eventually be agnostic to what collection element is inside +// Update all instances of menu then export interface AriaAutocompleteOptions extends Omit, 'children'>, DOMProps, InputDOMProps, AriaLabelingProps { /** The ref for the input element. */ inputRef: RefObject, - /** The ref for the list box. */ - listBoxRef: RefObject, + /** The ref for the menu. */ + menuRef: RefObject, /** An optional keyboard delegate implementation, to override the default. */ keyboardDelegate?: KeyboardDelegate, /** @@ -50,8 +51,8 @@ export interface AutocompleteAria extends ValidationResult { /** Props for the autocomplete input element. */ inputProps: InputHTMLAttributes, // TODO change this menu props - /** Props for the list box, to be passed to [useListBox](useListBox.html). */ - listBoxProps: AriaListBoxOptions, + /** Props for the menu, to be passed to [useMenu](useMenu.html). */ + menuProps: AriaMenuOptions, /** Props for the autocomplete description element, if any. */ descriptionProps: DOMAttributes, /** Props for the autocomplete error message element, if any. */ @@ -60,14 +61,14 @@ export interface AutocompleteAria extends ValidationResult { /** * Provides the behavior and accessibility implementation for a autocomplete component. - * A autocomplete combines a text input with a listbox, allowing users to filter a list of options to items matching a query. + * A autocomplete combines a text input with a menu, allowing users to filter a list of options to items matching a query. * @param props - Props for the autocomplete. * @param state - State for the autocomplete, as returned by `useAutocompleteState`. */ export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, - listBoxRef, + menuRef, keyboardDelegate, layoutDelegate, shouldFocusWrap, @@ -75,12 +76,13 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut isDisabled } = props; - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/combobox'); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); // TODO: we will only need the menu props for the id for listData (might need a replacement for aria-labelledby and autofocus?) let menuId = useId(); - // Set listbox id so it can be used when calling getItemId later - listData.set(state, {id: menuId}); + // TODO: this doesn't work because menu won't use the autocompletestate but rather its own tree state + // @ts-ignore + menuData.set(state, {id: menuId}); // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). // When virtualized, the layout object will be passed in as a prop and override this. @@ -90,10 +92,10 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut keyboardDelegate || new ListKeyboardDelegate({ collection, disabledKeys, - ref: listBoxRef, + ref: menuRef, layoutDelegate }) - ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, listBoxRef]); + ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, menuRef]); // Use useSelectableCollection to get the keyboard handlers to apply to the textfield let {collectionProps} = useSelectableCollection({ @@ -102,10 +104,10 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut disallowTypeAhead: true, disallowEmptySelection: true, shouldFocusWrap, - ref: inputRef, + ref: inputRef // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component - // TODO: If we are using menu, then maybe we get rid of this? - isVirtualized: true + // TODO: If we are using menu, then maybe we get rid of this? However will be applicable for other virtualized collection components + // isVirtualized: true }); let router = useRouter(); @@ -124,10 +126,10 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut // If the focused item is a link, trigger opening it. Items that are links are not selectable. if (state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { if (e.key === 'Enter') { - let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); + let item = menuRef.current?.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); if (item instanceof HTMLAnchorElement) { let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); - router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); + collectionItem && router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); } } @@ -185,21 +187,25 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut ...props, onChange: state.setInputValue, onKeyDown: !isReadOnly ? chain(collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, + // TODO: figure out how to fix these ts errors + // @ts-ignore onBlur, value: state.inputValue, + // @ts-ignore onFocus, autoComplete: 'off', validate: undefined, [privateValidationStateProp]: state }, inputRef); - let listBoxProps = useLabels({ + let menuProps = useLabels({ id: menuId, + // TODO: update this 'aria-label': stringFormatter.format('listboxLabel'), 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); - // If a touch happens on direct center of ComboBox input, might be virtual click from iPad so open ComboBox menu + // If a touch happens on direct center of Autocomplete input, might be virtual click from iPad let lastEventTime = useRef(0); let onTouchEnd = (e: TouchEvent) => { if (isDisabled || isReadOnly) { @@ -209,7 +215,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut // Sometimes VoiceOver on iOS fires two touchend events in quick succession. Ignore the second one. if (e.timeStamp - lastEventTime.current < 500) { e.preventDefault(); - inputRef.current.focus(); + inputRef.current?.focus(); return; } @@ -221,7 +227,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut if (touch.clientX === centerX && touch.clientY === centerY) { e.preventDefault(); - inputRef.current.focus(); + inputRef.current?.focus(); lastEventTime.current = e.timeStamp; } @@ -244,7 +250,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; let announcement = stringFormatter.format('focusAnnouncement', { - isGroupChange: section && sectionKey !== lastSection.current, + isGroupChange: !!section && sectionKey !== lastSection.current, groupTitle: sectionTitle, groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, optionText: focusedItem['aria-label'] || focusedItem.textValue || '', @@ -298,14 +304,17 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut 'aria-controls': menuId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', - 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, + // TODO: will need a way to get the currently focused menuitem's id. This is currently difficult since the + // menu uses useTreeState which useAutocomplete state doesn't substitute for + // 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, + 'aria-activedescendant': focusedItem ? `${menuId}-option-${focusedItem.key}` : undefined, onTouchEnd, // This disable's iOS's autocorrect suggestions, since the combo box provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false' }), - listBoxProps: mergeProps(listBoxProps, { + menuProps: mergeProps(menuProps, { shouldUseVirtualFocus: true, shouldSelectOnPressUp: true, shouldFocusOnHover: true, diff --git a/packages/@react-aria/menu/src/index.ts b/packages/@react-aria/menu/src/index.ts index 7aa44be2fce..18be8eacc3a 100644 --- a/packages/@react-aria/menu/src/index.ts +++ b/packages/@react-aria/menu/src/index.ts @@ -11,7 +11,7 @@ */ export {useMenuTrigger} from './useMenuTrigger'; -export {useMenu} from './useMenu'; +export {useMenu, menuData} from './useMenu'; export {useMenuItem} from './useMenuItem'; export {useMenuSection} from './useMenuSection'; export {useSubmenuTrigger} from './useSubmenuTrigger'; diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index b13c2a22f07..1f7c3137591 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -12,7 +12,7 @@ import {AriaMenuProps} from '@react-types/menu'; import {DOMAttributes, Key, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {TreeState} from '@react-stately/tree'; import {useSelectableList} from '@react-aria/selection'; @@ -29,12 +29,18 @@ export interface AriaMenuOptions extends Omit, 'children'>, * An optional keyboard delegate implementation for type to select, * to override the default. */ - keyboardDelegate?: KeyboardDelegate + keyboardDelegate?: KeyboardDelegate, + /** + * Whether the menu items should use virtual focus instead of being focused directly. + */ + shouldUseVirtualFocus?: boolean } interface MenuData { onClose?: () => void, - onAction?: (key: Key) => void + onAction?: (key: Key) => void, + shouldUseVirtualFocus?: boolean, + id: string } export const menuData = new WeakMap, MenuData>(); @@ -68,15 +74,19 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: linkBehavior: 'override' }); + let id = useId(props.id); menuData.set(state, { onClose: props.onClose, - onAction: props.onAction + onAction: props.onAction, + shouldUseVirtualFocus: props.shouldUseVirtualFocus, + id }); return { menuProps: mergeProps(domProps, {onKeyDown, onKeyUp}, { role: 'menu', ...listProps, + id, onKeyDown: (e) => { // don't clear the menu selected keys if the user is presses escape since escape closes the menu if (e.key !== 'Escape') { diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index aa19c17f7d4..eaf720178e1 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -123,6 +123,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let isDisabled = props.isDisabled ?? state.selectionManager.isDisabled(key); let isSelected = props.isSelected ?? state.selectionManager.isSelected(key); let data = menuData.get(state); + let item = state.collection.getItem(key); let onClose = props.onClose || data.onClose; let router = useRouter(); @@ -161,6 +162,13 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let descriptionId = useSlotId(); let keyboardId = useSlotId(); + if (data.shouldUseVirtualFocus) { + // TODO: will need to normalize the key and stuff, but need to finalize if + // every component that Autocomplete will accept as a filterable child would need to follow this same + // logic when creating the id + id = `${data.id}-option-${key}`; + } + let ariaProps = { id, 'aria-disabled': isDisabled || undefined, @@ -214,7 +222,8 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re // because we handle it ourselves. The behavior of menus // is slightly different from other collections because // actions are performed on key down rather than key up. - linkBehavior: 'none' + linkBehavior: 'none', + shouldUseVirtualFocus: data.shouldUseVirtualFocus }); let {pressProps, isPressed} = usePress({ diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 1a4031175fa..14e8c8b62cc 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -19,9 +19,9 @@ import {useControlledState} from '@react-stately/utils'; import {useEffect, useMemo, useRef, useState} from 'react'; export interface AutocompleteState extends Omit, 'focusStrategy' | 'open' | 'close' | 'toggle' | 'isOpen' | 'setOpen'>, FormValidationState{ - /** The current value of the combo box input. */ + /** The current value of the autocomplete input. */ inputValue: string, - /** Sets the value of the combo box input. */ + /** Sets the value of the autocomplete input. */ setInputValue(value: string): void, /** Selects the currently focused item and updates the input value. */ commit(): void, @@ -33,9 +33,9 @@ type FilterFn = (textValue: string, inputValue: string) => boolean; // TODO the below interface and props are pretty much copied from combobox props but without onOpenChange and any other open related ones. See if we need to remove anymore interface AutocompleteValidationValue { - /** The selected key in the ComboBox. */ + /** The selected key in the autocomplete. */ selectedKey: Key | null, - /** The value of the ComboBox input. */ + /** The value of the autocomplete input. */ inputValue: string } diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index afa19b5d5a2..9ba4d4048a6 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -22,7 +22,7 @@ import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import {ListBoxContext, ListStateContext} from './ListBox'; +import {MenuContext, MenuStateContext} from './Menu'; import React, {createContext, ForwardedRef, forwardRef, useMemo, useRef} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; @@ -62,9 +62,13 @@ export const AutocompleteStateContext = createContext | n function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, AutocompleteContext); let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; - // TODO will need to replace with menu (aka replicate Listbox's stuff in Menu) + + // TODO: not quite sure if we need to do the below or if I can just do something like }> + // This approach is a 1:1 copy of ComboBox where this renders the Autocomplete's children (aka the Menu) in the fake DOM and constructs a collection which we can filter + // via useAutocomplete state. Said state then gets propagated Menu via AutocompleteInner's context provider so that the Menu's rendered items are mirrored/match the filtered collection + // I think we still have to do this, but geting a bit tripped up with thinking if we could simplify it somehow let content = useMemo(() => ( - + {typeof children === 'function' ? children({ isDisabled, @@ -73,7 +77,7 @@ function Autocomplete(props: AutocompleteProps, ref: Forwar defaultChildren: null }) : children} - + ), [children, isDisabled, isInvalid, isRequired, props.items, props.defaultItems]); return ( @@ -105,7 +109,7 @@ function AutocompleteInner({props, collection, autocompleteRef let state = useAutocompleteState({ defaultFilter: props.defaultFilter || contains, ...props, - // If props.items isn't provided, rely on collection filtering (aka listbox.items is provided or defaultItems provided to Combobox) + // If props.items isn't provided, rely on collection filtering (aka menu.items is provided or defaultItems provided to Autocomplete) items: props.items, children: undefined, collection, @@ -113,13 +117,12 @@ function AutocompleteInner({props, collection, autocompleteRef }); let inputRef = useRef(null); - let listBoxRef = useRef(null); + let menuRef = useRef(null); let [labelRef, label] = useSlot(); - // TODO: replace with useAutocomplete let { inputProps, - listBoxProps, + menuProps, labelProps, descriptionProps, errorMessageProps, @@ -128,7 +131,7 @@ function AutocompleteInner({props, collection, autocompleteRef ...removeDataAttributes(props), label, inputRef, - listBoxRef, + menuRef, name: formValue === 'text' ? name : undefined, validationBehavior }, state); @@ -143,8 +146,7 @@ function AutocompleteInner({props, collection, autocompleteRef let renderProps = useRenderProps({ ...props, values: renderPropsState, - // TODO rename - defaultClassName: 'react-aria-ComboBox' + defaultClassName: 'react-aria-Autocomplete' }); let DOMProps = filterDOMProps(props); @@ -156,8 +158,13 @@ function AutocompleteInner({props, collection, autocompleteRef [AutocompleteStateContext, state], [LabelContext, {...labelProps, ref: labelRef}], [InputContext, {...inputProps, ref: inputRef}], - [ListBoxContext, {...listBoxProps, ref: listBoxRef}], - [ListStateContext, state], + [MenuContext, {...menuProps, ref: menuRef}], + // TODO: this would need to match the state type of whatever child component the autocomplete + // is filtering against... Ideally we'd somehow have the child component communicate its state upwards, upon which we we would filter it from here + // and send it back down but that feels circular. However we need a single SelectionManager to be used by the autocomplete and filtered collection's hooks + // so that the concepts of "selectedKey"/"focused" + // @ts-ignore + [MenuStateContext, state], [TextContext, { slots: { description: descriptionProps, @@ -182,7 +189,7 @@ function AutocompleteInner({props, collection, autocompleteRef } /** - * A autocomplete combines a text input with a listbox, allowing users to filter a list of options to items matching a query. + * A autocomplete combines a text input with a menu, allowing users to filter a list of options to items matching a query. */ const _Autocomplete = /*#__PURE__*/ (forwardRef as forwardRefType)(Autocomplete); export {_Autocomplete as Autocomplete}; diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 1af7a846d52..50ac88f8c7f 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -12,7 +12,7 @@ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria'; -import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; +import {MenuTriggerProps as BaseMenuTriggerProps, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; @@ -152,27 +152,45 @@ export interface MenuProps extends Omit, 'children'>, Collec function Menu(props: MenuProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, MenuContext); + let state = useContext(MenuStateContext); + + // TODO: mimics Listbox so that the Menu content can be controlled by an external field + if (state) { + return ; + } // Delay rendering the actual menu until we have the collection so that auto focus works properly. return ( }> - {collection => collection.size > 0 && } + {collection => collection.size > 0 && } ); } -interface MenuInnerProps { - props: MenuProps, - collection: ICollection>, - menuRef: RefObject -} - -function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { +function StandaloneMenu({props, menuRef, collection}) { + props = {...props, collection, children: null, items: null}; + // TODO: may need this later? + // let {layoutDelegate} = useContext(CollectionRendererContext); + // TODO: this state is different from the one set up by the autocomplete hooks meaning we won't be able to propagate + // the desired menuId and item id that useAutocomplete needs for aria-activedescendant (aka weak map tied to state that sets menu ID and then + // forces menu options to follow a certain pattern). To "fix" this, I split this to StandaloneMenu like it is done + // in ListBox but that means the case with Autocomplete wrapping Menu doesn't use tree state and thus doesn't have things like expanded keys and such. + // However, we need to use a useAutoComplete's state because we need the same selectionManager to be used by menu when checking if an item is focused. let state = useTreeState({ ...props, collection, children: undefined }); + return ; +} + +interface MenuInnerProps { + props: MenuProps, + menuRef: RefObject, + state: TreeState +} + +function MenuInner({props, menuRef: ref, state}: MenuInnerProps) { let [popoverContainer, setPopoverContainer] = useState(null); let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); let {menuProps} = useMenu({...props, isVirtualized}, state, ref); @@ -226,7 +244,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [MenuItemContext, null] ]}>
diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 5c613d6158a..617e0e5d4d8 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {Autocomplete, Input, Label, ListBox} from 'react-aria-components'; -import {MyListBoxItem} from './utils'; +import {Autocomplete, Header, Input, Keyboard, Label, Menu, Section, Separator, Text} from 'react-aria-components'; +import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; import {useAsyncList} from 'react-stately'; @@ -21,19 +21,38 @@ export default { }; export const AutocompleteExample = () => ( - +
- - Foo - Bar - Baz - Google - + +
+ Foo + Bar + Baz + Google +
+ +
+
Section 2
+ + Copy + Description + ⌘C + + + Cut + Description + ⌘X + + + Paste + Description + ⌘V + +
+
); @@ -44,18 +63,18 @@ interface AutocompleteItem { let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; export const AutocompleteRenderPropsStatic = () => ( - + {() => ( <>
- - Foo - Bar - Baz - + + Foo + Bar + Baz + )}
@@ -69,9 +88,9 @@ export const AutocompleteRenderPropsDefaultItems = () => (
- - {(item: AutocompleteItem) => {item.name}} - + + {(item: AutocompleteItem) => {item.name}} + )}
@@ -86,21 +105,21 @@ export const AutocompleteRenderPropsItems = {
- - {(item: AutocompleteItem) => {item.name}} - + + {(item: AutocompleteItem) => {item.name}} + )}
), parameters: { description: { - data: 'Note this won\'t filter the items in the listbox because it is fully controlled' + data: 'Note this won\'t filter the items in the Menu because it is fully controlled' } } }; -export const AutocompleteRenderPropsListBoxDynamic = () => ( +export const AutocompleteRenderPropsMenuDynamic = () => ( {() => ( <> @@ -108,9 +127,9 @@ export const AutocompleteRenderPropsListBoxDynamic = () => (
- - {item => {item.name}} - + + {item => {item.name}} + )}
@@ -146,10 +165,10 @@ export const AutocompleteAsyncLoadingExample = () => {
- + className={styles.menu}> - {item => {item.name}} - + {item => {item.name}} + ); }; diff --git a/yarn.lock b/yarn.lock index 315161ad2b8..3b3b82ab453 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5612,6 +5612,7 @@ __metadata: "@react-aria/selection": "npm:^3.20.0" "@react-aria/textfield": "npm:^3.14.9" "@react-aria/utils": "npm:^3.25.3" + "@react-stately/autocomplete": "npm:3.0.0-alpha.1" "@react-stately/collections": "npm:^3.11.0" "@react-stately/combobox": "npm:^3.10.0" "@react-stately/form": "npm:^3.0.6" From 779309be482053de36e3e746d1607926e6fa64da Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 11 Oct 2024 15:09:57 -0700 Subject: [PATCH 06/42] fix submenu --- packages/react-aria-components/src/Autocomplete.tsx | 4 ++-- packages/react-aria-components/src/Menu.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 9ba4d4048a6..504d984a1d9 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -15,6 +15,7 @@ import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomple import {Collection, Node} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; +import {ExternalMenuStateContext, MenuContext} from './Menu'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps} from '@react-aria/utils'; import {FormContext} from './Form'; @@ -22,7 +23,6 @@ import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import {MenuContext, MenuStateContext} from './Menu'; import React, {createContext, ForwardedRef, forwardRef, useMemo, useRef} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; @@ -164,7 +164,7 @@ function AutocompleteInner({props, collection, autocompleteRef // and send it back down but that feels circular. However we need a single SelectionManager to be used by the autocomplete and filtered collection's hooks // so that the concepts of "selectedKey"/"focused" // @ts-ignore - [MenuStateContext, state], + [ExternalMenuStateContext, state], [TextContext, { slots: { description: descriptionProps, diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 50ac88f8c7f..e7d313acab0 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -43,6 +43,7 @@ import {useSubmenuTrigger} from '@react-aria/menu'; export const MenuContext = createContext, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); +export const ExternalMenuStateContext = createContext | null>(null); export const RootMenuTriggerStateContext = createContext(null); export interface MenuTriggerProps extends BaseMenuTriggerProps { @@ -152,7 +153,7 @@ export interface MenuProps extends Omit, 'children'>, Collec function Menu(props: MenuProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, MenuContext); - let state = useContext(MenuStateContext); + let state = useContext(ExternalMenuStateContext); // TODO: mimics Listbox so that the Menu content can be controlled by an external field if (state) { From 78de7f82f1c25099deb0dd07d67f22dc3522a058 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 18 Oct 2024 17:20:31 -0700 Subject: [PATCH 07/42] Update autocomplete to have the wrapped menu filter itself --- .../@react-aria/autocomplete/package.json | 1 + .../autocomplete/src/useAutocomplete.ts | 368 +++++++----------- .../collections/src/BaseCollection.ts | 161 ++++++++ packages/@react-aria/menu/src/useMenu.ts | 12 +- .../autocomplete/src/useAutocompleteState.ts | 255 ++---------- .../src/Autocomplete.tsx | 140 +++---- packages/react-aria-components/src/Menu.tsx | 79 ++-- .../stories/Autocomplete.stories.tsx | 76 ++-- 8 files changed, 483 insertions(+), 609 deletions(-) diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index 26539457c26..c6713569e4b 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@react-aria/combobox": "^3.10.4", + "@react-aria/focus": "^3.18.3", "@react-aria/i18n": "^3.12.3", "@react-aria/listbox": "^3.13.4", "@react-aria/live-announcer": "^3.4.0", diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index f24223a1500..9a929e22de3 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -12,7 +12,7 @@ import {announce} from '@react-aria/live-announcer'; import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, KeyboardDelegate, LayoutDelegate, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {AriaMenuOptions, menuData} from '@react-aria/menu'; +import {AriaMenuItemProps, AriaMenuOptions, menuData} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, isAppleDevice, mergeProps, useId, useLabels, useRouter} from '@react-aria/utils'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; @@ -23,29 +23,17 @@ import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selecti import {privateValidationStateProp} from '@react-stately/form'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTextField} from '@react-aria/textfield'; +import { focusSafely } from '@react-aria/focus'; -export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps { - /** Whether keyboard navigation is circular. */ - shouldFocusWrap?: boolean -} +export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps {} // TODO: all of this is menu specific but will need to eventually be agnostic to what collection element is inside // Update all instances of menu then export interface AriaAutocompleteOptions extends Omit, 'children'>, DOMProps, InputDOMProps, AriaLabelingProps { /** The ref for the input element. */ - inputRef: RefObject, - /** The ref for the menu. */ - menuRef: RefObject, - /** An optional keyboard delegate implementation, to override the default. */ - keyboardDelegate?: KeyboardDelegate, - /** - * A delegate object that provides layout information for items in the collection. - * By default this uses the DOM, but this can be overridden to implement things like - * virtualized scrolling. - */ - layoutDelegate?: LayoutDelegate + inputRef: RefObject } -export interface AutocompleteAria extends ValidationResult { +export interface AutocompleteAria { /** Props for the label element. */ labelProps: DOMAttributes, /** Props for the autocomplete input element. */ @@ -55,8 +43,9 @@ export interface AutocompleteAria extends ValidationResult { menuProps: AriaMenuOptions, /** Props for the autocomplete description element, if any. */ descriptionProps: DOMAttributes, - /** Props for the autocomplete error message element, if any. */ - errorMessageProps: DOMAttributes + // TODO: fairly non-standard thing to return from a hook, discuss how best to share this with hook only users + // This is for the user to register a callback that upon recieving a keyboard event key returns the expected virtually focused node id + register: (callback: (string) => string) => void } /** @@ -68,49 +57,19 @@ export interface AutocompleteAria extends ValidationResult { export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, - menuRef, - keyboardDelegate, - layoutDelegate, - shouldFocusWrap, - isReadOnly, - isDisabled + isReadOnly } = props; - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); - // TODO: we will only need the menu props for the id for listData (might need a replacement for aria-labelledby and autofocus?) + // let router = useRouter(); let menuId = useId(); - // TODO: this doesn't work because menu won't use the autocompletestate but rather its own tree state - // @ts-ignore - menuData.set(state, {id: menuId}); - - // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). - // When virtualized, the layout object will be passed in as a prop and override this. - let {collection} = state; - let {disabledKeys} = state.selectionManager; - let delegate = useMemo(() => ( - keyboardDelegate || new ListKeyboardDelegate({ - collection, - disabledKeys, - ref: menuRef, - layoutDelegate - }) - ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, menuRef]); - - // Use useSelectableCollection to get the keyboard handlers to apply to the textfield - let {collectionProps} = useSelectableCollection({ - selectionManager: state.selectionManager, - keyboardDelegate: delegate, - disallowTypeAhead: true, - disallowEmptySelection: true, - shouldFocusWrap, - ref: inputRef - // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component - // TODO: If we are using menu, then maybe we get rid of this? However will be applicable for other virtualized collection components - // isVirtualized: true - }); - - let router = useRouter(); + // TODO: may need to move this into Autocomplete? Kinda odd to return this from the hook? Maybe the callback should be considered + // external to the hook, and the onus is on the user to pass in a onKeydown to this hook that updates state.focusedNodeId in response to a key + // stroke + let callbackRef = useRef<(string) => string>(null); + let register = (callback) => { + callbackRef.current = callback; + }; // For textfield specific keydown operations let onKeyDown = (e: BaseEvent>) => { @@ -119,85 +78,76 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } switch (e.key) { case 'Enter': - case 'Tab': - // TODO: Prevent form submission at all times? - e.preventDefault(); - - // If the focused item is a link, trigger opening it. Items that are links are not selectable. - if (state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { - if (e.key === 'Enter') { - let item = menuRef.current?.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); - if (item instanceof HTMLAnchorElement) { - let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); - collectionItem && router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); - } - } - - // TODO: previously used to call state.close here which would toggle selection for a link and set the input value to that link's input text - // I think that doens't make sense really so opting to do nothing here. - } else { - state.commit(); - } + // TODO: how best to trigger the focused element's action? I guess I could dispatch an event + // Also, we might want to add popoverRef so we can bring in MobileCombobox's additional handling for Enter + // to close virtual keyboard, depends if we think this experience is only for in a tray/popover + + + // // If the focused item is a link, trigger opening it. Items that are links are not selectable. + // if (state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { + // if (e.key === 'Enter') { + // let item = menuRef.current?.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); + // if (item instanceof HTMLAnchorElement) { + // let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); + // collectionItem && router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); + // } + // } + + // // TODO: previously used to call state.close here which would toggle selection for a link and set the input value to that link's input text + // // I think that doens't make sense really so opting to do nothing here. + // } else { + // state.commit(); + // } break; case 'Escape': - if ( - state.selectedKey !== null || - state.inputValue === '' || - props.allowsCustomValue - ) { - e.continuePropagation(); + if (state.inputValue !== '') { + state.setInputValue(''); } - // TODO: right now hitting escape multiple times will not clear the input field, perhaps only do that if the user provides a searchfiled to the autocomplete - state.revert(); - break; - case 'ArrowDown': - case 'ArrowUp': - state.selectionManager.setFocused(true); - break; - case 'ArrowLeft': - case 'ArrowRight': - state.selectionManager.setFocusedKey(null); break; } - }; - - let onBlur = (e: FocusEvent) => { - if (props.onBlur) { - props.onBlur(e); + if (callbackRef.current) { + let focusedNodeId = callbackRef.current(e.key); + state.setFocusedNodeId(focusedNodeId); } - state.setFocused(false); }; - let onFocus = (e: FocusEvent) => { - if (state.isFocused) { - return; - } + // let onBlur = (e: FocusEvent) => { + // if (props.onBlur) { + // props.onBlur(e); + // } - if (props.onFocus) { - props.onFocus(e); - } + // state.setFocused(false); + // }; - state.setFocused(true); - }; + // let onFocus = (e: FocusEvent) => { + // if (state.isFocused) { + // return; + // } - let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({ + // if (props.onFocus) { + // props.onFocus(e); + // } + + // state.setFocused(true); + // }; + + let {labelProps, inputProps, descriptionProps} = useTextField({ ...props, onChange: state.setInputValue, - onKeyDown: !isReadOnly ? chain(collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, - // TODO: figure out how to fix these ts errors - // @ts-ignore - onBlur, + onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, + // TODO: will I still need the blur and stuff + // // @ts-ignore + // onBlur, + // // @ts-ignore + // onFocus, value: state.inputValue, - // @ts-ignore - onFocus, autoComplete: 'off', - validate: undefined, - [privateValidationStateProp]: state + validate: undefined }, inputRef); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let menuProps = useLabels({ id: menuId, // TODO: update this @@ -205,110 +155,91 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); - // If a touch happens on direct center of Autocomplete input, might be virtual click from iPad - let lastEventTime = useRef(0); - let onTouchEnd = (e: TouchEvent) => { - if (isDisabled || isReadOnly) { - return; - } - - // Sometimes VoiceOver on iOS fires two touchend events in quick succession. Ignore the second one. - if (e.timeStamp - lastEventTime.current < 500) { - e.preventDefault(); - inputRef.current?.focus(); - return; - } - - let rect = (e.target as Element).getBoundingClientRect(); - let touch = e.changedTouches[0]; - - let centerX = Math.ceil(rect.left + .5 * rect.width); - let centerY = Math.ceil(rect.top + .5 * rect.height); - - if (touch.clientX === centerX && touch.clientY === centerY) { - e.preventDefault(); - inputRef.current?.focus(); - - lastEventTime.current = e.timeStamp; - } - }; - - // VoiceOver has issues with announcing aria-activedescendant properly on change - // (especially on iOS). We use a live region announcer to announce focus changes - // manually. In addition, section titles are announced when navigating into a new section. - let focusedItem = state.selectionManager.focusedKey != null - ? state.collection.getItem(state.selectionManager.focusedKey) - : undefined; - let sectionKey = focusedItem?.parentKey ?? null; - let itemKey = state.selectionManager.focusedKey ?? null; - let lastSection = useRef(sectionKey); - let lastItem = useRef(itemKey); + // TODO: add the stuff from mobile combobox, check if I need the below + // removed touch end since we did the same in MobileComboboxTray useEffect(() => { - if (isAppleDevice() && focusedItem != null && itemKey !== lastItem.current) { - let isSelected = state.selectionManager.isSelected(itemKey); - let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; - let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; - - let announcement = stringFormatter.format('focusAnnouncement', { - isGroupChange: !!section && sectionKey !== lastSection.current, - groupTitle: sectionTitle, - groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, - optionText: focusedItem['aria-label'] || focusedItem.textValue || '', - isSelected - }); - - announce(announcement); - } - - lastSection.current = sectionKey; - lastItem.current = itemKey; - }); - - // Announce the number of available suggestions when it changes - let optionCount = getItemCount(state.collection); - let lastSize = useRef(optionCount); - let [announced, setAnnounced] = useState(false); - - // TODO: test this behavior below, now that there isn't a open state this should just announce for the first render in which the field is focused? - useEffect(() => { - // Only announce the number of options available when the autocomplete first renders if there is no - // focused item, otherwise screen readers will typically read e.g. "1 of 6". - // The exception is VoiceOver since this isn't included in the message above. - let didRenderWithoutFocusedItem = !announced && (state.selectionManager.focusedKey == null || isAppleDevice()); - - if ((didRenderWithoutFocusedItem || optionCount !== lastSize.current)) { - let announcement = stringFormatter.format('countAnnouncement', {optionCount}); - announce(announcement); - setAnnounced(true); - } - - lastSize.current = optionCount; - }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); - - // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. - let lastSelectedKey = useRef(state.selectedKey); - useEffect(() => { - - if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) { - let optionText = state.selectedItem['aria-label'] || state.selectedItem.textValue || ''; - let announcement = stringFormatter.format('selectedAnnouncement', {optionText}); - announce(announcement); - } - - lastSelectedKey.current = state.selectedKey; - }); + focusSafely(inputRef.current); + }, []); + + + // TODO: decide where the announcements should go, pehaps make a separate hook so that the collection component can call it + // // VoiceOver has issues with announcing aria-activedescendant properly on change + // // (especially on iOS). We use a live region announcer to announce focus changes + // // manually. In addition, section titles are announced when navigating into a new section. + // let focusedItem = state.selectionManager.focusedKey != null + // ? state.collection.getItem(state.selectionManager.focusedKey) + // : undefined; + // let sectionKey = focusedItem?.parentKey ?? null; + // let itemKey = state.selectionManager.focusedKey ?? null; + // let lastSection = useRef(sectionKey); + // let lastItem = useRef(itemKey); + // useEffect(() => { + // if (isAppleDevice() && focusedItem != null && itemKey !== lastItem.current) { + // let isSelected = state.selectionManager.isSelected(itemKey); + // let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; + // let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; + + // let announcement = stringFormatter.format('focusAnnouncement', { + // isGroupChange: !!section && sectionKey !== lastSection.current, + // groupTitle: sectionTitle, + // groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, + // optionText: focusedItem['aria-label'] || focusedItem.textValue || '', + // isSelected + // }); + + // announce(announcement); + // } + + // lastSection.current = sectionKey; + // lastItem.current = itemKey; + // }); + + // // Announce the number of available suggestions when it changes + // let optionCount = getItemCount(state.collection); + // let lastSize = useRef(optionCount); + // let [announced, setAnnounced] = useState(false); + + // // TODO: test this behavior below, now that there isn't a open state this should just announce for the first render in which the field is focused? + // useEffect(() => { + // // Only announce the number of options available when the autocomplete first renders if there is no + // // focused item, otherwise screen readers will typically read e.g. "1 of 6". + // // The exception is VoiceOver since this isn't included in the message above. + // let didRenderWithoutFocusedItem = !announced && (state.selectionManager.focusedKey == null || isAppleDevice()); + + // if ((didRenderWithoutFocusedItem || optionCount !== lastSize.current)) { + // let announcement = stringFormatter.format('countAnnouncement', {optionCount}); + // announce(announcement); + // setAnnounced(true); + // } + + // lastSize.current = optionCount; + // }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); + + // // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. + // let lastSelectedKey = useRef(state.selectedKey); + // useEffect(() => { + + // if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) { + // let optionText = state.selectedItem['aria-label'] || state.selectedItem.textValue || ''; + // let announcement = stringFormatter.format('selectedAnnouncement', {optionText}); + // announce(announcement); + // } + + // lastSelectedKey.current = state.selectedKey; + // }); return { labelProps, inputProps: mergeProps(inputProps, { + 'aria-haspopup': 'listbox', 'aria-controls': menuId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', // TODO: will need a way to get the currently focused menuitem's id. This is currently difficult since the // menu uses useTreeState which useAutocomplete state doesn't substitute for // 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, - 'aria-activedescendant': focusedItem ? `${menuId}-option-${focusedItem.key}` : undefined, - onTouchEnd, + 'aria-activedescendant': state.focusedNodeId ?? undefined, + role: 'searchbox', // This disable's iOS's autocorrect suggestions, since the combo box provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. @@ -316,14 +247,15 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut }), menuProps: mergeProps(menuProps, { shouldUseVirtualFocus: true, - shouldSelectOnPressUp: true, - shouldFocusOnHover: true, - linkBehavior: 'selection' as const + // + onHoverStart: (e) => { + // TODO: another thing to thing about, what is the best way to past this to menu so that hovering on + // a item also updates the focusedNode + console.log('e', e.target) + state.setFocusedNodeId(e.target.id); + } }), descriptionProps, - errorMessageProps, - isInvalid, - validationErrors, - validationDetails + register }; } diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index f3312ac355d..c58b476fc5d 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -208,4 +208,165 @@ export class BaseCollection implements ICollection> { this.lastKey = lastKey; this.frozen = !isSSR; } + + // TODO: this is pretty specific to menu, will need to check if it is generic enough + // Will need to handle varying levels I assume but will revisit after I get searchable menu working for base menu + // TODO: an alternative is to simply walk the collection and add all item nodes that match the filter and any sections/separators we encounter + // to an array, then walk that new array and fix all the next/Prev keys while adding them to the new collection + filter(filterFn: (nodeValue: string) => boolean): BaseCollection { + let newCollection = new BaseCollection(); + // console.log('new', newCollection,); + // This tracks the last non-section item + let lastItem: Mutable>; + // This tracks the absolute last item in the collection + let lastNode: Mutable>; + let lastSeparator: Mutable>; + + for (let node of this) { + // console.log('node', node) + if (node.type === 'section' && node.hasChildNodes) { + let clonedSection: Mutable> = (node as CollectionNode).clone(); + let lastChildInSection: Mutable>; + for (let child of this.getChildren(node.key)) { + if (filterFn(child.textValue)) { + let clonedChild: Mutable> = (child as CollectionNode).clone(); + // eslint-disable-next-line max-depth + if (lastChildInSection == null) { + clonedSection.firstChildKey = clonedChild.key; + } + + // eslint-disable-next-line max-depth + if (newCollection.firstKey == null) { + newCollection.firstKey = clonedSection.key; + } + + // eslint-disable-next-line max-depth + if (lastChildInSection && lastChildInSection.parentKey === clonedChild.parentKey) { + lastChildInSection.nextKey = clonedChild.key; + clonedChild.prevKey = lastChildInSection.key; + } else { + clonedChild.prevKey = null; + } + + clonedChild.nextKey = null; + newCollection.addNode(clonedChild); + lastChildInSection = clonedChild; + } + } + + if (lastChildInSection && lastChildInSection.type !== 'header') { + clonedSection.lastChildKey = lastChildInSection.key; + + // If the old prev section was filtered out, will need to attach to whatever came before + if (lastNode == null) { + clonedSection.prevKey = null; + } else if (lastNode.type === 'section' || lastNode.type === 'separator') { + lastNode.nextKey = clonedSection.key; + clonedSection.prevKey = lastNode.key; + } + clonedSection.nextKey = null; + lastNode = clonedSection; + newCollection.addNode(clonedSection); + } + } else if (node.type === 'separator') { + // will need to check if previous section key exists, if it does then we add. After the full collection is created we'll need to remove it + let clonedSeparator: Mutable> = (node as CollectionNode).clone(); + clonedSeparator.nextKey = null; + if (lastNode?.type === 'section') { + lastNode.nextKey = clonedSeparator.key; + clonedSeparator.prevKey = lastNode.key; + lastNode = clonedSeparator; + lastSeparator = clonedSeparator; + newCollection.addNode(clonedSeparator); + } + } else if (filterFn(node.textValue)) { + let clonedNode: Mutable> = (node as CollectionNode).clone(); + if (newCollection.firstKey == null) { + newCollection.firstKey = clonedNode.key; + } + + if (lastItem && lastItem.parentKey === clonedNode.parentKey) { + lastItem.nextKey = clonedNode.key; + clonedNode.prevKey = lastItem.key; + } else { + clonedNode.prevKey = null; + } + + clonedNode.nextKey = null; + newCollection.addNode(clonedNode); + lastItem = clonedNode; + lastNode = clonedNode; + } + } + + if (lastSeparator && lastSeparator.nextKey === null) { + if (lastSeparator.prevKey != null) { + let lastSection = newCollection.getItem(lastSeparator.prevKey) as Mutable>; + lastSection.nextKey = null; + + if (lastNode.key === lastSeparator.key) { + // TODO: If the last node was the last tracked separator, then we can say the last node was the + // last section because the separator can only have a section as its previous node, not a menu item + // Is this a reasonable assumption? + lastNode = lastSection; + } + + } + newCollection.removeNode(lastSeparator.key); + } + + newCollection.lastKey = lastNode?.key; + + // for (let node of this) { + // // console.log('node', node) + // if (node.type === 'section' && node.hasChildNodes && lastNodeWithParent?.parentKey === node.key) { + // let clonedSection: Mutable> = (node as CollectionNode).clone(); + // clonedSection.firstChildKey = lastNodeWithParent.key; + // // TODO: This makes use of the assumption that if we encountered a section with a matching key as our lastNodeWithParent that the last node added to the cloned BaseCollection + // // Will need to double check if valid + // clonedSection.lastChildKey = newCollection.lastKey; + + // // If the prev section was filtered out, will need to attach to whatever came before + // if (lastSectionOrSeparator == null) { + // clonedSection.prevKey = null; + // } else { + // lastSectionOrSeparator.nextKey = clonedSection.key; + // clonedSection.prevKey = lastSectionOrSeparator.key; + // } + // clonedSection.nextKey = null; + // lastSectionOrSeparator = clonedSection; + // newCollection.addNode(clonedSection); + // } else if (node.type === 'separator') { + // // will need to check if previous section key exists, if it does then we add. After the full collection is created + // let clonedSeparator: Mutable> = (node as CollectionNode).clone(); + // clonedSeparator.nextKey = null; + // if (lastSectionOrSeparator?.type === 'section') { + // lastSectionOrSeparator.nextKey = clonedSeparator.key; + // clonedSeparator.prevKey = lastSectionOrSeparator.key; + // lastSectionOrSeparator = clonedSeparator; + // newCollection.addNode(clonedSeparator); + // } + // } else if (filterFn(node.textValue)) { + // let clonedNode: Mutable> = (node as CollectionNode).clone(); + // let parentKey = node.parentKey; + + // if (lastNode && lastNode.parentKey === clonedNode.parentKey) { + // lastNode.nextKey = clonedNode.key; + // clonedNode.prevKey = lastNode.key; + // } else { + // clonedNode.prevKey = null; + // } + + // clonedNode.nextKey = null; + // newCollection.addNode(clonedNode); + // lastNode = clonedNode; + // if (parentKey != null) { + // lastNodeWithParent = clonedNode; + // } + // } + // } + + return newCollection; + } + } diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index 1f7c3137591..2331093c2bf 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -11,7 +11,7 @@ */ import {AriaMenuProps} from '@react-types/menu'; -import {DOMAttributes, Key, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; +import {DOMAttributes, HoverEvent, Key, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {TreeState} from '@react-stately/tree'; import {useSelectableList} from '@react-aria/selection'; @@ -24,7 +24,6 @@ export interface MenuAria { export interface AriaMenuOptions extends Omit, 'children'>, KeyboardEvents { /** Whether the menu uses virtual scrolling. */ isVirtualized?: boolean, - /** * An optional keyboard delegate implementation for type to select, * to override the default. @@ -33,12 +32,17 @@ export interface AriaMenuOptions extends Omit, 'children'>, /** * Whether the menu items should use virtual focus instead of being focused directly. */ - shouldUseVirtualFocus?: boolean + shouldUseVirtualFocus?: boolean, + /** + * Handler that is called when a hover interaction starts on a menu item. + */ + onHoverStart?: (e: HoverEvent) => void } interface MenuData { onClose?: () => void, onAction?: (key: Key) => void, + onHoverStart?: (e: HoverEvent) => void, shouldUseVirtualFocus?: boolean, id: string } @@ -56,6 +60,7 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: shouldFocusWrap = true, onKeyDown, onKeyUp, + onHoverStart, ...otherProps } = props; @@ -79,6 +84,7 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: onClose: props.onClose, onAction: props.onAction, shouldUseVirtualFocus: props.shouldUseVirtualFocus, + onHoverStart, id }); diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 14e8c8b62cc..8f637e38093 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -10,269 +10,58 @@ * governing permissions and limitations under the License. */ -import {Collection, CollectionBase, CollectionStateBase, FocusableProps, HelpTextProps, InputBase, Key, LabelableProps, Node, SingleSelection, TextInputBase, Validation} from '@react-types/shared'; -import {FormValidationState, useFormValidationState} from '@react-stately/form'; -import {getChildNodes} from '@react-stately/collections'; -import {ListCollection, useSingleSelectListState} from '@react-stately/list'; -import {SelectState} from '@react-stately/select'; +import {FocusableProps, HelpTextProps, InputBase, LabelableProps, TextInputBase} from '@react-types/shared'; import {useControlledState} from '@react-stately/utils'; -import {useEffect, useMemo, useRef, useState} from 'react'; +import {useState} from 'react'; -export interface AutocompleteState extends Omit, 'focusStrategy' | 'open' | 'close' | 'toggle' | 'isOpen' | 'setOpen'>, FormValidationState{ +export interface AutocompleteState { /** The current value of the autocomplete input. */ inputValue: string, /** Sets the value of the autocomplete input. */ setInputValue(value: string): void, - /** Selects the currently focused item and updates the input value. */ - commit(): void, - /** Resets the input value to the previously selected item's text if any. */ - revert(): void + // TODO: debatable if this state hook needs to exist + /** The id of the current aria-activedescendant of the autocomplete input. */ + focusedNodeId: string, + /** Sets the id of the current aria-activedescendant of the autocomplete input. */ + setFocusedNodeId(value: string): void } -type FilterFn = (textValue: string, inputValue: string) => boolean; - -// TODO the below interface and props are pretty much copied from combobox props but without onOpenChange and any other open related ones. See if we need to remove anymore -interface AutocompleteValidationValue { - /** The selected key in the autocomplete. */ - selectedKey: Key | null, - /** The value of the autocomplete input. */ - inputValue: string -} - -export interface AutocompleteProps extends CollectionBase, Omit, InputBase, TextInputBase, Validation, FocusableProps, LabelableProps, HelpTextProps { - /** The list of autocomplete items (uncontrolled). */ - defaultItems?: Iterable, - /** The list of autocomplete items (controlled). */ - items?: Iterable, - /** Handler that is called when the selection changes. */ - onSelectionChange?: (key: Key | null) => void, +// TODO: vet these props, maybe move out of here since most of these are the component's props rather than the state option +// TODO: clean up the packge json here +export interface AutocompleteProps extends InputBase, TextInputBase, FocusableProps, LabelableProps, HelpTextProps { /** The value of the autocomplete input (controlled). */ inputValue?: string, /** The default value of the autocomplete input (uncontrolled). */ defaultInputValue?: string, /** Handler that is called when the autocomplete input value changes. */ onInputChange?: (value: string) => void, - /** Whether the autocomplete allows a non-item matching input value to be set. */ - allowsCustomValue?: boolean } -export interface AutocompleteStateOptions extends Omit, 'children'>, CollectionStateBase { - /** The filter function used to determine if a option should be included in the autocomplete list. */ - defaultFilter?: FilterFn -} +export interface AutocompleteStateOptions extends Omit, 'children'> {} /** - * Provides state management for a autocomplete component. Handles building a collection - * of items from props and manages the option selection state of the autocomplete component. In addition, it tracks the input value, - * focus state, and other properties of the autocomplete. + * Provides state management for a autocomplete component. */ export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState { - let { - defaultFilter, - allowsCustomValue - } = props; - - let [isFocused, setFocusedState] = useState(false); - - let onSelectionChange = (key) => { - if (props.onSelectionChange) { - props.onSelectionChange(key); + let onInputChange = (value) => { + if (props.onInputChange) { + props.onInputChange(value) } - // If key is the same, reset the inputValue - // (scenario: user clicks on already selected option) - if (key === selectedKey) { - resetInputValue(); - } - }; - - let {collection, - selectionManager, - selectedKey, - setSelectedKey, - selectedItem, - disabledKeys - } = useSingleSelectListState({ - ...props, - onSelectionChange, - items: props.items ?? props.defaultItems - }); - let defaultInputValue: string | null | undefined = props.defaultInputValue; - if (defaultInputValue == null) { - if (selectedKey == null) { - defaultInputValue = ''; - } else { - defaultInputValue = collection.getItem(selectedKey)?.textValue ?? ''; - } + setFocusedNodeId(null); } + let [focusedNodeId, setFocusedNodeId] = useState(null); let [inputValue, setInputValue] = useControlledState( props.inputValue, - defaultInputValue!, - props.onInputChange - ); - - let filteredCollection = useMemo(() => ( - // No default filter if items are controlled. - props.items != null || !defaultFilter - ? collection - : filterCollection(collection, inputValue, defaultFilter) - ), [collection, inputValue, defaultFilter, props.items]); - - // TODO: maybe revisit and see if we can simplify it more at all - let [lastValue, setLastValue] = useState(inputValue); - let resetInputValue = () => { - let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; - setLastValue(itemText); - setInputValue(itemText); - }; - - let lastSelectedKey = useRef(props.selectedKey ?? props.defaultSelectedKey ?? null); - let lastSelectedKeyText = useRef( - selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '' + '', + onInputChange ); - // intentional omit dependency array, want this to happen on every render - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - // Clear focused key when input value changes. - if (inputValue !== lastValue) { - selectionManager.setFocusedKey(null); - - // Set selectedKey to null when the user clears the input. - // If controlled, this is the application developer's responsibility. - if (inputValue === '' && (props.inputValue === undefined || props.selectedKey === undefined)) { - setSelectedKey(null); - } - } - - // If the selectedKey changed, update the input value. - // Do nothing if both inputValue and selectedKey are controlled. - // In this case, it's the user's responsibility to update inputValue in onSelectionChange. - if ( - selectedKey !== lastSelectedKey.current && - (props.inputValue === undefined || props.selectedKey === undefined) - ) { - resetInputValue(); - } else if (lastValue !== inputValue) { - setLastValue(inputValue); - } - - // Update the inputValue if the selected item's text changes from its last tracked value. - // This is to handle cases where a selectedKey is specified but the items aren't available (async loading) or the selected item's text value updates. - // Only reset if the user isn't currently within the field so we don't erroneously modify user input. - // If inputValue is controlled, it is the user's responsibility to update the inputValue when items change. - let selectedItemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; - if (!isFocused && selectedKey != null && props.inputValue === undefined && selectedKey === lastSelectedKey.current) { - if (lastSelectedKeyText.current !== selectedItemText) { - setLastValue(selectedItemText); - setInputValue(selectedItemText); - } - } - - lastSelectedKey.current = selectedKey; - lastSelectedKeyText.current = selectedItemText; - }); - - let validation = useFormValidationState({ - ...props, - value: useMemo(() => ({inputValue, selectedKey}), [inputValue, selectedKey]) - }); - - // Revert input value - let revert = () => { - if (allowsCustomValue && selectedKey == null) { - commitCustomValue(); - } else { - commitSelection(); - } - }; - - let commitCustomValue = () => { - lastSelectedKey.current = null; - setSelectedKey(null); - }; - - let commitSelection = () => { - // If multiple things are controlled, call onSelectionChange - if (props.selectedKey !== undefined && props.inputValue !== undefined) { - props.onSelectionChange?.(selectedKey); - let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; - setLastValue(itemText); - } else { - // If only a single aspect of autocomplete is controlled, reset input value - resetInputValue(); - } - }; - - const commitValue = () => { - if (allowsCustomValue) { - const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; - (inputValue === itemText) ? commitSelection() : commitCustomValue(); - } else { - // Reset inputValue - commitSelection(); - } - }; - - let commit = () => { - // Reset inputValue if the selected key is already the focused key. Otherwise - // fire onSelectionChange to allow the application to control the closing. - if (selectedKey === selectionManager.focusedKey) { - commitSelection(); - } else { - setSelectedKey(selectionManager.focusedKey); - } - }; - - let valueOnFocus = useRef(inputValue); - let setFocused = (isFocused: boolean) => { - if (isFocused) { - valueOnFocus.current = inputValue; - } else { - commitValue(); - - if (inputValue !== valueOnFocus.current) { - validation.commitValidation(); - } - } - - setFocusedState(isFocused); - }; return { - ...validation, - selectionManager, - selectedKey, - setSelectedKey, - disabledKeys, - isFocused, - setFocused, - selectedItem, - collection: filteredCollection, inputValue, setInputValue, - commit, - revert + focusedNodeId, + setFocusedNodeId }; } - -function filterCollection(collection: Collection>, inputValue: string, filter: FilterFn): Collection> { - return new ListCollection(filterNodes(collection, collection, inputValue, filter)); -} - -function filterNodes(collection: Collection>, nodes: Iterable>, inputValue: string, filter: FilterFn): Iterable> { - let filteredNode: Node[] = []; - for (let node of nodes) { - if (node.type === 'section' && node.hasChildNodes) { - let filtered = filterNodes(collection, getChildNodes(node, collection), inputValue, filter); - if ([...filtered].some(node => node.type === 'item')) { - filteredNode.push({...node, childNodes: filtered}); - } - } else if (node.type === 'item' && filter(node.textValue, inputValue)) { - filteredNode.push({...node}); - } else if (node.type !== 'item') { - filteredNode.push({...node}); - } - } - return filteredNode; -} diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 504d984a1d9..c156f17eb23 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -23,7 +23,7 @@ import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import React, {createContext, ForwardedRef, forwardRef, useMemo, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; @@ -32,116 +32,82 @@ export interface AutocompleteRenderProps { * Whether the autocomplete is disabled. * @selector [data-disabled] */ - isDisabled: boolean, - /** - * Whether the autocomplete is invalid. - * @selector [data-invalid] - */ - isInvalid: boolean, - /** - * Whether the autocomplete is required. - * @selector [data-required] - */ - isRequired: boolean + isDisabled: boolean } -export interface AutocompleteProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { +export interface AutocompleteProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RenderProps, SlotProps { /** The filter function used to determine if a option should be included in the autocomplete list. */ - defaultFilter?: (textValue: string, inputValue: string) => boolean, - /** - * Whether the text or key of the selected item is submitted as part of an HTML form. - * When `allowsCustomValue` is `true`, this option does not apply and the text is always submitted. - * @default 'key' - */ - formValue?: 'text' | 'key' + defaultFilter?: (textValue: string, inputValue: string) => boolean +} + +interface InternalAutocompleteContextValue { + register: (callback: (string: any) => string) => void, + filterFn: (nodeTextValue: string) => boolean } export const AutocompleteContext = createContext, HTMLDivElement>>(null); export const AutocompleteStateContext = createContext | null>(null); +// This context is to pass the register and filter down to whatever collection component is wrapped by the Autocomplete +export const InternalAutocompleteContext = createContext(null); function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, AutocompleteContext); - let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; + let {children, isDisabled = false, defaultFilter} = props; // TODO: not quite sure if we need to do the below or if I can just do something like }> // This approach is a 1:1 copy of ComboBox where this renders the Autocomplete's children (aka the Menu) in the fake DOM and constructs a collection which we can filter // via useAutocomplete state. Said state then gets propagated Menu via AutocompleteInner's context provider so that the Menu's rendered items are mirrored/match the filtered collection // I think we still have to do this, but geting a bit tripped up with thinking if we could simplify it somehow - let content = useMemo(() => ( - - {typeof children === 'function' - ? children({ - isDisabled, - isInvalid, - isRequired, - defaultChildren: null - }) - : children} - - ), [children, isDisabled, isInvalid, isRequired, props.items, props.defaultItems]); + // let content = useMemo(() => ( + // + // {typeof children === 'function' + // ? children({ + // isDisabled, + // defaultChildren: null + // }) + // : children} + // + // ), [children, isDisabled, props.items, props.defaultItems]); + + // return ( + // + // {collection => } + // + // ); return ( - - {collection => } - + ); } interface AutocompleteInnerProps { props: AutocompleteProps, - collection: Collection>, + // collection: Collection>, autocompleteRef: RefObject } -function AutocompleteInner({props, collection, autocompleteRef: ref}: AutocompleteInnerProps) { - let { - name, - formValue = 'key', - allowsCustomValue - } = props; - if (allowsCustomValue) { - formValue = 'text'; - } - - let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; - let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; - let {contains} = useFilter({sensitivity: 'base'}); - let state = useAutocompleteState({ - defaultFilter: props.defaultFilter || contains, - ...props, - // If props.items isn't provided, rely on collection filtering (aka menu.items is provided or defaultItems provided to Autocomplete) - items: props.items, - children: undefined, - collection, - validationBehavior - }); - +// TODO: maybe we don't need inner anymore +function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps) { + let {defaultFilter} = props; + let state = useAutocompleteState(props); let inputRef = useRef(null); - let menuRef = useRef(null); let [labelRef, label] = useSlot(); - + let {contains} = useFilter({sensitivity: 'base'}); let { inputProps, menuProps, labelProps, descriptionProps, - errorMessageProps, - ...validation + register } = useAutocomplete({ ...removeDataAttributes(props), label, - inputRef, - menuRef, - name: formValue === 'text' ? name : undefined, - validationBehavior + inputRef }, state); - // Only expose a subset of state to renderProps function to avoid infinite render loop let renderPropsState = useMemo(() => ({ - isDisabled: props.isDisabled || false, - isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false - }), [props.isDisabled, validation.isInvalid, props.isRequired]); + isDisabled: props.isDisabled || false + }), [props.isDisabled]); let renderProps = useRenderProps({ ...props, @@ -152,38 +118,34 @@ function AutocompleteInner({props, collection, autocompleteRef let DOMProps = filterDOMProps(props); delete DOMProps.id; + let filterFn = useCallback((nodeTextValue: string) => { + if (defaultFilter) { + return defaultFilter(nodeTextValue, state.inputValue); + } + return contains(nodeTextValue, state.inputValue); + }, [state.inputValue, defaultFilter, contains]) ; + return (
- {name && formValue === 'key' && } + data-disabled={props.isDisabled || undefined} /> ); } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index e7d313acab0..b4fdfe18c46 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -12,8 +12,8 @@ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria'; -import {MenuTriggerProps as BaseMenuTriggerProps, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; -import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; +import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; +import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; @@ -33,6 +33,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState } from 'react'; @@ -40,10 +41,10 @@ import {RootMenuTriggerState, useSubmenuTriggerState} from '@react-stately/menu' import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; import {useSubmenuTrigger} from '@react-aria/menu'; +import { InternalAutocompleteContext } from './Autocomplete'; export const MenuContext = createContext, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); -export const ExternalMenuStateContext = createContext | null>(null); export const RootMenuTriggerStateContext = createContext(null); export interface MenuTriggerProps extends BaseMenuTriggerProps { @@ -153,51 +154,71 @@ export interface MenuProps extends Omit, 'children'>, Collec function Menu(props: MenuProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, MenuContext); - let state = useContext(ExternalMenuStateContext); - - // TODO: mimics Listbox so that the Menu content can be controlled by an external field - if (state) { - return ; - } // Delay rendering the actual menu until we have the collection so that auto focus works properly. return ( }> - {collection => collection.size > 0 && } + {collection => collection.size > 0 && } ); } -function StandaloneMenu({props, menuRef, collection}) { - props = {...props, collection, children: null, items: null}; - // TODO: may need this later? - // let {layoutDelegate} = useContext(CollectionRendererContext); - // TODO: this state is different from the one set up by the autocomplete hooks meaning we won't be able to propagate - // the desired menuId and item id that useAutocomplete needs for aria-activedescendant (aka weak map tied to state that sets menu ID and then - // forces menu options to follow a certain pattern). To "fix" this, I split this to StandaloneMenu like it is done - // in ListBox but that means the case with Autocomplete wrapping Menu doesn't use tree state and thus doesn't have things like expanded keys and such. - // However, we need to use a useAutoComplete's state because we need the same selectionManager to be used by menu when checking if an item is focused. +interface MenuInnerProps { + props: MenuProps, + collection: BaseCollection, + menuRef: RefObject +} + +function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { + let {register, filterFn} = useContext(InternalAutocompleteContext) || {}; + // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, + // we always perform the filtering for them + let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useTreeState({ ...props, - collection, + collection: filteredCollection as ICollection>, children: undefined }); - return ; -} -interface MenuInnerProps { - props: MenuProps, - menuRef: RefObject, - state: TreeState -} + // TODO: this might be problematic since the collection is going to change everytime something gets filtered, + // meaning we want focused key to be reset + // This also feels a bit iffy since it is essentially recreating the logic in useSelectableList/Collection + // will need to handle Home/PgUp/PgDown/End. Would be best if we could just hook into that logics + useEffect(() => { + if (register) { + register(event => { + switch (event.key) { + case 'ArrowDown': { + let keyToFocus; + if (!state.selectionManager.isFocused) { + keyToFocus = state.collection.getFirstKey(); + } else { + keyToFocus = state.collection.getKeyAfter(state.selectionManager.focusedKey) + } + state.selectionManager.setFocusedKey(keyToFocus) + return keyToFocus; + } + case 'ArrowUp': { + let keyToFocus; + if (!state.selectionManager.isFocused) { + keyToFocus = state.collection.getFirstKey(); + } else { + keyToFocus = state.collection.getKeyAfter(state.selectionManager.focusedKey) + } + state.selectionManager.setFocusedKey(keyToFocus) + return keyToFocus; + } + } + }); + } + }, [register, state.selectionManager, state.collection]) -function MenuInner({props, menuRef: ref, state}: MenuInnerProps) { let [popoverContainer, setPopoverContainer] = useState(null); let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); let {menuProps} = useMenu({...props, isVirtualized}, state, ref); let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; let popoverContext = useContext(PopoverContext)!; - + console.log('state', state) let isSubmenu = (popoverContext as PopoverProps)?.trigger === 'SubmenuTrigger'; useInteractOutside({ ref, diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 617e0e5d4d8..1e9d3a027d9 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -80,44 +80,46 @@ export const AutocompleteRenderPropsStatic = () => ( ); -export const AutocompleteRenderPropsDefaultItems = () => ( - - {() => ( - <> - -
- -
- - {(item: AutocompleteItem) => {item.name}} - - - )} -
-); +// TODO: I don't this we'll want these anymore, should always favor putting items directly onto the wrapped collection component +// since w +// export const AutocompleteRenderPropsDefaultItems = () => ( +// +// {() => ( +// <> +// +//
+// +//
+// +// {(item: AutocompleteItem) => {item.name}} +// +// +// )} +//
+// ); -export const AutocompleteRenderPropsItems = { - render: () => ( - - {() => ( - <> - -
- -
- - {(item: AutocompleteItem) => {item.name}} - - - )} -
- ), - parameters: { - description: { - data: 'Note this won\'t filter the items in the Menu because it is fully controlled' - } - } -}; +// export const AutocompleteRenderPropsItems = { +// render: () => ( +// +// {() => ( +// <> +// +//
+// +//
+// +// {(item: AutocompleteItem) => {item.name}} +// +// +// )} +//
+// ), +// parameters: { +// description: { +// data: 'Note this won\'t filter the items in the Menu because it is fully controlled' +// } +// } +// }; export const AutocompleteRenderPropsMenuDynamic = () => ( From e0f098af9ef0b96ab92575aeb85c4677e0a4d70f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 21 Oct 2024 17:22:32 -0700 Subject: [PATCH 08/42] fix keyboard interactions, and clean up ended up going with dispatching events to the menu/menuitem so we can piggyback off of useSelectableCollection and menus press handling for submenutriggers, onAction, and link handling --- .../autocomplete/src/useAutocomplete.ts | 68 ++++-------- .../collections/src/BaseCollection.ts | 76 ++----------- .../@react-aria/interactions/src/usePress.ts | 2 +- packages/@react-aria/menu/src/useMenuItem.ts | 4 + .../autocomplete/src/useAutocompleteState.ts | 16 +-- .../src/Autocomplete.tsx | 54 +++------ packages/react-aria-components/src/Menu.tsx | 103 +++++++++++------- .../stories/Autocomplete.stories.tsx | 3 +- yarn.lock | 1 + 9 files changed, 129 insertions(+), 198 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 9a929e22de3..16bf07b6106 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,26 +10,22 @@ * governing permissions and limitations under the License. */ -import {announce} from '@react-aria/live-announcer'; -import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, KeyboardDelegate, LayoutDelegate, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {AriaMenuItemProps, AriaMenuOptions, menuData} from '@react-aria/menu'; +import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, RefObject} from '@react-types/shared'; +import {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {chain, isAppleDevice, mergeProps, useId, useLabels, useRouter} from '@react-aria/utils'; -import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; -import {getChildNodes, getItemCount} from '@react-stately/collections'; +import {chain, mergeProps, useId, useLabels} from '@react-aria/utils'; +import {focusSafely} from '@react-aria/focus'; +import {InputHTMLAttributes, KeyboardEvent, useEffect, useRef} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; -import {privateValidationStateProp} from '@react-stately/form'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTextField} from '@react-aria/textfield'; -import { focusSafely } from '@react-aria/focus'; -export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps {} +export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps {} // TODO: all of this is menu specific but will need to eventually be agnostic to what collection element is inside // Update all instances of menu then -export interface AriaAutocompleteOptions extends Omit, 'children'>, DOMProps, InputDOMProps, AriaLabelingProps { +export interface AriaAutocompleteOptions extends Omit, DOMProps, InputDOMProps, AriaLabelingProps { /** The ref for the input element. */ inputRef: RefObject } @@ -45,7 +41,7 @@ export interface AutocompleteAria { descriptionProps: DOMAttributes, // TODO: fairly non-standard thing to return from a hook, discuss how best to share this with hook only users // This is for the user to register a callback that upon recieving a keyboard event key returns the expected virtually focused node id - register: (callback: (string) => string) => void + register: (callback: (e: KeyboardEvent) => string) => void } /** @@ -54,19 +50,18 @@ export interface AutocompleteAria { * @param props - Props for the autocomplete. * @param state - State for the autocomplete, as returned by `useAutocompleteState`. */ -export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { +export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, isReadOnly } = props; - // let router = useRouter(); let menuId = useId(); // TODO: may need to move this into Autocomplete? Kinda odd to return this from the hook? Maybe the callback should be considered // external to the hook, and the onus is on the user to pass in a onKeydown to this hook that updates state.focusedNodeId in response to a key // stroke - let callbackRef = useRef<(string) => string>(null); + let callbackRef = useRef<(e: KeyboardEvent) => string>(null); let register = (callback) => { callbackRef.current = callback; }; @@ -78,39 +73,28 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } switch (e.key) { case 'Enter': - // TODO: how best to trigger the focused element's action? I guess I could dispatch an event + // TODO: how best to trigger the focused element's action? Currently having the registered callback handle dispatching a + // keyboard event // Also, we might want to add popoverRef so we can bring in MobileCombobox's additional handling for Enter // to close virtual keyboard, depends if we think this experience is only for in a tray/popover - - - // // If the focused item is a link, trigger opening it. Items that are links are not selectable. - // if (state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { - // if (e.key === 'Enter') { - // let item = menuRef.current?.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); - // if (item instanceof HTMLAnchorElement) { - // let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); - // collectionItem && router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); - // } - // } - - // // TODO: previously used to call state.close here which would toggle selection for a link and set the input value to that link's input text - // // I think that doens't make sense really so opting to do nothing here. - // } else { - // state.commit(); - // } break; case 'Escape': if (state.inputValue !== '') { state.setInputValue(''); } + break; + case 'Home': + case 'End': + // Prevent Fn + left/right from moving the text cursor in the input + e.preventDefault(); break; } + if (callbackRef.current) { - let focusedNodeId = callbackRef.current(e.key); + let focusedNodeId = callbackRef.current(e); state.setFocusedNodeId(focusedNodeId); } - }; // let onBlur = (e: FocusEvent) => { @@ -150,7 +134,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let menuProps = useLabels({ id: menuId, - // TODO: update this + // TODO: update this naming from listboxLabel 'aria-label': stringFormatter.format('listboxLabel'), 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); @@ -161,7 +145,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut focusSafely(inputRef.current); }, []); - // TODO: decide where the announcements should go, pehaps make a separate hook so that the collection component can call it // // VoiceOver has issues with announcing aria-activedescendant properly on change // // (especially on iOS). We use a live region announcer to announce focus changes @@ -235,23 +218,18 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut 'aria-controls': menuId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', - // TODO: will need a way to get the currently focused menuitem's id. This is currently difficult since the - // menu uses useTreeState which useAutocomplete state doesn't substitute for - // 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, 'aria-activedescendant': state.focusedNodeId ?? undefined, role: 'searchbox', - // This disable's iOS's autocorrect suggestions, since the combo box provides its own suggestions. + // This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false' }), menuProps: mergeProps(menuProps, { shouldUseVirtualFocus: true, - // onHoverStart: (e) => { - // TODO: another thing to thing about, what is the best way to past this to menu so that hovering on - // a item also updates the focusedNode - console.log('e', e.target) + // TODO: another thing to think about, what is the best way to past this to menu/wrapped collection component so that hovering on + // a item also updates the focusedNode. Perhaps we should just pass down setFocusedNodeId instead state.setFocusedNodeId(e.target.id); } }), diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index c58b476fc5d..953c0310dc2 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -215,15 +215,13 @@ export class BaseCollection implements ICollection> { // to an array, then walk that new array and fix all the next/Prev keys while adding them to the new collection filter(filterFn: (nodeValue: string) => boolean): BaseCollection { let newCollection = new BaseCollection(); - // console.log('new', newCollection,); - // This tracks the last non-section item + // This tracks the last non-section item, used to track the prevKey for non-sections items as we traverse the collection let lastItem: Mutable>; - // This tracks the absolute last item in the collection + // This tracks the absolute last node we've visited in the collection when filtering, used for setting up the filteredCollection's lastKey and + // for attaching the next/prevKey for sections/separators. let lastNode: Mutable>; - let lastSeparator: Mutable>; for (let node of this) { - // console.log('node', node) if (node.type === 'section' && node.hasChildNodes) { let clonedSection: Mutable> = (node as CollectionNode).clone(); let lastChildInSection: Mutable>; @@ -276,7 +274,6 @@ export class BaseCollection implements ICollection> { lastNode.nextKey = clonedSeparator.key; clonedSeparator.prevKey = lastNode.key; lastNode = clonedSeparator; - lastSeparator = clonedSeparator; newCollection.addNode(clonedSeparator); } } else if (filterFn(node.textValue)) { @@ -299,73 +296,18 @@ export class BaseCollection implements ICollection> { } } - if (lastSeparator && lastSeparator.nextKey === null) { - if (lastSeparator.prevKey != null) { - let lastSection = newCollection.getItem(lastSeparator.prevKey) as Mutable>; + if (lastNode.type === 'separator' && lastNode.nextKey === null) { + let lastSection; + if (lastNode.prevKey != null) { + lastSection = newCollection.getItem(lastNode.prevKey) as Mutable>; lastSection.nextKey = null; - - if (lastNode.key === lastSeparator.key) { - // TODO: If the last node was the last tracked separator, then we can say the last node was the - // last section because the separator can only have a section as its previous node, not a menu item - // Is this a reasonable assumption? - lastNode = lastSection; - } - } - newCollection.removeNode(lastSeparator.key); + newCollection.removeNode(lastNode.key); + lastNode = lastSection; } newCollection.lastKey = lastNode?.key; - // for (let node of this) { - // // console.log('node', node) - // if (node.type === 'section' && node.hasChildNodes && lastNodeWithParent?.parentKey === node.key) { - // let clonedSection: Mutable> = (node as CollectionNode).clone(); - // clonedSection.firstChildKey = lastNodeWithParent.key; - // // TODO: This makes use of the assumption that if we encountered a section with a matching key as our lastNodeWithParent that the last node added to the cloned BaseCollection - // // Will need to double check if valid - // clonedSection.lastChildKey = newCollection.lastKey; - - // // If the prev section was filtered out, will need to attach to whatever came before - // if (lastSectionOrSeparator == null) { - // clonedSection.prevKey = null; - // } else { - // lastSectionOrSeparator.nextKey = clonedSection.key; - // clonedSection.prevKey = lastSectionOrSeparator.key; - // } - // clonedSection.nextKey = null; - // lastSectionOrSeparator = clonedSection; - // newCollection.addNode(clonedSection); - // } else if (node.type === 'separator') { - // // will need to check if previous section key exists, if it does then we add. After the full collection is created - // let clonedSeparator: Mutable> = (node as CollectionNode).clone(); - // clonedSeparator.nextKey = null; - // if (lastSectionOrSeparator?.type === 'section') { - // lastSectionOrSeparator.nextKey = clonedSeparator.key; - // clonedSeparator.prevKey = lastSectionOrSeparator.key; - // lastSectionOrSeparator = clonedSeparator; - // newCollection.addNode(clonedSeparator); - // } - // } else if (filterFn(node.textValue)) { - // let clonedNode: Mutable> = (node as CollectionNode).clone(); - // let parentKey = node.parentKey; - - // if (lastNode && lastNode.parentKey === clonedNode.parentKey) { - // lastNode.nextKey = clonedNode.key; - // clonedNode.prevKey = lastNode.key; - // } else { - // clonedNode.prevKey = null; - // } - - // clonedNode.nextKey = null; - // newCollection.addNode(clonedNode); - // lastNode = clonedNode; - // if (parentKey != null) { - // lastNodeWithParent = clonedNode; - // } - // } - // } - return newCollection; } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index eddd8602bc9..4648ed0f446 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -942,7 +942,7 @@ function shouldPreventDefaultUp(target: Element) { if (target instanceof HTMLInputElement) { return false; } - + if (target instanceof HTMLButtonElement) { return target.type !== 'submit' && target.type !== 'reset'; } diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index eaf720178e1..c867c2bd2c0 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -242,6 +242,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re state.selectionManager.setFocusedKey(key); } hoverStartProp?.(e); + + if (data.onHoverStart) { + data.onHoverStart(e); + } }, onHoverChange, onHoverEnd diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 8f637e38093..f898f516a1b 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -14,7 +14,7 @@ import {FocusableProps, HelpTextProps, InputBase, LabelableProps, TextInputBase} import {useControlledState} from '@react-stately/utils'; import {useState} from 'react'; -export interface AutocompleteState { +export interface AutocompleteState { /** The current value of the autocomplete input. */ inputValue: string, /** Sets the value of the autocomplete input. */ @@ -28,28 +28,30 @@ export interface AutocompleteState { // TODO: vet these props, maybe move out of here since most of these are the component's props rather than the state option // TODO: clean up the packge json here -export interface AutocompleteProps extends InputBase, TextInputBase, FocusableProps, LabelableProps, HelpTextProps { +export interface AutocompleteProps extends InputBase, TextInputBase, FocusableProps, LabelableProps, HelpTextProps { /** The value of the autocomplete input (controlled). */ inputValue?: string, /** The default value of the autocomplete input (uncontrolled). */ defaultInputValue?: string, /** Handler that is called when the autocomplete input value changes. */ - onInputChange?: (value: string) => void, + onInputChange?: (value: string) => void } -export interface AutocompleteStateOptions extends Omit, 'children'> {} +// TODO: get rid of this if we don't have any extra things to omit from the options +export interface AutocompleteStateOptions extends Omit {} /** * Provides state management for a autocomplete component. */ -export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState { +export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState { let onInputChange = (value) => { if (props.onInputChange) { - props.onInputChange(value) + props.onInputChange(value); } + // TODO: weird that this is handled here? setFocusedNodeId(null); - } + }; let [focusedNodeId, setFocusedNodeId] = useState(null); let [inputValue, setInputValue] = useControlledState( diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index c156f17eb23..942d94c1d5b 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -12,18 +12,14 @@ import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; -import {Collection, Node} from 'react-stately'; -import {CollectionBuilder} from '@react-aria/collections'; -import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; -import {ExternalMenuStateContext, MenuContext} from './Menu'; -import {FieldErrorContext} from './FieldError'; +import {ContextValue, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils'; import {filterDOMProps} from '@react-aria/utils'; -import {FormContext} from './Form'; import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef} from 'react'; +import {MenuContext} from './Menu'; +import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useMemo, useRef} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; @@ -35,59 +31,37 @@ export interface AutocompleteRenderProps { isDisabled: boolean } -export interface AutocompleteProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RenderProps, SlotProps { +export interface AutocompleteProps extends Omit, RenderProps, SlotProps { /** The filter function used to determine if a option should be included in the autocomplete list. */ defaultFilter?: (textValue: string, inputValue: string) => boolean } interface InternalAutocompleteContextValue { - register: (callback: (string: any) => string) => void, - filterFn: (nodeTextValue: string) => boolean + register: (callback: (event: KeyboardEvent) => string) => void, + filterFn: (nodeTextValue: string) => boolean, + inputValue: string } -export const AutocompleteContext = createContext, HTMLDivElement>>(null); -export const AutocompleteStateContext = createContext | null>(null); +export const AutocompleteContext = createContext>(null); +export const AutocompleteStateContext = createContext(null); // This context is to pass the register and filter down to whatever collection component is wrapped by the Autocomplete export const InternalAutocompleteContext = createContext(null); -function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { +function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, AutocompleteContext); - let {children, isDisabled = false, defaultFilter} = props; - - // TODO: not quite sure if we need to do the below or if I can just do something like }> - // This approach is a 1:1 copy of ComboBox where this renders the Autocomplete's children (aka the Menu) in the fake DOM and constructs a collection which we can filter - // via useAutocomplete state. Said state then gets propagated Menu via AutocompleteInner's context provider so that the Menu's rendered items are mirrored/match the filtered collection - // I think we still have to do this, but geting a bit tripped up with thinking if we could simplify it somehow - // let content = useMemo(() => ( - // - // {typeof children === 'function' - // ? children({ - // isDisabled, - // defaultChildren: null - // }) - // : children} - // - // ), [children, isDisabled, props.items, props.defaultItems]); - - // return ( - // - // {collection => } - // - // ); return ( ); } - -interface AutocompleteInnerProps { - props: AutocompleteProps, +interface AutocompleteInnerProps { + props: AutocompleteProps, // collection: Collection>, autocompleteRef: RefObject } // TODO: maybe we don't need inner anymore -function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps) { +function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps) { let {defaultFilter} = props; let state = useAutocompleteState(props); let inputRef = useRef(null); @@ -138,7 +112,7 @@ function AutocompleteInner({props, autocompleteRef: ref}: Auto } }], [GroupContext, {isDisabled: props.isDisabled || false}], - [InternalAutocompleteContext, {register, filterFn}] + [InternalAutocompleteContext, {register, filterFn, inputValue: state.inputValue}] ]}>
, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); @@ -170,7 +171,7 @@ interface MenuInnerProps { } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - let {register, filterFn} = useContext(InternalAutocompleteContext) || {}; + let {register, filterFn, inputValue} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); @@ -180,45 +181,11 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne children: undefined }); - // TODO: this might be problematic since the collection is going to change everytime something gets filtered, - // meaning we want focused key to be reset - // This also feels a bit iffy since it is essentially recreating the logic in useSelectableList/Collection - // will need to handle Home/PgUp/PgDown/End. Would be best if we could just hook into that logics - useEffect(() => { - if (register) { - register(event => { - switch (event.key) { - case 'ArrowDown': { - let keyToFocus; - if (!state.selectionManager.isFocused) { - keyToFocus = state.collection.getFirstKey(); - } else { - keyToFocus = state.collection.getKeyAfter(state.selectionManager.focusedKey) - } - state.selectionManager.setFocusedKey(keyToFocus) - return keyToFocus; - } - case 'ArrowUp': { - let keyToFocus; - if (!state.selectionManager.isFocused) { - keyToFocus = state.collection.getFirstKey(); - } else { - keyToFocus = state.collection.getKeyAfter(state.selectionManager.focusedKey) - } - state.selectionManager.setFocusedKey(keyToFocus) - return keyToFocus; - } - } - }); - } - }, [register, state.selectionManager, state.collection]) - let [popoverContainer, setPopoverContainer] = useState(null); let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); let {menuProps} = useMenu({...props, isVirtualized}, state, ref); let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; let popoverContext = useContext(PopoverContext)!; - console.log('state', state) let isSubmenu = (popoverContext as PopoverProps)?.trigger === 'SubmenuTrigger'; useInteractOutside({ ref, @@ -240,6 +207,68 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne } }, [leftOffset, popoverContainer]); + let {id: menuId} = menuProps; + useEffect(() => { + if (register) { + register((e: ReactKeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': + case 'Home': + case 'End': + case 'PageDown': + case 'PageUp': + if (!state.selectionManager.isFocused) { + state.selectionManager.setFocused(true); + } + break; + case 'ArrowLeft': + case 'ArrowRight': + // TODO: will need to special case this so it doesn't clear the focused key if we are currently + // focused on a submenutrigger + if (state.selectionManager.isFocused) { + state.selectionManager.setFocused(false); + } + break; + } + + let focusedId; + if (state.selectionManager.focusedKey == null) { + // TODO: calling menuProps.onKeyDown as an alternative to this doesn't quite work because of the check we do to prevent events from bubbling down. Perhaps + // dispatch the event as well to the menu since I don't think we want tot change the check in useSelectableCollection + // since we wouldn't want events to bubble through to the table + ref.current?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ); + } else { + // If there is a focused key, dispatch an event to the menu item in question. This allows us to excute any existing onAction or link navigations + // that would have happen in a non-virtual focus case. + focusedId = `${menuId}-option-${state.selectionManager.focusedKey}`; + let item = ref.current?.querySelector(`#${CSS.escape(focusedId)}`); + item?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ); + } + focusedId = `${menuId}-option-${state.selectionManager.focusedKey}`; + return focusedId; + }); + } + }, [register, state.selectionManager, menuId, ref]); + + useEffect(() => { + // TODO: retested in NVDA. It seems like NVDA properly announces what new letter you are typing even if we maintain virtual focus on + // a item in the list. However, it won't announce the letter your cursor is now on if you don't clear the virtual focus when using left/right + // arrows. + // Clear the focused key if the inputValue changed for NVDA + // Also feels a bit weird that we need to make sure that the focusedId AND the selection manager here need to both be cleared of the focused + // item, would be nice if it was all centralized. Maybe a reason to go back to having the autocomplete hooks create and manage + // the collection/selection manager but then it might cause issues when we need to wrap a Table which won't use BaseCollection but rather has + // its own collection + state.selectionManager.setFocused(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue]); + + let renderProps = useRenderProps({ defaultClassName: 'react-aria-Menu', className: props.className, diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 1e9d3a027d9..49ba7989bd3 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -162,12 +162,13 @@ export const AutocompleteAsyncLoadingExample = () => { }); return ( - +
+ items={list.items} className={styles.menu}> {item => {item.name}} diff --git a/yarn.lock b/yarn.lock index 3b3b82ab453..e44da35918e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5603,6 +5603,7 @@ __metadata: resolution: "@react-aria/autocomplete@workspace:packages/@react-aria/autocomplete" dependencies: "@react-aria/combobox": "npm:^3.10.4" + "@react-aria/focus": "npm:^3.18.3" "@react-aria/i18n": "npm:^3.12.3" "@react-aria/listbox": "npm:^3.13.4" "@react-aria/live-announcer": "npm:^3.4.0" From 5914d11721f5e4b46e8c930d592b30009f55f879 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 22 Oct 2024 11:55:52 -0700 Subject: [PATCH 09/42] fix menu, add more stories, fix strict --- .../@react-aria/autocomplete/intl/ar-AE.json | 2 +- .../@react-aria/autocomplete/intl/bg-BG.json | 2 +- .../@react-aria/autocomplete/intl/cs-CZ.json | 2 +- .../@react-aria/autocomplete/intl/da-DK.json | 2 +- .../@react-aria/autocomplete/intl/de-DE.json | 2 +- .../@react-aria/autocomplete/intl/el-GR.json | 2 +- .../@react-aria/autocomplete/intl/en-US.json | 2 +- .../@react-aria/autocomplete/intl/es-ES.json | 2 +- .../@react-aria/autocomplete/intl/et-EE.json | 2 +- .../@react-aria/autocomplete/intl/fi-FI.json | 2 +- .../@react-aria/autocomplete/intl/fr-FR.json | 2 +- .../@react-aria/autocomplete/intl/he-IL.json | 2 +- .../@react-aria/autocomplete/intl/hr-HR.json | 2 +- .../@react-aria/autocomplete/intl/hu-HU.json | 2 +- .../@react-aria/autocomplete/intl/it-IT.json | 2 +- .../@react-aria/autocomplete/intl/ja-JP.json | 2 +- .../@react-aria/autocomplete/intl/ko-KR.json | 2 +- .../@react-aria/autocomplete/intl/lt-LT.json | 2 +- .../@react-aria/autocomplete/intl/lv-LV.json | 2 +- .../@react-aria/autocomplete/intl/nb-NO.json | 2 +- .../@react-aria/autocomplete/intl/nl-NL.json | 2 +- .../@react-aria/autocomplete/intl/pl-PL.json | 2 +- .../@react-aria/autocomplete/intl/pt-BR.json | 2 +- .../@react-aria/autocomplete/intl/pt-PT.json | 2 +- .../@react-aria/autocomplete/intl/ro-RO.json | 2 +- .../@react-aria/autocomplete/intl/ru-RU.json | 2 +- .../@react-aria/autocomplete/intl/sk-SK.json | 2 +- .../@react-aria/autocomplete/intl/sl-SI.json | 2 +- .../@react-aria/autocomplete/intl/sr-SP.json | 2 +- .../@react-aria/autocomplete/intl/sv-SE.json | 2 +- .../@react-aria/autocomplete/intl/tr-TR.json | 2 +- .../@react-aria/autocomplete/intl/uk-UA.json | 2 +- .../@react-aria/autocomplete/intl/zh-CN.json | 2 +- .../@react-aria/autocomplete/intl/zh-TW.json | 2 +- .../autocomplete/src/useAutocomplete.ts | 67 +---- .../collections/src/BaseCollection.ts | 20 +- .../autocomplete/src/useAutocompleteState.ts | 18 +- .../src/Autocomplete.tsx | 8 +- packages/react-aria-components/src/Menu.tsx | 8 +- .../stories/Autocomplete.stories.tsx | 271 +++++++++++------- 40 files changed, 239 insertions(+), 221 deletions(-) diff --git a/packages/@react-aria/autocomplete/intl/ar-AE.json b/packages/@react-aria/autocomplete/intl/ar-AE.json index fc0949be5ba..feb9a101140 100644 --- a/packages/@react-aria/autocomplete/intl/ar-AE.json +++ b/packages/@react-aria/autocomplete/intl/ar-AE.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# خيار} other {# خيارات}} متاحة.", "focusAnnouncement": "{isGroupChange, select, true {المجموعة المدخلة {groupTitle}, مع {groupCount, plural, one {# خيار} other {# خيارات}}. } other {}}{optionText}{isSelected, select, true {, محدد} other {}}", - "listboxLabel": "مقترحات", + "menuLabel": "مقترحات", "selectedAnnouncement": "{optionText}، محدد" } diff --git a/packages/@react-aria/autocomplete/intl/bg-BG.json b/packages/@react-aria/autocomplete/intl/bg-BG.json index a213204e41c..e5b7ef37109 100644 --- a/packages/@react-aria/autocomplete/intl/bg-BG.json +++ b/packages/@react-aria/autocomplete/intl/bg-BG.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# опция} other {# опции}} на разположение.", "focusAnnouncement": "{isGroupChange, select, true {Въведена група {groupTitle}, с {groupCount, plural, one {# опция} other {# опции}}. } other {}}{optionText}{isSelected, select, true {, избрани} other {}}", - "listboxLabel": "Предложения", + "menuLabel": "Предложения", "selectedAnnouncement": "{optionText}, избрани" } diff --git a/packages/@react-aria/autocomplete/intl/cs-CZ.json b/packages/@react-aria/autocomplete/intl/cs-CZ.json index 41152807f7b..e12db923049 100644 --- a/packages/@react-aria/autocomplete/intl/cs-CZ.json +++ b/packages/@react-aria/autocomplete/intl/cs-CZ.json @@ -1,6 +1,6 @@ { "countAnnouncement": "K dispozici {optionCount, plural, one {je # možnost} other {jsou/je # možnosti/-í}}.", "focusAnnouncement": "{isGroupChange, select, true {Zadaná skupina „{groupTitle}“ {groupCount, plural, one {s # možností} other {se # možnostmi}}. } other {}}{optionText}{isSelected, select, true { (vybráno)} other {}}", - "listboxLabel": "Návrhy", + "menuLabel": "Návrhy", "selectedAnnouncement": "{optionText}, vybráno" } diff --git a/packages/@react-aria/autocomplete/intl/da-DK.json b/packages/@react-aria/autocomplete/intl/da-DK.json index b8be8c29595..bfd6db53360 100644 --- a/packages/@react-aria/autocomplete/intl/da-DK.json +++ b/packages/@react-aria/autocomplete/intl/da-DK.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# mulighed tilgængelig} other {# muligheder tilgængelige}}.", "focusAnnouncement": "{isGroupChange, select, true {Angivet gruppe {groupTitle}, med {groupCount, plural, one {# mulighed} other {# muligheder}}. } other {}}{optionText}{isSelected, select, true {, valgt} other {}}", - "listboxLabel": "Forslag", + "menuLabel": "Forslag", "selectedAnnouncement": "{optionText}, valgt" } diff --git a/packages/@react-aria/autocomplete/intl/de-DE.json b/packages/@react-aria/autocomplete/intl/de-DE.json index f1746b14927..b307c49f89d 100644 --- a/packages/@react-aria/autocomplete/intl/de-DE.json +++ b/packages/@react-aria/autocomplete/intl/de-DE.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# Option} other {# Optionen}} verfügbar.", "focusAnnouncement": "{isGroupChange, select, true {Eingetretene Gruppe {groupTitle}, mit {groupCount, plural, one {# Option} other {# Optionen}}. } other {}}{optionText}{isSelected, select, true {, ausgewählt} other {}}", - "listboxLabel": "Empfehlungen", + "menuLabel": "Empfehlungen", "selectedAnnouncement": "{optionText}, ausgewählt" } diff --git a/packages/@react-aria/autocomplete/intl/el-GR.json b/packages/@react-aria/autocomplete/intl/el-GR.json index f1fb8f0c7e0..54ad842a5f5 100644 --- a/packages/@react-aria/autocomplete/intl/el-GR.json +++ b/packages/@react-aria/autocomplete/intl/el-GR.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# επιλογή} other {# επιλογές }} διαθέσιμες.", "focusAnnouncement": "{isGroupChange, select, true {Εισαγμένη ομάδα {groupTitle}, με {groupCount, plural, one {# επιλογή} other {# επιλογές}}. } other {}}{optionText}{isSelected, select, true {, επιλεγμένο} other {}}", - "listboxLabel": "Προτάσεις", + "menuLabel": "Προτάσεις", "selectedAnnouncement": "{optionText}, επιλέχθηκε" } diff --git a/packages/@react-aria/autocomplete/intl/en-US.json b/packages/@react-aria/autocomplete/intl/en-US.json index 0d751d72110..cd6b09dda76 100644 --- a/packages/@react-aria/autocomplete/intl/en-US.json +++ b/packages/@react-aria/autocomplete/intl/en-US.json @@ -2,5 +2,5 @@ "focusAnnouncement": "{isGroupChange, select, true {Entered group {groupTitle}, with {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, selected} other {}}", "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} available.", "selectedAnnouncement": "{optionText}, selected", - "listboxLabel": "Suggestions" + "menuLabel": "Suggestions" } diff --git a/packages/@react-aria/autocomplete/intl/es-ES.json b/packages/@react-aria/autocomplete/intl/es-ES.json index 16fee43d61c..34ee7d26539 100644 --- a/packages/@react-aria/autocomplete/intl/es-ES.json +++ b/packages/@react-aria/autocomplete/intl/es-ES.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# opción} other {# opciones}} disponible(s).", "focusAnnouncement": "{isGroupChange, select, true {Se ha unido al grupo {groupTitle}, con {groupCount, plural, one {# opción} other {# opciones}}. } other {}}{optionText}{isSelected, select, true {, seleccionado} other {}}", - "listboxLabel": "Sugerencias", + "menuLabel": "Sugerencias", "selectedAnnouncement": "{optionText}, seleccionado" } diff --git a/packages/@react-aria/autocomplete/intl/et-EE.json b/packages/@react-aria/autocomplete/intl/et-EE.json index ff91c38570f..c7bea2704fd 100644 --- a/packages/@react-aria/autocomplete/intl/et-EE.json +++ b/packages/@react-aria/autocomplete/intl/et-EE.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# valik} other {# valikud}} saadaval.", "focusAnnouncement": "{isGroupChange, select, true {Sisestatud rühm {groupTitle}, valikuga {groupCount, plural, one {# valik} other {# valikud}}. } other {}}{optionText}{isSelected, select, true {, valitud} other {}}", - "listboxLabel": "Soovitused", + "menuLabel": "Soovitused", "selectedAnnouncement": "{optionText}, valitud" } diff --git a/packages/@react-aria/autocomplete/intl/fi-FI.json b/packages/@react-aria/autocomplete/intl/fi-FI.json index 90504c138c4..bb1cddb3ca3 100644 --- a/packages/@react-aria/autocomplete/intl/fi-FI.json +++ b/packages/@react-aria/autocomplete/intl/fi-FI.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# vaihtoehto} other {# vaihtoehdot}} saatavilla.", "focusAnnouncement": "{isGroupChange, select, true {Mentiin ryhmään {groupTitle}, {groupCount, plural, one {# vaihtoehdon} other {# vaihtoehdon}} kanssa.} other {}}{optionText}{isSelected, select, true {, valittu} other {}}", - "listboxLabel": "Ehdotukset", + "menuLabel": "Ehdotukset", "selectedAnnouncement": "{optionText}, valittu" } diff --git a/packages/@react-aria/autocomplete/intl/fr-FR.json b/packages/@react-aria/autocomplete/intl/fr-FR.json index 2737cd1f5f3..227364ad435 100644 --- a/packages/@react-aria/autocomplete/intl/fr-FR.json +++ b/packages/@react-aria/autocomplete/intl/fr-FR.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} disponible(s).", "focusAnnouncement": "{isGroupChange, select, true {Groupe {groupTitle} rejoint, avec {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, sélectionné(s)} other {}}", - "listboxLabel": "Suggestions", + "menuLabel": "Suggestions", "selectedAnnouncement": "{optionText}, sélectionné" } diff --git a/packages/@react-aria/autocomplete/intl/he-IL.json b/packages/@react-aria/autocomplete/intl/he-IL.json index 01db0ac0d7d..3d2d7e3c5cf 100644 --- a/packages/@react-aria/autocomplete/intl/he-IL.json +++ b/packages/@react-aria/autocomplete/intl/he-IL.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {אפשרות #} other {# אפשרויות}} במצב זמין.", "focusAnnouncement": "{isGroupChange, select, true {נכנס לקבוצה {groupTitle}, עם {groupCount, plural, one {אפשרות #} other {# אפשרויות}}. } other {}}{optionText}{isSelected, select, true {, נבחר} other {}}", - "listboxLabel": "הצעות", + "menuLabel": "הצעות", "selectedAnnouncement": "{optionText}, נבחר" } diff --git a/packages/@react-aria/autocomplete/intl/hr-HR.json b/packages/@react-aria/autocomplete/intl/hr-HR.json index 28d2f0f4cd0..f0c5c77317d 100644 --- a/packages/@react-aria/autocomplete/intl/hr-HR.json +++ b/packages/@react-aria/autocomplete/intl/hr-HR.json @@ -1,6 +1,6 @@ { "countAnnouncement": "Dostupno još: {optionCount, plural, one {# opcija} other {# opcije/a}}.", "focusAnnouncement": "{isGroupChange, select, true {Unesena skupina {groupTitle}, s {groupCount, plural, one {# opcijom} other {# opcije/a}}. } other {}}{optionText}{isSelected, select, true {, odabranih} other {}}", - "listboxLabel": "Prijedlozi", + "menuLabel": "Prijedlozi", "selectedAnnouncement": "{optionText}, odabrano" } diff --git a/packages/@react-aria/autocomplete/intl/hu-HU.json b/packages/@react-aria/autocomplete/intl/hu-HU.json index 59ff457671a..0e75c3ab0ac 100644 --- a/packages/@react-aria/autocomplete/intl/hu-HU.json +++ b/packages/@react-aria/autocomplete/intl/hu-HU.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# lehetőség} other {# lehetőség}} áll rendelkezésre.", "focusAnnouncement": "{isGroupChange, select, true {Belépett a(z) {groupTitle} csoportba, amely {groupCount, plural, one {# lehetőséget} other {# lehetőséget}} tartalmaz. } other {}}{optionText}{isSelected, select, true {, kijelölve} other {}}", - "listboxLabel": "Javaslatok", + "menuLabel": "Javaslatok", "selectedAnnouncement": "{optionText}, kijelölve" } diff --git a/packages/@react-aria/autocomplete/intl/it-IT.json b/packages/@react-aria/autocomplete/intl/it-IT.json index 21a8ac5fbaa..f2425f0d2f7 100644 --- a/packages/@react-aria/autocomplete/intl/it-IT.json +++ b/packages/@react-aria/autocomplete/intl/it-IT.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# opzione disponibile} other {# opzioni disponibili}}.", "focusAnnouncement": "{isGroupChange, select, true {Ingresso nel gruppo {groupTitle}, con {groupCount, plural, one {# opzione} other {# opzioni}}. } other {}}{optionText}{isSelected, select, true {, selezionato} other {}}", - "listboxLabel": "Suggerimenti", + "menuLabel": "Suggerimenti", "selectedAnnouncement": "{optionText}, selezionato" } diff --git a/packages/@react-aria/autocomplete/intl/ja-JP.json b/packages/@react-aria/autocomplete/intl/ja-JP.json index 7fc42638944..9e243e6e263 100644 --- a/packages/@react-aria/autocomplete/intl/ja-JP.json +++ b/packages/@react-aria/autocomplete/intl/ja-JP.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# 個のオプション} other {# 個のオプション}}を利用できます。", "focusAnnouncement": "{isGroupChange, select, true {入力されたグループ {groupTitle}、{groupCount, plural, one {# 個のオプション} other {# 個のオプション}}を含む。} other {}}{optionText}{isSelected, select, true {、選択済み} other {}}", - "listboxLabel": "候補", + "menuLabel": "候補", "selectedAnnouncement": "{optionText}、選択済み" } diff --git a/packages/@react-aria/autocomplete/intl/ko-KR.json b/packages/@react-aria/autocomplete/intl/ko-KR.json index c1c5a976819..f83731bb9f8 100644 --- a/packages/@react-aria/autocomplete/intl/ko-KR.json +++ b/packages/@react-aria/autocomplete/intl/ko-KR.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {#개 옵션} other {#개 옵션}}을 사용할 수 있습니다.", "focusAnnouncement": "{isGroupChange, select, true {입력한 그룹 {groupTitle}, {groupCount, plural, one {#개 옵션} other {#개 옵션}}. } other {}}{optionText}{isSelected, select, true {, 선택됨} other {}}", - "listboxLabel": "제안", + "menuLabel": "제안", "selectedAnnouncement": "{optionText}, 선택됨" } diff --git a/packages/@react-aria/autocomplete/intl/lt-LT.json b/packages/@react-aria/autocomplete/intl/lt-LT.json index bfeee55cef9..8e6a04f7c01 100644 --- a/packages/@react-aria/autocomplete/intl/lt-LT.json +++ b/packages/@react-aria/autocomplete/intl/lt-LT.json @@ -1,6 +1,6 @@ { "countAnnouncement": "Yra {optionCount, plural, one {# parinktis} other {# parinktys (-ių)}}.", "focusAnnouncement": "{isGroupChange, select, true {Įvesta grupė {groupTitle}, su {groupCount, plural, one {# parinktimi} other {# parinktimis (-ių)}}. } other {}}{optionText}{isSelected, select, true {, pasirinkta} other {}}", - "listboxLabel": "Pasiūlymai", + "menuLabel": "Pasiūlymai", "selectedAnnouncement": "{optionText}, pasirinkta" } diff --git a/packages/@react-aria/autocomplete/intl/lv-LV.json b/packages/@react-aria/autocomplete/intl/lv-LV.json index ab9559f1d04..eca1c62ab9d 100644 --- a/packages/@react-aria/autocomplete/intl/lv-LV.json +++ b/packages/@react-aria/autocomplete/intl/lv-LV.json @@ -1,6 +1,6 @@ { "countAnnouncement": "Pieejamo opciju skaits: {optionCount, plural, one {# opcija} other {# opcijas}}.", "focusAnnouncement": "{isGroupChange, select, true {Ievadīta grupa {groupTitle}, ar {groupCount, plural, one {# opciju} other {# opcijām}}. } other {}}{optionText}{isSelected, select, true {, atlasīta} other {}}", - "listboxLabel": "Ieteikumi", + "menuLabel": "Ieteikumi", "selectedAnnouncement": "{optionText}, atlasīta" } diff --git a/packages/@react-aria/autocomplete/intl/nb-NO.json b/packages/@react-aria/autocomplete/intl/nb-NO.json index 5827af48745..51c85ad50ae 100644 --- a/packages/@react-aria/autocomplete/intl/nb-NO.json +++ b/packages/@react-aria/autocomplete/intl/nb-NO.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# alternativ} other {# alternativer}} finnes.", "focusAnnouncement": "{isGroupChange, select, true {Angitt gruppe {groupTitle}, med {groupCount, plural, one {# alternativ} other {# alternativer}}. } other {}}{optionText}{isSelected, select, true {, valgt} other {}}", - "listboxLabel": "Forslag", + "menuLabel": "Forslag", "selectedAnnouncement": "{optionText}, valgt" } diff --git a/packages/@react-aria/autocomplete/intl/nl-NL.json b/packages/@react-aria/autocomplete/intl/nl-NL.json index bfe2e10883e..e0077c81505 100644 --- a/packages/@react-aria/autocomplete/intl/nl-NL.json +++ b/packages/@react-aria/autocomplete/intl/nl-NL.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# optie} other {# opties}} beschikbaar.", "focusAnnouncement": "{isGroupChange, select, true {Groep {groupTitle} ingevoerd met {groupCount, plural, one {# optie} other {# opties}}. } other {}}{optionText}{isSelected, select, true {, geselecteerd} other {}}", - "listboxLabel": "Suggesties", + "menuLabel": "Suggesties", "selectedAnnouncement": "{optionText}, geselecteerd" } diff --git a/packages/@react-aria/autocomplete/intl/pl-PL.json b/packages/@react-aria/autocomplete/intl/pl-PL.json index 1bde3c435e0..c6666fd066c 100644 --- a/packages/@react-aria/autocomplete/intl/pl-PL.json +++ b/packages/@react-aria/autocomplete/intl/pl-PL.json @@ -1,6 +1,6 @@ { "countAnnouncement": "dostępna/dostępne(-nych) {optionCount, plural, one {# opcja} other {# opcje(-i)}}.", "focusAnnouncement": "{isGroupChange, select, true {Dołączono do grupy {groupTitle}, z {groupCount, plural, one {# opcją} other {# opcjami}}. } other {}}{optionText}{isSelected, select, true {, wybrano} other {}}", - "listboxLabel": "Sugestie", + "menuLabel": "Sugestie", "selectedAnnouncement": "{optionText}, wybrano" } diff --git a/packages/@react-aria/autocomplete/intl/pt-BR.json b/packages/@react-aria/autocomplete/intl/pt-BR.json index 4330af7c0ec..1c2eac37a50 100644 --- a/packages/@react-aria/autocomplete/intl/pt-BR.json +++ b/packages/@react-aria/autocomplete/intl/pt-BR.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# opção} other {# opções}} disponível.", "focusAnnouncement": "{isGroupChange, select, true {Grupo inserido {groupTitle}, com {groupCount, plural, one {# opção} other {# opções}}. } other {}}{optionText}{isSelected, select, true {, selecionado} other {}}", - "listboxLabel": "Sugestões", + "menuLabel": "Sugestões", "selectedAnnouncement": "{optionText}, selecionado" } diff --git a/packages/@react-aria/autocomplete/intl/pt-PT.json b/packages/@react-aria/autocomplete/intl/pt-PT.json index d44b9324dd9..a75866801a9 100644 --- a/packages/@react-aria/autocomplete/intl/pt-PT.json +++ b/packages/@react-aria/autocomplete/intl/pt-PT.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# opção} other {# opções}} disponível.", "focusAnnouncement": "{isGroupChange, select, true {Grupo introduzido {groupTitle}, com {groupCount, plural, one {# opção} other {# opções}}. } other {}}{optionText}{isSelected, select, true {, selecionado} other {}}", - "listboxLabel": "Sugestões", + "menuLabel": "Sugestões", "selectedAnnouncement": "{optionText}, selecionado" } diff --git a/packages/@react-aria/autocomplete/intl/ro-RO.json b/packages/@react-aria/autocomplete/intl/ro-RO.json index 81799479638..eb11a523927 100644 --- a/packages/@react-aria/autocomplete/intl/ro-RO.json +++ b/packages/@react-aria/autocomplete/intl/ro-RO.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# opțiune} other {# opțiuni}} disponibile.", "focusAnnouncement": "{isGroupChange, select, true {Grup {groupTitle} introdus, cu {groupCount, plural, one {# opțiune} other {# opțiuni}}. } other {}}{optionText}{isSelected, select, true {, selectat} other {}}", - "listboxLabel": "Sugestii", + "menuLabel": "Sugestii", "selectedAnnouncement": "{optionText}, selectat" } diff --git a/packages/@react-aria/autocomplete/intl/ru-RU.json b/packages/@react-aria/autocomplete/intl/ru-RU.json index 768de8157c6..d163708c9fe 100644 --- a/packages/@react-aria/autocomplete/intl/ru-RU.json +++ b/packages/@react-aria/autocomplete/intl/ru-RU.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# параметр} other {# параметров}} доступно.", "focusAnnouncement": "{isGroupChange, select, true {Введенная группа {groupTitle}, с {groupCount, plural, one {# параметром} other {# параметрами}}. } other {}}{optionText}{isSelected, select, true {, выбранными} other {}}", - "listboxLabel": "Предложения", + "menuLabel": "Предложения", "selectedAnnouncement": "{optionText}, выбрано" } diff --git a/packages/@react-aria/autocomplete/intl/sk-SK.json b/packages/@react-aria/autocomplete/intl/sk-SK.json index 9e2c5121279..7e18a4ede60 100644 --- a/packages/@react-aria/autocomplete/intl/sk-SK.json +++ b/packages/@react-aria/autocomplete/intl/sk-SK.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# možnosť} other {# možnosti/-í}} k dispozícii.", "focusAnnouncement": "{isGroupChange, select, true {Zadaná skupina {groupTitle}, s {groupCount, plural, one {# možnosťou} other {# možnosťami}}. } other {}}{optionText}{isSelected, select, true {, vybraté} other {}}", - "listboxLabel": "Návrhy", + "menuLabel": "Návrhy", "selectedAnnouncement": "{optionText}, vybraté" } diff --git a/packages/@react-aria/autocomplete/intl/sl-SI.json b/packages/@react-aria/autocomplete/intl/sl-SI.json index 3b8c4edf79b..d7c2313f8f1 100644 --- a/packages/@react-aria/autocomplete/intl/sl-SI.json +++ b/packages/@react-aria/autocomplete/intl/sl-SI.json @@ -1,6 +1,6 @@ { "countAnnouncement": "Na voljo je {optionCount, plural, one {# opcija} other {# opcije}}.", "focusAnnouncement": "{isGroupChange, select, true {Vnesena skupina {groupTitle}, z {groupCount, plural, one {# opcija} other {# opcije}}. } other {}}{optionText}{isSelected, select, true {, izbrano} other {}}", - "listboxLabel": "Predlogi", + "menuLabel": "Predlogi", "selectedAnnouncement": "{optionText}, izbrano" } diff --git a/packages/@react-aria/autocomplete/intl/sr-SP.json b/packages/@react-aria/autocomplete/intl/sr-SP.json index ad39438b523..14a432d713d 100644 --- a/packages/@react-aria/autocomplete/intl/sr-SP.json +++ b/packages/@react-aria/autocomplete/intl/sr-SP.json @@ -1,6 +1,6 @@ { "countAnnouncement": "Dostupno još: {optionCount, plural, one {# opcija} other {# opcije/a}}.", "focusAnnouncement": "{isGroupChange, select, true {Unesena grupa {groupTitle}, s {groupCount, plural, one {# opcijom} other {# optione/a}}. } other {}}{optionText}{isSelected, select, true {, izabranih} other {}}", - "listboxLabel": "Predlozi", + "menuLabel": "Predlozi", "selectedAnnouncement": "{optionText}, izabrano" } diff --git a/packages/@react-aria/autocomplete/intl/sv-SE.json b/packages/@react-aria/autocomplete/intl/sv-SE.json index 579fdf61df1..520052c19cf 100644 --- a/packages/@react-aria/autocomplete/intl/sv-SE.json +++ b/packages/@react-aria/autocomplete/intl/sv-SE.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# alternativ} other {# alternativ}} tillgängliga.", "focusAnnouncement": "{isGroupChange, select, true {Ingick i gruppen {groupTitle} med {groupCount, plural, one {# alternativ} other {# alternativ}}. } other {}}{optionText}{isSelected, select, true {, valda} other {}}", - "listboxLabel": "Förslag", + "menuLabel": "Förslag", "selectedAnnouncement": "{optionText}, valda" } diff --git a/packages/@react-aria/autocomplete/intl/tr-TR.json b/packages/@react-aria/autocomplete/intl/tr-TR.json index f2be96a361f..2796e895ef0 100644 --- a/packages/@react-aria/autocomplete/intl/tr-TR.json +++ b/packages/@react-aria/autocomplete/intl/tr-TR.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# seçenek} other {# seçenekler}} kullanılabilir.", "focusAnnouncement": "{isGroupChange, select, true {Girilen grup {groupTitle}, ile {groupCount, plural, one {# seçenek} other {# seçenekler}}. } other {}}{optionText}{isSelected, select, true {, seçildi} other {}}", - "listboxLabel": "Öneriler", + "menuLabel": "Öneriler", "selectedAnnouncement": "{optionText}, seçildi" } diff --git a/packages/@react-aria/autocomplete/intl/uk-UA.json b/packages/@react-aria/autocomplete/intl/uk-UA.json index e4dbc439ea7..2b42178c6dd 100644 --- a/packages/@react-aria/autocomplete/intl/uk-UA.json +++ b/packages/@react-aria/autocomplete/intl/uk-UA.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# параметр} other {# параметри(-ів)}} доступно.", "focusAnnouncement": "{isGroupChange, select, true {Введена група {groupTitle}, з {groupCount, plural, one {# параметр} other {# параметри(-ів)}}. } other {}}{optionText}{isSelected, select, true {, вибрано} other {}}", - "listboxLabel": "Пропозиції", + "menuLabel": "Пропозиції", "selectedAnnouncement": "{optionText}, вибрано" } diff --git a/packages/@react-aria/autocomplete/intl/zh-CN.json b/packages/@react-aria/autocomplete/intl/zh-CN.json index 5cf6e21add6..49156240642 100644 --- a/packages/@react-aria/autocomplete/intl/zh-CN.json +++ b/packages/@react-aria/autocomplete/intl/zh-CN.json @@ -1,6 +1,6 @@ { "countAnnouncement": "有 {optionCount, plural, one {# 个选项} other {# 个选项}}可用。", "focusAnnouncement": "{isGroupChange, select, true {进入了 {groupTitle} 组,其中有 {groupCount, plural, one {# 个选项} other {# 个选项}}. } other {}}{optionText}{isSelected, select, true {, 已选择} other {}}", - "listboxLabel": "建议", + "menuLabel": "建议", "selectedAnnouncement": "{optionText}, 已选择" } diff --git a/packages/@react-aria/autocomplete/intl/zh-TW.json b/packages/@react-aria/autocomplete/intl/zh-TW.json index be3f207abfb..c0a03b70576 100644 --- a/packages/@react-aria/autocomplete/intl/zh-TW.json +++ b/packages/@react-aria/autocomplete/intl/zh-TW.json @@ -1,6 +1,6 @@ { "countAnnouncement": "{optionCount, plural, one {# 選項} other {# 選項}} 可用。", "focusAnnouncement": "{isGroupChange, select, true {輸入的群組 {groupTitle}, 有 {groupCount, plural, one {# 選項} other {# 選項}}. } other {}}{optionText}{isSelected, select, true {, 已選取} other {}}", - "listboxLabel": "建議", + "menuLabel": "建議", "selectedAnnouncement": "{optionText}, 已選取" } diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 16bf07b6106..f678024f9b6 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, FocusableElement, InputDOMProps, RefObject} from '@react-types/shared'; import {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, mergeProps, useId, useLabels} from '@react-aria/utils'; @@ -24,8 +24,8 @@ import {useTextField} from '@react-aria/textfield'; export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps {} // TODO: all of this is menu specific but will need to eventually be agnostic to what collection element is inside -// Update all instances of menu then -export interface AriaAutocompleteOptions extends Omit, DOMProps, InputDOMProps, AriaLabelingProps { +// Update all instances of "menu" then +export interface AriaAutocompleteOptions extends Omit { /** The ref for the input element. */ inputRef: RefObject } @@ -34,13 +34,13 @@ export interface AutocompleteAria { labelProps: DOMAttributes, /** Props for the autocomplete input element. */ inputProps: InputHTMLAttributes, - // TODO change this menu props /** Props for the menu, to be passed to [useMenu](useMenu.html). */ menuProps: AriaMenuOptions, /** Props for the autocomplete description element, if any. */ descriptionProps: DOMAttributes, // TODO: fairly non-standard thing to return from a hook, discuss how best to share this with hook only users // This is for the user to register a callback that upon recieving a keyboard event key returns the expected virtually focused node id + /** Register function that expects a callback function that returns the newlly virtually focused menu option when provided with the keyboard action that occurs in the input field. */ register: (callback: (e: KeyboardEvent) => string) => void } @@ -71,13 +71,12 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco if (e.nativeEvent.isComposing) { return; } + + // TODO: how best to trigger the focused element's action? Currently having the registered callback handle dispatching a + // keyboard event + // Also, we might want to add popoverRef so we can bring in MobileCombobox's additional handling for Enter + // to close virtual keyboard, depends if we think this experience is only for in a tray/popover switch (e.key) { - case 'Enter': - // TODO: how best to trigger the focused element's action? Currently having the registered callback handle dispatching a - // keyboard event - // Also, we might want to add popoverRef so we can bring in MobileCombobox's additional handling for Enter - // to close virtual keyboard, depends if we think this experience is only for in a tray/popover - break; case 'Escape': if (state.inputValue !== '') { state.setInputValue(''); @@ -97,35 +96,10 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco } }; - // let onBlur = (e: FocusEvent) => { - // if (props.onBlur) { - // props.onBlur(e); - // } - - // state.setFocused(false); - // }; - - // let onFocus = (e: FocusEvent) => { - // if (state.isFocused) { - // return; - // } - - // if (props.onFocus) { - // props.onFocus(e); - // } - - // state.setFocused(true); - // }; - let {labelProps, inputProps, descriptionProps} = useTextField({ - ...props, + ...props as any, onChange: state.setInputValue, onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, - // TODO: will I still need the blur and stuff - // // @ts-ignore - // onBlur, - // // @ts-ignore - // onFocus, value: state.inputValue, autoComplete: 'off', validate: undefined @@ -134,16 +108,15 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let menuProps = useLabels({ id: menuId, - // TODO: update this naming from listboxLabel - 'aria-label': stringFormatter.format('listboxLabel'), + 'aria-label': stringFormatter.format('menuLabel'), 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); - // TODO: add the stuff from mobile combobox, check if I need the below + // TODO: add the stuff from mobile combobox, check if I need the below when testing in mobile devices // removed touch end since we did the same in MobileComboboxTray useEffect(() => { - focusSafely(inputRef.current); - }, []); + focusSafely(inputRef.current as FocusableElement); + }, [inputRef]); // TODO: decide where the announcements should go, pehaps make a separate hook so that the collection component can call it // // VoiceOver has issues with announcing aria-activedescendant properly on change @@ -198,18 +171,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco // lastSize.current = optionCount; // }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); - // // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. - // let lastSelectedKey = useRef(state.selectedKey); - // useEffect(() => { - // if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) { - // let optionText = state.selectedItem['aria-label'] || state.selectedItem.textValue || ''; - // let announcement = stringFormatter.format('selectedAnnouncement', {optionText}); - // announce(announcement); - // } - - // lastSelectedKey.current = state.selectedKey; - // }); + // TODO: Omitted the custom announcement for selection because we expect to only trigger onActions for Autocomplete, selected key isn't a thing return { labelProps, diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 953c0310dc2..4766c11e459 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -215,16 +215,14 @@ export class BaseCollection implements ICollection> { // to an array, then walk that new array and fix all the next/Prev keys while adding them to the new collection filter(filterFn: (nodeValue: string) => boolean): BaseCollection { let newCollection = new BaseCollection(); - // This tracks the last non-section item, used to track the prevKey for non-sections items as we traverse the collection - let lastItem: Mutable>; // This tracks the absolute last node we've visited in the collection when filtering, used for setting up the filteredCollection's lastKey and - // for attaching the next/prevKey for sections/separators. - let lastNode: Mutable>; + // for updating the next/prevKey for every non-filtered node. + let lastNode: Mutable> | null = null; for (let node of this) { if (node.type === 'section' && node.hasChildNodes) { let clonedSection: Mutable> = (node as CollectionNode).clone(); - let lastChildInSection: Mutable>; + let lastChildInSection: Mutable> | null = null; for (let child of this.getChildren(node.key)) { if (filterFn(child.textValue)) { let clonedChild: Mutable> = (child as CollectionNode).clone(); @@ -282,21 +280,20 @@ export class BaseCollection implements ICollection> { newCollection.firstKey = clonedNode.key; } - if (lastItem && lastItem.parentKey === clonedNode.parentKey) { - lastItem.nextKey = clonedNode.key; - clonedNode.prevKey = lastItem.key; + if (lastNode != null && (lastNode.type !== 'section' && lastNode.type !== 'separator') && lastNode.parentKey === clonedNode.parentKey) { + lastNode.nextKey = clonedNode.key; + clonedNode.prevKey = lastNode.key; } else { clonedNode.prevKey = null; } clonedNode.nextKey = null; newCollection.addNode(clonedNode); - lastItem = clonedNode; lastNode = clonedNode; } } - if (lastNode.type === 'separator' && lastNode.nextKey === null) { + if (lastNode?.type === 'separator' && lastNode.nextKey === null) { let lastSection; if (lastNode.prevKey != null) { lastSection = newCollection.getItem(lastNode.prevKey) as Mutable>; @@ -306,9 +303,8 @@ export class BaseCollection implements ICollection> { lastNode = lastSection; } - newCollection.lastKey = lastNode?.key; + newCollection.lastKey = lastNode?.key || null; return newCollection; } - } diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index f898f516a1b..193a186fb7d 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -26,8 +26,6 @@ export interface AutocompleteState { setFocusedNodeId(value: string): void } -// TODO: vet these props, maybe move out of here since most of these are the component's props rather than the state option -// TODO: clean up the packge json here export interface AutocompleteProps extends InputBase, TextInputBase, FocusableProps, LabelableProps, HelpTextProps { /** The value of the autocomplete input (controlled). */ inputValue?: string, @@ -37,16 +35,22 @@ export interface AutocompleteProps extends InputBase, TextInputBase, FocusablePr onInputChange?: (value: string) => void } -// TODO: get rid of this if we don't have any extra things to omit from the options +// Emulate our other stately hooks which accept all "base" props even if not used export interface AutocompleteStateOptions extends Omit {} /** * Provides state management for a autocomplete component. */ export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState { + let { + onInputChange: propsOnInputChange, + inputValue: propsInputValue, + defaultInputValue: propsDefaultInputValue = '' + } = props; + let onInputChange = (value) => { - if (props.onInputChange) { - props.onInputChange(value); + if (propsOnInputChange) { + propsOnInputChange(value); } // TODO: weird that this is handled here? @@ -55,8 +59,8 @@ export function useAutocompleteState(props: AutocompleteStateOptions): Autocompl let [focusedNodeId, setFocusedNodeId] = useState(null); let [inputValue, setInputValue] = useControlledState( - props.inputValue, - '', + propsInputValue, + propsDefaultInputValue!, onInputChange ); diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 942d94c1d5b..323b7259850 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -79,13 +79,13 @@ function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps inputRef }, state); - let renderPropsState = useMemo(() => ({ - isDisabled: props.isDisabled || false - }), [props.isDisabled]); + let renderValues = { + isDisabled: props.isDisabled || false, + }; let renderProps = useRenderProps({ ...props, - values: renderPropsState, + values: renderValues, defaultClassName: 'react-aria-Autocomplete' }); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 2572b333500..479a348f025 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -173,7 +173,7 @@ interface MenuInnerProps { function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { let {register, filterFn, inputValue} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, - // we always perform the filtering for them + // we always perform the filtering for them. let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useTreeState({ ...props, @@ -264,7 +264,11 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne // item, would be nice if it was all centralized. Maybe a reason to go back to having the autocomplete hooks create and manage // the collection/selection manager but then it might cause issues when we need to wrap a Table which won't use BaseCollection but rather has // its own collection - state.selectionManager.setFocused(false); + // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue + if (inputValue != null) { + state.selectionManager.setFocused(false); + state.selectionManager.setFocusedKey(null); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputValue]); diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 49ba7989bd3..f374eaa278f 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -10,134 +10,147 @@ * governing permissions and limitations under the License. */ +import {action} from '@storybook/addon-actions'; import {Autocomplete, Header, Input, Keyboard, Label, Menu, Section, Separator, Text} from 'react-aria-components'; import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; import {useAsyncList} from 'react-stately'; +import {useFilter} from 'react-aria'; export default { - title: 'React Aria Components' + title: 'React Aria Components', + argTypes: { + onAction: { + table: { + disable: true + } + } + }, + args: { + onAction: action('onAction') + } }; -export const AutocompleteExample = () => ( - - -
- -
- -
- Foo - Bar - Baz - Google -
- -
-
Section 2
- - Copy - Description - ⌘C - - - Cut - Description - ⌘X - - - Paste - Description - ⌘V - -
-
-
-); - +export const AutocompleteExample = { + render: ({onAction}) => { + return ( + + +
+ +
+ +
+ Foo + Bar + Baz + Google +
+ +
+
Section 2
+ + Copy + Description + ⌘C + + + Cut + Description + ⌘X + + + Paste + Description + ⌘V + +
+
+
+ ); + }, + name: 'Autocomplete complex static' +}; interface AutocompleteItem { id: string, name: string } let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; -export const AutocompleteRenderPropsStatic = () => ( - - {() => ( - <> - -
- -
- - Foo - Bar - Baz - - - )} -
-); -// TODO: I don't this we'll want these anymore, should always favor putting items directly onto the wrapped collection component -// since w -// export const AutocompleteRenderPropsDefaultItems = () => ( -// -// {() => ( -// <> -// -//
-// -//
-// -// {(item: AutocompleteItem) => {item.name}} -// -// -// )} -//
-// ); +export const AutocompleteRenderPropsMenuDynamic = { + render: ({onAction}) => { + return ( + + {() => ( + <> + +
+ +
+ + {item => {item.name}} + + + )} +
+ ); + }, + name: 'Autocomplete, render props, dynamic menu' +}; -// export const AutocompleteRenderPropsItems = { -// render: () => ( -// -// {() => ( -// <> -// -//
-// -//
-// -// {(item: AutocompleteItem) => {item.name}} -// -// -// )} -//
-// ), -// parameters: { -// description: { -// data: 'Note this won\'t filter the items in the Menu because it is fully controlled' -// } -// } -// }; +export const AutocompleteOnActionOnMenuItems = { + render: () => { + return ( + + {() => ( + <> + +
+ +
+ + Foo + Bar + Baz + + + )} +
+ ); + }, + name: 'Autocomplete, onAction on menu items' +}; -export const AutocompleteRenderPropsMenuDynamic = () => ( - - {() => ( - <> - -
- -
- - {item => {item.name}} - - - )} -
-); -export const AutocompleteAsyncLoadingExample = () => { +export const AutocompleteRenderPropsIsDisabled = { + render: ({onAction, isDisabled}) => { + return ( + + {({isDisabled}) => ( + <> + +
+ +
+ + {item => {item.name}} + + + )} +
+ ); + }, + name: 'Autocomplete, render props, isDisabled toggle', + argTypes: { + isDisabled: { + control: 'boolean' + } + } +}; + +const AsyncExample = ({onAction}) => { let list = useAsyncList({ async load({filterText}) { let json = await new Promise(resolve => { @@ -169,9 +182,47 @@ export const AutocompleteAsyncLoadingExample = () => {
items={list.items} - className={styles.menu}> + className={styles.menu} + onAction={onAction}> {item => {item.name}}
); }; + +export const AutocompleteAsyncLoadingExample = { + render: ({onAction}) => { + return ; + }, + name: 'Autocomplete, useAsync level filtering' +}; + + +const CaseSensitiveFilter = ({onAction}) => { + let {contains} = useFilter({ + sensitivity: 'case' + }); + let defaultFilter = (itemText, input) => contains(itemText, input); + return ( + + {() => ( + <> + +
+ +
+ + {item => {item.name}} + + + )} +
+ ); +}; + +export const AutocompleteCaseSensitive = { + render: ({onAction}) => { + return ; + }, + name: 'Autocomplete, case sensitive filter' +}; From 12480fd3b0db5966a482bead2e019264417250bc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 22 Oct 2024 15:41:41 -0700 Subject: [PATCH 10/42] add announcements to menu and various clean up --- .../@react-aria/autocomplete/intl/ar-AE.json | 5 +- .../@react-aria/autocomplete/intl/bg-BG.json | 5 +- .../@react-aria/autocomplete/intl/cs-CZ.json | 5 +- .../@react-aria/autocomplete/intl/da-DK.json | 5 +- .../@react-aria/autocomplete/intl/de-DE.json | 5 +- .../@react-aria/autocomplete/intl/el-GR.json | 5 +- .../@react-aria/autocomplete/intl/en-US.json | 3 - .../@react-aria/autocomplete/intl/es-ES.json | 5 +- .../@react-aria/autocomplete/intl/et-EE.json | 5 +- .../@react-aria/autocomplete/intl/fi-FI.json | 5 +- .../@react-aria/autocomplete/intl/fr-FR.json | 5 +- .../@react-aria/autocomplete/intl/he-IL.json | 5 +- .../@react-aria/autocomplete/intl/hr-HR.json | 5 +- .../@react-aria/autocomplete/intl/hu-HU.json | 5 +- .../@react-aria/autocomplete/intl/it-IT.json | 5 +- .../@react-aria/autocomplete/intl/ja-JP.json | 5 +- .../@react-aria/autocomplete/intl/ko-KR.json | 5 +- .../@react-aria/autocomplete/intl/lt-LT.json | 5 +- .../@react-aria/autocomplete/intl/lv-LV.json | 5 +- .../@react-aria/autocomplete/intl/nb-NO.json | 5 +- .../@react-aria/autocomplete/intl/nl-NL.json | 5 +- .../@react-aria/autocomplete/intl/pl-PL.json | 5 +- .../@react-aria/autocomplete/intl/pt-BR.json | 5 +- .../@react-aria/autocomplete/intl/pt-PT.json | 5 +- .../@react-aria/autocomplete/intl/ro-RO.json | 5 +- .../@react-aria/autocomplete/intl/ru-RU.json | 5 +- .../@react-aria/autocomplete/intl/sk-SK.json | 5 +- .../@react-aria/autocomplete/intl/sl-SI.json | 5 +- .../@react-aria/autocomplete/intl/sr-SP.json | 5 +- .../@react-aria/autocomplete/intl/sv-SE.json | 5 +- .../@react-aria/autocomplete/intl/tr-TR.json | 5 +- .../@react-aria/autocomplete/intl/uk-UA.json | 5 +- .../@react-aria/autocomplete/intl/zh-CN.json | 5 +- .../@react-aria/autocomplete/intl/zh-TW.json | 5 +- .../@react-aria/autocomplete/package.json | 7 -- .../autocomplete/src/useAutocomplete.ts | 68 +++------------- packages/@react-aria/combobox/README.md | 2 +- packages/@react-aria/menu/intl/ar-AE.json | 4 +- packages/@react-aria/menu/intl/en-US.json | 4 +- packages/@react-aria/menu/package.json | 2 + packages/@react-aria/menu/src/index.ts | 3 +- packages/@react-aria/menu/src/useMenu.ts | 79 ++++++++++++++++--- packages/@react-aria/menu/src/useMenuItem.ts | 6 +- packages/@react-aria/menu/src/utils.ts | 42 ++++++++++ packages/@react-spectrum/combobox/README.md | 2 +- .../@react-stately/autocomplete/README.md | 2 +- .../@react-stately/autocomplete/package.json | 6 -- packages/@react-stately/combobox/README.md | 2 +- packages/@react-types/combobox/README.md | 2 +- packages/react-aria-components/package.json | 1 - .../src/Autocomplete.tsx | 4 +- packages/react-aria-components/src/Menu.tsx | 8 +- yarn.lock | 9 +-- 53 files changed, 178 insertions(+), 243 deletions(-) create mode 100644 packages/@react-aria/menu/src/utils.ts diff --git a/packages/@react-aria/autocomplete/intl/ar-AE.json b/packages/@react-aria/autocomplete/intl/ar-AE.json index feb9a101140..f262b0d23c6 100644 --- a/packages/@react-aria/autocomplete/intl/ar-AE.json +++ b/packages/@react-aria/autocomplete/intl/ar-AE.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# خيار} other {# خيارات}} متاحة.", - "focusAnnouncement": "{isGroupChange, select, true {المجموعة المدخلة {groupTitle}, مع {groupCount, plural, one {# خيار} other {# خيارات}}. } other {}}{optionText}{isSelected, select, true {, محدد} other {}}", - "menuLabel": "مقترحات", - "selectedAnnouncement": "{optionText}، محدد" + "menuLabel": "مقترحات" } diff --git a/packages/@react-aria/autocomplete/intl/bg-BG.json b/packages/@react-aria/autocomplete/intl/bg-BG.json index e5b7ef37109..07b96e3441a 100644 --- a/packages/@react-aria/autocomplete/intl/bg-BG.json +++ b/packages/@react-aria/autocomplete/intl/bg-BG.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# опция} other {# опции}} на разположение.", - "focusAnnouncement": "{isGroupChange, select, true {Въведена група {groupTitle}, с {groupCount, plural, one {# опция} other {# опции}}. } other {}}{optionText}{isSelected, select, true {, избрани} other {}}", - "menuLabel": "Предложения", - "selectedAnnouncement": "{optionText}, избрани" + "menuLabel": "Предложения" } diff --git a/packages/@react-aria/autocomplete/intl/cs-CZ.json b/packages/@react-aria/autocomplete/intl/cs-CZ.json index e12db923049..01bb851f4fe 100644 --- a/packages/@react-aria/autocomplete/intl/cs-CZ.json +++ b/packages/@react-aria/autocomplete/intl/cs-CZ.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "K dispozici {optionCount, plural, one {je # možnost} other {jsou/je # možnosti/-í}}.", - "focusAnnouncement": "{isGroupChange, select, true {Zadaná skupina „{groupTitle}“ {groupCount, plural, one {s # možností} other {se # možnostmi}}. } other {}}{optionText}{isSelected, select, true { (vybráno)} other {}}", - "menuLabel": "Návrhy", - "selectedAnnouncement": "{optionText}, vybráno" + "menuLabel": "Návrhy" } diff --git a/packages/@react-aria/autocomplete/intl/da-DK.json b/packages/@react-aria/autocomplete/intl/da-DK.json index bfd6db53360..9ceb48d2a8e 100644 --- a/packages/@react-aria/autocomplete/intl/da-DK.json +++ b/packages/@react-aria/autocomplete/intl/da-DK.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# mulighed tilgængelig} other {# muligheder tilgængelige}}.", - "focusAnnouncement": "{isGroupChange, select, true {Angivet gruppe {groupTitle}, med {groupCount, plural, one {# mulighed} other {# muligheder}}. } other {}}{optionText}{isSelected, select, true {, valgt} other {}}", - "menuLabel": "Forslag", - "selectedAnnouncement": "{optionText}, valgt" + "menuLabel": "Forslag" } diff --git a/packages/@react-aria/autocomplete/intl/de-DE.json b/packages/@react-aria/autocomplete/intl/de-DE.json index b307c49f89d..56058ae1fb1 100644 --- a/packages/@react-aria/autocomplete/intl/de-DE.json +++ b/packages/@react-aria/autocomplete/intl/de-DE.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# Option} other {# Optionen}} verfügbar.", - "focusAnnouncement": "{isGroupChange, select, true {Eingetretene Gruppe {groupTitle}, mit {groupCount, plural, one {# Option} other {# Optionen}}. } other {}}{optionText}{isSelected, select, true {, ausgewählt} other {}}", - "menuLabel": "Empfehlungen", - "selectedAnnouncement": "{optionText}, ausgewählt" + "menuLabel": "Empfehlungen" } diff --git a/packages/@react-aria/autocomplete/intl/el-GR.json b/packages/@react-aria/autocomplete/intl/el-GR.json index 54ad842a5f5..9444f67ed64 100644 --- a/packages/@react-aria/autocomplete/intl/el-GR.json +++ b/packages/@react-aria/autocomplete/intl/el-GR.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# επιλογή} other {# επιλογές }} διαθέσιμες.", - "focusAnnouncement": "{isGroupChange, select, true {Εισαγμένη ομάδα {groupTitle}, με {groupCount, plural, one {# επιλογή} other {# επιλογές}}. } other {}}{optionText}{isSelected, select, true {, επιλεγμένο} other {}}", - "menuLabel": "Προτάσεις", - "selectedAnnouncement": "{optionText}, επιλέχθηκε" + "menuLabel": "Προτάσεις" } diff --git a/packages/@react-aria/autocomplete/intl/en-US.json b/packages/@react-aria/autocomplete/intl/en-US.json index cd6b09dda76..c8ff5623aac 100644 --- a/packages/@react-aria/autocomplete/intl/en-US.json +++ b/packages/@react-aria/autocomplete/intl/en-US.json @@ -1,6 +1,3 @@ { - "focusAnnouncement": "{isGroupChange, select, true {Entered group {groupTitle}, with {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, selected} other {}}", - "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} available.", - "selectedAnnouncement": "{optionText}, selected", "menuLabel": "Suggestions" } diff --git a/packages/@react-aria/autocomplete/intl/es-ES.json b/packages/@react-aria/autocomplete/intl/es-ES.json index 34ee7d26539..830fed8afe9 100644 --- a/packages/@react-aria/autocomplete/intl/es-ES.json +++ b/packages/@react-aria/autocomplete/intl/es-ES.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# opción} other {# opciones}} disponible(s).", - "focusAnnouncement": "{isGroupChange, select, true {Se ha unido al grupo {groupTitle}, con {groupCount, plural, one {# opción} other {# opciones}}. } other {}}{optionText}{isSelected, select, true {, seleccionado} other {}}", - "menuLabel": "Sugerencias", - "selectedAnnouncement": "{optionText}, seleccionado" + "menuLabel": "Sugerencias" } diff --git a/packages/@react-aria/autocomplete/intl/et-EE.json b/packages/@react-aria/autocomplete/intl/et-EE.json index c7bea2704fd..35f34268e40 100644 --- a/packages/@react-aria/autocomplete/intl/et-EE.json +++ b/packages/@react-aria/autocomplete/intl/et-EE.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# valik} other {# valikud}} saadaval.", - "focusAnnouncement": "{isGroupChange, select, true {Sisestatud rühm {groupTitle}, valikuga {groupCount, plural, one {# valik} other {# valikud}}. } other {}}{optionText}{isSelected, select, true {, valitud} other {}}", - "menuLabel": "Soovitused", - "selectedAnnouncement": "{optionText}, valitud" + "menuLabel": "Soovitused" } diff --git a/packages/@react-aria/autocomplete/intl/fi-FI.json b/packages/@react-aria/autocomplete/intl/fi-FI.json index bb1cddb3ca3..3cd1b888423 100644 --- a/packages/@react-aria/autocomplete/intl/fi-FI.json +++ b/packages/@react-aria/autocomplete/intl/fi-FI.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# vaihtoehto} other {# vaihtoehdot}} saatavilla.", - "focusAnnouncement": "{isGroupChange, select, true {Mentiin ryhmään {groupTitle}, {groupCount, plural, one {# vaihtoehdon} other {# vaihtoehdon}} kanssa.} other {}}{optionText}{isSelected, select, true {, valittu} other {}}", - "menuLabel": "Ehdotukset", - "selectedAnnouncement": "{optionText}, valittu" + "menuLabel": "Ehdotukset" } diff --git a/packages/@react-aria/autocomplete/intl/fr-FR.json b/packages/@react-aria/autocomplete/intl/fr-FR.json index 227364ad435..c8ff5623aac 100644 --- a/packages/@react-aria/autocomplete/intl/fr-FR.json +++ b/packages/@react-aria/autocomplete/intl/fr-FR.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} disponible(s).", - "focusAnnouncement": "{isGroupChange, select, true {Groupe {groupTitle} rejoint, avec {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, sélectionné(s)} other {}}", - "menuLabel": "Suggestions", - "selectedAnnouncement": "{optionText}, sélectionné" + "menuLabel": "Suggestions" } diff --git a/packages/@react-aria/autocomplete/intl/he-IL.json b/packages/@react-aria/autocomplete/intl/he-IL.json index 3d2d7e3c5cf..537109e89d8 100644 --- a/packages/@react-aria/autocomplete/intl/he-IL.json +++ b/packages/@react-aria/autocomplete/intl/he-IL.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {אפשרות #} other {# אפשרויות}} במצב זמין.", - "focusAnnouncement": "{isGroupChange, select, true {נכנס לקבוצה {groupTitle}, עם {groupCount, plural, one {אפשרות #} other {# אפשרויות}}. } other {}}{optionText}{isSelected, select, true {, נבחר} other {}}", - "menuLabel": "הצעות", - "selectedAnnouncement": "{optionText}, נבחר" + "menuLabel": "הצעות" } diff --git a/packages/@react-aria/autocomplete/intl/hr-HR.json b/packages/@react-aria/autocomplete/intl/hr-HR.json index f0c5c77317d..42fae42125c 100644 --- a/packages/@react-aria/autocomplete/intl/hr-HR.json +++ b/packages/@react-aria/autocomplete/intl/hr-HR.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "Dostupno još: {optionCount, plural, one {# opcija} other {# opcije/a}}.", - "focusAnnouncement": "{isGroupChange, select, true {Unesena skupina {groupTitle}, s {groupCount, plural, one {# opcijom} other {# opcije/a}}. } other {}}{optionText}{isSelected, select, true {, odabranih} other {}}", - "menuLabel": "Prijedlozi", - "selectedAnnouncement": "{optionText}, odabrano" + "menuLabel": "Prijedlozi" } diff --git a/packages/@react-aria/autocomplete/intl/hu-HU.json b/packages/@react-aria/autocomplete/intl/hu-HU.json index 0e75c3ab0ac..44f7922d83e 100644 --- a/packages/@react-aria/autocomplete/intl/hu-HU.json +++ b/packages/@react-aria/autocomplete/intl/hu-HU.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# lehetőség} other {# lehetőség}} áll rendelkezésre.", - "focusAnnouncement": "{isGroupChange, select, true {Belépett a(z) {groupTitle} csoportba, amely {groupCount, plural, one {# lehetőséget} other {# lehetőséget}} tartalmaz. } other {}}{optionText}{isSelected, select, true {, kijelölve} other {}}", - "menuLabel": "Javaslatok", - "selectedAnnouncement": "{optionText}, kijelölve" + "menuLabel": "Javaslatok" } diff --git a/packages/@react-aria/autocomplete/intl/it-IT.json b/packages/@react-aria/autocomplete/intl/it-IT.json index f2425f0d2f7..93fc370c8ba 100644 --- a/packages/@react-aria/autocomplete/intl/it-IT.json +++ b/packages/@react-aria/autocomplete/intl/it-IT.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# opzione disponibile} other {# opzioni disponibili}}.", - "focusAnnouncement": "{isGroupChange, select, true {Ingresso nel gruppo {groupTitle}, con {groupCount, plural, one {# opzione} other {# opzioni}}. } other {}}{optionText}{isSelected, select, true {, selezionato} other {}}", - "menuLabel": "Suggerimenti", - "selectedAnnouncement": "{optionText}, selezionato" + "menuLabel": "Suggerimenti" } diff --git a/packages/@react-aria/autocomplete/intl/ja-JP.json b/packages/@react-aria/autocomplete/intl/ja-JP.json index 9e243e6e263..45926ec2e1f 100644 --- a/packages/@react-aria/autocomplete/intl/ja-JP.json +++ b/packages/@react-aria/autocomplete/intl/ja-JP.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# 個のオプション} other {# 個のオプション}}を利用できます。", - "focusAnnouncement": "{isGroupChange, select, true {入力されたグループ {groupTitle}、{groupCount, plural, one {# 個のオプション} other {# 個のオプション}}を含む。} other {}}{optionText}{isSelected, select, true {、選択済み} other {}}", - "menuLabel": "候補", - "selectedAnnouncement": "{optionText}、選択済み" + "menuLabel": "候補" } diff --git a/packages/@react-aria/autocomplete/intl/ko-KR.json b/packages/@react-aria/autocomplete/intl/ko-KR.json index f83731bb9f8..47a289537d1 100644 --- a/packages/@react-aria/autocomplete/intl/ko-KR.json +++ b/packages/@react-aria/autocomplete/intl/ko-KR.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {#개 옵션} other {#개 옵션}}을 사용할 수 있습니다.", - "focusAnnouncement": "{isGroupChange, select, true {입력한 그룹 {groupTitle}, {groupCount, plural, one {#개 옵션} other {#개 옵션}}. } other {}}{optionText}{isSelected, select, true {, 선택됨} other {}}", - "menuLabel": "제안", - "selectedAnnouncement": "{optionText}, 선택됨" + "menuLabel": "제안" } diff --git a/packages/@react-aria/autocomplete/intl/lt-LT.json b/packages/@react-aria/autocomplete/intl/lt-LT.json index 8e6a04f7c01..e791bcc8ef3 100644 --- a/packages/@react-aria/autocomplete/intl/lt-LT.json +++ b/packages/@react-aria/autocomplete/intl/lt-LT.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "Yra {optionCount, plural, one {# parinktis} other {# parinktys (-ių)}}.", - "focusAnnouncement": "{isGroupChange, select, true {Įvesta grupė {groupTitle}, su {groupCount, plural, one {# parinktimi} other {# parinktimis (-ių)}}. } other {}}{optionText}{isSelected, select, true {, pasirinkta} other {}}", - "menuLabel": "Pasiūlymai", - "selectedAnnouncement": "{optionText}, pasirinkta" + "menuLabel": "Pasiūlymai" } diff --git a/packages/@react-aria/autocomplete/intl/lv-LV.json b/packages/@react-aria/autocomplete/intl/lv-LV.json index eca1c62ab9d..5839aa7c1e5 100644 --- a/packages/@react-aria/autocomplete/intl/lv-LV.json +++ b/packages/@react-aria/autocomplete/intl/lv-LV.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "Pieejamo opciju skaits: {optionCount, plural, one {# opcija} other {# opcijas}}.", - "focusAnnouncement": "{isGroupChange, select, true {Ievadīta grupa {groupTitle}, ar {groupCount, plural, one {# opciju} other {# opcijām}}. } other {}}{optionText}{isSelected, select, true {, atlasīta} other {}}", - "menuLabel": "Ieteikumi", - "selectedAnnouncement": "{optionText}, atlasīta" + "menuLabel": "Ieteikumi" } diff --git a/packages/@react-aria/autocomplete/intl/nb-NO.json b/packages/@react-aria/autocomplete/intl/nb-NO.json index 51c85ad50ae..9ceb48d2a8e 100644 --- a/packages/@react-aria/autocomplete/intl/nb-NO.json +++ b/packages/@react-aria/autocomplete/intl/nb-NO.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# alternativ} other {# alternativer}} finnes.", - "focusAnnouncement": "{isGroupChange, select, true {Angitt gruppe {groupTitle}, med {groupCount, plural, one {# alternativ} other {# alternativer}}. } other {}}{optionText}{isSelected, select, true {, valgt} other {}}", - "menuLabel": "Forslag", - "selectedAnnouncement": "{optionText}, valgt" + "menuLabel": "Forslag" } diff --git a/packages/@react-aria/autocomplete/intl/nl-NL.json b/packages/@react-aria/autocomplete/intl/nl-NL.json index e0077c81505..9c1e90c0aeb 100644 --- a/packages/@react-aria/autocomplete/intl/nl-NL.json +++ b/packages/@react-aria/autocomplete/intl/nl-NL.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# optie} other {# opties}} beschikbaar.", - "focusAnnouncement": "{isGroupChange, select, true {Groep {groupTitle} ingevoerd met {groupCount, plural, one {# optie} other {# opties}}. } other {}}{optionText}{isSelected, select, true {, geselecteerd} other {}}", - "menuLabel": "Suggesties", - "selectedAnnouncement": "{optionText}, geselecteerd" + "menuLabel": "Suggesties" } diff --git a/packages/@react-aria/autocomplete/intl/pl-PL.json b/packages/@react-aria/autocomplete/intl/pl-PL.json index c6666fd066c..950eb325788 100644 --- a/packages/@react-aria/autocomplete/intl/pl-PL.json +++ b/packages/@react-aria/autocomplete/intl/pl-PL.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "dostępna/dostępne(-nych) {optionCount, plural, one {# opcja} other {# opcje(-i)}}.", - "focusAnnouncement": "{isGroupChange, select, true {Dołączono do grupy {groupTitle}, z {groupCount, plural, one {# opcją} other {# opcjami}}. } other {}}{optionText}{isSelected, select, true {, wybrano} other {}}", - "menuLabel": "Sugestie", - "selectedAnnouncement": "{optionText}, wybrano" + "menuLabel": "Sugestie" } diff --git a/packages/@react-aria/autocomplete/intl/pt-BR.json b/packages/@react-aria/autocomplete/intl/pt-BR.json index 1c2eac37a50..25a143572b2 100644 --- a/packages/@react-aria/autocomplete/intl/pt-BR.json +++ b/packages/@react-aria/autocomplete/intl/pt-BR.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# opção} other {# opções}} disponível.", - "focusAnnouncement": "{isGroupChange, select, true {Grupo inserido {groupTitle}, com {groupCount, plural, one {# opção} other {# opções}}. } other {}}{optionText}{isSelected, select, true {, selecionado} other {}}", - "menuLabel": "Sugestões", - "selectedAnnouncement": "{optionText}, selecionado" + "menuLabel": "Sugestões" } diff --git a/packages/@react-aria/autocomplete/intl/pt-PT.json b/packages/@react-aria/autocomplete/intl/pt-PT.json index a75866801a9..25a143572b2 100644 --- a/packages/@react-aria/autocomplete/intl/pt-PT.json +++ b/packages/@react-aria/autocomplete/intl/pt-PT.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# opção} other {# opções}} disponível.", - "focusAnnouncement": "{isGroupChange, select, true {Grupo introduzido {groupTitle}, com {groupCount, plural, one {# opção} other {# opções}}. } other {}}{optionText}{isSelected, select, true {, selecionado} other {}}", - "menuLabel": "Sugestões", - "selectedAnnouncement": "{optionText}, selecionado" + "menuLabel": "Sugestões" } diff --git a/packages/@react-aria/autocomplete/intl/ro-RO.json b/packages/@react-aria/autocomplete/intl/ro-RO.json index eb11a523927..f57781374ab 100644 --- a/packages/@react-aria/autocomplete/intl/ro-RO.json +++ b/packages/@react-aria/autocomplete/intl/ro-RO.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# opțiune} other {# opțiuni}} disponibile.", - "focusAnnouncement": "{isGroupChange, select, true {Grup {groupTitle} introdus, cu {groupCount, plural, one {# opțiune} other {# opțiuni}}. } other {}}{optionText}{isSelected, select, true {, selectat} other {}}", - "menuLabel": "Sugestii", - "selectedAnnouncement": "{optionText}, selectat" + "menuLabel": "Sugestii" } diff --git a/packages/@react-aria/autocomplete/intl/ru-RU.json b/packages/@react-aria/autocomplete/intl/ru-RU.json index d163708c9fe..07b96e3441a 100644 --- a/packages/@react-aria/autocomplete/intl/ru-RU.json +++ b/packages/@react-aria/autocomplete/intl/ru-RU.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# параметр} other {# параметров}} доступно.", - "focusAnnouncement": "{isGroupChange, select, true {Введенная группа {groupTitle}, с {groupCount, plural, one {# параметром} other {# параметрами}}. } other {}}{optionText}{isSelected, select, true {, выбранными} other {}}", - "menuLabel": "Предложения", - "selectedAnnouncement": "{optionText}, выбрано" + "menuLabel": "Предложения" } diff --git a/packages/@react-aria/autocomplete/intl/sk-SK.json b/packages/@react-aria/autocomplete/intl/sk-SK.json index 7e18a4ede60..01bb851f4fe 100644 --- a/packages/@react-aria/autocomplete/intl/sk-SK.json +++ b/packages/@react-aria/autocomplete/intl/sk-SK.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# možnosť} other {# možnosti/-í}} k dispozícii.", - "focusAnnouncement": "{isGroupChange, select, true {Zadaná skupina {groupTitle}, s {groupCount, plural, one {# možnosťou} other {# možnosťami}}. } other {}}{optionText}{isSelected, select, true {, vybraté} other {}}", - "menuLabel": "Návrhy", - "selectedAnnouncement": "{optionText}, vybraté" + "menuLabel": "Návrhy" } diff --git a/packages/@react-aria/autocomplete/intl/sl-SI.json b/packages/@react-aria/autocomplete/intl/sl-SI.json index d7c2313f8f1..01b720d4a62 100644 --- a/packages/@react-aria/autocomplete/intl/sl-SI.json +++ b/packages/@react-aria/autocomplete/intl/sl-SI.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "Na voljo je {optionCount, plural, one {# opcija} other {# opcije}}.", - "focusAnnouncement": "{isGroupChange, select, true {Vnesena skupina {groupTitle}, z {groupCount, plural, one {# opcija} other {# opcije}}. } other {}}{optionText}{isSelected, select, true {, izbrano} other {}}", - "menuLabel": "Predlogi", - "selectedAnnouncement": "{optionText}, izbrano" + "menuLabel": "Predlogi" } diff --git a/packages/@react-aria/autocomplete/intl/sr-SP.json b/packages/@react-aria/autocomplete/intl/sr-SP.json index 14a432d713d..2ec701a5dac 100644 --- a/packages/@react-aria/autocomplete/intl/sr-SP.json +++ b/packages/@react-aria/autocomplete/intl/sr-SP.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "Dostupno još: {optionCount, plural, one {# opcija} other {# opcije/a}}.", - "focusAnnouncement": "{isGroupChange, select, true {Unesena grupa {groupTitle}, s {groupCount, plural, one {# opcijom} other {# optione/a}}. } other {}}{optionText}{isSelected, select, true {, izabranih} other {}}", - "menuLabel": "Predlozi", - "selectedAnnouncement": "{optionText}, izabrano" + "menuLabel": "Predlozi" } diff --git a/packages/@react-aria/autocomplete/intl/sv-SE.json b/packages/@react-aria/autocomplete/intl/sv-SE.json index 520052c19cf..81f43b773fb 100644 --- a/packages/@react-aria/autocomplete/intl/sv-SE.json +++ b/packages/@react-aria/autocomplete/intl/sv-SE.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# alternativ} other {# alternativ}} tillgängliga.", - "focusAnnouncement": "{isGroupChange, select, true {Ingick i gruppen {groupTitle} med {groupCount, plural, one {# alternativ} other {# alternativ}}. } other {}}{optionText}{isSelected, select, true {, valda} other {}}", - "menuLabel": "Förslag", - "selectedAnnouncement": "{optionText}, valda" + "menuLabel": "Förslag" } diff --git a/packages/@react-aria/autocomplete/intl/tr-TR.json b/packages/@react-aria/autocomplete/intl/tr-TR.json index 2796e895ef0..ea2d4f79de7 100644 --- a/packages/@react-aria/autocomplete/intl/tr-TR.json +++ b/packages/@react-aria/autocomplete/intl/tr-TR.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# seçenek} other {# seçenekler}} kullanılabilir.", - "focusAnnouncement": "{isGroupChange, select, true {Girilen grup {groupTitle}, ile {groupCount, plural, one {# seçenek} other {# seçenekler}}. } other {}}{optionText}{isSelected, select, true {, seçildi} other {}}", - "menuLabel": "Öneriler", - "selectedAnnouncement": "{optionText}, seçildi" + "menuLabel": "Öneriler" } diff --git a/packages/@react-aria/autocomplete/intl/uk-UA.json b/packages/@react-aria/autocomplete/intl/uk-UA.json index 2b42178c6dd..ab6ea6461ca 100644 --- a/packages/@react-aria/autocomplete/intl/uk-UA.json +++ b/packages/@react-aria/autocomplete/intl/uk-UA.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# параметр} other {# параметри(-ів)}} доступно.", - "focusAnnouncement": "{isGroupChange, select, true {Введена група {groupTitle}, з {groupCount, plural, one {# параметр} other {# параметри(-ів)}}. } other {}}{optionText}{isSelected, select, true {, вибрано} other {}}", - "menuLabel": "Пропозиції", - "selectedAnnouncement": "{optionText}, вибрано" + "menuLabel": "Пропозиції" } diff --git a/packages/@react-aria/autocomplete/intl/zh-CN.json b/packages/@react-aria/autocomplete/intl/zh-CN.json index 49156240642..53578ff64c0 100644 --- a/packages/@react-aria/autocomplete/intl/zh-CN.json +++ b/packages/@react-aria/autocomplete/intl/zh-CN.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "有 {optionCount, plural, one {# 个选项} other {# 个选项}}可用。", - "focusAnnouncement": "{isGroupChange, select, true {进入了 {groupTitle} 组,其中有 {groupCount, plural, one {# 个选项} other {# 个选项}}. } other {}}{optionText}{isSelected, select, true {, 已选择} other {}}", - "menuLabel": "建议", - "selectedAnnouncement": "{optionText}, 已选择" + "menuLabel": "建议" } diff --git a/packages/@react-aria/autocomplete/intl/zh-TW.json b/packages/@react-aria/autocomplete/intl/zh-TW.json index c0a03b70576..5fdc5cf20c4 100644 --- a/packages/@react-aria/autocomplete/intl/zh-TW.json +++ b/packages/@react-aria/autocomplete/intl/zh-TW.json @@ -1,6 +1,3 @@ { - "countAnnouncement": "{optionCount, plural, one {# 選項} other {# 選項}} 可用。", - "focusAnnouncement": "{isGroupChange, select, true {輸入的群組 {groupTitle}, 有 {groupCount, plural, one {# 選項} other {# 選項}}. } other {}}{optionText}{isSelected, select, true {, 已選取} other {}}", - "menuLabel": "建議", - "selectedAnnouncement": "{optionText}, 已選取" + "menuLabel": "建議" } diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index c6713569e4b..8a5446d06cd 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -26,20 +26,13 @@ "@react-aria/focus": "^3.18.3", "@react-aria/i18n": "^3.12.3", "@react-aria/listbox": "^3.13.4", - "@react-aria/live-announcer": "^3.4.0", - "@react-aria/menu": "^3.15.4", - "@react-aria/overlays": "^3.23.3", "@react-aria/searchfield": "^3.7.9", - "@react-aria/selection": "^3.20.0", "@react-aria/textfield": "^3.14.9", "@react-aria/utils": "^3.25.3", "@react-stately/autocomplete": "3.0.0-alpha.1", - "@react-stately/collections": "^3.11.0", "@react-stately/combobox": "^3.10.0", - "@react-stately/form": "^3.0.6", "@react-types/autocomplete": "3.0.0-alpha.26", "@react-types/button": "^3.10.0", - "@react-types/combobox": "^3.13.0", "@react-types/shared": "^3.25.0", "@swc/helpers": "^0.5.0" }, diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index f678024f9b6..c49bae4c7c1 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, FocusableElement, InputDOMProps, RefObject} from '@react-types/shared'; -import {AriaMenuOptions} from '@react-aria/menu'; +import type {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, mergeProps, useId, useLabels} from '@react-aria/utils'; import {focusSafely} from '@react-aria/focus'; @@ -80,12 +80,16 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco case 'Escape': if (state.inputValue !== '') { state.setInputValue(''); + } else { + e.continuePropagation(); } break; case 'Home': case 'End': - // Prevent Fn + left/right from moving the text cursor in the input + case 'ArrowUp': + case 'ArrowDown': + // Prevent these keys from moving the text cursor in the input e.preventDefault(); break; } @@ -118,62 +122,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco focusSafely(inputRef.current as FocusableElement); }, [inputRef]); - // TODO: decide where the announcements should go, pehaps make a separate hook so that the collection component can call it - // // VoiceOver has issues with announcing aria-activedescendant properly on change - // // (especially on iOS). We use a live region announcer to announce focus changes - // // manually. In addition, section titles are announced when navigating into a new section. - // let focusedItem = state.selectionManager.focusedKey != null - // ? state.collection.getItem(state.selectionManager.focusedKey) - // : undefined; - // let sectionKey = focusedItem?.parentKey ?? null; - // let itemKey = state.selectionManager.focusedKey ?? null; - // let lastSection = useRef(sectionKey); - // let lastItem = useRef(itemKey); - // useEffect(() => { - // if (isAppleDevice() && focusedItem != null && itemKey !== lastItem.current) { - // let isSelected = state.selectionManager.isSelected(itemKey); - // let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; - // let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; - - // let announcement = stringFormatter.format('focusAnnouncement', { - // isGroupChange: !!section && sectionKey !== lastSection.current, - // groupTitle: sectionTitle, - // groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, - // optionText: focusedItem['aria-label'] || focusedItem.textValue || '', - // isSelected - // }); - - // announce(announcement); - // } - - // lastSection.current = sectionKey; - // lastItem.current = itemKey; - // }); - - // // Announce the number of available suggestions when it changes - // let optionCount = getItemCount(state.collection); - // let lastSize = useRef(optionCount); - // let [announced, setAnnounced] = useState(false); - - // // TODO: test this behavior below, now that there isn't a open state this should just announce for the first render in which the field is focused? - // useEffect(() => { - // // Only announce the number of options available when the autocomplete first renders if there is no - // // focused item, otherwise screen readers will typically read e.g. "1 of 6". - // // The exception is VoiceOver since this isn't included in the message above. - // let didRenderWithoutFocusedItem = !announced && (state.selectionManager.focusedKey == null || isAppleDevice()); - - // if ((didRenderWithoutFocusedItem || optionCount !== lastSize.current)) { - // let announcement = stringFormatter.format('countAnnouncement', {optionCount}); - // announce(announcement); - // setAnnounced(true); - // } - - // lastSize.current = optionCount; - // }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); - - - // TODO: Omitted the custom announcement for selection because we expect to only trigger onActions for Autocomplete, selected key isn't a thing - return { labelProps, inputProps: mergeProps(inputProps, { @@ -182,6 +130,10 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', 'aria-activedescendant': state.focusedNodeId ?? undefined, + // TODO: note that the searchbox role causes newly typed letters to interrupt the announcement of the number of available options in Safari. + // I tested on iPad/Android/etc and the combobox role doesn't seem to do that but it will announce that there is a listbox which isn't true + // and it will say press Control Option Space to display a list of options which is also incorrect. To be fair though, our combobox doesn't open with + // that combination of buttons role: 'searchbox', // This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions. autoCorrect: 'off', diff --git a/packages/@react-aria/combobox/README.md b/packages/@react-aria/combobox/README.md index f5eb659a8b5..04f76a90228 100644 --- a/packages/@react-aria/combobox/README.md +++ b/packages/@react-aria/combobox/README.md @@ -1,3 +1,3 @@ # @react-aria/combobox -This package is part of [react-spectrum](https://github.com/adobe-private/react-spectrum-v3). See the repo for more details. +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-aria/menu/intl/ar-AE.json b/packages/@react-aria/menu/intl/ar-AE.json index b10ce6704a3..e14d8345e38 100644 --- a/packages/@react-aria/menu/intl/ar-AE.json +++ b/packages/@react-aria/menu/intl/ar-AE.json @@ -1,3 +1,5 @@ { - "longPressMessage": "اضغط مطولاً أو اضغط على Alt + السهم لأسفل لفتح القائمة" + "longPressMessage": "اضغط مطولاً أو اضغط على Alt + السهم لأسفل لفتح القائمة", + "countAnnouncement": "{optionCount, plural, one {# خيار} other {# خيارات}} متاحة.", + "focusAnnouncement": "{isGroupChange, select, true {المجموعة المدخلة {groupTitle}, مع {groupCount, plural, one {# خيار} other {# خيارات}}. } other {}}{optionText}{isSelected, select, true {, محدد} other {}}" } diff --git a/packages/@react-aria/menu/intl/en-US.json b/packages/@react-aria/menu/intl/en-US.json index a5ffe907ddc..fee4baa383b 100644 --- a/packages/@react-aria/menu/intl/en-US.json +++ b/packages/@react-aria/menu/intl/en-US.json @@ -1,3 +1,5 @@ { - "longPressMessage": "Long press or press Alt + ArrowDown to open menu" + "longPressMessage": "Long press or press Alt + ArrowDown to open menu", + "focusAnnouncement": "{isGroupChange, select, true {Entered group {groupTitle}, with {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, selected} other {}}", + "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} available." } diff --git a/packages/@react-aria/menu/package.json b/packages/@react-aria/menu/package.json index 5d5269a7ad4..1f03554c6c9 100644 --- a/packages/@react-aria/menu/package.json +++ b/packages/@react-aria/menu/package.json @@ -25,10 +25,12 @@ "@react-aria/focus": "^3.18.3", "@react-aria/i18n": "^3.12.3", "@react-aria/interactions": "^3.22.3", + "@react-aria/live-announcer": "^3.4.0", "@react-aria/overlays": "^3.23.3", "@react-aria/selection": "^3.20.0", "@react-aria/utils": "^3.25.3", "@react-stately/collections": "^3.11.0", + "@react-stately/list": "^3.11.0", "@react-stately/menu": "^3.8.3", "@react-stately/tree": "^3.8.5", "@react-types/button": "^3.10.0", diff --git a/packages/@react-aria/menu/src/index.ts b/packages/@react-aria/menu/src/index.ts index 18be8eacc3a..a9b520f8802 100644 --- a/packages/@react-aria/menu/src/index.ts +++ b/packages/@react-aria/menu/src/index.ts @@ -11,10 +11,11 @@ */ export {useMenuTrigger} from './useMenuTrigger'; -export {useMenu, menuData} from './useMenu'; +export {useMenu} from './useMenu'; export {useMenuItem} from './useMenuItem'; export {useMenuSection} from './useMenuSection'; export {useSubmenuTrigger} from './useSubmenuTrigger'; +export {getItemId, menuData} from './utils'; export type {AriaMenuProps} from '@react-types/menu'; export type {AriaMenuTriggerProps, MenuTriggerAria} from './useMenuTrigger'; diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index 2331093c2bf..2663379588d 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -10,10 +10,17 @@ * governing permissions and limitations under the License. */ +import {announce} from '@react-aria/live-announcer'; import {AriaMenuProps} from '@react-types/menu'; -import {DOMAttributes, HoverEvent, Key, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {DOMAttributes, HoverEvent, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; +import {filterDOMProps, isAppleDevice, mergeProps, useId} from '@react-aria/utils'; +import {getChildNodes, getItemCount} from '@react-stately/collections'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {menuData} from './utils'; import {TreeState} from '@react-stately/tree'; +import {useEffect, useRef, useState} from 'react'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSelectableList} from '@react-aria/selection'; export interface MenuAria { @@ -39,16 +46,6 @@ export interface AriaMenuOptions extends Omit, 'children'>, onHoverStart?: (e: HoverEvent) => void } -interface MenuData { - onClose?: () => void, - onAction?: (key: Key) => void, - onHoverStart?: (e: HoverEvent) => void, - shouldUseVirtualFocus?: boolean, - id: string -} - -export const menuData = new WeakMap, MenuData>(); - /** * Provides the behavior and accessibility implementation for a menu component. * A menu displays a list of actions or options that a user can choose. @@ -88,6 +85,64 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: id }); + // TODO: for now I'm putting these announcement into menu but would like to discuss where we may this these could go + // I was thinking perhaps in @react-aria/interactions (it is interaction specific aka virtualFocus) or @react-aria/collections (dependent on tracking a focused key in a collection) + // but those felt kinda iffy. A new package? + // TODO: port all other translations/remove stuff if not needed + + // VoiceOver has issues with announcing aria-activedescendant properly on change + // (especially on iOS). We use a live region announcer to announce focus changes + // manually. In addition, section titles are announced when navigating into a new section. + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/menu'); + let focusedItem = state.selectionManager.focusedKey != null + ? state.collection.getItem(state.selectionManager.focusedKey) + : undefined; + let sectionKey = focusedItem?.parentKey ?? null; + let itemKey = state.selectionManager.focusedKey ?? null; + let lastSection = useRef(sectionKey); + let lastItem = useRef(itemKey); + useEffect(() => { + if (isAppleDevice() && focusedItem != null && itemKey !== lastItem.current) { + let isSelected = state.selectionManager.isSelected(itemKey); + let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; + let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; + let announcement = stringFormatter.format('focusAnnouncement', { + isGroupChange: !!section && sectionKey !== lastSection.current, + groupTitle: sectionTitle, + groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, + optionText: focusedItem['aria-label'] || focusedItem.textValue || '', + isSelected + }); + + announce(announcement); + } + + lastSection.current = sectionKey; + lastItem.current = itemKey; + }); + + // Announce the number of available suggestions when it changes + let optionCount = getItemCount(state.collection); + let lastSize = useRef(optionCount); + let [announced, setAnnounced] = useState(false); + + // TODO: test this behavior below, now that there isn't a open state this should just announce for the first render in which the field is focused? + useEffect(() => { + // Only announce the number of options available when the autocomplete first renders if there is no + // focused item, otherwise screen readers will typically read e.g. "1 of 6". + // The exception is VoiceOver since this isn't included in the message above. + let didRenderWithoutFocusedItem = !announced && (state.selectionManager.focusedKey == null || isAppleDevice()); + if (didRenderWithoutFocusedItem || optionCount !== lastSize.current) { + let announcement = stringFormatter.format('countAnnouncement', {optionCount}); + announce(announcement); + setAnnounced(true); + } + + lastSize.current = optionCount; + }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); + + // TODO: Omitted the custom announcement for selection because we expect to only trigger onActions for Autocomplete, selected key isn't a thing + return { menuProps: mergeProps(domProps, {onKeyDown, onKeyUp}, { role: 'menu', diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index c867c2bd2c0..1b856f3daf7 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -13,8 +13,8 @@ import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject, RouterOptions} from '@react-types/shared'; import {filterDOMProps, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; +import {getItemId, menuData} from './utils'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; -import {menuData} from './useMenu'; import {TreeState} from '@react-stately/tree'; import {useSelectableItem} from '@react-aria/selection'; @@ -163,10 +163,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let keyboardId = useSlotId(); if (data.shouldUseVirtualFocus) { - // TODO: will need to normalize the key and stuff, but need to finalize if - // every component that Autocomplete will accept as a filterable child would need to follow this same + // TODO: finalize if every component that Autocomplete will accept as a filterable child would need to follow this same // logic when creating the id id = `${data.id}-option-${key}`; + id = getItemId(state, key); } let ariaProps = { diff --git a/packages/@react-aria/menu/src/utils.ts b/packages/@react-aria/menu/src/utils.ts new file mode 100644 index 00000000000..68348328de1 --- /dev/null +++ b/packages/@react-aria/menu/src/utils.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {HoverEvent, Key} from '@react-types/shared'; +import {TreeState} from '@react-stately/tree'; + +interface MenuData { + onClose?: () => void, + onAction?: (key: Key) => void, + onHoverStart?: (e: HoverEvent) => void, + shouldUseVirtualFocus?: boolean, + id: string +} + +export const menuData = new WeakMap, MenuData>(); + +function normalizeKey(key: Key): string { + if (typeof key === 'string') { + return key.replace(/\s*/g, ''); + } + + return '' + key; +} + +export function getItemId(state: TreeState, itemKey: Key): string { + let data = menuData.get(state); + + if (!data) { + throw new Error('Unknown menu'); + } + + return `${data.id}-option-${normalizeKey(itemKey)}`; +} diff --git a/packages/@react-spectrum/combobox/README.md b/packages/@react-spectrum/combobox/README.md index 45c1c37bfd5..6f8bddd69e4 100644 --- a/packages/@react-spectrum/combobox/README.md +++ b/packages/@react-spectrum/combobox/README.md @@ -1,3 +1,3 @@ # @react-spectrum/combobox -This package is part of [react-spectrum](https://github.com/adobe-private/react-spectrum-v3). See the repo for more details. +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-stately/autocomplete/README.md b/packages/@react-stately/autocomplete/README.md index 815a5ccee2d..ca449de39f4 100644 --- a/packages/@react-stately/autocomplete/README.md +++ b/packages/@react-stately/autocomplete/README.md @@ -1,3 +1,3 @@ # @react-stately/autocomplete -This package is part of [react-spectrum](https://github.com/adobe-private/react-spectrum-v3). See the repo for more details. +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-stately/autocomplete/package.json b/packages/@react-stately/autocomplete/package.json index 00dd5e0e825..6e598ff09f2 100644 --- a/packages/@react-stately/autocomplete/package.json +++ b/packages/@react-stately/autocomplete/package.json @@ -22,13 +22,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-stately/collections": "^3.11.0", - "@react-stately/form": "^3.0.6", - "@react-stately/list": "^3.11.0", - "@react-stately/overlays": "^3.6.11", - "@react-stately/select": "^3.6.8", "@react-stately/utils": "^3.10.4", - "@react-types/combobox": "^3.13.0", "@react-types/shared": "^3.25.0", "@swc/helpers": "^0.5.0" }, diff --git a/packages/@react-stately/combobox/README.md b/packages/@react-stately/combobox/README.md index aeb919adc58..516e2013ffb 100644 --- a/packages/@react-stately/combobox/README.md +++ b/packages/@react-stately/combobox/README.md @@ -1,3 +1,3 @@ # @react-stately/combobox -This package is part of [react-spectrum](https://github.com/adobe-private/react-spectrum-v3). See the repo for more details. +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-types/combobox/README.md b/packages/@react-types/combobox/README.md index e958168e2ea..f707814b827 100644 --- a/packages/@react-types/combobox/README.md +++ b/packages/@react-types/combobox/README.md @@ -1,3 +1,3 @@ # @react-types/combobox -This package is part of [react-spectrum](https://github.com/adobe-private/react-spectrum-v3). See the repo for more details. +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 6945f230488..130e7f49f45 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -55,7 +55,6 @@ "@react-aria/virtualizer": "^4.0.3", "@react-stately/autocomplete": "3.0.0-alpha.1", "@react-stately/color": "^3.8.0", - "@react-stately/combobox": "^3.10.0", "@react-stately/disclosure": "3.0.0-alpha.0", "@react-stately/layout": "^4.0.3", "@react-stately/menu": "^3.8.3", diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 323b7259850..2c62de600db 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -19,7 +19,7 @@ import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; import {MenuContext} from './Menu'; -import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useMemo, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useRef} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; @@ -80,7 +80,7 @@ function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps }, state); let renderValues = { - isDisabled: props.isDisabled || false, + isDisabled: props.isDisabled || false }; let renderProps = useRenderProps({ diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 479a348f025..cf92495d340 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -18,6 +18,7 @@ import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionCont import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; +import {getItemId, useSubmenuTrigger} from '@react-aria/menu'; import {HeaderContext} from './Header'; import {InternalAutocompleteContext} from './Autocomplete'; import {KeyboardContext} from './Keyboard'; @@ -42,7 +43,6 @@ import React, { import {RootMenuTriggerState, useSubmenuTriggerState} from '@react-stately/menu'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; -import {useSubmenuTrigger} from '@react-aria/menu'; export const MenuContext = createContext, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); @@ -243,17 +243,17 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne } else { // If there is a focused key, dispatch an event to the menu item in question. This allows us to excute any existing onAction or link navigations // that would have happen in a non-virtual focus case. - focusedId = `${menuId}-option-${state.selectionManager.focusedKey}`; + focusedId = getItemId(state, state.selectionManager.focusedKey); let item = ref.current?.querySelector(`#${CSS.escape(focusedId)}`); item?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); } - focusedId = `${menuId}-option-${state.selectionManager.focusedKey}`; + focusedId = getItemId(state, state.selectionManager.focusedKey); return focusedId; }); } - }, [register, state.selectionManager, menuId, ref]); + }, [register, state, menuId, ref]); useEffect(() => { // TODO: retested in NVDA. It seems like NVDA properly announces what new letter you are typing even if we maintain virtual focus on diff --git a/yarn.lock b/yarn.lock index e44da35918e..30f1ca75e67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5606,20 +5606,13 @@ __metadata: "@react-aria/focus": "npm:^3.18.3" "@react-aria/i18n": "npm:^3.12.3" "@react-aria/listbox": "npm:^3.13.4" - "@react-aria/live-announcer": "npm:^3.4.0" - "@react-aria/menu": "npm:^3.15.4" - "@react-aria/overlays": "npm:^3.23.3" "@react-aria/searchfield": "npm:^3.7.9" - "@react-aria/selection": "npm:^3.20.0" "@react-aria/textfield": "npm:^3.14.9" "@react-aria/utils": "npm:^3.25.3" "@react-stately/autocomplete": "npm:3.0.0-alpha.1" - "@react-stately/collections": "npm:^3.11.0" "@react-stately/combobox": "npm:^3.10.0" - "@react-stately/form": "npm:^3.0.6" "@react-types/autocomplete": "npm:3.0.0-alpha.26" "@react-types/button": "npm:^3.10.0" - "@react-types/combobox": "npm:^3.13.0" "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: @@ -6028,10 +6021,12 @@ __metadata: "@react-aria/focus": "npm:^3.18.3" "@react-aria/i18n": "npm:^3.12.3" "@react-aria/interactions": "npm:^3.22.3" + "@react-aria/live-announcer": "npm:^3.4.0" "@react-aria/overlays": "npm:^3.23.3" "@react-aria/selection": "npm:^3.20.0" "@react-aria/utils": "npm:^3.25.3" "@react-stately/collections": "npm:^3.11.0" + "@react-stately/list": "npm:^3.11.0" "@react-stately/menu": "npm:^3.8.3" "@react-stately/tree": "npm:^3.8.5" "@react-types/button": "npm:^3.10.0" From ad8942a7242d7461db093efb2fc193688d2f953a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 22 Oct 2024 15:58:54 -0700 Subject: [PATCH 11/42] update yarn.lock --- yarn.lock | 7 ------- 1 file changed, 7 deletions(-) diff --git a/yarn.lock b/yarn.lock index 30f1ca75e67..213bf1aa3ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8084,13 +8084,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/autocomplete@workspace:packages/@react-stately/autocomplete" dependencies: - "@react-stately/collections": "npm:^3.11.0" - "@react-stately/form": "npm:^3.0.6" - "@react-stately/list": "npm:^3.11.0" - "@react-stately/overlays": "npm:^3.6.11" - "@react-stately/select": "npm:^3.6.8" "@react-stately/utils": "npm:^3.10.4" - "@react-types/combobox": "npm:^3.13.0" "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: @@ -28797,7 +28791,6 @@ __metadata: "@react-aria/virtualizer": "npm:^4.0.3" "@react-stately/autocomplete": "npm:3.0.0-alpha.1" "@react-stately/color": "npm:^3.8.0" - "@react-stately/combobox": "npm:^3.10.0" "@react-stately/disclosure": "npm:3.0.0-alpha.0" "@react-stately/layout": "npm:^4.0.3" "@react-stately/menu": "npm:^3.8.3" From 9812c3c8bffbc00b709dc4de2910a745ddffcb87 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 23 Oct 2024 11:55:13 -0700 Subject: [PATCH 12/42] get rid of dom node in Autocomplete and fix readOnly bugs --- .../autocomplete/src/useAutocomplete.ts | 10 +- .../src/Autocomplete.tsx | 43 +--- .../stories/Autocomplete.stories.tsx | 210 +++++++++--------- 3 files changed, 118 insertions(+), 145 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index c49bae4c7c1..07d1d9f64ed 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -15,13 +15,15 @@ import type {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, mergeProps, useId, useLabels} from '@react-aria/utils'; import {focusSafely} from '@react-aria/focus'; -import {InputHTMLAttributes, KeyboardEvent, useEffect, useRef} from 'react'; +import {InputHTMLAttributes, KeyboardEvent, ReactNode, useEffect, useRef} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTextField} from '@react-aria/textfield'; -export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps {} +export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps { + children: ReactNode +} // TODO: all of this is menu specific but will need to eventually be agnostic to what collection element is inside // Update all instances of "menu" then @@ -78,7 +80,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco // to close virtual keyboard, depends if we think this experience is only for in a tray/popover switch (e.key) { case 'Escape': - if (state.inputValue !== '') { + if (state.inputValue !== '' && !isReadOnly) { state.setInputValue(''); } else { e.continuePropagation(); @@ -103,7 +105,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco let {labelProps, inputProps, descriptionProps} = useTextField({ ...props as any, onChange: state.setInputValue, - onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, + onKeyDown: chain(onKeyDown, props.onKeyDown), value: state.inputValue, autoComplete: 'off', validate: undefined diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 2c62de600db..3308c1c438d 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -13,20 +13,20 @@ import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; import {ContextValue, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils'; -import {filterDOMProps} from '@react-aria/utils'; -import {forwardRefType, RefObject} from '@react-types/shared'; -import {GroupContext} from './Group'; +import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; import {LabelContext} from './Label'; import {MenuContext} from './Menu'; -import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; +import {useObjectRef} from '@react-aria/utils'; +// TODO: I've kept isDisabled because it might be useful to a user for changing what the menu renders if the autocomplete is disabled, +// but what about isReadOnly. TBH is isReadOnly useful in the first place? What would a readonly Autocomplete do? export interface AutocompleteRenderProps { /** * Whether the autocomplete is disabled. - * @selector [data-disabled] */ isDisabled: boolean } @@ -42,29 +42,16 @@ interface InternalAutocompleteContextValue { inputValue: string } -export const AutocompleteContext = createContext>(null); +export const AutocompleteContext = createContext>(null); export const AutocompleteStateContext = createContext(null); // This context is to pass the register and filter down to whatever collection component is wrapped by the Autocomplete export const InternalAutocompleteContext = createContext(null); -function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { +function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, AutocompleteContext); - - return ( - - ); -} -interface AutocompleteInnerProps { - props: AutocompleteProps, - // collection: Collection>, - autocompleteRef: RefObject -} - -// TODO: maybe we don't need inner anymore -function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps) { let {defaultFilter} = props; let state = useAutocompleteState(props); - let inputRef = useRef(null); + let inputRef = useObjectRef(ref); let [labelRef, label] = useSlot(); let {contains} = useFilter({sensitivity: 'base'}); let { @@ -85,13 +72,9 @@ function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps let renderProps = useRenderProps({ ...props, - values: renderValues, - defaultClassName: 'react-aria-Autocomplete' + values: renderValues }); - let DOMProps = filterDOMProps(props); - delete DOMProps.id; - let filterFn = useCallback((nodeTextValue: string) => { if (defaultFilter) { return defaultFilter(nodeTextValue, state.inputValue); @@ -111,15 +94,9 @@ function AutocompleteInner({props, autocompleteRef: ref}: AutocompleteInnerProps description: descriptionProps } }], - [GroupContext, {isDisabled: props.isDisabled || false}], [InternalAutocompleteContext, {register, filterFn, inputValue: state.inputValue}] ]}> -
+ {renderProps.children} ); } diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index f374eaa278f..f300929bc15 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -25,6 +25,12 @@ export default { table: { disable: true } + }, + isDisabled: { + control: 'boolean' + }, + isReadOnly: { + control: 'boolean' } }, args: { @@ -32,41 +38,44 @@ export default { } }; +// TODO: get rid of flex aroun input and bring back render props and add isReadOnly and make sure they both get propagted to the input export const AutocompleteExample = { - render: ({onAction}) => { + render: ({onAction, isDisabled, isReadOnly}) => { return ( - - -
+ + {/* TODO: would the expectation be that a user would render a Group here? Or maybe we could add a wrapper element provided by Autocomplete automatically? */} +
+ + Please select an option below. + +
+ Foo + Bar + Baz + Google +
+ +
+
Section 2
+ + Copy + Description + ⌘C + + + Cut + Description + ⌘X + + + Paste + Description + ⌘V + +
+
- -
- Foo - Bar - Baz - Google -
- -
-
Section 2
- - Copy - Description - ⌘C - - - Cut - Description - ⌘X - - - Paste - Description - ⌘V - -
-
); }, @@ -79,78 +88,65 @@ interface AutocompleteItem { let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; -export const AutocompleteRenderPropsMenuDynamic = { - render: ({onAction}) => { +export const AutocompleteMenuDynamic = { + render: ({onAction, isDisabled, isReadOnly}) => { return ( - - {() => ( - <> - -
- -
- - {item => {item.name}} - - - )} + +
+ + + Please select an option below. + + {item => {item.name}} + +
); }, - name: 'Autocomplete, render props, dynamic menu' + name: 'Autocomplete, dynamic menu' }; -export const AutocompleteOnActionOnMenuItems = { - render: () => { +export const AutocompleteRenderPropsIsDisabled = { + render: ({onAction, isDisabled, isReadOnly}) => { return ( - - {() => ( - <> + + {({isDisabled}) => ( +
-
- -
- - Foo - Bar - Baz + + Please select an option below. + + {item => {item.name}} - +
)}
); }, - name: 'Autocomplete, onAction on menu items' + name: 'Autocomplete, render props, custom menu isDisabled behavior' }; - -export const AutocompleteRenderPropsIsDisabled = { - render: ({onAction, isDisabled}) => { +export const AutocompleteOnActionOnMenuItems = { + render: ({isDisabled, isReadOnly}) => { return ( - - {({isDisabled}) => ( - <> - -
- -
- - {item => {item.name}} - - - )} + +
+ + + Please select an option below. + + Foo + Bar + Baz + +
); }, - name: 'Autocomplete, render props, isDisabled toggle', - argTypes: { - isDisabled: { - control: 'boolean' - } - } + name: 'Autocomplete, onAction on menu items' }; -const AsyncExample = ({onAction}) => { +const AsyncExample = ({onAction, isDisabled, isReadOnly}) => { let list = useAsyncList({ async load({filterText}) { let json = await new Promise(resolve => { @@ -175,54 +171,52 @@ const AsyncExample = ({onAction}) => { }); return ( - - -
+ +
+ + Please select an option below. + + items={items} + className={styles.menu} + onAction={onAction}> + {item => {item.name}} +
- - items={list.items} - className={styles.menu} - onAction={onAction}> - {item => {item.name}} -
); }; export const AutocompleteAsyncLoadingExample = { - render: ({onAction}) => { - return ; + render: (args) => { + return ; }, name: 'Autocomplete, useAsync level filtering' }; -const CaseSensitiveFilter = ({onAction}) => { +const CaseSensitiveFilter = ({onAction, isDisabled, isReadOnly}) => { let {contains} = useFilter({ sensitivity: 'case' }); let defaultFilter = (itemText, input) => contains(itemText, input); return ( - - {() => ( - <> - -
- -
- - {item => {item.name}} - - - )} + +
+ + + Please select an option below. + + {item => {item.name}} + +
); }; export const AutocompleteCaseSensitive = { - render: ({onAction}) => { - return ; + render: (args) => { + return ; }, name: 'Autocomplete, case sensitive filter' }; From 84789377c96dc2c0281ed6e4b96ba8620a88bb1c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 23 Oct 2024 13:49:55 -0700 Subject: [PATCH 13/42] fix build failure --- packages/react-aria-components/package.json | 2 +- .../stories/Autocomplete.stories.tsx | 1 - yarn.lock | 28 +++---------------- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 7927fae4aa3..8f9126df236 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -40,7 +40,7 @@ "@internationalized/date": "^3.5.6", "@internationalized/string": "^3.2.4", "@react-aria/accordion": "3.0.0-alpha.35", - "@react-aria/autocomplete": "3.0.0-alpha.34", + "@react-aria/autocomplete": "3.0.0-alpha.35", "@react-aria/collections": "3.0.0-alpha.5", "@react-aria/color": "^3.0.1", "@react-aria/disclosure": "3.0.0-alpha.1", diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index f300929bc15..d090295327d 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -38,7 +38,6 @@ export default { } }; -// TODO: get rid of flex aroun input and bring back render props and add isReadOnly and make sure they both get propagted to the input export const AutocompleteExample = { render: ({onAction, isDisabled, isReadOnly}) => { return ( diff --git a/yarn.lock b/yarn.lock index 22e1f2247b5..f0d3e4eb81d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5657,26 +5657,6 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/autocomplete@npm:3.0.0-alpha.34": - version: 3.0.0-alpha.34 - resolution: "@react-aria/autocomplete@npm:3.0.0-alpha.34" - dependencies: - "@react-aria/combobox": "npm:^3.10.4" - "@react-aria/listbox": "npm:^3.13.4" - "@react-aria/searchfield": "npm:^3.7.9" - "@react-aria/utils": "npm:^3.25.3" - "@react-stately/combobox": "npm:^3.10.0" - "@react-types/autocomplete": "npm:3.0.0-alpha.26" - "@react-types/button": "npm:^3.10.0" - "@react-types/shared": "npm:^3.25.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10c0/122be5e6e0085e3c6e847a48fd179820da60700954e46aab6f5a0a5f3d9f0d8cb21730ca2c3765c66fcb89408dc2afbd884cd99efa01ac4b3805d24328405a49 - languageName: node - linkType: hard - "@react-aria/autocomplete@npm:3.0.0-alpha.35, @react-aria/autocomplete@workspace:packages/@react-aria/autocomplete": version: 0.0.0-use.local resolution: "@react-aria/autocomplete@workspace:packages/@react-aria/autocomplete" @@ -5809,7 +5789,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/combobox@npm:^3.10.4, @react-aria/combobox@npm:^3.10.5, @react-aria/combobox@workspace:packages/@react-aria/combobox": +"@react-aria/combobox@npm:^3.10.5, @react-aria/combobox@workspace:packages/@react-aria/combobox": version: 0.0.0-use.local resolution: "@react-aria/combobox@workspace:packages/@react-aria/combobox" dependencies: @@ -6066,7 +6046,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/listbox@npm:^3.13.4, @react-aria/listbox@npm:^3.13.5, @react-aria/listbox@workspace:packages/@react-aria/listbox": +"@react-aria/listbox@npm:^3.13.5, @react-aria/listbox@workspace:packages/@react-aria/listbox": version: 0.0.0-use.local resolution: "@react-aria/listbox@workspace:packages/@react-aria/listbox" dependencies: @@ -6235,7 +6215,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/searchfield@npm:^3.7.10, @react-aria/searchfield@npm:^3.7.9, @react-aria/searchfield@workspace:packages/@react-aria/searchfield": +"@react-aria/searchfield@npm:^3.7.10, @react-aria/searchfield@workspace:packages/@react-aria/searchfield": version: 0.0.0-use.local resolution: "@react-aria/searchfield@workspace:packages/@react-aria/searchfield" dependencies: @@ -29005,7 +28985,7 @@ __metadata: "@internationalized/date": "npm:^3.5.6" "@internationalized/string": "npm:^3.2.4" "@react-aria/accordion": "npm:3.0.0-alpha.35" - "@react-aria/autocomplete": "npm:3.0.0-alpha.34" + "@react-aria/autocomplete": "npm:3.0.0-alpha.35" "@react-aria/collections": "npm:3.0.0-alpha.5" "@react-aria/color": "npm:^3.0.1" "@react-aria/disclosure": "npm:3.0.0-alpha.1" From 3fbd35f90fd82b5f0d72b623775cf44c858f585f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 23 Oct 2024 14:34:56 -0700 Subject: [PATCH 14/42] test against popover experience --- .../autocomplete/src/useAutocomplete.ts | 16 ++------ packages/react-aria-components/src/Menu.tsx | 4 ++ .../stories/Autocomplete.stories.tsx | 37 ++++++++++++++++++- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 07d1d9f64ed..3402d537d67 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,12 +10,11 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, FocusableElement, InputDOMProps, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, RefObject} from '@react-types/shared'; import type {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, mergeProps, useId, useLabels} from '@react-aria/utils'; -import {focusSafely} from '@react-aria/focus'; -import {InputHTMLAttributes, KeyboardEvent, ReactNode, useEffect, useRef} from 'react'; +import {InputHTMLAttributes, KeyboardEvent, ReactNode, useRef} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -76,8 +75,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco // TODO: how best to trigger the focused element's action? Currently having the registered callback handle dispatching a // keyboard event - // Also, we might want to add popoverRef so we can bring in MobileCombobox's additional handling for Enter - // to close virtual keyboard, depends if we think this experience is only for in a tray/popover switch (e.key) { case 'Escape': if (state.inputValue !== '' && !isReadOnly) { @@ -107,8 +104,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco onChange: state.setInputValue, onKeyDown: chain(onKeyDown, props.onKeyDown), value: state.inputValue, - autoComplete: 'off', - validate: undefined + autoComplete: 'off' }, inputRef); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); @@ -118,12 +114,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); - // TODO: add the stuff from mobile combobox, check if I need the below when testing in mobile devices - // removed touch end since we did the same in MobileComboboxTray - useEffect(() => { - focusSafely(inputRef.current as FocusableElement); - }, [inputRef]); - return { labelProps, inputProps: mergeProps(inputProps, { diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index cf92495d340..a66b5f78988 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -230,6 +230,10 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne state.selectionManager.setFocused(false); } break; + case 'Escape': + // If hitting Escape, don't dispatch any events since useAutocomplete will handle whether or not + // to continuePropagation to the overlay depending on the inputValue + return; } let focusedId; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index d090295327d..c7c68780da7 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Header, Input, Keyboard, Label, Menu, Section, Separator, Text} from 'react-aria-components'; +import {Autocomplete, Button, Dialog, DialogTrigger, Header, Input, Keyboard, Label, Menu, Popover, Section, Separator, Text} from 'react-aria-components'; import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; @@ -219,3 +219,38 @@ export const AutocompleteCaseSensitive = { }, name: 'Autocomplete, case sensitive filter' }; + +export const AutocompleteInPopover = { + render: ({onAction, isDisabled, isReadOnly}) => { + return ( + + + + + +
+ + + Please select an option below. + + {item => {item.name}} + +
+
+
+
+
+ + ); + }, + name: 'Autocomplete in popover' +}; From e15d9992a39ee94309cd54e5e38c438c47b683d0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 23 Oct 2024 17:23:10 -0700 Subject: [PATCH 15/42] fix popover story --- .../src/Autocomplete.tsx | 15 ++++++++-- packages/react-aria-components/src/Menu.tsx | 1 - .../stories/Autocomplete.stories.tsx | 30 +++++++++---------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 3308c1c438d..717f99a6bfa 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -17,10 +17,10 @@ import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; import {LabelContext} from './Label'; import {MenuContext} from './Menu'; -import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback} from 'react'; +import {mergeProps, useObjectRef} from '@react-aria/utils'; +import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useRef} from 'react'; import {TextContext} from './Text'; import {useFilter} from 'react-aria'; -import {useObjectRef} from '@react-aria/utils'; // TODO: I've kept isDisabled because it might be useful to a user for changing what the menu renders if the autocomplete is disabled, // but what about isReadOnly. TBH is isReadOnly useful in the first place? What would a readonly Autocomplete do? @@ -48,6 +48,14 @@ export const AutocompleteStateContext = createContext( export const InternalAutocompleteContext = createContext(null); function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { + // TODO: Needed for the onClose from MenuTrigger so that the menu is closed automatically. Probably won't have to do the same for other collection components, + // but its a bit annoying since we could potentially need to do the same for every collection component Autocomplete will support? + // Alternatively, this won't be a problem if we didn't use MenuContext below and instead used a generic autocomplete context so they don't collide. + // Might be better if we do that since then the supported collection components would access that generic context instead and we wouldn't pull into unrelated + // components when you are just using the Autocomplete and one other collection component + let menuRef = useRef(null); + let [contextMenuProps, contextMenuRef] = useContextProps({}, menuRef, MenuContext); + [props, ref] = useContextProps(props, ref, AutocompleteContext); let {defaultFilter} = props; let state = useAutocompleteState(props); @@ -88,7 +96,8 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef { return ( - + @@ -235,20 +237,18 @@ export const AutocompleteInPopover = { border: '1px solid gray', padding: 20 }}> - - -
- - - Please select an option below. - - {item => {item.name}} - -
-
-
+ +
+ + + Please select an option below. + + {item => {item.name}} + +
+
-
+ ); }, From 368a677f629cf63fa35bbf93a1160434700310dd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Oct 2024 10:57:05 -0700 Subject: [PATCH 16/42] properly clear aria-activedecendant --- .../autocomplete/src/useAutocomplete.ts | 2 +- .../autocomplete/src/useAutocompleteState.ts | 4 ++-- .../src/Autocomplete.tsx | 22 +++++-------------- packages/react-aria-components/src/Menu.tsx | 19 +++++++++------- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 3402d537d67..4f4bbcd2f6e 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -42,7 +42,7 @@ export interface AutocompleteAria { // TODO: fairly non-standard thing to return from a hook, discuss how best to share this with hook only users // This is for the user to register a callback that upon recieving a keyboard event key returns the expected virtually focused node id /** Register function that expects a callback function that returns the newlly virtually focused menu option when provided with the keyboard action that occurs in the input field. */ - register: (callback: (e: KeyboardEvent) => string) => void + register: (callback: (e: KeyboardEvent) => string | null) => void } /** diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 193a186fb7d..66510009dab 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -21,9 +21,9 @@ export interface AutocompleteState { setInputValue(value: string): void, // TODO: debatable if this state hook needs to exist /** The id of the current aria-activedescendant of the autocomplete input. */ - focusedNodeId: string, + focusedNodeId: string | null, /** Sets the id of the current aria-activedescendant of the autocomplete input. */ - setFocusedNodeId(value: string): void + setFocusedNodeId(value: string | null): void } export interface AutocompleteProps extends InputBase, TextInputBase, FocusableProps, LabelableProps, HelpTextProps { diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 717f99a6bfa..ebd0953ccd8 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -11,16 +11,15 @@ */ import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete'; +import {AriaMenuOptions, useFilter} from 'react-aria'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; import {ContextValue, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils'; import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import {MenuContext} from './Menu'; -import {mergeProps, useObjectRef} from '@react-aria/utils'; -import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback} from 'react'; import {TextContext} from './Text'; -import {useFilter} from 'react-aria'; +import {useObjectRef} from '@react-aria/utils'; // TODO: I've kept isDisabled because it might be useful to a user for changing what the menu renders if the autocomplete is disabled, // but what about isReadOnly. TBH is isReadOnly useful in the first place? What would a readonly Autocomplete do? @@ -39,7 +38,8 @@ export interface AutocompleteProps extends Omit string) => void, filterFn: (nodeTextValue: string) => boolean, - inputValue: string + inputValue: string, + menuProps: AriaMenuOptions } export const AutocompleteContext = createContext>(null); @@ -48,14 +48,6 @@ export const AutocompleteStateContext = createContext( export const InternalAutocompleteContext = createContext(null); function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { - // TODO: Needed for the onClose from MenuTrigger so that the menu is closed automatically. Probably won't have to do the same for other collection components, - // but its a bit annoying since we could potentially need to do the same for every collection component Autocomplete will support? - // Alternatively, this won't be a problem if we didn't use MenuContext below and instead used a generic autocomplete context so they don't collide. - // Might be better if we do that since then the supported collection components would access that generic context instead and we wouldn't pull into unrelated - // components when you are just using the Autocomplete and one other collection component - let menuRef = useRef(null); - let [contextMenuProps, contextMenuRef] = useContextProps({}, menuRef, MenuContext); - [props, ref] = useContextProps(props, ref, AutocompleteContext); let {defaultFilter} = props; let state = useAutocompleteState(props); @@ -96,14 +88,12 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef {renderProps.children} diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 4097983d9e9..de5a0c5a69f 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -15,7 +15,7 @@ import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, cr import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; -import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, useEffectEvent, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; import {getItemId, useSubmenuTrigger} from '@react-aria/menu'; import {HeaderContext} from './Header'; @@ -170,7 +170,7 @@ interface MenuInnerProps { } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - let {register, filterFn, inputValue} = useContext(InternalAutocompleteContext) || {}; + let {register, filterFn, inputValue, menuProps: autocompleteMenuProps} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them. let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); @@ -182,7 +182,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne let [popoverContainer, setPopoverContainer] = useState(null); let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); - let {menuProps} = useMenu({...props, isVirtualized}, state, ref); + let {menuProps} = useMenu({...props, ...autocompleteMenuProps, isVirtualized}, state, ref); let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; let popoverContext = useContext(PopoverContext)!; let isSubmenu = (popoverContext as PopoverProps)?.trigger === 'SubmenuTrigger'; @@ -226,7 +226,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne // TODO: will need to special case this so it doesn't clear the focused key if we are currently // focused on a submenutrigger if (state.selectionManager.isFocused) { - state.selectionManager.setFocused(false); + clearVirtualFocus(); } break; case 'Escape': @@ -252,12 +252,17 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); } - focusedId = getItemId(state, state.selectionManager.focusedKey); + focusedId = state.selectionManager.focusedKey ? getItemId(state, state.selectionManager.focusedKey) : null; return focusedId; }); } }, [register, state, menuId, ref]); + let clearVirtualFocus = useEffectEvent(() => { + state.selectionManager.setFocused(false); + state.selectionManager.setFocusedKey(null); + }); + useEffect(() => { // TODO: retested in NVDA. It seems like NVDA properly announces what new letter you are typing even if we maintain virtual focus on // a item in the list. However, it won't announce the letter your cursor is now on if you don't clear the virtual focus when using left/right @@ -269,10 +274,8 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne // its own collection // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue if (inputValue != null) { - state.selectionManager.setFocused(false); - state.selectionManager.setFocusedKey(null); + clearVirtualFocus(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputValue]); From 82b31206720c175494cbd0d92b53ba623a9c418c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Oct 2024 11:42:24 -0700 Subject: [PATCH 17/42] cleanup --- packages/@react-aria/autocomplete/package.json | 1 - packages/@react-aria/menu/src/useMenuItem.ts | 4 ---- packages/@react-stately/autocomplete/src/index.ts | 1 - .../@react-stately/autocomplete/src/useAutocompleteState.ts | 1 - .../react-aria-components/stories/Autocomplete.stories.tsx | 1 - 5 files changed, 8 deletions(-) diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index 1cc40122ec0..4899e6822da 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@react-aria/combobox": "^3.10.5", - "@react-aria/focus": "^3.18.3", "@react-aria/i18n": "^3.12.3", "@react-aria/listbox": "^3.13.5", "@react-aria/searchfield": "^3.7.10", diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 1b856f3daf7..8293cc5e829 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -123,7 +123,6 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let isDisabled = props.isDisabled ?? state.selectionManager.isDisabled(key); let isSelected = props.isSelected ?? state.selectionManager.isSelected(key); let data = menuData.get(state); - let item = state.collection.getItem(key); let onClose = props.onClose || data.onClose; let router = useRouter(); @@ -163,9 +162,6 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let keyboardId = useSlotId(); if (data.shouldUseVirtualFocus) { - // TODO: finalize if every component that Autocomplete will accept as a filterable child would need to follow this same - // logic when creating the id - id = `${data.id}-option-${key}`; id = getItemId(state, key); } diff --git a/packages/@react-stately/autocomplete/src/index.ts b/packages/@react-stately/autocomplete/src/index.ts index 0d35f371a84..c95279ae2fb 100644 --- a/packages/@react-stately/autocomplete/src/index.ts +++ b/packages/@react-stately/autocomplete/src/index.ts @@ -12,5 +12,4 @@ export {useAutocompleteState} from './useAutocompleteState'; -// TODO export the types export type {AutocompleteProps, AutocompleteStateOptions, AutocompleteState} from './useAutocompleteState'; diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 66510009dab..2023176c15e 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -19,7 +19,6 @@ export interface AutocompleteState { inputValue: string, /** Sets the value of the autocomplete input. */ setInputValue(value: string): void, - // TODO: debatable if this state hook needs to exist /** The id of the current aria-activedescendant of the autocomplete input. */ focusedNodeId: string | null, /** Sets the id of the current aria-activedescendant of the autocomplete input. */ diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 853e2713b59..0ea07aac3d1 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -42,7 +42,6 @@ export const AutocompleteExample = { render: ({onAction, isDisabled, isReadOnly}) => { return ( - {/* TODO: would the expectation be that a user would render a Group here? Or maybe we could add a wrapper element provided by Autocomplete automatically? */}
From 423b73c50e659b34d12ecea6c7d137965b8f4eed Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Oct 2024 16:56:17 -0700 Subject: [PATCH 18/42] fix build --- yarn.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index f0d3e4eb81d..847c26af898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5662,7 +5662,6 @@ __metadata: resolution: "@react-aria/autocomplete@workspace:packages/@react-aria/autocomplete" dependencies: "@react-aria/combobox": "npm:^3.10.5" - "@react-aria/focus": "npm:^3.18.3" "@react-aria/i18n": "npm:^3.12.3" "@react-aria/listbox": "npm:^3.13.5" "@react-aria/searchfield": "npm:^3.7.10" @@ -5904,7 +5903,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/focus@npm:^3.18.3, @react-aria/focus@npm:^3.18.4, @react-aria/focus@workspace:packages/@react-aria/focus": +"@react-aria/focus@npm:^3.18.4, @react-aria/focus@workspace:packages/@react-aria/focus": version: 0.0.0-use.local resolution: "@react-aria/focus@workspace:packages/@react-aria/focus" dependencies: From fe5bfbe4fca75b346edc6b4bca991c0bdb054cfb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Oct 2024 17:23:06 -0700 Subject: [PATCH 19/42] properly focus trap the autocomplete popover --- .../stories/Autocomplete.stories.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 0ea07aac3d1..4adc0f6cf4f 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Header, Input, Keyboard, Label, Menu, MenuTrigger, Popover, Section, Separator, Text} from 'react-aria-components'; +import {Autocomplete, Button, Dialog, Header, Input, Keyboard, Label, Menu, MenuTrigger, Popover, Section, Separator, Text} from 'react-aria-components'; import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; @@ -236,16 +236,18 @@ export const AutocompleteInPopover = { border: '1px solid gray', padding: 20 }}> - -
- - - Please select an option below. - - {item => {item.name}} - -
-
+ + +
+ + + Please select an option below. + + {item => {item.name}} + +
+
+
From 477ca7f7979707dcc08fb1e1f6ebf80041e1278f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Nov 2024 11:31:22 -0700 Subject: [PATCH 20/42] update interaction pattern as per discussion for now we will have first item auto focus, no custom announcements, and clear focus on backspace or arrow left/right. See https://docs.google.com/spreadsheets/d/12M--aeeNK4Kruzg8GnO7L-_DUsQE29_bWJzu4yI4-UA/edit?gid=1690380375#gid=1690380375 for a testing matrix --- .../autocomplete/src/useAutocomplete.ts | 3 +- packages/@react-aria/menu/intl/ar-AE.json | 4 +- packages/@react-aria/menu/intl/en-US.json | 4 +- packages/@react-aria/menu/package.json | 2 - packages/@react-aria/menu/src/useMenu.ts | 66 +------------------ .../autocomplete/src/useAutocompleteState.ts | 3 - packages/react-aria-components/src/Menu.tsx | 46 +++++++++---- 7 files changed, 38 insertions(+), 90 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 4f4bbcd2f6e..1c22e9c20fb 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -138,7 +138,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco // TODO: another thing to think about, what is the best way to past this to menu/wrapped collection component so that hovering on // a item also updates the focusedNode. Perhaps we should just pass down setFocusedNodeId instead state.setFocusedNodeId(e.target.id); - } + }, + disallowTypeAhead: true }), descriptionProps, register diff --git a/packages/@react-aria/menu/intl/ar-AE.json b/packages/@react-aria/menu/intl/ar-AE.json index e14d8345e38..b10ce6704a3 100644 --- a/packages/@react-aria/menu/intl/ar-AE.json +++ b/packages/@react-aria/menu/intl/ar-AE.json @@ -1,5 +1,3 @@ { - "longPressMessage": "اضغط مطولاً أو اضغط على Alt + السهم لأسفل لفتح القائمة", - "countAnnouncement": "{optionCount, plural, one {# خيار} other {# خيارات}} متاحة.", - "focusAnnouncement": "{isGroupChange, select, true {المجموعة المدخلة {groupTitle}, مع {groupCount, plural, one {# خيار} other {# خيارات}}. } other {}}{optionText}{isSelected, select, true {, محدد} other {}}" + "longPressMessage": "اضغط مطولاً أو اضغط على Alt + السهم لأسفل لفتح القائمة" } diff --git a/packages/@react-aria/menu/intl/en-US.json b/packages/@react-aria/menu/intl/en-US.json index fee4baa383b..a5ffe907ddc 100644 --- a/packages/@react-aria/menu/intl/en-US.json +++ b/packages/@react-aria/menu/intl/en-US.json @@ -1,5 +1,3 @@ { - "longPressMessage": "Long press or press Alt + ArrowDown to open menu", - "focusAnnouncement": "{isGroupChange, select, true {Entered group {groupTitle}, with {groupCount, plural, one {# option} other {# options}}. } other {}}{optionText}{isSelected, select, true {, selected} other {}}", - "countAnnouncement": "{optionCount, plural, one {# option} other {# options}} available." + "longPressMessage": "Long press or press Alt + ArrowDown to open menu" } diff --git a/packages/@react-aria/menu/package.json b/packages/@react-aria/menu/package.json index ce834c67b40..9607b7acff3 100644 --- a/packages/@react-aria/menu/package.json +++ b/packages/@react-aria/menu/package.json @@ -25,12 +25,10 @@ "@react-aria/focus": "^3.18.4", "@react-aria/i18n": "^3.12.3", "@react-aria/interactions": "^3.22.4", - "@react-aria/live-announcer": "^3.4.0", "@react-aria/overlays": "^3.23.4", "@react-aria/selection": "^3.20.1", "@react-aria/utils": "^3.25.3", "@react-stately/collections": "^3.11.0", - "@react-stately/list": "^3.11.0", "@react-stately/menu": "^3.8.3", "@react-stately/tree": "^3.8.5", "@react-types/button": "^3.10.0", diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index 2663379588d..f7f6a1dd9ae 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -10,17 +10,11 @@ * governing permissions and limitations under the License. */ -import {announce} from '@react-aria/live-announcer'; import {AriaMenuProps} from '@react-types/menu'; import {DOMAttributes, HoverEvent, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, isAppleDevice, mergeProps, useId} from '@react-aria/utils'; -import {getChildNodes, getItemCount} from '@react-stately/collections'; -// @ts-ignore -import intlMessages from '../intl/*.json'; +import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {menuData} from './utils'; import {TreeState} from '@react-stately/tree'; -import {useEffect, useRef, useState} from 'react'; -import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSelectableList} from '@react-aria/selection'; export interface MenuAria { @@ -85,64 +79,6 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: id }); - // TODO: for now I'm putting these announcement into menu but would like to discuss where we may this these could go - // I was thinking perhaps in @react-aria/interactions (it is interaction specific aka virtualFocus) or @react-aria/collections (dependent on tracking a focused key in a collection) - // but those felt kinda iffy. A new package? - // TODO: port all other translations/remove stuff if not needed - - // VoiceOver has issues with announcing aria-activedescendant properly on change - // (especially on iOS). We use a live region announcer to announce focus changes - // manually. In addition, section titles are announced when navigating into a new section. - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/menu'); - let focusedItem = state.selectionManager.focusedKey != null - ? state.collection.getItem(state.selectionManager.focusedKey) - : undefined; - let sectionKey = focusedItem?.parentKey ?? null; - let itemKey = state.selectionManager.focusedKey ?? null; - let lastSection = useRef(sectionKey); - let lastItem = useRef(itemKey); - useEffect(() => { - if (isAppleDevice() && focusedItem != null && itemKey !== lastItem.current) { - let isSelected = state.selectionManager.isSelected(itemKey); - let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; - let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; - let announcement = stringFormatter.format('focusAnnouncement', { - isGroupChange: !!section && sectionKey !== lastSection.current, - groupTitle: sectionTitle, - groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, - optionText: focusedItem['aria-label'] || focusedItem.textValue || '', - isSelected - }); - - announce(announcement); - } - - lastSection.current = sectionKey; - lastItem.current = itemKey; - }); - - // Announce the number of available suggestions when it changes - let optionCount = getItemCount(state.collection); - let lastSize = useRef(optionCount); - let [announced, setAnnounced] = useState(false); - - // TODO: test this behavior below, now that there isn't a open state this should just announce for the first render in which the field is focused? - useEffect(() => { - // Only announce the number of options available when the autocomplete first renders if there is no - // focused item, otherwise screen readers will typically read e.g. "1 of 6". - // The exception is VoiceOver since this isn't included in the message above. - let didRenderWithoutFocusedItem = !announced && (state.selectionManager.focusedKey == null || isAppleDevice()); - if (didRenderWithoutFocusedItem || optionCount !== lastSize.current) { - let announcement = stringFormatter.format('countAnnouncement', {optionCount}); - announce(announcement); - setAnnounced(true); - } - - lastSize.current = optionCount; - }, [announced, setAnnounced, optionCount, stringFormatter, state.selectionManager.focusedKey]); - - // TODO: Omitted the custom announcement for selection because we expect to only trigger onActions for Autocomplete, selected key isn't a thing - return { menuProps: mergeProps(domProps, {onKeyDown, onKeyUp}, { role: 'menu', diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 2023176c15e..67af1758b05 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -51,9 +51,6 @@ export function useAutocompleteState(props: AutocompleteStateOptions): Autocompl if (propsOnInputChange) { propsOnInputChange(value); } - - // TODO: weird that this is handled here? - setFocusedNodeId(null); }; let [focusedNodeId, setFocusedNodeId] = useState(null); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 2d6252bc446..5408f6d4b9d 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -11,6 +11,7 @@ */ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria'; +import {AutocompleteStateContext, InternalAutocompleteContext} from './Autocomplete'; import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; @@ -19,7 +20,6 @@ import {filterDOMProps, useEffectEvent, useObjectRef, useResizeObserver} from '@ import {forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; import {getItemId, useSubmenuTrigger} from '@react-aria/menu'; import {HeaderContext} from './Header'; -import {InternalAutocompleteContext} from './Autocomplete'; import {KeyboardContext} from './Keyboard'; import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext, PopoverProps} from './Popover'; @@ -172,6 +172,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne let {register, filterFn, inputValue, menuProps: autocompleteMenuProps} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them. + let {setFocusedNodeId} = useContext(AutocompleteStateContext) || {}; let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useTreeState({ ...props, @@ -225,7 +226,8 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne // TODO: will need to special case this so it doesn't clear the focused key if we are currently // focused on a submenutrigger if (state.selectionManager.isFocused) { - clearVirtualFocus(); + state.selectionManager.setFocused(false); + state.selectionManager.setFocusedKey(null); } break; case 'Escape': @@ -257,26 +259,44 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne } }, [register, state, menuId, ref]); + // Update the focused key to be the first item in the menu only if the input value changes (aka match spotlight/other implementations). + let focusFirstItem = useEffectEvent(() => { + // TODO: the below is pretty much what the listkeyboard delegate would do when finding the first key + state.selectionManager.setFocused(true); + let focusedNode = state.collection.getItem(state.selectionManager.focusedKey); + if (focusedNode == null || focusedNode.prevKey != null) { + let key = state.collection.getFirstKey(); + while (key != null) { + let item = state.collection.getItem(key); + if (item?.type === 'item' && !state.selectionManager.isDisabled(key)) { + break; + } + key = state.collection.getKeyAfter(key); + } + state.selectionManager.setFocusedKey(key); + setFocusedNodeId && setFocusedNodeId(key == null ? null : getItemId(state, key)); + } + }); + let clearVirtualFocus = useEffectEvent(() => { state.selectionManager.setFocused(false); state.selectionManager.setFocusedKey(null); + setFocusedNodeId && setFocusedNodeId(null); }); + let lastInputValue = useRef(null); useEffect(() => { - // TODO: retested in NVDA. It seems like NVDA properly announces what new letter you are typing even if we maintain virtual focus on - // a item in the list. However, it won't announce the letter your cursor is now on if you don't clear the virtual focus when using left/right - // arrows. - // Clear the focused key if the inputValue changed for NVDA - // Also feels a bit weird that we need to make sure that the focusedId AND the selection manager here need to both be cleared of the focused - // item, would be nice if it was all centralized. Maybe a reason to go back to having the autocomplete hooks create and manage - // the collection/selection manager but then it might cause issues when we need to wrap a Table which won't use BaseCollection but rather has - // its own collection // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue if (inputValue != null) { - clearVirtualFocus(); - } - }, [inputValue]); + if (lastInputValue.current == null || lastInputValue.current?.length <= inputValue.length) { + focusFirstItem(); + } else { + clearVirtualFocus(); + } + lastInputValue.current = inputValue; + } + }, [inputValue, focusFirstItem, clearVirtualFocus]); let renderProps = useRenderProps({ defaultClassName: 'react-aria-Menu', From 9a58613b46263e829db1ee38e44981096685776e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Nov 2024 13:05:55 -0700 Subject: [PATCH 21/42] update yarn.lock --- yarn.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index ded888f8961..e5587112bb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6094,12 +6094,10 @@ __metadata: "@react-aria/focus": "npm:^3.18.4" "@react-aria/i18n": "npm:^3.12.3" "@react-aria/interactions": "npm:^3.22.4" - "@react-aria/live-announcer": "npm:^3.4.0" "@react-aria/overlays": "npm:^3.23.4" "@react-aria/selection": "npm:^3.20.1" "@react-aria/utils": "npm:^3.25.3" "@react-stately/collections": "npm:^3.11.0" - "@react-stately/list": "npm:^3.11.0" "@react-stately/menu": "npm:^3.8.3" "@react-stately/tree": "npm:^3.8.5" "@react-types/button": "npm:^3.10.0" From 574bd6714d266e0faba734a7d6d7613e0573baf3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 4 Nov 2024 16:32:02 -0800 Subject: [PATCH 22/42] dont autofocus if user hasnt typed in the field yet --- packages/react-aria-components/src/Menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 5408f6d4b9d..350c0e1e863 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -288,7 +288,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne useEffect(() => { // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue if (inputValue != null) { - if (lastInputValue.current == null || lastInputValue.current?.length <= inputValue.length) { + if (lastInputValue != null && lastInputValue.current !== inputValue && lastInputValue.current?.length <= inputValue.length) { focusFirstItem(); } else { clearVirtualFocus(); From ae7a00feeb55a53cb0e4eae881720f679e09eccf Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Nov 2024 13:01:48 -0800 Subject: [PATCH 23/42] add delay for now to make NVDA announcement better --- packages/react-aria-components/src/Menu.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 350c0e1e863..e7650f7b7e3 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -259,6 +259,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne } }, [register, state, menuId, ref]); + let timeout = useRef | undefined>(undefined); // Update the focused key to be the first item in the menu only if the input value changes (aka match spotlight/other implementations). let focusFirstItem = useEffectEvent(() => { // TODO: the below is pretty much what the listkeyboard delegate would do when finding the first key @@ -273,8 +274,13 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne } key = state.collection.getKeyAfter(key); } + + clearTimeout(timeout.current); state.selectionManager.setFocusedKey(key); - setFocusedNodeId && setFocusedNodeId(key == null ? null : getItemId(state, key)); + timeout.current = setTimeout(() => { + setFocusedNodeId && setFocusedNodeId(key == null ? null : getItemId(state, key)); + console.log('set focused id') + }, 500); } }); @@ -288,7 +294,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne useEffect(() => { // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue if (inputValue != null) { - if (lastInputValue != null && lastInputValue.current !== inputValue && lastInputValue.current?.length <= inputValue.length) { + if (lastInputValue.current != null && lastInputValue.current !== inputValue && lastInputValue.current?.length <= inputValue.length) { focusFirstItem(); } else { clearVirtualFocus(); From 7b11c5d4f270554cf3d7516459fcf1cef8a2760d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Nov 2024 15:47:55 -0800 Subject: [PATCH 24/42] fix lint and scrap custom announcements complications making the custom announcements for safari have a delay for when typing ahead but not when using the arrow keys to move through the options, so stashed it for now --- packages/react-aria-components/src/Menu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index e7650f7b7e3..0e1ae523b18 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -279,7 +279,6 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne state.selectionManager.setFocusedKey(key); timeout.current = setTimeout(() => { setFocusedNodeId && setFocusedNodeId(key == null ? null : getItemId(state, key)); - console.log('set focused id') }, 500); } }); From 04e8777ca9a7416151242068dda1c33901adc32c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Nov 2024 17:22:33 -0800 Subject: [PATCH 25/42] intial tests --- .../test/AriaAutoComplete.test-util.tsx | 184 ++++++++++++++++++ .../test/AutoComplete.test.tsx | 120 ++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/react-aria-components/test/AriaAutoComplete.test-util.tsx create mode 100644 packages/react-aria-components/test/AutoComplete.test.tsx diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx new file mode 100644 index 00000000000..fc525f6adad --- /dev/null +++ b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx @@ -0,0 +1,184 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, render, within} from '@testing-library/react'; +import { + pointerMap +} from '@react-spectrum/test-utils-internal'; +import userEvent from '@testing-library/user-event'; + +// TODO: bring this in when a test util is written so that we can have proper testing for all interaction modalities +// let describeInteractions = ((name, tests) => describe.each` +// interactionType +// ${'mouse'} +// ${'keyboard'} +// ${'touch'} +// `(`${name} - $interactionType`, tests)); + +// TODO: place somewhere central? +interface AriaBaseTestProps { + setup?: () => void, + prefix?: string +} + +interface RendererArgs { + autocompleteProps?: any, + menuProps?: any +} +interface AriaAutocompleteTestProps extends AriaBaseTestProps { + renderers: { + // needs to wrap a menu with at three items, all enabled. The items should be Foo, Bar, and Baz + standard: (args: RendererArgs) => ReturnType, + // needs at least two sections, each with three items + sections?: (args: RendererArgs) => ReturnType, + // needs a item with a link + links?: (args: RendererArgs) => ReturnType, + // needs a menu with three items, with the middle one disabled + disabledOption?: (args: RendererArgs) => ReturnType, + controlled?: (args: RendererArgs) => ReturnType + // TODO, add tests for this when we support it + // submenus?: (props?: {name: string}) => ReturnType + } +} +export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocompleteTestProps) => { + describe(prefix ? prefix + 'AriaAutocomplete' : 'AriaAutocomplete', function () { + // let onOpenChange = jest.fn(); + // let onOpen = jest.fn(); + // let onClose = jest.fn(); + // let onSelect = jest.fn(); + // let onSelectionChange = jest.fn(); + let user; + setup?.(); + + beforeAll(function () { + user = userEvent.setup({delay: null, pointerMap}); + // window.HTMLElement.prototype.scrollIntoView = jest.fn(); + jest.useFakeTimers(); + }); + + afterEach(() => { + // onOpenChange.mockClear(); + // onOpen.mockClear(); + // onClose.mockClear(); + // onSelect.mockClear(); + // onSelectionChange.mockClear(); + act(() => jest.runAllTimers()); + }); + + it('has default behavior (input field renders with expected attributes)', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('type', 'text'); + expect(input).toHaveAttribute('aria-controls'); + + let menu = getByRole('menu'); + expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')); + }); + + it('should support disabling the field', async function () { + let {getByRole} = renderers.standard({autocompleteProps: {isDisabled: true}}); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('disabled'); + }); + + it('should support making the field read only', async function () { + let {getByRole} = renderers.standard({autocompleteProps: {isReadOnly: true}}); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('readonly'); + }); + + it('should support filtering', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + expect(input).toHaveValue(''); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('F'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Foo'); + + expect(input).toHaveValue('F'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(document.activeElement).toBe(input); + + await user.keyboard('{Backspace}'); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + }); + + it('should support keyboard navigation', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + expect(document.activeElement).toBe(input); + }); + + it('should clear the focused key when using ArrowLeft and ArrowRight', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('Foo'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowRight}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowLeft}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + }); + + // it('should trigger the menu\'s onAction', async function () { + + // }); + + // it('temp', async function () { + + // }); + + // TODO: test filtering with defaultValue, controlled value + // test that onaction fires + // check defaultFilter + + // write tests for sections (filtering and keyboard navigation) and for link (triggers link) + // skips disabled keys, skips sections + }); +}; diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/AutoComplete.test.tsx new file mode 100644 index 00000000000..884e1665fc6 --- /dev/null +++ b/packages/react-aria-components/test/AutoComplete.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {AriaAutocompleteTests} from './AriaAutoComplete.test-util'; +import {Autocomplete, Header, Input, Keyboard, Label, Menu, MenuItem, Section, Separator, Text} from '..'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +let TestAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( + + + + Please select an option below. + +
+ Foo + Bar + Baz + Google +
+ +
+
Section 2
+ + Copy + Description + ⌘C + + + Cut + Description + ⌘X + + + Paste + Description + ⌘V + +
+
+
+); + +let renderAutoComplete = (autocompleteProps = {}, menuProps = {}) => render(); + +describe('Menu', () => { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + }); + + it('dummy test for now', async function () { + renderAutoComplete(); + await user.tab(); + }); + + // TODO: RAC specific tests go here (renderProps, data attributes, etc) +}); + +let StaticAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( + + + + + Foo + Bar + Baz + + +); + +interface AutocompleteItem { + id: string, + name: string +} +let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; + +let DynamicAutoComplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( + + + + + {(item: AutocompleteItem) => {item.name}} + + +); + +AriaAutocompleteTests({ + prefix: 'rac-static', + renderers: { + standard: ({autocompleteProps, menuProps}) => render( + + ) + } +}); + +AriaAutocompleteTests({ + prefix: 'rac-dynamic', + renderers: { + standard: ({autocompleteProps, menuProps}) => render( + + ) + } +}); From 79c90648477852d48d3c5b412a3501b30f11fb2e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 6 Nov 2024 15:27:09 -0800 Subject: [PATCH 26/42] more tests and fixes to BaseCollection and keyboard interactions from testing --- .../collections/src/BaseCollection.ts | 37 +- packages/react-aria-components/src/Menu.tsx | 4 +- .../stories/Autocomplete.stories.tsx | 58 ++-- .../test/AriaAutoComplete.test-util.tsx | 326 ++++++++++++++++-- .../test/AutoComplete.test.tsx | 82 ++++- 5 files changed, 436 insertions(+), 71 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 4766c11e459..4e12d96a73b 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -224,7 +224,7 @@ export class BaseCollection implements ICollection> { let clonedSection: Mutable> = (node as CollectionNode).clone(); let lastChildInSection: Mutable> | null = null; for (let child of this.getChildren(node.key)) { - if (filterFn(child.textValue)) { + if (filterFn(child.textValue) || child.type === 'header') { let clonedChild: Mutable> = (child as CollectionNode).clone(); // eslint-disable-next-line max-depth if (lastChildInSection == null) { @@ -250,22 +250,31 @@ export class BaseCollection implements ICollection> { } } - if (lastChildInSection && lastChildInSection.type !== 'header') { - clonedSection.lastChildKey = lastChildInSection.key; - - // If the old prev section was filtered out, will need to attach to whatever came before - if (lastNode == null) { - clonedSection.prevKey = null; - } else if (lastNode.type === 'section' || lastNode.type === 'separator') { - lastNode.nextKey = clonedSection.key; - clonedSection.prevKey = lastNode.key; + // Add newly filtered section to collection if it has any valid child nodes, otherwise remove it and its header if any + if (lastChildInSection) { + if (lastChildInSection.type !== 'header') { + clonedSection.lastChildKey = lastChildInSection.key; + + // If the old prev section was filtered out, will need to attach to whatever came before + if (lastNode == null) { + clonedSection.prevKey = null; + } else if (lastNode.type === 'section' || lastNode.type === 'separator') { + lastNode.nextKey = clonedSection.key; + clonedSection.prevKey = lastNode.key; + } + clonedSection.nextKey = null; + lastNode = clonedSection; + newCollection.addNode(clonedSection); + } else { + if (newCollection.firstKey === clonedSection.key) { + newCollection.firstKey = null; + } + newCollection.removeNode(lastChildInSection.key); } - clonedSection.nextKey = null; - lastNode = clonedSection; - newCollection.addNode(clonedSection); } } else if (node.type === 'separator') { - // will need to check if previous section key exists, if it does then we add. After the full collection is created we'll need to remove it + // will need to check if previous section key exists, if it does then we add the separator to the collection. + // After the full collection is created we'll need to remove it it is the last node in the section (aka no following section after the separator) let clonedSeparator: Mutable> = (node as CollectionNode).clone(); clonedSeparator.nextKey = null; if (lastNode?.type === 'section') { diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 0e1ae523b18..625550d561e 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -230,9 +230,11 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne state.selectionManager.setFocusedKey(null); } break; + case ' ': case 'Escape': // If hitting Escape, don't dispatch any events since useAutocomplete will handle whether or not - // to continuePropagation to the overlay depending on the inputValue + // to continuePropagation to the overlay depending on the inputValue. + // Space shouldn't trigger onAction so early return. return; } diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 4adc0f6cf4f..b112bb03c84 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -41,37 +41,25 @@ export default { export const AutocompleteExample = { render: ({onAction, isDisabled, isReadOnly}) => { return ( - +
Please select an option below. -
- Foo - Bar - Baz - Google -
- -
-
Section 2
- - Copy - Description - ⌘C - - - Cut - Description - ⌘X - - - Paste - Description - ⌘V - -
+
+
Section 1
+ Foo + Bar + Baz +
+ +
+
Section 2
+ Copy + Cut + Paste +
@@ -144,6 +132,24 @@ export const AutocompleteOnActionOnMenuItems = { name: 'Autocomplete, onAction on menu items' }; +export const AutocompleteDisabledKeys = { + render: ({onAction, isDisabled, isReadOnly}) => { + return ( + +
+ + + Please select an option below. + + {item => {item.name}} + +
+
+ ); + }, + name: 'Autocomplete, disabled key' +}; + const AsyncExample = ({onAction, isDisabled, isReadOnly}) => { let list = useAsyncList({ async load({filterText}) { diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx index fc525f6adad..8bc53339445 100644 --- a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx @@ -12,6 +12,7 @@ import {act, render, within} from '@testing-library/react'; import { + mockClickDefault, pointerMap } from '@react-spectrum/test-utils-internal'; import userEvent from '@testing-library/user-event'; @@ -41,36 +42,24 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps { // needs at least two sections, each with three items sections?: (args: RendererArgs) => ReturnType, // needs a item with a link - links?: (args: RendererArgs) => ReturnType, - // needs a menu with three items, with the middle one disabled - disabledOption?: (args: RendererArgs) => ReturnType, - controlled?: (args: RendererArgs) => ReturnType + links?: (args: RendererArgs) => ReturnType // TODO, add tests for this when we support it // submenus?: (props?: {name: string}) => ReturnType } } export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocompleteTestProps) => { describe(prefix ? prefix + 'AriaAutocomplete' : 'AriaAutocomplete', function () { - // let onOpenChange = jest.fn(); - // let onOpen = jest.fn(); - // let onClose = jest.fn(); - // let onSelect = jest.fn(); - // let onSelectionChange = jest.fn(); + let onAction = jest.fn(); let user; setup?.(); beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); - // window.HTMLElement.prototype.scrollIntoView = jest.fn(); jest.useFakeTimers(); }); afterEach(() => { - // onOpenChange.mockClear(); - // onOpen.mockClear(); - // onClose.mockClear(); - // onSelect.mockClear(); - // onSelectionChange.mockClear(); + onAction.mockClear(); act(() => jest.runAllTimers()); }); @@ -79,9 +68,19 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple let input = getByRole('searchbox'); expect(input).toHaveAttribute('type', 'text'); expect(input).toHaveAttribute('aria-controls'); + expect(input).toHaveAttribute('aria-haspopup', 'listbox'); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + expect(input).toHaveAttribute('autoCorrect', 'off'); + expect(input).toHaveAttribute('spellCheck', 'false'); let menu = getByRole('menu'); expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')); + + let label = document.getElementById(input.getAttribute('aria-labelledby')); + expect(label).toHaveTextContent('Test'); + + let description = document.getElementById(input.getAttribute('aria-describedby')); + expect(description).toHaveTextContent('Please select an option below'); }); it('should support disabling the field', async function () { @@ -123,6 +122,42 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(document.activeElement).toBe(input); }); + it('should support custom filtering', async function () { + let {getByRole} = renderers.standard({autocompleteProps: {defaultFilter: () => true}}); + let input = getByRole('searchbox'); + expect(input).toHaveValue(''); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('F'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent('Foo'); + }); + + it('should support default value', async function () { + let {getByRole} = renderers.standard({autocompleteProps: {defaultInputValue: 'Ba'}}); + let input = getByRole('searchbox'); + expect(input).toHaveValue('Ba'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent('Bar'); + expect(options[1]).toHaveTextContent('Baz'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('z'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Baz'); + }); + it('should support keyboard navigation', async function () { let {getByRole} = renderers.standard({}); let input = getByRole('searchbox'); @@ -166,19 +201,262 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(document.activeElement).toBe(input); }); - // it('should trigger the menu\'s onAction', async function () { + it('should trigger the wrapped element\'s onAction when hitting Enter', async function () { + let {getByRole} = renderers.standard({menuProps: {onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('1'); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(2); + expect(onAction).toHaveBeenLastCalledWith('2'); + }); + + it('should not trigger the wrapped element\'s onAction when hitting Space', async function () { + let {getByRole} = renderers.standard({menuProps: {onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('[Space]'); + act(() => jest.runAllTimers()); + expect(onAction).toHaveBeenCalledTimes(0); + options = within(menu).queryAllByRole('menuitem'); + expect(options).toHaveLength(0); + }); + + it('should properly skip over disabled keys', async function () { + let {getByRole} = renderers.standard({menuProps: {disabledKeys: ['2'], onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveAttribute('aria-disabled', 'true'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.click(options[0]); + expect(onAction).toHaveBeenCalledTimes(0); + }); + + it('should update the aria-activedescendant when hovering over an item', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.hover(options[1]); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(document.activeElement).toBe(input); + }); + + it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox') as HTMLInputElement; + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(3); + + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{Home}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{End}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + }); + + if (renderers.links) { + describe('with links', function () { + it('should trigger the link option when hitting Enter', async function () { + let {getByRole} = renderers.links({menuProps: {onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + + let options = within(menu).getAllByRole('menuitem'); + expect(options[2].tagName).toBe('A'); + expect(options[2]).toHaveAttribute('href', 'https://google.com'); + let onClick = mockClickDefault(); + + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('3'); + expect(onClick).toHaveBeenCalledTimes(1); + window.removeEventListener('click', onClick); + }); + }); + } + + if (renderers.sections) { + describe('with sections', function () { + it('should properly skip over sections when keyboard navigating', async function () { + let {getByRole, debug} = renderers.sections({}); + debug() + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let sections = within(menu).getAllByRole('group'); + expect(sections).toHaveLength(2); + expect(sections[0]).toHaveTextContent('Section 1'); + expect(sections[1]).toHaveTextContent('Section 2'); + expect(within(menu).getByRole('separator')).toBeInTheDocument(); + + let firstSecOpts = within(sections[0]).getAllByRole('menuitem'); + expect(firstSecOpts).toHaveLength(3); + let secondSecOpts = within(sections[1]).getAllByRole('menuitem'); + expect(secondSecOpts).toHaveLength(3); + + await user.tab(); + expect(document.activeElement).toBe(input); + for (let section of sections) { + let options = within(section).getAllByRole('menuitem'); + for (let opt of options) { + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', opt.id); + } + } + }); - // }); + it('should omit section titles and dividers when filtering', async function () { + let {getByRole} = renderers.sections({}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let sections = within(menu).getAllByRole('group'); + expect(sections).toHaveLength(2); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(6); + let divider = within(menu).getAllByRole('separator'); + expect(divider).toHaveLength(1); - // it('temp', async function () { + // This should just have the first section + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('F'); + act(() => jest.runAllTimers()); + sections = within(menu).getAllByRole('group'); + expect(sections).toHaveLength(1); + expect(sections[0]).toHaveAttribute('aria-labelledby'); + expect(document.getElementById(sections[0].getAttribute('aria-labelledby')!)).toHaveTextContent('Section 1'); + options = within(sections[0]).getAllByRole('menuitem'); + expect(options).toHaveLength(1); + divider = within(menu).queryAllByRole('separator'); + expect(divider).toHaveLength(0); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveTextContent('Foo') - // }); + // Return to unfiltered view + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + sections = within(menu).getAllByRole('group'); + expect(sections).toHaveLength(2); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(6); + divider = within(menu).getAllByRole('separator'); + expect(divider).toHaveLength(1); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveTextContent('Foo') - // TODO: test filtering with defaultValue, controlled value - // test that onaction fires - // check defaultFilter + // This should just have the second section + await user.keyboard('e'); + act(() => jest.runAllTimers()); + sections = within(menu).getAllByRole('group'); + expect(sections).toHaveLength(1); + expect(sections[0]).toHaveAttribute('aria-labelledby'); + expect(document.getElementById(sections[0].getAttribute('aria-labelledby')!)).toHaveTextContent('Section 2'); + options = within(sections[0]).getAllByRole('menuitem'); + expect(options).toHaveLength(1); + divider = within(menu).queryAllByRole('separator'); + expect(divider).toHaveLength(0); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveTextContent('Paste') - // write tests for sections (filtering and keyboard navigation) and for link (triggers link) - // skips disabled keys, skips sections + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + // This should just have both sections + await user.keyboard('a'); + act(() => jest.runAllTimers()); + sections = within(menu).getAllByRole('group'); + expect(sections).toHaveLength(2); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + divider = within(menu).queryAllByRole('separator'); + expect(divider).toHaveLength(1); + let firstSecOpts = within(sections[0]).getAllByRole('menuitem'); + expect(firstSecOpts).toHaveLength(2); + let secondSecOpts = within(sections[1]).getAllByRole('menuitem'); + expect(secondSecOpts).toHaveLength(1); + expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); + expect(firstSecOpts[0]).toHaveTextContent('Bar') + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[1].id); + expect(firstSecOpts[1]).toHaveTextContent('Baz') + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', secondSecOpts[0].id); + expect(secondSecOpts[0]).toHaveTextContent('Paste') + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); + }); + }); + } }); }; diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/AutoComplete.test.tsx index 884e1665fc6..eb012f6aa6c 100644 --- a/packages/react-aria-components/test/AutoComplete.test.tsx +++ b/packages/react-aria-components/test/AutoComplete.test.tsx @@ -26,7 +26,7 @@ let TestAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteP Foo Bar Baz - Google + Google
@@ -77,10 +77,11 @@ let StaticAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocomplet + Please select an option below. - Foo - Bar - Baz + Foo + Bar + Baz ); @@ -92,21 +93,79 @@ interface AutocompleteItem { let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; let DynamicAutoComplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( - + + Please select an option below. {(item: AutocompleteItem) => {item.name}} ); +let WithLinks = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( + + + + Please select an option below. + + Foo + Bar + Google + + +); + +let ControlledAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => { + let [inputValue, setInputValue] = React.useState(''); + + return ( + + + + Please select an option below. + + {(item: AutocompleteItem) => {item.name}} + + + ); +}; + +let SectionsAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( + + + + Please select an option below. + +
+
Section 1
+ Foo + Bar + Baz +
+ +
+
Section 2
+ Copy + Cut + Paste +
+
+
+); + AriaAutocompleteTests({ prefix: 'rac-static', renderers: { standard: ({autocompleteProps, menuProps}) => render( - ) + ), + links: ({autocompleteProps, menuProps}) => render( + + ), + sections: ({autocompleteProps, menuProps}) => render( + + ), } }); @@ -118,3 +177,14 @@ AriaAutocompleteTests({ ) } }); + +// TODO: maybe a bit overkill to run all the tests just for a controlled configuration, ideally would just have a +// subset of filter specific tests +// AriaAutocompleteTests({ +// prefix: 'rac-controlled', +// renderers: { +// standard: ({autocompleteProps, menuProps}) => render( +// +// ) +// } +// }); From b20b626ace45873a3216a950c7a78ea279be6e90 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 6 Nov 2024 16:18:08 -0800 Subject: [PATCH 27/42] fix lint and add RAC test --- .../src/Autocomplete.tsx | 1 + .../stories/Autocomplete.stories.tsx | 42 ++++-- .../test/AriaAutoComplete.test-util.tsx | 129 ++++++++++-------- .../test/AutoComplete.test.tsx | 107 ++++++--------- 4 files changed, 142 insertions(+), 137 deletions(-) diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index ebd0953ccd8..c652c0d1937 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -75,6 +75,7 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef { if (defaultFilter) { return defaultFilter(nodeTextValue, state.inputValue); diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index b112bb03c84..8fdf386e7ee 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -41,25 +41,39 @@ export default { export const AutocompleteExample = { render: ({onAction, isDisabled, isReadOnly}) => { return ( - +
Please select an option below. -
-
Section 1
- Foo - Bar - Baz -
- -
-
Section 2
- Copy - Cut - Paste -
+
+ Foo + Bar + Baz + Google + Option + Option with a space +
+ +
+
Section 2
+ + Copy + Description + ⌘C + + + Cut + Description + ⌘X + + + Paste + Description + ⌘V + +
diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx index 8bc53339445..ad571b6e4fb 100644 --- a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx @@ -42,7 +42,8 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps { // needs at least two sections, each with three items sections?: (args: RendererArgs) => ReturnType, // needs a item with a link - links?: (args: RendererArgs) => ReturnType + links?: (args: RendererArgs) => ReturnType, + controlled?: (args: RendererArgs) => ReturnType // TODO, add tests for this when we support it // submenus?: (props?: {name: string}) => ReturnType } @@ -74,12 +75,12 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(input).toHaveAttribute('spellCheck', 'false'); let menu = getByRole('menu'); - expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')); + expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); - let label = document.getElementById(input.getAttribute('aria-labelledby')); + let label = document.getElementById(input.getAttribute('aria-labelledby')!); expect(label).toHaveTextContent('Test'); - let description = document.getElementById(input.getAttribute('aria-describedby')); + let description = document.getElementById(input.getAttribute('aria-describedby')!); expect(description).toHaveTextContent('Please select an option below'); }); @@ -95,50 +96,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(input).toHaveAttribute('readonly'); }); - it('should support filtering', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - expect(input).toHaveValue(''); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(3); - - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('F'); - act(() => jest.runAllTimers()); - options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('Foo'); - - expect(input).toHaveValue('F'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(document.activeElement).toBe(input); - - await user.keyboard('{Backspace}'); - options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(3); - expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(document.activeElement).toBe(input); - }); - - it('should support custom filtering', async function () { - let {getByRole} = renderers.standard({autocompleteProps: {defaultFilter: () => true}}); - let input = getByRole('searchbox'); - expect(input).toHaveValue(''); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(3); - - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('F'); - act(() => jest.runAllTimers()); - options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(3); - expect(options[0]).toHaveTextContent('Foo'); - }); - it('should support default value', async function () { let {getByRole} = renderers.standard({autocompleteProps: {defaultInputValue: 'Ba'}}); let input = getByRole('searchbox'); @@ -315,10 +272,67 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(input.selectionStart).toBe(2); }); + let filterTests = (renderer) => { + describe('text filtering', function () { + it('should support filtering', async function () { + let {getByRole} = renderer({}); + let input = getByRole('searchbox'); + expect(input).toHaveValue(''); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('F'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Foo'); + + expect(input).toHaveValue('F'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(document.activeElement).toBe(input); + + await user.keyboard('{Backspace}'); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + }); + + // TODO this is RAC specific logic but maybe defaultFilter should move into the hooks? + it('should support custom filtering', async function () { + let {getByRole} = renderer({autocompleteProps: {defaultFilter: () => true}}); + let input = getByRole('searchbox'); + expect(input).toHaveValue(''); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('F'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent('Foo'); + }); + }); + }; + + filterTests(renderers.standard); + + if (renderers.controlled) { + describe('controlled', function () { + filterTests(renderers.controlled); + }); + } + if (renderers.links) { describe('with links', function () { it('should trigger the link option when hitting Enter', async function () { - let {getByRole} = renderers.links({menuProps: {onAction}}); + let {getByRole} = (renderers.links!)({menuProps: {onAction}}); let input = getByRole('searchbox'); let menu = getByRole('menu'); expect(input).not.toHaveAttribute('aria-activedescendant'); @@ -347,8 +361,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple if (renderers.sections) { describe('with sections', function () { it('should properly skip over sections when keyboard navigating', async function () { - let {getByRole, debug} = renderers.sections({}); - debug() + let {getByRole} = (renderers.sections!)({}); let input = getByRole('searchbox'); let menu = getByRole('menu'); let sections = within(menu).getAllByRole('group'); @@ -374,7 +387,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple }); it('should omit section titles and dividers when filtering', async function () { - let {getByRole} = renderers.sections({}); + let {getByRole} = (renderers.sections!)({}); let input = getByRole('searchbox'); let menu = getByRole('menu'); let sections = within(menu).getAllByRole('group'); @@ -399,7 +412,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(divider).toHaveLength(0); await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(options[0]).toHaveTextContent('Foo') + expect(options[0]).toHaveTextContent('Foo'); // Return to unfiltered view await user.keyboard('{Backspace}'); @@ -413,7 +426,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(input).not.toHaveAttribute('aria-activedescendant'); await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(options[0]).toHaveTextContent('Foo') + expect(options[0]).toHaveTextContent('Foo'); // This should just have the second section await user.keyboard('e'); @@ -428,7 +441,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(divider).toHaveLength(0); await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(options[0]).toHaveTextContent('Paste') + expect(options[0]).toHaveTextContent('Paste'); await user.keyboard('{Backspace}'); act(() => jest.runAllTimers()); @@ -446,13 +459,13 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple let secondSecOpts = within(sections[1]).getAllByRole('menuitem'); expect(secondSecOpts).toHaveLength(1); expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); - expect(firstSecOpts[0]).toHaveTextContent('Bar') + expect(firstSecOpts[0]).toHaveTextContent('Bar'); await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[1].id); - expect(firstSecOpts[1]).toHaveTextContent('Baz') + expect(firstSecOpts[1]).toHaveTextContent('Baz'); await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', secondSecOpts[0].id); - expect(secondSecOpts[0]).toHaveTextContent('Paste') + expect(secondSecOpts[0]).toHaveTextContent('Paste'); await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); }); diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/AutoComplete.test.tsx index eb012f6aa6c..546bc51f4f1 100644 --- a/packages/react-aria-components/test/AutoComplete.test.tsx +++ b/packages/react-aria-components/test/AutoComplete.test.tsx @@ -10,54 +10,36 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {act, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutoComplete.test-util'; -import {Autocomplete, Header, Input, Keyboard, Label, Menu, MenuItem, Section, Separator, Text} from '..'; +import {Autocomplete, Header, Input, Label, Menu, MenuItem, Section, Separator, Text} from '..'; import React from 'react'; -import userEvent from '@testing-library/user-event'; +interface AutocompleteItem { + id: string, + name: string +} -let TestAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( - - - - Please select an option below. - -
- Foo - Bar - Baz - Google -
- -
-
Section 2
- - Copy - Description - ⌘C - - - Cut - Description - ⌘X - - - Paste - Description - ⌘V - -
-
+let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; + +let TestAutocompleteRenderProps = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( + + {({isDisabled}) => ( +
+ + + Please select an option below. + + {(item: AutocompleteItem) => {item.name}} + +
+ )}
); -let renderAutoComplete = (autocompleteProps = {}, menuProps = {}) => render(); - -describe('Menu', () => { - let user; +let renderAutoComplete = (autocompleteProps = {}, menuProps = {}) => render(); +describe('Autocomplete', () => { beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); jest.useFakeTimers(); }); @@ -65,16 +47,23 @@ describe('Menu', () => { act(() => {jest.runAllTimers();}); }); - it('dummy test for now', async function () { - renderAutoComplete(); - await user.tab(); + it('provides isDisabled as a renderProp', async function () { + let {getByRole, queryByRole, rerender} = renderAutoComplete(); + let input = getByRole('searchbox'); + expect(input).not.toHaveAttribute('disabled'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(3); + + rerender(); + input = getByRole('searchbox'); + expect(input).toHaveAttribute('disabled'); + expect(queryByRole('menu')).toBeFalsy(); }); - - // TODO: RAC specific tests go here (renderProps, data attributes, etc) }); let StaticAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( - + Please select an option below. @@ -86,12 +75,6 @@ let StaticAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocomplet ); -interface AutocompleteItem { - id: string, - name: string -} -let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; - let DynamicAutoComplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( @@ -124,8 +107,10 @@ let ControlledAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocom Please select an option below. - - {(item: AutocompleteItem) => {item.name}} + + Foo + Bar + Baz ); @@ -166,6 +151,9 @@ AriaAutocompleteTests({ sections: ({autocompleteProps, menuProps}) => render( ), + controlled: ({autocompleteProps, menuProps}) => render( + + ) } }); @@ -177,14 +165,3 @@ AriaAutocompleteTests({ ) } }); - -// TODO: maybe a bit overkill to run all the tests just for a controlled configuration, ideally would just have a -// subset of filter specific tests -// AriaAutocompleteTests({ -// prefix: 'rac-controlled', -// renderers: { -// standard: ({autocompleteProps, menuProps}) => render( -// -// ) -// } -// }); From 2d0a15fdf60bf229db281e6fe18039c8753a0b8b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 7 Nov 2024 10:13:17 -0800 Subject: [PATCH 28/42] use MenuSection --- .../stories/Autocomplete.stories.tsx | 10 +++++----- .../test/AutoComplete.test.tsx | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 8fdf386e7ee..c0c37f767bc 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Dialog, Header, Input, Keyboard, Label, Menu, MenuTrigger, Popover, Section, Separator, Text} from 'react-aria-components'; +import {Autocomplete, Button, Dialog, Header, Input, Keyboard, Label, Menu, MenuSection, MenuTrigger, Popover, Separator, Text} from 'react-aria-components'; import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; @@ -47,16 +47,16 @@ export const AutocompleteExample = { Please select an option below. -
+ Foo Bar Baz Google Option Option with a space -
+ -
+
Section 2
Copy @@ -73,7 +73,7 @@ export const AutocompleteExample = { Description ⌘V -
+
diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/AutoComplete.test.tsx index 546bc51f4f1..31236be4608 100644 --- a/packages/react-aria-components/test/AutoComplete.test.tsx +++ b/packages/react-aria-components/test/AutoComplete.test.tsx @@ -12,7 +12,7 @@ import {act, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutoComplete.test-util'; -import {Autocomplete, Header, Input, Label, Menu, MenuItem, Section, Separator, Text} from '..'; +import {Autocomplete, Header, Input, Label, Menu, MenuItem, MenuSection, Separator, Text} from '..'; import React from 'react'; interface AutocompleteItem { id: string, @@ -116,25 +116,25 @@ let ControlledAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocom ); }; -let SectionsAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( +let MenuSectionsAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( Please select an option below. -
-
Section 1
+ +
MenuSection 1
Foo Bar Baz -
+ -
-
Section 2
+ +
MenuSection 2
Copy Cut Paste -
+
); @@ -149,7 +149,7 @@ AriaAutocompleteTests({ ), sections: ({autocompleteProps, menuProps}) => render( - + ), controlled: ({autocompleteProps, menuProps}) => render( From c87a5078053d8f25cf4fd3a33093ef0ed44492a4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 20 Nov 2024 14:30:09 -0800 Subject: [PATCH 29/42] (WIP) Refactor autocomplete logic to use custom events to update virtual focus (#7378) * get clear and focus first working with custom events * fix delayed id update and update id on item hover * refactor logic to use custom events to move virtual focus instead of a callback ref * move logic out of menu and down into selection hooks * use useEvent didnt use it in autocomplete hook since the ref might not initially exist which is a problem for useEvent (it wont setup the listener) --- .../autocomplete/src/useAutocomplete.ts | 153 ++++++++++++++---- packages/@react-aria/menu/src/useMenu.ts | 10 +- packages/@react-aria/menu/src/useMenuItem.ts | 5 +- packages/@react-aria/menu/src/utils.ts | 5 +- .../selection/src/useSelectableCollection.ts | 54 ++++++- .../selection/src/useSelectableItem.ts | 36 +++-- packages/@react-aria/utils/src/constants.ts | 17 ++ packages/@react-aria/utils/src/index.ts | 1 + .../src/Autocomplete.tsx | 20 ++- packages/react-aria-components/src/Menu.tsx | 110 +------------ .../test/AriaAutoComplete.test-util.tsx | 36 +++++ 11 files changed, 279 insertions(+), 168 deletions(-) create mode 100644 packages/@react-aria/utils/src/constants.ts diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 1c22e9c20fb..1fa95040841 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,8 +13,8 @@ import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, RefObject} from '@react-types/shared'; import type {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {chain, mergeProps, useId, useLabels} from '@react-aria/utils'; -import {InputHTMLAttributes, KeyboardEvent, ReactNode, useRef} from 'react'; +import {chain, CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, mergeProps, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels} from '@react-aria/utils'; +import {InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode, useEffect, useRef} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -28,7 +28,9 @@ export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, Inpu // Update all instances of "menu" then export interface AriaAutocompleteOptions extends Omit { /** The ref for the input element. */ - inputRef: RefObject + inputRef: RefObject, + /** The ref for the wrapped collection element. */ + collectionRef: RefObject } export interface AutocompleteAria { /** Props for the label element. */ @@ -38,11 +40,7 @@ export interface AutocompleteAria { /** Props for the menu, to be passed to [useMenu](useMenu.html). */ menuProps: AriaMenuOptions, /** Props for the autocomplete description element, if any. */ - descriptionProps: DOMAttributes, - // TODO: fairly non-standard thing to return from a hook, discuss how best to share this with hook only users - // This is for the user to register a callback that upon recieving a keyboard event key returns the expected virtually focused node id - /** Register function that expects a callback function that returns the newlly virtually focused menu option when provided with the keyboard action that occurs in the input field. */ - register: (callback: (e: KeyboardEvent) => string | null) => void + descriptionProps: DOMAttributes } /** @@ -54,27 +52,98 @@ export interface AutocompleteAria { export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, - isReadOnly + isReadOnly, + collectionRef } = props; let menuId = useId(); + let timeout = useRef | undefined>(undefined); + let keyToDelay = useRef<{key: string | null, delay: number | null}>({key: null, delay: null}); + // Create listeners for updateActiveDescendant events that will be fired by wrapped collection whenever the focused key changes + // so we can update the tracked active descendant for virtual focus + useEffect(() => { + let collection = collectionRef.current; + // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement + // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update and + let setUpDelay = (e) => { + let {detail} = e; + keyToDelay.current = {key: detail?.key, delay: detail?.delay}; + }; - // TODO: may need to move this into Autocomplete? Kinda odd to return this from the hook? Maybe the callback should be considered - // external to the hook, and the onus is on the user to pass in a onKeydown to this hook that updates state.focusedNodeId in response to a key - // stroke - let callbackRef = useRef<(e: KeyboardEvent) => string>(null); - let register = (callback) => { - callbackRef.current = callback; - }; + let updateActiveDescendant = (e) => { + let {detail} = e; + clearTimeout(timeout.current); + e.stopPropagation(); + + if (detail?.id != null) { + if (detail?.key === keyToDelay.current.key && keyToDelay.current.delay != null) { + timeout.current = setTimeout(() => { + state.setFocusedNodeId(detail.id); + }, keyToDelay.current.delay); + } else { + state.setFocusedNodeId(detail.id); + } + } else { + state.setFocusedNodeId(null); + } + + keyToDelay.current = {key: null, delay: null}; + }; + + collection?.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + collection?.addEventListener(DELAY_UPDATE, setUpDelay); + return () => { + collection?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + collection?.removeEventListener(DELAY_UPDATE, setUpDelay); + }; + }, [state, collectionRef]); + + let focusFirstItem = useEffectEvent(() => { + let focusFirstEvent = new CustomEvent(FOCUS_EVENT, { + cancelable: true, + bubbles: true, + detail: { + focusStrategy: 'first' + } + }); + + collectionRef.current?.dispatchEvent(focusFirstEvent); + }); + + let clearVirtualFocus = useEffectEvent(() => { + state.setFocusedNodeId(null); + let clearFocusEvent = new CustomEvent(CLEAR_FOCUS_EVENT, { + cancelable: true, + bubbles: true + }); + clearTimeout(timeout.current); + keyToDelay.current = {key: null, delay: null}; + + collectionRef.current?.dispatchEvent(clearFocusEvent); + }); + + // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text + // for screen reader announcements + let lastInputValue = useRef(null); + useEffect(() => { + // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue + if (state.inputValue != null) { + if (lastInputValue.current != null && lastInputValue.current !== state.inputValue && lastInputValue.current?.length <= state.inputValue.length) { + focusFirstItem(); + } else { + clearVirtualFocus(); + } + + lastInputValue.current = state.inputValue; + } + }, [state.inputValue, focusFirstItem, clearVirtualFocus]); // For textfield specific keydown operations - let onKeyDown = (e: BaseEvent>) => { + let onKeyDown = (e: BaseEvent>) => { if (e.nativeEvent.isComposing) { return; } - // TODO: how best to trigger the focused element's action? Currently having the registered callback handle dispatching a - // keyboard event switch (e.key) { case 'Escape': if (state.inputValue !== '' && !isReadOnly) { @@ -83,19 +152,47 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco e.continuePropagation(); } - break; + return; + case ' ': + // Space shouldn't trigger onAction so early return. + return; case 'Home': case 'End': + case 'PageDown': + case 'PageUp': case 'ArrowUp': - case 'ArrowDown': + case 'ArrowDown': { // Prevent these keys from moving the text cursor in the input e.preventDefault(); + // Move virtual focus into the wrapped collection + let focusCollection = new CustomEvent(FOCUS_EVENT, { + cancelable: true, + bubbles: true + }); + + collectionRef.current?.dispatchEvent(focusCollection); + break; + } + case 'ArrowLeft': + case 'ArrowRight': + // TODO: will need to special case this so it doesn't clear the focused key if we are currently + // focused on a submenutrigger? May not need to since focus would + // But what about wrapped grids where ArrowLeft and ArrowRight should navigate left/right + clearVirtualFocus(); break; } - if (callbackRef.current) { - let focusedNodeId = callbackRef.current(e); - state.setFocusedNodeId(focusedNodeId); + // Emulate the keyboard events that happen in the input field in the wrapped collection. This is for triggering things like onAction via Enter + // or moving focus from one item to another + if (state.focusedNodeId == null) { + collectionRef.current?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ); + } else { + let item = collectionRef.current?.querySelector(`#${CSS.escape(state.focusedNodeId)}`); + item?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ); } }; @@ -134,14 +231,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco }), menuProps: mergeProps(menuProps, { shouldUseVirtualFocus: true, - onHoverStart: (e) => { - // TODO: another thing to think about, what is the best way to past this to menu/wrapped collection component so that hovering on - // a item also updates the focusedNode. Perhaps we should just pass down setFocusedNodeId instead - state.setFocusedNodeId(e.target.id); - }, disallowTypeAhead: true }), - descriptionProps, - register + descriptionProps }; } diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index f7f6a1dd9ae..dd1a9955c2f 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -11,7 +11,7 @@ */ import {AriaMenuProps} from '@react-types/menu'; -import {DOMAttributes, HoverEvent, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; +import {DOMAttributes, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {menuData} from './utils'; import {TreeState} from '@react-stately/tree'; @@ -33,11 +33,7 @@ export interface AriaMenuOptions extends Omit, 'children'>, /** * Whether the menu items should use virtual focus instead of being focused directly. */ - shouldUseVirtualFocus?: boolean, - /** - * Handler that is called when a hover interaction starts on a menu item. - */ - onHoverStart?: (e: HoverEvent) => void + shouldUseVirtualFocus?: boolean } /** @@ -51,7 +47,6 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: shouldFocusWrap = true, onKeyDown, onKeyUp, - onHoverStart, ...otherProps } = props; @@ -75,7 +70,6 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: onClose: props.onClose, onAction: props.onAction, shouldUseVirtualFocus: props.shouldUseVirtualFocus, - onHoverStart, id }); diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index a404385a7bf..899bfba8eb6 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -214,6 +214,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re }; let {itemProps, isFocused} = useSelectableItem({ + id, selectionManager: selectionManager, key, ref, @@ -243,10 +244,6 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re selectionManager.setFocusedKey(key); } hoverStartProp?.(e); - - if (data.onHoverStart) { - data.onHoverStart(e); - } }, onHoverChange, onHoverEnd diff --git a/packages/@react-aria/menu/src/utils.ts b/packages/@react-aria/menu/src/utils.ts index 68348328de1..9ab27dccd1f 100644 --- a/packages/@react-aria/menu/src/utils.ts +++ b/packages/@react-aria/menu/src/utils.ts @@ -10,19 +10,20 @@ * governing permissions and limitations under the License. */ -import {HoverEvent, Key} from '@react-types/shared'; +import {Key} from '@react-types/shared'; import {TreeState} from '@react-stately/tree'; interface MenuData { onClose?: () => void, onAction?: (key: Key) => void, - onHoverStart?: (e: HoverEvent) => void, shouldUseVirtualFocus?: boolean, id: string } export const menuData = new WeakMap, MenuData>(); +// TODO: Will need to see about perhaps moving this into useSelectableCollection so we have +// dispatch the custom event to set the active descendant upwards which needs the id function normalizeKey(key: Key): string { if (typeof key === 'string') { return key.replace(/\s*/g, ''); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index c136d127ed7..272b8cd2748 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,11 +10,11 @@ * governing permissions and limitations under the License. */ +import {CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEvent, useRouter} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; -import {focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils'; import {MultipleSelectionManager} from '@react-stately/selection'; @@ -379,6 +379,58 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } }; + // Add event listeners for custom virtual events. These handle updating the focused key in response to various keyboard events + // at the autocomplete level + useEvent(ref, FOCUS_EVENT, (e: CustomEvent) => { + if (shouldUseVirtualFocus) { + let {detail} = e; + e.stopPropagation(); + manager.setFocused(true); + + // If the user is typing forwards, autofocus the first option in the list. + if (detail?.focusStrategy === 'first') { + let keyToFocus = delegate.getFirstKey(); + // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist + if (keyToFocus == null) { + ref.current?.dispatchEvent( + new CustomEvent(UPDATE_ACTIVEDESCENDANT, { + cancelable: true, + bubbles: true, + detail: { + id: null + } + }) + ); + } else { + // TODO: this feels gross, ideally would've wanted to intercept/redispatch the event that the focused items + // dispatches on focusedKey change, but that didn't work well due to issues with the even listeners + // alternative would be to generate the full focused option id in useSelectableCollection and dispatch that upwards + ref.current?.dispatchEvent( + new CustomEvent(DELAY_UPDATE, { + cancelable: true, + bubbles: true, + detail: { + // Tell autocomplete what key to look out for + key: keyToFocus, + delay: 500 + } + }) + ); + } + + manager.setFocusedKey(keyToFocus); + } + } + }); + + useEvent(ref, CLEAR_FOCUS_EVENT, (e) => { + if (shouldUseVirtualFocus) { + e.stopPropagation(); + manager.setFocused(false); + manager.setFocusedKey(null); + } + }); + const autoFocusRef = useRef(autoFocus); useEffect(() => { if (autoFocusRef.current) { diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index a9e73b9ea34..55d38b6ed9b 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -10,15 +10,15 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, FocusableElement, Key, LongPressEvent, PressEvent, RefObject} from '@react-types/shared'; +import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PressEvent, RefObject} from '@react-types/shared'; import {focusSafely} from '@react-aria/focus'; import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils'; -import {mergeProps, openLink, useRouter} from '@react-aria/utils'; +import {mergeProps, openLink, UPDATE_ACTIVEDESCENDANT, useId, useRouter} from '@react-aria/utils'; import {MultipleSelectionManager} from '@react-stately/selection'; import {PressProps, useLongPress, usePress} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; -export interface SelectableItemOptions { +export interface SelectableItemOptions extends DOMProps { /** * An interface for reading and updating multiple selection state. */ @@ -108,6 +108,7 @@ export interface SelectableItemAria extends SelectableItemStates { */ export function useSelectableItem(options: SelectableItemOptions): SelectableItemAria { let { + id, selectionManager: manager, key, ref, @@ -120,7 +121,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte linkBehavior = 'action' } = options; let router = useRouter(); - + id = useId(id); let onSelect = (e: PressEvent | LongPressEvent | PointerEvent) => { if (e.pointerType === 'keyboard' && isNonContiguousSelectionModifier(e)) { manager.toggleSelection(key); @@ -161,11 +162,24 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte // Focus the associated DOM node when this item becomes the focusedKey useEffect(() => { let isFocused = key === manager.focusedKey; - if (isFocused && manager.isFocused && !shouldUseVirtualFocus) { - if (focus) { - focus(); - } else if (document.activeElement !== ref.current) { - focusSafely(ref.current); + if (isFocused && manager.isFocused) { + if (!shouldUseVirtualFocus) { + if (focus) { + focus(); + } else if (document.activeElement !== ref.current) { + focusSafely(ref.current); + } + } else { + let updateActiveDescendant = new CustomEvent(UPDATE_ACTIVEDESCENDANT, { + cancelable: true, + bubbles: true, + detail: { + id, + key + } + }); + + ref.current?.dispatchEvent(updateActiveDescendant); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -347,12 +361,14 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } } : undefined; + // todo generate ID here that will then be used to fire/update the aria-activedescendant and also pass it to useMenuItem/other selectable items + return { itemProps: mergeProps( itemProps, allowsSelection || hasPrimaryAction ? pressProps : {}, longPressEnabled ? longPressProps : {}, - {onDoubleClick, onDragStartCapture, onClick} + {onDoubleClick, onDragStartCapture, onClick, id} ), isPressed, isSelected: manager.isSelected(key), diff --git a/packages/@react-aria/utils/src/constants.ts b/packages/@react-aria/utils/src/constants.ts new file mode 100644 index 00000000000..83c00383f09 --- /dev/null +++ b/packages/@react-aria/utils/src/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// TODO: Custom event names for updating the autocomplete's aria-activedecendant. +export const CLEAR_FOCUS_EVENT = 'react-aria-clear-focus'; +export const FOCUS_EVENT = 'react-aria-focus'; +export const UPDATE_ACTIVEDESCENDANT = 'react-aria-update-activedescendant'; +export const DELAY_UPDATE = 'react-aria-delay-update'; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 30ac024c942..433d4b27dfa 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -42,3 +42,4 @@ export {useEffectEvent} from './useEffectEvent'; export {useDeepMemo} from './useDeepMemo'; export {useFormReset} from './useFormReset'; export {useLoadMore} from './useLoadMore'; +export {CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants'; diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index c652c0d1937..0584f2574b7 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -17,7 +17,7 @@ import {ContextValue, Provider, removeDataAttributes, RenderProps, SlotProps, us import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import React, {createContext, ForwardedRef, forwardRef, KeyboardEvent, useCallback} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, RefObject, useCallback, useRef} from 'react'; import {TextContext} from './Text'; import {useObjectRef} from '@react-aria/utils'; @@ -36,10 +36,10 @@ export interface AutocompleteProps extends Omit string) => void, filterFn: (nodeTextValue: string) => boolean, inputValue: string, - menuProps: AriaMenuOptions + menuProps: AriaMenuOptions, + collectionRef: RefObject } export const AutocompleteContext = createContext>(null); @@ -52,18 +52,19 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef(ref); + let collectionRef = useRef(null); let [labelRef, label] = useSlot(); let {contains} = useFilter({sensitivity: 'base'}); let { inputProps, menuProps, labelProps, - descriptionProps, - register + descriptionProps } = useAutocomplete({ ...removeDataAttributes(props), label, - inputRef + inputRef, + collectionRef }, state); let renderValues = { @@ -94,7 +95,12 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef {renderProps.children} diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index b2c010ef3c4..3b9a8be488d 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -11,15 +11,14 @@ */ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria'; -import {AutocompleteStateContext, InternalAutocompleteContext} from './Autocomplete'; import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; -import {filterDOMProps, useEffectEvent, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, mergeRefs, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {FocusStrategy, forwardRefType, HoverEvents, Key, LinkDOMProps, MultipleSelection} from '@react-types/shared'; -import {getItemId, useSubmenuTrigger} from '@react-aria/menu'; import {HeaderContext} from './Header'; +import {InternalAutocompleteContext} from './Autocomplete'; import {KeyboardContext} from './Keyboard'; import {MultipleSelectionState, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; import {OverlayTriggerStateContext} from './Dialog'; @@ -30,7 +29,6 @@ import React, { ForwardedRef, forwardRef, ReactElement, - KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject, useCallback, @@ -43,6 +41,7 @@ import React, { import {RootMenuTriggerState, useSubmenuTriggerState} from '@react-stately/menu'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; +import {useSubmenuTrigger} from '@react-aria/menu'; export const MenuContext = createContext, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); @@ -171,10 +170,10 @@ interface MenuInnerProps { } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - let {register, filterFn, inputValue, menuProps: autocompleteMenuProps} = useContext(InternalAutocompleteContext) || {}; + let {filterFn, menuProps: autocompleteMenuProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them. - let {setFocusedNodeId} = useContext(AutocompleteStateContext) || {}; + ref = useObjectRef(mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject : null)); let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useTreeState({ ...props, @@ -208,105 +207,6 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne } }, [leftOffset, popoverContainer]); - let {id: menuId} = menuProps; - useEffect(() => { - if (register) { - register((e: ReactKeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - case 'ArrowUp': - case 'Home': - case 'End': - case 'PageDown': - case 'PageUp': - if (!state.selectionManager.isFocused) { - state.selectionManager.setFocused(true); - } - break; - case 'ArrowLeft': - case 'ArrowRight': - // TODO: will need to special case this so it doesn't clear the focused key if we are currently - // focused on a submenutrigger - if (state.selectionManager.isFocused) { - state.selectionManager.setFocused(false); - state.selectionManager.setFocusedKey(null); - } - break; - case ' ': - case 'Escape': - // If hitting Escape, don't dispatch any events since useAutocomplete will handle whether or not - // to continuePropagation to the overlay depending on the inputValue. - // Space shouldn't trigger onAction so early return. - return; - } - - let focusedId; - if (state.selectionManager.focusedKey == null) { - // TODO: calling menuProps.onKeyDown as an alternative to this doesn't quite work because of the check we do to prevent events from bubbling down. Perhaps - // dispatch the event as well to the menu since I don't think we want tot change the check in useSelectableCollection - // since we wouldn't want events to bubble through to the table - ref.current?.dispatchEvent( - new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ); - } else { - // If there is a focused key, dispatch an event to the menu item in question. This allows us to excute any existing onAction or link navigations - // that would have happen in a non-virtual focus case. - focusedId = getItemId(state, state.selectionManager.focusedKey); - let item = ref.current?.querySelector(`#${CSS.escape(focusedId)}`); - item?.dispatchEvent( - new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ); - } - focusedId = state.selectionManager.focusedKey ? getItemId(state, state.selectionManager.focusedKey) : null; - return focusedId; - }); - } - }, [register, state, menuId, ref]); - - let timeout = useRef | undefined>(undefined); - // Update the focused key to be the first item in the menu only if the input value changes (aka match spotlight/other implementations). - let focusFirstItem = useEffectEvent(() => { - // TODO: the below is pretty much what the listkeyboard delegate would do when finding the first key - state.selectionManager.setFocused(true); - let focusedNode = state.collection.getItem(state.selectionManager.focusedKey); - if (focusedNode == null || focusedNode.prevKey != null) { - let key = state.collection.getFirstKey(); - while (key != null) { - let item = state.collection.getItem(key); - if (item?.type === 'item' && !state.selectionManager.isDisabled(key)) { - break; - } - key = state.collection.getKeyAfter(key); - } - - clearTimeout(timeout.current); - state.selectionManager.setFocusedKey(key); - timeout.current = setTimeout(() => { - setFocusedNodeId && setFocusedNodeId(key == null ? null : getItemId(state, key)); - }, 500); - } - }); - - let clearVirtualFocus = useEffectEvent(() => { - state.selectionManager.setFocused(false); - state.selectionManager.setFocusedKey(null); - setFocusedNodeId && setFocusedNodeId(null); - }); - - let lastInputValue = useRef(null); - useEffect(() => { - // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue - if (inputValue != null) { - if (lastInputValue.current != null && lastInputValue.current !== inputValue && lastInputValue.current?.length <= inputValue.length) { - focusFirstItem(); - } else { - clearVirtualFocus(); - } - - lastInputValue.current = inputValue; - } - }, [inputValue, focusFirstItem, clearVirtualFocus]); - let renderProps = useRenderProps({ defaultClassName: 'react-aria-Menu', className: props.className, diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx index ad571b6e4fb..0b159dd1579 100644 --- a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx @@ -147,6 +147,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(document.activeElement).toBe(input); await user.keyboard('Foo'); + act(() => jest.runAllTimers()); let options = within(menu).getAllByRole('menuitem'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); await user.keyboard('{ArrowRight}'); @@ -235,12 +236,47 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple await user.tab(); expect(document.activeElement).toBe(input); + // Need to press to set a modality + await user.click(input); await user.hover(options[1]); act(() => jest.runAllTimers()); expect(input).toHaveAttribute('aria-activedescendant', options[1].id); expect(document.activeElement).toBe(input); }); + it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('a'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + act(() => jest.advanceTimersByTime(500)); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + }); + + it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('a'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + }); + it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { let {getByRole} = renderers.standard({}); let input = getByRole('searchbox') as HTMLInputElement; From 3871e7b9737c11231cb68abd26c76c750445ec3b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 20 Nov 2024 16:10:25 -0800 Subject: [PATCH 30/42] fix lint and test with wrapping Listbox --- .../@react-aria/autocomplete/intl/ar-AE.json | 2 +- .../@react-aria/autocomplete/intl/bg-BG.json | 2 +- .../@react-aria/autocomplete/intl/cs-CZ.json | 2 +- .../@react-aria/autocomplete/intl/da-DK.json | 2 +- .../@react-aria/autocomplete/intl/de-DE.json | 2 +- .../@react-aria/autocomplete/intl/el-GR.json | 2 +- .../@react-aria/autocomplete/intl/en-US.json | 2 +- .../@react-aria/autocomplete/intl/es-ES.json | 2 +- .../@react-aria/autocomplete/intl/et-EE.json | 2 +- .../@react-aria/autocomplete/intl/fi-FI.json | 2 +- .../@react-aria/autocomplete/intl/fr-FR.json | 2 +- .../@react-aria/autocomplete/intl/he-IL.json | 2 +- .../@react-aria/autocomplete/intl/hr-HR.json | 2 +- .../@react-aria/autocomplete/intl/hu-HU.json | 2 +- .../@react-aria/autocomplete/intl/it-IT.json | 2 +- .../@react-aria/autocomplete/intl/ja-JP.json | 2 +- .../@react-aria/autocomplete/intl/ko-KR.json | 2 +- .../@react-aria/autocomplete/intl/lt-LT.json | 2 +- .../@react-aria/autocomplete/intl/lv-LV.json | 2 +- .../@react-aria/autocomplete/intl/nb-NO.json | 2 +- .../@react-aria/autocomplete/intl/nl-NL.json | 2 +- .../@react-aria/autocomplete/intl/pl-PL.json | 2 +- .../@react-aria/autocomplete/intl/pt-BR.json | 2 +- .../@react-aria/autocomplete/intl/pt-PT.json | 2 +- .../@react-aria/autocomplete/intl/ro-RO.json | 2 +- .../@react-aria/autocomplete/intl/ru-RU.json | 2 +- .../@react-aria/autocomplete/intl/sk-SK.json | 2 +- .../@react-aria/autocomplete/intl/sl-SI.json | 2 +- .../@react-aria/autocomplete/intl/sr-SP.json | 2 +- .../@react-aria/autocomplete/intl/sv-SE.json | 2 +- .../@react-aria/autocomplete/intl/tr-TR.json | 2 +- .../@react-aria/autocomplete/intl/uk-UA.json | 2 +- .../@react-aria/autocomplete/intl/zh-CN.json | 2 +- .../@react-aria/autocomplete/intl/zh-TW.json | 2 +- .../@react-aria/autocomplete/src/index.ts | 2 +- .../autocomplete/src/useAutocomplete.ts | 30 ++++--- packages/@react-aria/listbox/src/useOption.ts | 6 +- .../selection/src/useSelectableCollection.ts | 5 +- .../@react-stately/autocomplete/package.json | 2 +- .../autocomplete/src/useAutocompleteState.ts | 2 +- .../src/Autocomplete.tsx | 10 +-- .../react-aria-components/src/ListBox.tsx | 12 ++- packages/react-aria-components/src/Menu.tsx | 3 +- .../stories/Autocomplete.stories.tsx | 86 ++++++++++++++++++- .../stories/GridList.stories.tsx | 2 +- .../test/ListBox.test.js | 47 +++++----- yarn.lock | 2 +- 47 files changed, 185 insertions(+), 92 deletions(-) diff --git a/packages/@react-aria/autocomplete/intl/ar-AE.json b/packages/@react-aria/autocomplete/intl/ar-AE.json index f262b0d23c6..eff90fd463b 100644 --- a/packages/@react-aria/autocomplete/intl/ar-AE.json +++ b/packages/@react-aria/autocomplete/intl/ar-AE.json @@ -1,3 +1,3 @@ { - "menuLabel": "مقترحات" + "collectionLabel": "مقترحات" } diff --git a/packages/@react-aria/autocomplete/intl/bg-BG.json b/packages/@react-aria/autocomplete/intl/bg-BG.json index 07b96e3441a..d4f4383ef4a 100644 --- a/packages/@react-aria/autocomplete/intl/bg-BG.json +++ b/packages/@react-aria/autocomplete/intl/bg-BG.json @@ -1,3 +1,3 @@ { - "menuLabel": "Предложения" + "collectionLabel": "Предложения" } diff --git a/packages/@react-aria/autocomplete/intl/cs-CZ.json b/packages/@react-aria/autocomplete/intl/cs-CZ.json index 01bb851f4fe..f2137a5b589 100644 --- a/packages/@react-aria/autocomplete/intl/cs-CZ.json +++ b/packages/@react-aria/autocomplete/intl/cs-CZ.json @@ -1,3 +1,3 @@ { - "menuLabel": "Návrhy" + "collectionLabel": "Návrhy" } diff --git a/packages/@react-aria/autocomplete/intl/da-DK.json b/packages/@react-aria/autocomplete/intl/da-DK.json index 9ceb48d2a8e..7e1c1db1d21 100644 --- a/packages/@react-aria/autocomplete/intl/da-DK.json +++ b/packages/@react-aria/autocomplete/intl/da-DK.json @@ -1,3 +1,3 @@ { - "menuLabel": "Forslag" + "collectionLabel": "Forslag" } diff --git a/packages/@react-aria/autocomplete/intl/de-DE.json b/packages/@react-aria/autocomplete/intl/de-DE.json index 56058ae1fb1..fb67594ddfc 100644 --- a/packages/@react-aria/autocomplete/intl/de-DE.json +++ b/packages/@react-aria/autocomplete/intl/de-DE.json @@ -1,3 +1,3 @@ { - "menuLabel": "Empfehlungen" + "collectionLabel": "Empfehlungen" } diff --git a/packages/@react-aria/autocomplete/intl/el-GR.json b/packages/@react-aria/autocomplete/intl/el-GR.json index 9444f67ed64..ba8af7fb4b5 100644 --- a/packages/@react-aria/autocomplete/intl/el-GR.json +++ b/packages/@react-aria/autocomplete/intl/el-GR.json @@ -1,3 +1,3 @@ { - "menuLabel": "Προτάσεις" + "collectionLabel": "Προτάσεις" } diff --git a/packages/@react-aria/autocomplete/intl/en-US.json b/packages/@react-aria/autocomplete/intl/en-US.json index c8ff5623aac..a14096ec15f 100644 --- a/packages/@react-aria/autocomplete/intl/en-US.json +++ b/packages/@react-aria/autocomplete/intl/en-US.json @@ -1,3 +1,3 @@ { - "menuLabel": "Suggestions" + "collectionLabel": "Suggestions" } diff --git a/packages/@react-aria/autocomplete/intl/es-ES.json b/packages/@react-aria/autocomplete/intl/es-ES.json index 830fed8afe9..72d14dc91a4 100644 --- a/packages/@react-aria/autocomplete/intl/es-ES.json +++ b/packages/@react-aria/autocomplete/intl/es-ES.json @@ -1,3 +1,3 @@ { - "menuLabel": "Sugerencias" + "collectionLabel": "Sugerencias" } diff --git a/packages/@react-aria/autocomplete/intl/et-EE.json b/packages/@react-aria/autocomplete/intl/et-EE.json index 35f34268e40..7d158fd1fed 100644 --- a/packages/@react-aria/autocomplete/intl/et-EE.json +++ b/packages/@react-aria/autocomplete/intl/et-EE.json @@ -1,3 +1,3 @@ { - "menuLabel": "Soovitused" + "collectionLabel": "Soovitused" } diff --git a/packages/@react-aria/autocomplete/intl/fi-FI.json b/packages/@react-aria/autocomplete/intl/fi-FI.json index 3cd1b888423..cf228937d6d 100644 --- a/packages/@react-aria/autocomplete/intl/fi-FI.json +++ b/packages/@react-aria/autocomplete/intl/fi-FI.json @@ -1,3 +1,3 @@ { - "menuLabel": "Ehdotukset" + "collectionLabel": "Ehdotukset" } diff --git a/packages/@react-aria/autocomplete/intl/fr-FR.json b/packages/@react-aria/autocomplete/intl/fr-FR.json index c8ff5623aac..a14096ec15f 100644 --- a/packages/@react-aria/autocomplete/intl/fr-FR.json +++ b/packages/@react-aria/autocomplete/intl/fr-FR.json @@ -1,3 +1,3 @@ { - "menuLabel": "Suggestions" + "collectionLabel": "Suggestions" } diff --git a/packages/@react-aria/autocomplete/intl/he-IL.json b/packages/@react-aria/autocomplete/intl/he-IL.json index 537109e89d8..1972fe85961 100644 --- a/packages/@react-aria/autocomplete/intl/he-IL.json +++ b/packages/@react-aria/autocomplete/intl/he-IL.json @@ -1,3 +1,3 @@ { - "menuLabel": "הצעות" + "collectionLabel": "הצעות" } diff --git a/packages/@react-aria/autocomplete/intl/hr-HR.json b/packages/@react-aria/autocomplete/intl/hr-HR.json index 42fae42125c..4c71bffaf90 100644 --- a/packages/@react-aria/autocomplete/intl/hr-HR.json +++ b/packages/@react-aria/autocomplete/intl/hr-HR.json @@ -1,3 +1,3 @@ { - "menuLabel": "Prijedlozi" + "collectionLabel": "Prijedlozi" } diff --git a/packages/@react-aria/autocomplete/intl/hu-HU.json b/packages/@react-aria/autocomplete/intl/hu-HU.json index 44f7922d83e..f5b920bde63 100644 --- a/packages/@react-aria/autocomplete/intl/hu-HU.json +++ b/packages/@react-aria/autocomplete/intl/hu-HU.json @@ -1,3 +1,3 @@ { - "menuLabel": "Javaslatok" + "collectionLabel": "Javaslatok" } diff --git a/packages/@react-aria/autocomplete/intl/it-IT.json b/packages/@react-aria/autocomplete/intl/it-IT.json index 93fc370c8ba..d9fa204e572 100644 --- a/packages/@react-aria/autocomplete/intl/it-IT.json +++ b/packages/@react-aria/autocomplete/intl/it-IT.json @@ -1,3 +1,3 @@ { - "menuLabel": "Suggerimenti" + "collectionLabel": "Suggerimenti" } diff --git a/packages/@react-aria/autocomplete/intl/ja-JP.json b/packages/@react-aria/autocomplete/intl/ja-JP.json index 45926ec2e1f..e0502a4da87 100644 --- a/packages/@react-aria/autocomplete/intl/ja-JP.json +++ b/packages/@react-aria/autocomplete/intl/ja-JP.json @@ -1,3 +1,3 @@ { - "menuLabel": "候補" + "collectionLabel": "候補" } diff --git a/packages/@react-aria/autocomplete/intl/ko-KR.json b/packages/@react-aria/autocomplete/intl/ko-KR.json index 47a289537d1..ede122f54af 100644 --- a/packages/@react-aria/autocomplete/intl/ko-KR.json +++ b/packages/@react-aria/autocomplete/intl/ko-KR.json @@ -1,3 +1,3 @@ { - "menuLabel": "제안" + "collectionLabel": "제안" } diff --git a/packages/@react-aria/autocomplete/intl/lt-LT.json b/packages/@react-aria/autocomplete/intl/lt-LT.json index e791bcc8ef3..e82309aa71e 100644 --- a/packages/@react-aria/autocomplete/intl/lt-LT.json +++ b/packages/@react-aria/autocomplete/intl/lt-LT.json @@ -1,3 +1,3 @@ { - "menuLabel": "Pasiūlymai" + "collectionLabel": "Pasiūlymai" } diff --git a/packages/@react-aria/autocomplete/intl/lv-LV.json b/packages/@react-aria/autocomplete/intl/lv-LV.json index 5839aa7c1e5..f3882bc7326 100644 --- a/packages/@react-aria/autocomplete/intl/lv-LV.json +++ b/packages/@react-aria/autocomplete/intl/lv-LV.json @@ -1,3 +1,3 @@ { - "menuLabel": "Ieteikumi" + "collectionLabel": "Ieteikumi" } diff --git a/packages/@react-aria/autocomplete/intl/nb-NO.json b/packages/@react-aria/autocomplete/intl/nb-NO.json index 9ceb48d2a8e..7e1c1db1d21 100644 --- a/packages/@react-aria/autocomplete/intl/nb-NO.json +++ b/packages/@react-aria/autocomplete/intl/nb-NO.json @@ -1,3 +1,3 @@ { - "menuLabel": "Forslag" + "collectionLabel": "Forslag" } diff --git a/packages/@react-aria/autocomplete/intl/nl-NL.json b/packages/@react-aria/autocomplete/intl/nl-NL.json index 9c1e90c0aeb..cac07485296 100644 --- a/packages/@react-aria/autocomplete/intl/nl-NL.json +++ b/packages/@react-aria/autocomplete/intl/nl-NL.json @@ -1,3 +1,3 @@ { - "menuLabel": "Suggesties" + "collectionLabel": "Suggesties" } diff --git a/packages/@react-aria/autocomplete/intl/pl-PL.json b/packages/@react-aria/autocomplete/intl/pl-PL.json index 950eb325788..d3439f8a8c0 100644 --- a/packages/@react-aria/autocomplete/intl/pl-PL.json +++ b/packages/@react-aria/autocomplete/intl/pl-PL.json @@ -1,3 +1,3 @@ { - "menuLabel": "Sugestie" + "collectionLabel": "Sugestie" } diff --git a/packages/@react-aria/autocomplete/intl/pt-BR.json b/packages/@react-aria/autocomplete/intl/pt-BR.json index 25a143572b2..74b8bf87b13 100644 --- a/packages/@react-aria/autocomplete/intl/pt-BR.json +++ b/packages/@react-aria/autocomplete/intl/pt-BR.json @@ -1,3 +1,3 @@ { - "menuLabel": "Sugestões" + "collectionLabel": "Sugestões" } diff --git a/packages/@react-aria/autocomplete/intl/pt-PT.json b/packages/@react-aria/autocomplete/intl/pt-PT.json index 25a143572b2..74b8bf87b13 100644 --- a/packages/@react-aria/autocomplete/intl/pt-PT.json +++ b/packages/@react-aria/autocomplete/intl/pt-PT.json @@ -1,3 +1,3 @@ { - "menuLabel": "Sugestões" + "collectionLabel": "Sugestões" } diff --git a/packages/@react-aria/autocomplete/intl/ro-RO.json b/packages/@react-aria/autocomplete/intl/ro-RO.json index f57781374ab..c1522bf66ab 100644 --- a/packages/@react-aria/autocomplete/intl/ro-RO.json +++ b/packages/@react-aria/autocomplete/intl/ro-RO.json @@ -1,3 +1,3 @@ { - "menuLabel": "Sugestii" + "collectionLabel": "Sugestii" } diff --git a/packages/@react-aria/autocomplete/intl/ru-RU.json b/packages/@react-aria/autocomplete/intl/ru-RU.json index 07b96e3441a..d4f4383ef4a 100644 --- a/packages/@react-aria/autocomplete/intl/ru-RU.json +++ b/packages/@react-aria/autocomplete/intl/ru-RU.json @@ -1,3 +1,3 @@ { - "menuLabel": "Предложения" + "collectionLabel": "Предложения" } diff --git a/packages/@react-aria/autocomplete/intl/sk-SK.json b/packages/@react-aria/autocomplete/intl/sk-SK.json index 01bb851f4fe..f2137a5b589 100644 --- a/packages/@react-aria/autocomplete/intl/sk-SK.json +++ b/packages/@react-aria/autocomplete/intl/sk-SK.json @@ -1,3 +1,3 @@ { - "menuLabel": "Návrhy" + "collectionLabel": "Návrhy" } diff --git a/packages/@react-aria/autocomplete/intl/sl-SI.json b/packages/@react-aria/autocomplete/intl/sl-SI.json index 01b720d4a62..06c94203ab3 100644 --- a/packages/@react-aria/autocomplete/intl/sl-SI.json +++ b/packages/@react-aria/autocomplete/intl/sl-SI.json @@ -1,3 +1,3 @@ { - "menuLabel": "Predlogi" + "collectionLabel": "Predlogi" } diff --git a/packages/@react-aria/autocomplete/intl/sr-SP.json b/packages/@react-aria/autocomplete/intl/sr-SP.json index 2ec701a5dac..56dab3c7f95 100644 --- a/packages/@react-aria/autocomplete/intl/sr-SP.json +++ b/packages/@react-aria/autocomplete/intl/sr-SP.json @@ -1,3 +1,3 @@ { - "menuLabel": "Predlozi" + "collectionLabel": "Predlozi" } diff --git a/packages/@react-aria/autocomplete/intl/sv-SE.json b/packages/@react-aria/autocomplete/intl/sv-SE.json index 81f43b773fb..b384f9df2e8 100644 --- a/packages/@react-aria/autocomplete/intl/sv-SE.json +++ b/packages/@react-aria/autocomplete/intl/sv-SE.json @@ -1,3 +1,3 @@ { - "menuLabel": "Förslag" + "collectionLabel": "Förslag" } diff --git a/packages/@react-aria/autocomplete/intl/tr-TR.json b/packages/@react-aria/autocomplete/intl/tr-TR.json index ea2d4f79de7..45639c244f5 100644 --- a/packages/@react-aria/autocomplete/intl/tr-TR.json +++ b/packages/@react-aria/autocomplete/intl/tr-TR.json @@ -1,3 +1,3 @@ { - "menuLabel": "Öneriler" + "collectionLabel": "Öneriler" } diff --git a/packages/@react-aria/autocomplete/intl/uk-UA.json b/packages/@react-aria/autocomplete/intl/uk-UA.json index ab6ea6461ca..13f6e10d476 100644 --- a/packages/@react-aria/autocomplete/intl/uk-UA.json +++ b/packages/@react-aria/autocomplete/intl/uk-UA.json @@ -1,3 +1,3 @@ { - "menuLabel": "Пропозиції" + "collectionLabel": "Пропозиції" } diff --git a/packages/@react-aria/autocomplete/intl/zh-CN.json b/packages/@react-aria/autocomplete/intl/zh-CN.json index 53578ff64c0..8e091c3d136 100644 --- a/packages/@react-aria/autocomplete/intl/zh-CN.json +++ b/packages/@react-aria/autocomplete/intl/zh-CN.json @@ -1,3 +1,3 @@ { - "menuLabel": "建议" + "collectionLabel": "建议" } diff --git a/packages/@react-aria/autocomplete/intl/zh-TW.json b/packages/@react-aria/autocomplete/intl/zh-TW.json index 5fdc5cf20c4..591ee5ad525 100644 --- a/packages/@react-aria/autocomplete/intl/zh-TW.json +++ b/packages/@react-aria/autocomplete/intl/zh-TW.json @@ -1,3 +1,3 @@ { - "menuLabel": "建議" + "collectionLabel": "建議" } diff --git a/packages/@react-aria/autocomplete/src/index.ts b/packages/@react-aria/autocomplete/src/index.ts index b257386b8c4..3635d09fcf2 100644 --- a/packages/@react-aria/autocomplete/src/index.ts +++ b/packages/@react-aria/autocomplete/src/index.ts @@ -14,4 +14,4 @@ export {useAutocomplete} from './useAutocomplete'; export type {AriaSearchAutocompleteOptions, SearchAutocompleteAria} from './useSearchAutocomplete'; export type {AriaSearchAutocompleteProps} from '@react-types/autocomplete'; -export type {AriaAutocompleteProps, AriaAutocompleteOptions, AutocompleteAria} from './useAutocomplete'; +export type {AriaAutocompleteProps, AriaAutocompleteOptions, AutocompleteAria, CollectionOptions} from './useAutocomplete'; diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 1fa95040841..01a8e9d4308 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -11,7 +11,6 @@ */ import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, RefObject} from '@react-types/shared'; -import type {AriaMenuOptions} from '@react-aria/menu'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {chain, CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, mergeProps, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels} from '@react-aria/utils'; import {InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode, useEffect, useRef} from 'react'; @@ -20,43 +19,45 @@ import intlMessages from '../intl/*.json'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTextField} from '@react-aria/textfield'; +export interface CollectionOptions extends DOMProps, AriaLabelingProps { + shouldUseVirtualFocus: boolean, + disallowTypeAhead: boolean +} export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps { children: ReactNode } -// TODO: all of this is menu specific but will need to eventually be agnostic to what collection element is inside -// Update all instances of "menu" then export interface AriaAutocompleteOptions extends Omit { /** The ref for the input element. */ inputRef: RefObject, /** The ref for the wrapped collection element. */ collectionRef: RefObject } -export interface AutocompleteAria { +export interface AutocompleteAria { /** Props for the label element. */ labelProps: DOMAttributes, /** Props for the autocomplete input element. */ inputProps: InputHTMLAttributes, - /** Props for the menu, to be passed to [useMenu](useMenu.html). */ - menuProps: AriaMenuOptions, + /** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */ + collectionProps: CollectionOptions, /** Props for the autocomplete description element, if any. */ descriptionProps: DOMAttributes } /** * Provides the behavior and accessibility implementation for a autocomplete component. - * A autocomplete combines a text input with a menu, allowing users to filter a list of options to items matching a query. + * A autocomplete combines a text input with a collection, allowing users to filter the collection's contents match a query. * @param props - Props for the autocomplete. * @param state - State for the autocomplete, as returned by `useAutocompleteState`. */ -export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { +export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, isReadOnly, collectionRef } = props; - let menuId = useId(); + let collectionId = useId(); let timeout = useRef | undefined>(undefined); let keyToDelay = useRef<{key: string | null, delay: number | null}>({key: null, delay: null}); // Create listeners for updateActiveDescendant events that will be fired by wrapped collection whenever the focused key changes @@ -205,9 +206,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco }, inputRef); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); - let menuProps = useLabels({ - id: menuId, - 'aria-label': stringFormatter.format('menuLabel'), + let collectionProps = useLabels({ + id: collectionId, + 'aria-label': stringFormatter.format('collectionLabel'), 'aria-labelledby': props['aria-labelledby'] || labelProps.id }); @@ -215,7 +216,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco labelProps, inputProps: mergeProps(inputProps, { 'aria-haspopup': 'listbox', - 'aria-controls': menuId, + 'aria-controls': collectionId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', 'aria-activedescendant': state.focusedNodeId ?? undefined, @@ -229,7 +230,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false' }), - menuProps: mergeProps(menuProps, { + collectionProps: mergeProps(collectionProps, { + // TODO: shouldFocusOnHover? shouldUseVirtualFocus: true, disallowTypeAhead: true }), diff --git a/packages/@react-aria/listbox/src/useOption.ts b/packages/@react-aria/listbox/src/useOption.ts index 1080620e007..82dc5c3f1a0 100644 --- a/packages/@react-aria/listbox/src/useOption.ts +++ b/packages/@react-aria/listbox/src/useOption.ts @@ -125,6 +125,7 @@ export function useOption(props: AriaOptionProps, state: ListState, ref: R } let onAction = data?.onAction ? () => data?.onAction?.(key) : undefined; + let id = getItemId(state, key); let {itemProps, isPressed, isFocused, hasAction, allowsSelection} = useSelectableItem({ selectionManager: state.selectionManager, key, @@ -135,7 +136,8 @@ export function useOption(props: AriaOptionProps, state: ListState, ref: R shouldUseVirtualFocus, isDisabled, onAction: onAction || item?.props?.onAction ? chain(item?.props?.onAction, onAction) : undefined, - linkBehavior: data?.linkBehavior + linkBehavior: data?.linkBehavior, + id }); let {hoverProps} = useHover({ @@ -156,7 +158,7 @@ export function useOption(props: AriaOptionProps, state: ListState, ref: R optionProps: { ...optionProps, ...mergeProps(domProps, itemProps, hoverProps, linkProps), - id: getItemId(state, key) + id }, labelProps: { id: labelId diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index f030ca1a79c..ea73ce66e8c 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -387,7 +387,8 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Add event listeners for custom virtual events. These handle updating the focused key in response to various keyboard events // at the autocomplete level - useEvent(ref, FOCUS_EVENT, (e: CustomEvent) => { + // TODO: fix type later + useEvent(ref, FOCUS_EVENT, (e: any) => { if (shouldUseVirtualFocus) { let {detail} = e; e.stopPropagation(); @@ -395,7 +396,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // If the user is typing forwards, autofocus the first option in the list. if (detail?.focusStrategy === 'first') { - let keyToFocus = delegate.getFirstKey(); + let keyToFocus = delegate.getFirstKey?.() ?? null; // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist if (keyToFocus == null) { ref.current?.dispatchEvent( diff --git a/packages/@react-stately/autocomplete/package.json b/packages/@react-stately/autocomplete/package.json index 6e598ff09f2..5c7739639cd 100644 --- a/packages/@react-stately/autocomplete/package.json +++ b/packages/@react-stately/autocomplete/package.json @@ -27,7 +27,7 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 67af1758b05..d1771f77c92 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -53,7 +53,7 @@ export function useAutocompleteState(props: AutocompleteStateOptions): Autocompl } }; - let [focusedNodeId, setFocusedNodeId] = useState(null); + let [focusedNodeId, setFocusedNodeId] = useState(null); let [inputValue, setInputValue] = useControlledState( propsInputValue, propsDefaultInputValue!, diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 0584f2574b7..bd1b6476ee9 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -10,8 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete'; -import {AriaMenuOptions, useFilter} from 'react-aria'; +import {AriaAutocompleteProps, CollectionOptions, useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; import {ContextValue, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils'; import {forwardRefType} from '@react-types/shared'; @@ -19,6 +18,7 @@ import {InputContext} from './Input'; import {LabelContext} from './Label'; import React, {createContext, ForwardedRef, forwardRef, RefObject, useCallback, useRef} from 'react'; import {TextContext} from './Text'; +import {useFilter} from 'react-aria'; import {useObjectRef} from '@react-aria/utils'; // TODO: I've kept isDisabled because it might be useful to a user for changing what the menu renders if the autocomplete is disabled, @@ -38,7 +38,7 @@ export interface AutocompleteProps extends Omit boolean, inputValue: string, - menuProps: AriaMenuOptions, + collectionProps: CollectionOptions, collectionRef: RefObject } @@ -57,7 +57,7 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index f193b202fc8..8610745a729 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -17,9 +17,10 @@ import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleRe import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, useListState} from 'react-stately'; -import {filterDOMProps, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, mergeRefs, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; +import {InternalAutocompleteContext} from './Autocomplete'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; @@ -103,8 +104,13 @@ function ListBox(props: ListBoxProps, ref: ForwardedRef; + let {filterFn, collectionProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; + + // TODO: for some reason this breaks the listbox test for virtualization but locally it seems to work fine... + listBoxRef = useObjectRef(mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject : null)); + let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); + let state = useListState({...props, collection: filteredCollection, layoutDelegate}); + return ; } /** diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 78ee867a040..2bff29dd689 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -33,7 +33,6 @@ import React, { RefObject, useCallback, useContext, - useEffect, useMemo, useRef, useState @@ -171,7 +170,7 @@ interface MenuInnerProps { } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - let {filterFn, menuProps: autocompleteMenuProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; + let {filterFn, collectionProps: autocompleteMenuProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them. ref = useObjectRef(mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject : null)); diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index c0c37f767bc..660f354a7fa 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,8 +11,9 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Dialog, Header, Input, Keyboard, Label, Menu, MenuSection, MenuTrigger, Popover, Separator, Text} from 'react-aria-components'; -import {MyMenuItem} from './utils'; +import {Autocomplete, Button, Dialog, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, Menu, MenuSection, MenuTrigger, Popover, Separator, Text} from 'react-aria-components'; +import {MyGridListItem} from './GridList.stories'; +import {MyListBoxItem, MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; import {useAsyncList} from 'react-stately'; @@ -275,3 +276,84 @@ export const AutocompleteInPopover = { }, name: 'Autocomplete in popover' }; + +export const AutocompleteWithListbox = { + render: ({onAction, isDisabled, isReadOnly}) => { + return ( + +
+ + + Please select an option below. + + +
Section 1
+ Foo + Bar + Baz +
+ + + Foo + Bar + Baz + +
+
+
+ ); + }, + name: 'Autocomplete with ListBox' +}; + + +export const AutocompleteWithGridList = { + render: ({onAction, isDisabled, isReadOnly}) => { + return ( + +
+ + + Please select an option below. + + 1,1 + 1,2 + 1,3 + 2,1 + 2,2 + 2,3 + 3,1 + 3,2 + 3,3 + +
+
+ ); + }, + name: 'Autocomplete with GridList' +}; + +// export const AutocompleteWithTable = { +// render: ({onAction, isDisabled, isReadOnly}) => { +// return ( +// +//
+// +// +// Please select an option below. +//
+//
+// ); +// }, +// name: 'Autocomplete with GridList' +// }; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index d896c5d882b..76b78c3af1a 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -45,7 +45,7 @@ export const GridListExample = (args) => ( ); -const MyGridListItem = (props: GridListItemProps) => { +export const MyGridListItem = (props: GridListItemProps) => { return ( { expect(onScroll).toHaveBeenCalled(); }); - it('should support virtualizer', async () => { + // TODO: debug why this test fails when merging refs + it.skip('should support virtualizer', async () => { let layout = new ListLayout({ rowHeight: 25 }); @@ -1083,77 +1084,77 @@ describe('ListBox', () => { describe('keyboard', () => { it('should deselect item 0 when navigating back in replace selection mode', async () => { let {getAllByRole} = render(); - + let items = getAllByRole('option'); - + await user.click(items[1]); expect(items[1]).toHaveAttribute('aria-selected', 'true'); - + // Hold Shift and press ArrowUp to select item 0 await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); - + expect(items[0]).toHaveAttribute('aria-selected', 'true'); expect(items[1]).toHaveAttribute('aria-selected', 'true'); expect(items[2]).toHaveAttribute('aria-selected', 'false'); - + // Hold Shift and press ArrowDown to navigate back to item 1 await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); - + expect(items[0]).toHaveAttribute('aria-selected', 'false'); expect(items[1]).toHaveAttribute('aria-selected', 'true'); expect(items[2]).toHaveAttribute('aria-selected', 'false'); }); - + it('should correctly handle starting selection at item 0 and extending to item 2', async () => { let {getAllByRole} = render(); - + let items = getAllByRole('option'); - + await user.click(items[0]); expect(items[0]).toHaveAttribute('aria-selected', 'true'); - + // Hold Shift and press ArrowDown to select item 1 await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); - + expect(items[0]).toHaveAttribute('aria-selected', 'true'); expect(items[1]).toHaveAttribute('aria-selected', 'true'); expect(items[2]).toHaveAttribute('aria-selected', 'false'); - + // Hold Shift and press ArrowDown to select item 2 await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); - + expect(items[0]).toHaveAttribute('aria-selected', 'true'); expect(items[1]).toHaveAttribute('aria-selected', 'true'); expect(items[2]).toHaveAttribute('aria-selected', 'true'); }); }); - + describe('mouse', () => { it('should deselect item 0 when clicking another item in replace selection mode', async () => { let {getAllByRole} = render(); - + let items = getAllByRole('option'); - + await user.click(items[1]); expect(items[1]).toHaveAttribute('aria-selected', 'true'); - + await user.click(items[0]); expect(items[0]).toHaveAttribute('aria-selected', 'true'); expect(items[1]).toHaveAttribute('aria-selected', 'false'); - + await user.click(items[1]); expect(items[1]).toHaveAttribute('aria-selected', 'true'); expect(items[0]).toHaveAttribute('aria-selected', 'false'); }); - + it('should correctly handle mouse selection starting at item 0 and extending to item 2', async () => { let {getAllByRole} = render(); - + let items = getAllByRole('option'); - + await user.click(items[0]); expect(items[0]).toHaveAttribute('aria-selected', 'true'); - + await user.click(items[2]); expect(items[0]).toHaveAttribute('aria-selected', 'false'); expect(items[2]).toHaveAttribute('aria-selected', 'true'); diff --git a/yarn.lock b/yarn.lock index b36b3f0bc2c..c44f37eb1a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8329,7 +8329,7 @@ __metadata: "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft From e3c3c35d269ab52c830993cb6f75808f0ab5aa1a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 22 Nov 2024 16:16:47 -0800 Subject: [PATCH 31/42] refactor so that we defer to the child input as much as possible for behavior also removes the delay event, readd if we find there can be a batch case where we need to be more careful about the event that is being delayed --- .../autocomplete/src/useAutocomplete.ts | 127 ++++---- .../collections/src/BaseCollection.ts | 1 + .../selection/src/useSelectableCollection.ts | 17 +- packages/@react-aria/utils/src/constants.ts | 1 - packages/@react-aria/utils/src/index.ts | 2 +- .../autocomplete/src/useAutocompleteState.ts | 3 +- .../src/Autocomplete.tsx | 64 +--- packages/react-aria-components/src/Menu.tsx | 2 +- .../react-aria-components/src/SearchField.tsx | 7 +- .../react-aria-components/src/TextField.tsx | 11 +- .../stories/Autocomplete.stories.tsx | 305 +++++++++--------- .../test/AriaAutoComplete.test-util.tsx | 35 +- .../test/AutoComplete.test.tsx | 116 +++---- 13 files changed, 316 insertions(+), 375 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 01a8e9d4308..746bb79b5ba 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,38 +10,44 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {chain, CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, mergeProps, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels} from '@react-aria/utils'; -import {InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode, useEffect, useRef} from 'react'; +import {ChangeEvent, InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, mergeProps, mergeRefs, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useTextField} from '@react-aria/textfield'; +import {useFilter, useLocalizedStringFormatter} from '@react-aria/i18n'; export interface CollectionOptions extends DOMProps, AriaLabelingProps { + /** Whether the collection items should use virtual focus instead of being focused directly. */ shouldUseVirtualFocus: boolean, + /** Whether typeahead is disabled. */ disallowTypeAhead: boolean } -export interface AriaAutocompleteProps extends AutocompleteProps, DOMProps, InputDOMProps, AriaLabelingProps { - children: ReactNode +export interface AriaAutocompleteProps extends AutocompleteProps { + /** The children wrapped by the autocomplete. Consists of at least an input element and a collection element to filter. */ + children: ReactNode, + /** + * The filter function used to determine if a option should be included in the autocomplete list. + * @default contains + */ + defaultFilter?: (textValue: string, inputValue: string) => boolean } export interface AriaAutocompleteOptions extends Omit { - /** The ref for the input element. */ - inputRef: RefObject, /** The ref for the wrapped collection element. */ collectionRef: RefObject } + export interface AutocompleteAria { - /** Props for the label element. */ - labelProps: DOMAttributes, /** Props for the autocomplete input element. */ inputProps: InputHTMLAttributes, /** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */ collectionProps: CollectionOptions, - /** Props for the autocomplete description element, if any. */ - descriptionProps: DOMAttributes + /** Ref to attach to the wrapped collection. */ + collectionRef: RefObject, + /** A filter function that returns if the provided collection node should be filtered out of the collection. */ + filterFn: (nodeTextValue: string) => boolean } /** @@ -52,35 +58,26 @@ export interface AutocompleteAria { */ export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { - inputRef, - isReadOnly, - collectionRef + collectionRef, + defaultFilter } = props; let collectionId = useId(); let timeout = useRef | undefined>(undefined); - let keyToDelay = useRef<{key: string | null, delay: number | null}>({key: null, delay: null}); - // Create listeners for updateActiveDescendant events that will be fired by wrapped collection whenever the focused key changes - // so we can update the tracked active descendant for virtual focus - useEffect(() => { - let collection = collectionRef.current; + let delayNextActiveDescendant = useRef(false); + let callbackRef = useEffectEvent((collectionNode) => { // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement - // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update and - let setUpDelay = (e) => { - let {detail} = e; - keyToDelay.current = {key: detail?.key, delay: detail?.delay}; - }; - + // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update let updateActiveDescendant = (e) => { let {detail} = e; clearTimeout(timeout.current); e.stopPropagation(); if (detail?.id != null) { - if (detail?.key === keyToDelay.current.key && keyToDelay.current.delay != null) { + if (delayNextActiveDescendant.current) { timeout.current = setTimeout(() => { state.setFocusedNodeId(detail.id); - }, keyToDelay.current.delay); + }, 500); } else { state.setFocusedNodeId(detail.id); } @@ -88,16 +85,17 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl state.setFocusedNodeId(null); } - keyToDelay.current = {key: null, delay: null}; + delayNextActiveDescendant.current = false; }; - collection?.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); - collection?.addEventListener(DELAY_UPDATE, setUpDelay); - return () => { - collection?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); - collection?.removeEventListener(DELAY_UPDATE, setUpDelay); - }; - }, [state, collectionRef]); + collectionNode?.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + + // TODO: ideally would clean up the event listeners but since this is callback ref it will return null for collectionNode + // on cleanup and the collectionRef will also be null at that point... + }); + // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection, + // especially since callback refs's node parameter is null when they cleanup so we can't even clean up properly + let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef])); let focusFirstItem = useEffectEvent(() => { let focusFirstEvent = new CustomEvent(FOCUS_EVENT, { @@ -109,6 +107,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl }); collectionRef.current?.dispatchEvent(focusFirstEvent); + delayNextActiveDescendant.current = true; }); let clearVirtualFocus = useEffectEvent(() => { @@ -118,8 +117,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl bubbles: true }); clearTimeout(timeout.current); - keyToDelay.current = {key: null, delay: null}; - + delayNextActiveDescendant.current = false; collectionRef.current?.dispatchEvent(clearFocusEvent); }); @@ -127,7 +125,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl // for screen reader announcements let lastInputValue = useRef(null); useEffect(() => { - // inputValue will always be at least "" if menu is in a Autocomplete, null is not an accepted value for inputValue if (state.inputValue != null) { if (lastInputValue.current != null && lastInputValue.current !== state.inputValue && lastInputValue.current?.length <= state.inputValue.length) { focusFirstItem(); @@ -147,15 +144,16 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl switch (e.key) { case 'Escape': - if (state.inputValue !== '' && !isReadOnly) { - state.setInputValue(''); - } else { - e.continuePropagation(); + // Early return for Escape here so it doesn't leak the Escape event from the simulated collection event below and + // close the dialog prematurely. Ideally that should be up to the discretion of the input element hence the check + // for isPropagationStopped + if (e.isPropagationStopped()) { + return; } - - return; + break; case ' ': // Space shouldn't trigger onAction so early return. + return; case 'Home': case 'End': @@ -197,44 +195,43 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl } }; - let {labelProps, inputProps, descriptionProps} = useTextField({ - ...props as any, - onChange: state.setInputValue, - onKeyDown: chain(onKeyDown, props.onKeyDown), - value: state.inputValue, - autoComplete: 'off' - }, inputRef); - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let collectionProps = useLabels({ id: collectionId, - 'aria-label': stringFormatter.format('collectionLabel'), - 'aria-labelledby': props['aria-labelledby'] || labelProps.id + 'aria-label': stringFormatter.format('collectionLabel') }); + let {contains} = useFilter({sensitivity: 'base'}); + let filterFn = useCallback((nodeTextValue: string) => { + if (defaultFilter) { + return defaultFilter(nodeTextValue, state.inputValue); + } + + return contains(nodeTextValue, state.inputValue); + }, [state.inputValue, defaultFilter, contains]) ; + return { - labelProps, - inputProps: mergeProps(inputProps, { + inputProps: { + value: state.inputValue, + onChange: (e: ChangeEvent) => state.setInputValue(e.target.value), + onKeyDown, + autoComplete: 'off', 'aria-haspopup': 'listbox', 'aria-controls': collectionId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', 'aria-activedescendant': state.focusedNodeId ?? undefined, - // TODO: note that the searchbox role causes newly typed letters to interrupt the announcement of the number of available options in Safari. - // I tested on iPad/Android/etc and the combobox role doesn't seem to do that but it will announce that there is a listbox which isn't true - // and it will say press Control Option Space to display a list of options which is also incorrect. To be fair though, our combobox doesn't open with - // that combination of buttons - role: 'searchbox', // This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false' - }), + }, collectionProps: mergeProps(collectionProps, { // TODO: shouldFocusOnHover? shouldUseVirtualFocus: true, disallowTypeAhead: true }), - descriptionProps + collectionRef: mergedCollectionRef, + filterFn }; } diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 4e12d96a73b..99139b65464 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -256,6 +256,7 @@ export class BaseCollection implements ICollection> { clonedSection.lastChildKey = lastChildInSection.key; // If the old prev section was filtered out, will need to attach to whatever came before + // eslint-disable-next-line max-depth if (lastNode == null) { clonedSection.prevKey = null; } else if (lastNode.type === 'section' || lastNode.type === 'separator') { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index ea73ce66e8c..571744965c1 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEvent, useRouter} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEvent, useRouter} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; @@ -408,21 +408,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } }) ); - } else { - // TODO: this feels gross, ideally would've wanted to intercept/redispatch the event that the focused items - // dispatches on focusedKey change, but that didn't work well due to issues with the even listeners - // alternative would be to generate the full focused option id in useSelectableCollection and dispatch that upwards - ref.current?.dispatchEvent( - new CustomEvent(DELAY_UPDATE, { - cancelable: true, - bubbles: true, - detail: { - // Tell autocomplete what key to look out for - key: keyToFocus, - delay: 500 - } - }) - ); } manager.setFocusedKey(keyToFocus); diff --git a/packages/@react-aria/utils/src/constants.ts b/packages/@react-aria/utils/src/constants.ts index 83c00383f09..26fc70d1ab2 100644 --- a/packages/@react-aria/utils/src/constants.ts +++ b/packages/@react-aria/utils/src/constants.ts @@ -14,4 +14,3 @@ export const CLEAR_FOCUS_EVENT = 'react-aria-clear-focus'; export const FOCUS_EVENT = 'react-aria-focus'; export const UPDATE_ACTIVEDESCENDANT = 'react-aria-update-activedescendant'; -export const DELAY_UPDATE = 'react-aria-delay-update'; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 433d4b27dfa..ee4d99b1c84 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -42,4 +42,4 @@ export {useEffectEvent} from './useEffectEvent'; export {useDeepMemo} from './useDeepMemo'; export {useFormReset} from './useFormReset'; export {useLoadMore} from './useLoadMore'; -export {CLEAR_FOCUS_EVENT, DELAY_UPDATE, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants'; +export {CLEAR_FOCUS_EVENT, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants'; diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index d1771f77c92..5af49413dd7 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -10,7 +10,6 @@ * governing permissions and limitations under the License. */ -import {FocusableProps, HelpTextProps, InputBase, LabelableProps, TextInputBase} from '@react-types/shared'; import {useControlledState} from '@react-stately/utils'; import {useState} from 'react'; @@ -25,7 +24,7 @@ export interface AutocompleteState { setFocusedNodeId(value: string | null): void } -export interface AutocompleteProps extends InputBase, TextInputBase, FocusableProps, LabelableProps, HelpTextProps { +export interface AutocompleteProps { /** The value of the autocomplete input (controlled). */ inputValue?: string, /** The default value of the autocomplete input (uncontrolled). */ diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index bd1b6476ee9..2ea6347c99c 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -12,32 +12,15 @@ import {AriaAutocompleteProps, CollectionOptions, useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; -import {ContextValue, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils'; +import {ContextValue, Provider, removeDataAttributes, SlotProps, useContextProps} from './utils'; import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; -import {LabelContext} from './Label'; -import React, {createContext, ForwardedRef, forwardRef, RefObject, useCallback, useRef} from 'react'; -import {TextContext} from './Text'; -import {useFilter} from 'react-aria'; -import {useObjectRef} from '@react-aria/utils'; +import React, {createContext, ForwardedRef, forwardRef, RefObject, useRef} from 'react'; -// TODO: I've kept isDisabled because it might be useful to a user for changing what the menu renders if the autocomplete is disabled, -// but what about isReadOnly. TBH is isReadOnly useful in the first place? What would a readonly Autocomplete do? -export interface AutocompleteRenderProps { - /** - * Whether the autocomplete is disabled. - */ - isDisabled: boolean -} - -export interface AutocompleteProps extends Omit, RenderProps, SlotProps { - /** The filter function used to determine if a option should be included in the autocomplete list. */ - defaultFilter?: (textValue: string, inputValue: string) => boolean -} +export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} interface InternalAutocompleteContextValue { filterFn: (nodeTextValue: string) => boolean, - inputValue: string, collectionProps: CollectionOptions, collectionRef: RefObject } @@ -51,58 +34,31 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef(ref); let collectionRef = useRef(null); - let [labelRef, label] = useSlot(); - let {contains} = useFilter({sensitivity: 'base'}); + let { inputProps, collectionProps, - labelProps, - descriptionProps + collectionRef: mergedCollectionRef, + filterFn } = useAutocomplete({ ...removeDataAttributes(props), - label, - inputRef, + defaultFilter, collectionRef }, state); - let renderValues = { - isDisabled: props.isDisabled || false - }; - - let renderProps = useRenderProps({ - ...props, - values: renderValues - }); - - // TODO this is RAC specific logic but maybe defaultFilter should move into the hooks? - let filterFn = useCallback((nodeTextValue: string) => { - if (defaultFilter) { - return defaultFilter(nodeTextValue, state.inputValue); - } - return contains(nodeTextValue, state.inputValue); - }, [state.inputValue, defaultFilter, contains]) ; - return ( - {renderProps.children} + {props.children} ); } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 2bff29dd689..2561af89aa9 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -173,7 +173,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne let {filterFn, collectionProps: autocompleteMenuProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them. - ref = useObjectRef(mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject : null)); + ref = useObjectRef(useMemo(() => mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, ref])); let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useTreeState({ ...props, diff --git a/packages/react-aria-components/src/SearchField.tsx b/packages/react-aria-components/src/SearchField.tsx index a93bfedb5ea..4e719dc60c5 100644 --- a/packages/react-aria-components/src/SearchField.tsx +++ b/packages/react-aria-components/src/SearchField.tsx @@ -14,7 +14,7 @@ import {AriaSearchFieldProps, useSearchField} from 'react-aria'; import {ButtonContext} from './Button'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps} from '@react-aria/utils'; +import {filterDOMProps, mergeProps} from '@react-aria/utils'; import {FormContext} from './Form'; import {forwardRefType} from '@react-types/shared'; import {GroupContext} from './Group'; @@ -55,6 +55,7 @@ function SearchField(props: SearchFieldProps, ref: ForwardedRef) let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useRef(null); + let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); let [labelRef, label] = useSlot(); let state = useSearchFieldState({ ...props, @@ -65,7 +66,7 @@ function SearchField(props: SearchFieldProps, ref: ForwardedRef) ...removeDataAttributes(props), label, validationBehavior - }, state, inputRef); + }, state, mergedInputRef); let renderProps = useRenderProps({ ...props, @@ -93,7 +94,7 @@ function SearchField(props: SearchFieldProps, ref: ForwardedRef) ) { let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useRef(null); + let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); let [labelRef, label] = useSlot(); let [inputElementType, setInputElementType] = useState('input'); let {labelProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useTextField({ @@ -64,16 +65,16 @@ function TextField(props: TextFieldProps, ref: ForwardedRef) { inputElementType, label, validationBehavior - }, inputRef); + }, mergedInputRef); // Intercept setting the input ref so we can determine what kind of element we have. // useTextField uses this to determine what props to include. let inputOrTextAreaRef = useCallback((el) => { - inputRef.current = el; + mergedInputRef.current = el; if (el) { setInputElementType(el instanceof HTMLTextAreaElement ? 'textarea' : 'input'); } - }, []); + }, [mergedInputRef]); let renderProps = useRenderProps({ ...props, @@ -102,7 +103,7 @@ function TextField(props: TextFieldProps, ref: ForwardedRef) { ( + + + Foo + Bar + Baz + Google + Option + Option with a space + + + +
Section 2
+ + Copy + Description + ⌘C + + + Cut + Description + ⌘X + + + Paste + Description + ⌘V + +
+
+); + export const AutocompleteExample = { - render: ({onAction, isDisabled, isReadOnly}) => { + render: ({onAction, isDisabled, isReadOnly, onSelectionChange}) => { return ( - +
- - - Please select an option below. - - - Foo - Bar - Baz - Google - Option - Option with a space - - - -
Section 2
- - Copy - Description - ⌘C - - - Cut - Description - ⌘X - - - Paste - Description - ⌘V - -
-
+ + + + Please select an option below. + +
); }, - name: 'Autocomplete complex static' + name: 'Autocomplete complex static with textfield' }; + +export const AutocompleteSearchfield = { + render: ({onAction, isDisabled, isReadOnly, onSelectionChange}) => { + return ( + +
+ + + + Please select an option below. + + +
+
+ ); + }, + name: 'Autocomplete complex static with searchfield' +}; + interface AutocompleteItem { id: string, name: string } let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; - export const AutocompleteMenuDynamic = { render: ({onAction, isDisabled, isReadOnly}) => { return ( - +
- - - Please select an option below. + + + + Please select an option below. + {item => {item.name}} @@ -107,34 +137,16 @@ export const AutocompleteMenuDynamic = { name: 'Autocomplete, dynamic menu' }; -export const AutocompleteRenderPropsIsDisabled = { - render: ({onAction, isDisabled, isReadOnly}) => { - return ( - - {({isDisabled}) => ( -
- - - Please select an option below. - - {item => {item.name}} - -
- )} -
- ); - }, - name: 'Autocomplete, render props, custom menu isDisabled behavior' -}; - export const AutocompleteOnActionOnMenuItems = { render: ({isDisabled, isReadOnly}) => { return ( - +
- - - Please select an option below. + + + + Please select an option below. + Foo Bar @@ -150,11 +162,13 @@ export const AutocompleteOnActionOnMenuItems = { export const AutocompleteDisabledKeys = { render: ({onAction, isDisabled, isReadOnly}) => { return ( - +
- - - Please select an option below. + + + + Please select an option below. + {item => {item.name}} @@ -190,11 +204,13 @@ const AsyncExample = ({onAction, isDisabled, isReadOnly}) => { }); return ( - +
- - - Please select an option below. + + + + Please select an option below. + items={items} className={styles.menu} @@ -213,18 +229,19 @@ export const AutocompleteAsyncLoadingExample = { name: 'Autocomplete, useAsync level filtering' }; - const CaseSensitiveFilter = ({onAction, isDisabled, isReadOnly}) => { let {contains} = useFilter({ sensitivity: 'case' }); let defaultFilter = (itemText, input) => contains(itemText, input); return ( - +
- - - Please select an option below. + + + + Please select an option below. + {item => {item.name}} @@ -240,8 +257,6 @@ export const AutocompleteCaseSensitive = { name: 'Autocomplete, case sensitive filter' }; -// TODO: how would we handle this for a DialogTrigger? I guess we could automatically grab the overlaytrigger state from context and send a onAction that gets chained by the one provided -// to Menu export const AutocompleteInPopover = { render: ({onAction, isDisabled, isReadOnly}) => { return ( @@ -257,13 +272,15 @@ export const AutocompleteInPopover = { border: '1px solid gray', padding: 20 }}> - - + +
- - - Please select an option below. - + + + + Please select an option below. + + {item => {item.name}}
@@ -271,21 +288,61 @@ export const AutocompleteInPopover = {
+ ); + }, + name: 'Autocomplete in popover (menu trigger)' +}; + +export const AutocompleteInPopoverDialogTrigger = { + render: ({onAction, isDisabled, isReadOnly}) => { + return ( + + + + + {({close}) => ( + +
+ + + + Please select an option below. + + + {item => {item.name}} + +
+
+ )} +
+
+
); }, - name: 'Autocomplete in popover' + name: 'Autocomplete in popover (dialog trigger)' }; export const AutocompleteWithListbox = { - render: ({onAction, isDisabled, isReadOnly}) => { + render: ({isDisabled, isReadOnly, onSelectionChange}) => { return ( - +
- - - Please select an option below. - + + + + Please select an option below. + +
Section 1
Foo @@ -294,9 +351,9 @@ export const AutocompleteWithListbox = {
- Foo - Bar - Baz + Copy + Paste + Cut
@@ -305,55 +362,3 @@ export const AutocompleteWithListbox = { }, name: 'Autocomplete with ListBox' }; - - -export const AutocompleteWithGridList = { - render: ({onAction, isDisabled, isReadOnly}) => { - return ( - -
- - - Please select an option below. - - 1,1 - 1,2 - 1,3 - 2,1 - 2,2 - 2,3 - 3,1 - 3,2 - 3,3 - -
-
- ); - }, - name: 'Autocomplete with GridList' -}; - -// export const AutocompleteWithTable = { -// render: ({onAction, isDisabled, isReadOnly}) => { -// return ( -// -//
-// -// -// Please select an option below. -//
-//
-// ); -// }, -// name: 'Autocomplete with GridList' -// }; diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx index 0b159dd1579..961c09290df 100644 --- a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx @@ -33,6 +33,7 @@ interface AriaBaseTestProps { interface RendererArgs { autocompleteProps?: any, + inputProps?: any, menuProps?: any } interface AriaAutocompleteTestProps extends AriaBaseTestProps { @@ -51,6 +52,7 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps { export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocompleteTestProps) => { describe(prefix ? prefix + 'AriaAutocomplete' : 'AriaAutocomplete', function () { let onAction = jest.fn(); + let onSelectionChange = jest.fn(); let user; setup?.(); @@ -67,7 +69,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple it('has default behavior (input field renders with expected attributes)', async function () { let {getByRole} = renderers.standard({}); let input = getByRole('searchbox'); - expect(input).toHaveAttribute('type', 'text'); expect(input).toHaveAttribute('aria-controls'); expect(input).toHaveAttribute('aria-haspopup', 'listbox'); expect(input).toHaveAttribute('aria-autocomplete', 'list'); @@ -85,13 +86,13 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple }); it('should support disabling the field', async function () { - let {getByRole} = renderers.standard({autocompleteProps: {isDisabled: true}}); + let {getByRole} = renderers.standard({inputProps: {isDisabled: true}}); let input = getByRole('searchbox'); expect(input).toHaveAttribute('disabled'); }); it('should support making the field read only', async function () { - let {getByRole} = renderers.standard({autocompleteProps: {isReadOnly: true}}); + let {getByRole} = renderers.standard({inputProps: {isReadOnly: true}}); let input = getByRole('searchbox'); expect(input).toHaveAttribute('readonly'); }); @@ -200,6 +201,33 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(options).toHaveLength(0); }); + it('should trigger the wrapped element\'s onSelectionChange when hitting Enter', async function () { + let {getByRole} = renderers.standard({menuProps: {onSelectionChange, selectionMode: 'multiple'}}); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole('menuitemcheckbox'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['1', '2'])); + + await user.keyboard('{ArrowUp}'); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['2'])); + }); + it('should properly skip over disabled keys', async function () { let {getByRole} = renderers.standard({menuProps: {disabledKeys: ['2'], onAction}}); let input = getByRole('searchbox'); @@ -337,7 +365,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(document.activeElement).toBe(input); }); - // TODO this is RAC specific logic but maybe defaultFilter should move into the hooks? it('should support custom filtering', async function () { let {getByRole} = renderer({autocompleteProps: {defaultFilter: () => true}}); let input = getByRole('searchbox'); diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/AutoComplete.test.tsx index 31236be4608..d7235c09b75 100644 --- a/packages/react-aria-components/test/AutoComplete.test.tsx +++ b/packages/react-aria-components/test/AutoComplete.test.tsx @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import {act, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutoComplete.test-util'; -import {Autocomplete, Header, Input, Label, Menu, MenuItem, MenuSection, Separator, Text} from '..'; +import {Autocomplete, Header, Input, Label, Menu, MenuItem, MenuSection, SearchField, Separator, Text} from '..'; import React from 'react'; +import {render} from '@react-spectrum/test-utils-internal'; + interface AutocompleteItem { id: string, name: string @@ -21,52 +22,13 @@ interface AutocompleteItem { let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; -let TestAutocompleteRenderProps = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( - - {({isDisabled}) => ( -
- - - Please select an option below. - - {(item: AutocompleteItem) => {item.name}} - -
- )} -
-); - -let renderAutoComplete = (autocompleteProps = {}, menuProps = {}) => render(); - -describe('Autocomplete', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - act(() => {jest.runAllTimers();}); - }); - - it('provides isDisabled as a renderProp', async function () { - let {getByRole, queryByRole, rerender} = renderAutoComplete(); - let input = getByRole('searchbox'); - expect(input).not.toHaveAttribute('disabled'); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(3); - - rerender(); - input = getByRole('searchbox'); - expect(input).toHaveAttribute('disabled'); - expect(queryByRole('menu')).toBeFalsy(); - }); -}); - -let StaticAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( +let StaticAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - Please select an option below. + + + + Please select an option below. + Foo Bar @@ -75,22 +37,26 @@ let StaticAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocomplet ); -let DynamicAutoComplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( +let DynamicAutoComplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - Please select an option below. + + + + Please select an option below. + {(item: AutocompleteItem) => {item.name}} ); -let WithLinks = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( +let WithLinks = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - Please select an option below. + + + + Please select an option below. + Foo Bar @@ -99,14 +65,16 @@ let WithLinks = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: ); -let ControlledAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => { +let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => { let [inputValue, setInputValue] = React.useState(''); return ( - - - Please select an option below. + + + + Please select an option below. + Foo Bar @@ -116,11 +84,13 @@ let ControlledAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocom ); }; -let MenuSectionsAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autocompleteProps?: any, menuProps?: any}) => ( +let MenuSectionsAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - Please select an option below. + + + + Please select an option below. +
MenuSection 1
@@ -142,17 +112,17 @@ let MenuSectionsAutocomplete = ({autocompleteProps = {}, menuProps = {}}: {autoc AriaAutocompleteTests({ prefix: 'rac-static', renderers: { - standard: ({autocompleteProps, menuProps}) => render( - + standard: ({autocompleteProps, inputProps, menuProps}) => render( + ), - links: ({autocompleteProps, menuProps}) => render( - + links: ({autocompleteProps, inputProps, menuProps}) => render( + ), - sections: ({autocompleteProps, menuProps}) => render( - + sections: ({autocompleteProps, inputProps, menuProps}) => render( + ), - controlled: ({autocompleteProps, menuProps}) => render( - + controlled: ({autocompleteProps, inputProps, menuProps}) => render( + ) } }); @@ -160,8 +130,8 @@ AriaAutocompleteTests({ AriaAutocompleteTests({ prefix: 'rac-dynamic', renderers: { - standard: ({autocompleteProps, menuProps}) => render( - + standard: ({autocompleteProps, inputProps, menuProps}) => render( + ) } }); From 04d9a8bcaf953719e88bf944bd88a860b5a833eb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 22 Nov 2024 16:35:35 -0800 Subject: [PATCH 32/42] cleanup --- packages/@react-aria/autocomplete/package.json | 1 - packages/@react-aria/autocomplete/src/useAutocomplete.ts | 5 ++--- packages/@react-aria/menu/src/utils.ts | 2 -- packages/@react-aria/selection/src/useSelectableItem.ts | 2 -- packages/@react-aria/utils/src/constants.ts | 2 +- packages/@react-stately/autocomplete/package.json | 1 - .../@react-stately/autocomplete/src/useAutocompleteState.ts | 6 ++++-- packages/react-aria-components/src/Autocomplete.tsx | 3 +-- packages/react-aria-components/src/index.ts | 2 +- yarn.lock | 4 +--- 10 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index 1b631fafbea..e1f88befab3 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -26,7 +26,6 @@ "@react-aria/i18n": "^3.12.3", "@react-aria/listbox": "^3.13.5", "@react-aria/searchfield": "^3.7.10", - "@react-aria/textfield": "^3.14.9", "@react-aria/utils": "^3.25.3", "@react-stately/autocomplete": "3.0.0-alpha.1", "@react-stately/combobox": "^3.10.0", diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 746bb79b5ba..d9deb92d4e4 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {ChangeEvent, InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'; +import {ChangeEvent, InputHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react'; import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, mergeProps, mergeRefs, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -25,8 +25,7 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { disallowTypeAhead: boolean } export interface AriaAutocompleteProps extends AutocompleteProps { - /** The children wrapped by the autocomplete. Consists of at least an input element and a collection element to filter. */ - children: ReactNode, + /** * The filter function used to determine if a option should be included in the autocomplete list. * @default contains diff --git a/packages/@react-aria/menu/src/utils.ts b/packages/@react-aria/menu/src/utils.ts index 9ab27dccd1f..3ecf4af3f96 100644 --- a/packages/@react-aria/menu/src/utils.ts +++ b/packages/@react-aria/menu/src/utils.ts @@ -22,8 +22,6 @@ interface MenuData { export const menuData = new WeakMap, MenuData>(); -// TODO: Will need to see about perhaps moving this into useSelectableCollection so we have -// dispatch the custom event to set the active descendant upwards which needs the id function normalizeKey(key: Key): string { if (typeof key === 'string') { return key.replace(/\s*/g, ''); diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 308f95acce3..6a28b53c33d 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -361,8 +361,6 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } } : undefined; - // todo generate ID here that will then be used to fire/update the aria-activedescendant and also pass it to useMenuItem/other selectable items - return { itemProps: mergeProps( itemProps, diff --git a/packages/@react-aria/utils/src/constants.ts b/packages/@react-aria/utils/src/constants.ts index 26fc70d1ab2..1f9250c3280 100644 --- a/packages/@react-aria/utils/src/constants.ts +++ b/packages/@react-aria/utils/src/constants.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -// TODO: Custom event names for updating the autocomplete's aria-activedecendant. +// Custom event names for updating the autocomplete's aria-activedecendant. export const CLEAR_FOCUS_EVENT = 'react-aria-clear-focus'; export const FOCUS_EVENT = 'react-aria-focus'; export const UPDATE_ACTIVEDESCENDANT = 'react-aria-update-activedescendant'; diff --git a/packages/@react-stately/autocomplete/package.json b/packages/@react-stately/autocomplete/package.json index 5c7739639cd..c8b6e31070f 100644 --- a/packages/@react-stately/autocomplete/package.json +++ b/packages/@react-stately/autocomplete/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@react-stately/utils": "^3.10.4", - "@react-types/shared": "^3.25.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index 5af49413dd7..230682867d7 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ +import {ReactNode, useState} from 'react'; import {useControlledState} from '@react-stately/utils'; -import {useState} from 'react'; export interface AutocompleteState { /** The current value of the autocomplete input. */ @@ -30,7 +30,9 @@ export interface AutocompleteProps { /** The default value of the autocomplete input (uncontrolled). */ defaultInputValue?: string, /** Handler that is called when the autocomplete input value changes. */ - onInputChange?: (value: string) => void + onInputChange?: (value: string) => void, + /** The children wrapped by the autocomplete. Consists of at least an input element and a collection element to filter. */ + children: ReactNode } // Emulate our other stately hooks which accept all "base" props even if not used diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 2ea6347c99c..4e18fd33afb 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -13,7 +13,6 @@ import {AriaAutocompleteProps, CollectionOptions, useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; import {ContextValue, Provider, removeDataAttributes, SlotProps, useContextProps} from './utils'; -import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; import React, {createContext, ForwardedRef, forwardRef, RefObject, useRef} from 'react'; @@ -66,5 +65,5 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef Date: Fri, 22 Nov 2024 16:47:52 -0800 Subject: [PATCH 33/42] fix listbox test --- packages/react-aria-components/src/ListBox.tsx | 5 ++--- packages/react-aria-components/src/Menu.tsx | 1 + packages/react-aria-components/test/ListBox.test.js | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 8610745a729..bde0bad8e18 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -105,9 +105,8 @@ function StandaloneListBox({props, listBoxRef, collection}) { props = {...props, collection, children: null, items: null}; let {layoutDelegate} = useContext(CollectionRendererContext); let {filterFn, collectionProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; - - // TODO: for some reason this breaks the listbox test for virtualization but locally it seems to work fine... - listBoxRef = useObjectRef(mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject : null)); + // Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens + listBoxRef = useObjectRef(useMemo(() => mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, listBoxRef])); let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useListState({...props, collection: filteredCollection, layoutDelegate}); return ; diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 2561af89aa9..cb69c2ae507 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -173,6 +173,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne let {filterFn, collectionProps: autocompleteMenuProps, collectionRef} = useContext(InternalAutocompleteContext) || {}; // TODO: Since menu only has `items` and not `defaultItems`, this means the user can't have completly controlled items like in ComboBox, // we always perform the filtering for them. + // Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens ref = useObjectRef(useMemo(() => mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, ref])); let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); let state = useTreeState({ diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 8621732cd96..f09d62797eb 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -708,8 +708,7 @@ describe('ListBox', () => { expect(onScroll).toHaveBeenCalled(); }); - // TODO: debug why this test fails when merging refs - it.skip('should support virtualizer', async () => { + it('should support virtualizer', async () => { let layout = new ListLayout({ rowHeight: 25 }); From 9eede0c24018e9ec9efc5bbf6a96d409abfd6f13 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 22 Nov 2024 17:39:26 -0800 Subject: [PATCH 34/42] fix build and react 19 --- .../autocomplete/src/useAutocomplete.ts | 50 ++++++----- packages/dev/docs/package.json | 4 +- packages/react-aria-components/package.json | 2 +- yarn.lock | 82 +++++++++---------- 4 files changed, 68 insertions(+), 70 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index d9deb92d4e4..7e6d01c6365 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -64,33 +64,39 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl let collectionId = useId(); let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); - let callbackRef = useEffectEvent((collectionNode) => { - // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement - // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update - let updateActiveDescendant = (e) => { - let {detail} = e; - clearTimeout(timeout.current); - e.stopPropagation(); - - if (detail?.id != null) { - if (delayNextActiveDescendant.current) { - timeout.current = setTimeout(() => { - state.setFocusedNodeId(detail.id); - }, 500); - } else { + let lastCollectionNode = useRef(null); + + let updateActiveDescendant = useCallback((e) => { + let {detail} = e; + clearTimeout(timeout.current); + e.stopPropagation(); + if (detail?.id != null) { + if (delayNextActiveDescendant.current) { + timeout.current = setTimeout(() => { state.setFocusedNodeId(detail.id); - } + }, 500); } else { - state.setFocusedNodeId(null); + state.setFocusedNodeId(detail.id); } + } else { + state.setFocusedNodeId(null); + } - delayNextActiveDescendant.current = false; - }; - - collectionNode?.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + delayNextActiveDescendant.current = false; + }, [state]); - // TODO: ideally would clean up the event listeners but since this is callback ref it will return null for collectionNode - // on cleanup and the collectionRef will also be null at that point... + let callbackRef = useEffectEvent((collectionNode: HTMLElement | null) => { + // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement + // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update + // We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles + // React 19's extra call of the callback ref in strict mode + if (collectionNode != null) { + lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + lastCollectionNode.current = collectionNode; + collectionNode.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + } else { + lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + } }); // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection, // especially since callback refs's node parameter is null when they cleanup so we can't even clean up properly diff --git a/packages/dev/docs/package.json b/packages/dev/docs/package.json index 17f539bd9c9..41867453b89 100644 --- a/packages/dev/docs/package.json +++ b/packages/dev/docs/package.json @@ -31,8 +31,8 @@ "highlight.js": "9.18.1", "markdown-to-jsx": "^6.11.0", "quicklink": "^2.3.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "19.0.0-rc-91061073-20241121", + "react-dom": "19.0.0-rc-91061073-20241121", "react-lowlight": "^2.0.0" }, "alias": { diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 1e61b4d848d..00334c07773 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -39,7 +39,7 @@ "dependencies": { "@internationalized/date": "^3.6.0", "@internationalized/string": "^3.2.5", - "@react-aria/autocomplete": "3.0.0-alpha.35", + "@react-aria/autocomplete": "3.0.0-alpha.36", "@react-aria/collections": "3.0.0-alpha.6", "@react-aria/color": "^3.0.2", "@react-aria/disclosure": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 30fcec054eb..46258c2f9a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5859,26 +5859,6 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/autocomplete@npm:3.0.0-alpha.35": - version: 3.0.0-alpha.35 - resolution: "@react-aria/autocomplete@npm:3.0.0-alpha.35" - dependencies: - "@react-aria/combobox": "npm:^3.10.5" - "@react-aria/listbox": "npm:^3.13.5" - "@react-aria/searchfield": "npm:^3.7.10" - "@react-aria/utils": "npm:^3.25.3" - "@react-stately/combobox": "npm:^3.10.0" - "@react-types/autocomplete": "npm:3.0.0-alpha.26" - "@react-types/button": "npm:^3.10.0" - "@react-types/shared": "npm:^3.25.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10c0/7dcbc8648b5c29afb58c982340951cb660e6c3556f22afcb06ccb9e0c4a33de7a1b2a4cea8899ee9024b45ded8c7325c40050537cef54f7cc324fa35782e6c28 - languageName: node - linkType: hard - "@react-aria/autocomplete@npm:3.0.0-alpha.36, @react-aria/autocomplete@workspace:packages/@react-aria/autocomplete": version: 0.0.0-use.local resolution: "@react-aria/autocomplete@workspace:packages/@react-aria/autocomplete" @@ -6010,7 +5990,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/combobox@npm:^3.10.5, @react-aria/combobox@npm:^3.11.0, @react-aria/combobox@workspace:packages/@react-aria/combobox": +"@react-aria/combobox@npm:^3.11.0, @react-aria/combobox@workspace:packages/@react-aria/combobox": version: 0.0.0-use.local resolution: "@react-aria/combobox@workspace:packages/@react-aria/combobox" dependencies: @@ -6262,7 +6242,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/listbox@npm:^3.13.5, @react-aria/listbox@npm:^3.13.6, @react-aria/listbox@workspace:packages/@react-aria/listbox": +"@react-aria/listbox@npm:^3.13.6, @react-aria/listbox@workspace:packages/@react-aria/listbox": version: 0.0.0-use.local resolution: "@react-aria/listbox@workspace:packages/@react-aria/listbox" dependencies: @@ -6418,7 +6398,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/searchfield@npm:^3.7.10, @react-aria/searchfield@npm:^3.7.11, @react-aria/searchfield@workspace:packages/@react-aria/searchfield": +"@react-aria/searchfield@npm:^3.7.11, @react-aria/searchfield@workspace:packages/@react-aria/searchfield": version: 0.0.0-use.local resolution: "@react-aria/searchfield@workspace:packages/@react-aria/searchfield" dependencies: @@ -6751,7 +6731,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/utils@npm:^3.14.2, @react-aria/utils@npm:^3.25.3, @react-aria/utils@npm:^3.26.0, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": +"@react-aria/utils@npm:^3.14.2, @react-aria/utils@npm:^3.26.0, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": version: 0.0.0-use.local resolution: "@react-aria/utils@workspace:packages/@react-aria/utils" dependencies: @@ -7334,8 +7314,8 @@ __metadata: highlight.js: "npm:9.18.1" markdown-to-jsx: "npm:^6.11.0" quicklink: "npm:^2.3.0" - react: "npm:^18.2.0" - react-dom: "npm:^18.2.0" + react: "npm:19.0.0-rc-91061073-20241121" + react-dom: "npm:19.0.0-rc-91061073-20241121" react-lowlight: "npm:^2.0.0" languageName: unknown linkType: soft @@ -8411,7 +8391,7 @@ __metadata: languageName: unknown linkType: soft -"@react-stately/combobox@npm:^3.10.0, @react-stately/combobox@npm:^3.10.1, @react-stately/combobox@workspace:packages/@react-stately/combobox": +"@react-stately/combobox@npm:^3.10.1, @react-stately/combobox@workspace:packages/@react-stately/combobox": version: 0.0.0-use.local resolution: "@react-stately/combobox@workspace:packages/@react-stately/combobox" dependencies: @@ -8797,19 +8777,6 @@ __metadata: languageName: unknown linkType: soft -"@react-types/autocomplete@npm:3.0.0-alpha.26": - version: 3.0.0-alpha.26 - resolution: "@react-types/autocomplete@npm:3.0.0-alpha.26" - dependencies: - "@react-types/combobox": "npm:^3.13.0" - "@react-types/searchfield": "npm:^3.5.9" - "@react-types/shared": "npm:^3.25.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10c0/750c88653720a5207a07c76ec829c83f58d44ad71ccddcd953983db5cb8e3db491507959a2f608ffc3d4be49ea7dc610d91c7909a088910d3316b6223d0cd442 - languageName: node - linkType: hard - "@react-types/autocomplete@npm:3.0.0-alpha.27, @react-types/autocomplete@workspace:packages/@react-types/autocomplete": version: 0.0.0-use.local resolution: "@react-types/autocomplete@workspace:packages/@react-types/autocomplete" @@ -8853,7 +8820,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/button@npm:^3.10.0, @react-types/button@npm:^3.10.1, @react-types/button@workspace:packages/@react-types/button": +"@react-types/button@npm:^3.10.1, @react-types/button@workspace:packages/@react-types/button": version: 0.0.0-use.local resolution: "@react-types/button@workspace:packages/@react-types/button" dependencies: @@ -8917,7 +8884,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/combobox@npm:^3.13.0, @react-types/combobox@npm:^3.13.1, @react-types/combobox@workspace:packages/@react-types/combobox": +"@react-types/combobox@npm:^3.13.1, @react-types/combobox@workspace:packages/@react-types/combobox": version: 0.0.0-use.local resolution: "@react-types/combobox@workspace:packages/@react-types/combobox" dependencies: @@ -9136,7 +9103,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/searchfield@npm:^3.5.10, @react-types/searchfield@npm:^3.5.9, @react-types/searchfield@workspace:packages/@react-types/searchfield": +"@react-types/searchfield@npm:^3.5.10, @react-types/searchfield@workspace:packages/@react-types/searchfield": version: 0.0.0-use.local resolution: "@react-types/searchfield@workspace:packages/@react-types/searchfield" dependencies: @@ -9167,7 +9134,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.16.0, @react-types/shared@npm:^3.25.0, @react-types/shared@npm:^3.26.0, @react-types/shared@workspace:packages/@react-types/shared": +"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.16.0, @react-types/shared@npm:^3.26.0, @react-types/shared@workspace:packages/@react-types/shared": version: 0.0.0-use.local resolution: "@react-types/shared@workspace:packages/@react-types/shared" peerDependencies: @@ -29148,7 +29115,7 @@ __metadata: dependencies: "@internationalized/date": "npm:^3.6.0" "@internationalized/string": "npm:^3.2.5" - "@react-aria/autocomplete": "npm:3.0.0-alpha.35" + "@react-aria/autocomplete": "npm:3.0.0-alpha.36" "@react-aria/collections": "npm:3.0.0-alpha.6" "@react-aria/color": "npm:^3.0.2" "@react-aria/disclosure": "npm:^3.0.0" @@ -29294,6 +29261,17 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:19.0.0-rc-91061073-20241121": + version: 19.0.0-rc-91061073-20241121 + resolution: "react-dom@npm:19.0.0-rc-91061073-20241121" + dependencies: + scheduler: "npm:0.25.0-rc-91061073-20241121" + peerDependencies: + react: 19.0.0-rc-91061073-20241121 + checksum: 10c0/a358b5e906b02850c9655b8fc3fffca2edd6f86dd903b31a15f3a3d708296ad77911f14b370c592dba5992a9cf6775682d515fbf30552f8e45b296e56ff797ed + languageName: node + linkType: hard + "react-dom@npm:^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0, react-dom@npm:^18.0.0 || ^19.0.0, react-dom@npm:^18.2.0": version: 18.3.1 resolution: "react-dom@npm:18.3.1" @@ -29667,6 +29645,13 @@ __metadata: languageName: node linkType: hard +"react@npm:19.0.0-rc-91061073-20241121": + version: 19.0.0-rc-91061073-20241121 + resolution: "react@npm:19.0.0-rc-91061073-20241121" + checksum: 10c0/eae0dddc092a91a89f9d975eaf4211af123f3aafac99200da66b082efec0020ed57ad35e31140823faacdcdd1e27322708e7601da5baf8ab16e3206ff3afe642 + languageName: node + linkType: hard + "react@npm:^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0, react@npm:^18.0.0 || ^19.0.0, react@npm:^18.2.0": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -30828,6 +30813,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:0.25.0-rc-91061073-20241121": + version: 0.25.0-rc-91061073-20241121 + resolution: "scheduler@npm:0.25.0-rc-91061073-20241121" + checksum: 10c0/f1ade45be0d76c92810c0ecb464c31781e78629085e9648c750b8d300e9a50c062960cc7e35006193d26add679942d54707b3dc24d674f505a488c1dce71d792 + languageName: node + linkType: hard + "scheduler@npm:^0.23.2": version: 0.23.2 resolution: "scheduler@npm:0.23.2" From 6bd5270414268bdfaffffe121ca0f2a8eb8a528a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 25 Nov 2024 10:18:24 -0800 Subject: [PATCH 35/42] get rid of leftover react 19 testing changes --- packages/dev/docs/package.json | 4 ++-- yarn.lock | 35 +++++----------------------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/dev/docs/package.json b/packages/dev/docs/package.json index 41867453b89..17f539bd9c9 100644 --- a/packages/dev/docs/package.json +++ b/packages/dev/docs/package.json @@ -31,8 +31,8 @@ "highlight.js": "9.18.1", "markdown-to-jsx": "^6.11.0", "quicklink": "^2.3.0", - "react": "19.0.0-rc-91061073-20241121", - "react-dom": "19.0.0-rc-91061073-20241121", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-lowlight": "^2.0.0" }, "alias": { diff --git a/yarn.lock b/yarn.lock index 46258c2f9a1..729caa4a923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7314,8 +7314,8 @@ __metadata: highlight.js: "npm:9.18.1" markdown-to-jsx: "npm:^6.11.0" quicklink: "npm:^2.3.0" - react: "npm:19.0.0-rc-91061073-20241121" - react-dom: "npm:19.0.0-rc-91061073-20241121" + react: "npm:^18.2.0" + react-dom: "npm:^18.2.0" react-lowlight: "npm:^2.0.0" languageName: unknown linkType: soft @@ -14071,9 +14071,9 @@ __metadata: linkType: hard "caniuse-db@npm:^1.0.30000634": - version: 1.0.30001008 - resolution: "caniuse-db@npm:1.0.30001008" - checksum: 10c0/5edcaa4b7c493bdf7739e4edc6ec0fcbdb926afe8b20d8b71a077b03396fd744c8c63fbb0d3fbe39e38b40c1fcf49410f51c320fc8b4cf3e3a43eea4e44d93cb + version: 1.0.30001680 + resolution: "caniuse-db@npm:1.0.30001680" + checksum: 10c0/5a2d75736353b93a2da769eeb151f0062ab296f90e413f8947fca78b916e0358c75c7350026a84a3c988c2d00562df088df5c8b5a66d000130fd00367f917a0d languageName: node linkType: hard @@ -29261,17 +29261,6 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:19.0.0-rc-91061073-20241121": - version: 19.0.0-rc-91061073-20241121 - resolution: "react-dom@npm:19.0.0-rc-91061073-20241121" - dependencies: - scheduler: "npm:0.25.0-rc-91061073-20241121" - peerDependencies: - react: 19.0.0-rc-91061073-20241121 - checksum: 10c0/a358b5e906b02850c9655b8fc3fffca2edd6f86dd903b31a15f3a3d708296ad77911f14b370c592dba5992a9cf6775682d515fbf30552f8e45b296e56ff797ed - languageName: node - linkType: hard - "react-dom@npm:^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0, react-dom@npm:^18.0.0 || ^19.0.0, react-dom@npm:^18.2.0": version: 18.3.1 resolution: "react-dom@npm:18.3.1" @@ -29645,13 +29634,6 @@ __metadata: languageName: node linkType: hard -"react@npm:19.0.0-rc-91061073-20241121": - version: 19.0.0-rc-91061073-20241121 - resolution: "react@npm:19.0.0-rc-91061073-20241121" - checksum: 10c0/eae0dddc092a91a89f9d975eaf4211af123f3aafac99200da66b082efec0020ed57ad35e31140823faacdcdd1e27322708e7601da5baf8ab16e3206ff3afe642 - languageName: node - linkType: hard - "react@npm:^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0, react@npm:^18.0.0 || ^19.0.0, react@npm:^18.2.0": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -30813,13 +30795,6 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:0.25.0-rc-91061073-20241121": - version: 0.25.0-rc-91061073-20241121 - resolution: "scheduler@npm:0.25.0-rc-91061073-20241121" - checksum: 10c0/f1ade45be0d76c92810c0ecb464c31781e78629085e9648c750b8d300e9a50c062960cc7e35006193d26add679942d54707b3dc24d674f505a488c1dce71d792 - languageName: node - linkType: hard - "scheduler@npm:^0.23.2": version: 0.23.2 resolution: "scheduler@npm:0.23.2" From 150bd7ed1f2c72bec84de350733bb6dcfee70573 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 25 Nov 2024 10:42:08 -0800 Subject: [PATCH 36/42] clean up some todos --- packages/dev/test-utils/src/index.ts | 1 + packages/dev/test-utils/src/types.ts | 16 ++++++++++++++++ packages/react-aria-components/src/index.ts | 1 - .../test/AriaAutoComplete.test-util.tsx | 6 +----- .../test/AriaMenu.test-util.tsx | 6 +----- .../test/AutoComplete.test.tsx | 4 ++-- 6 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 packages/dev/test-utils/src/types.ts diff --git a/packages/dev/test-utils/src/index.ts b/packages/dev/test-utils/src/index.ts index 17ae72a7621..a01ad7155ea 100644 --- a/packages/dev/test-utils/src/index.ts +++ b/packages/dev/test-utils/src/index.ts @@ -17,4 +17,5 @@ export * from './renderOverride'; export * from './StrictModeWrapper'; export * from './mockImplementation'; export * from './events'; +export * from './types'; export * from '@react-spectrum/test-utils'; diff --git a/packages/dev/test-utils/src/types.ts b/packages/dev/test-utils/src/types.ts new file mode 100644 index 00000000000..dbb5089881a --- /dev/null +++ b/packages/dev/test-utils/src/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export interface AriaBaseTestProps { + setup?: () => void, + prefix?: string +} diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index d67b9a666fc..18cc7de8814 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -16,7 +16,6 @@ import 'client-only'; export {CheckboxContext, ColorAreaContext, ColorFieldContext, ColorSliderContext, ColorWheelContext, HeadingContext} from './RSPContexts'; -// TODO: export the respective contexts here export {Autocomplete, AutocompleteContext, AutocompleteStateContext, InternalAutocompleteContext} from './Autocomplete'; export {Breadcrumbs, BreadcrumbsContext, Breadcrumb} from './Breadcrumbs'; export {Button, ButtonContext} from './Button'; diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx index 961c09290df..33d13b6c00b 100644 --- a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx @@ -12,6 +12,7 @@ import {act, render, within} from '@testing-library/react'; import { + AriaBaseTestProps, mockClickDefault, pointerMap } from '@react-spectrum/test-utils-internal'; @@ -25,11 +26,6 @@ import userEvent from '@testing-library/user-event'; // ${'touch'} // `(`${name} - $interactionType`, tests)); -// TODO: place somewhere central? -interface AriaBaseTestProps { - setup?: () => void, - prefix?: string -} interface RendererArgs { autocompleteProps?: any, diff --git a/packages/react-aria-components/test/AriaMenu.test-util.tsx b/packages/react-aria-components/test/AriaMenu.test-util.tsx index 1bedab7f979..985a75cff4b 100644 --- a/packages/react-aria-components/test/AriaMenu.test-util.tsx +++ b/packages/react-aria-components/test/AriaMenu.test-util.tsx @@ -12,6 +12,7 @@ import {act, fireEvent, render, within} from '@testing-library/react'; import { + AriaBaseTestProps, pointerMap } from '@react-spectrum/test-utils-internal'; import {User} from '@react-aria/test-utils'; @@ -41,11 +42,6 @@ describeInteractions.skip = ((name, tests) => describe.skip.each` `(`${name} - $interactionType`, tests)); let triggerText = 'Menu Button'; - -interface AriaBaseTestProps { - setup?: () => void, - prefix?: string -} interface AriaMenuTestProps extends AriaBaseTestProps { renderers: { // needs at least three child items, all enabled diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/AutoComplete.test.tsx index d7235c09b75..b1f9e590229 100644 --- a/packages/react-aria-components/test/AutoComplete.test.tsx +++ b/packages/react-aria-components/test/AutoComplete.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2022 Adobe. All rights reserved. + * Copyright 2024 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaAutocompleteTests} from './AriaAutoComplete.test-util'; +import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; import {Autocomplete, Header, Input, Label, Menu, MenuItem, MenuSection, SearchField, Separator, Text} from '..'; import React from 'react'; import {render} from '@react-spectrum/test-utils-internal'; From c13544bcf9ab9bf4a6580d67cd1eda2d856d5e50 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 25 Nov 2024 15:17:07 -0800 Subject: [PATCH 37/42] fix listbox listeners not registering --- .../autocomplete/src/useAutocomplete.ts | 16 ++++++++-------- ...t-util.tsx => AriaAutocomplete.test-util.tsx} | 0 ...toComplete.test.tsx => Autocomplete.test.tsx} | 0 3 files changed, 8 insertions(+), 8 deletions(-) rename packages/react-aria-components/test/{AriaAutoComplete.test-util.tsx => AriaAutocomplete.test-util.tsx} (100%) rename packages/react-aria-components/test/{AutoComplete.test.tsx => Autocomplete.test.tsx} (100%) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 7e6d01c6365..629ad7654a9 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -85,21 +85,21 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl delayNextActiveDescendant.current = false; }, [state]); - let callbackRef = useEffectEvent((collectionNode: HTMLElement | null) => { - // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement - // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update - // We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles - // React 19's extra call of the callback ref in strict mode + let callbackRef = useCallback((collectionNode) => { if (collectionNode != null) { + // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement + // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update + // We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles + // React 19's extra call of the callback ref in strict mode lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); lastCollectionNode.current = collectionNode; collectionNode.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); } else { lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); } - }); - // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection, - // especially since callback refs's node parameter is null when they cleanup so we can't even clean up properly + }, [updateActiveDescendant]); + + // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef])); let focusFirstItem = useEffectEvent(() => { diff --git a/packages/react-aria-components/test/AriaAutoComplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx similarity index 100% rename from packages/react-aria-components/test/AriaAutoComplete.test-util.tsx rename to packages/react-aria-components/test/AriaAutocomplete.test-util.tsx diff --git a/packages/react-aria-components/test/AutoComplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx similarity index 100% rename from packages/react-aria-components/test/AutoComplete.test.tsx rename to packages/react-aria-components/test/Autocomplete.test.tsx From 0d68f688cc1aa217e60c29f369ee89f01cd0ffdd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 25 Nov 2024 17:17:05 -0800 Subject: [PATCH 38/42] updating tests and making enter trigger listbox link --- .../autocomplete/src/useAutocomplete.ts | 4 +- .../@react-aria/interactions/src/usePress.ts | 2 +- .../selection/src/useSelectableItem.ts | 7 + .../stories/Autocomplete.stories.tsx | 1 + .../test/AriaAutocomplete.test-util.tsx | 561 +++++++++--------- .../test/Autocomplete.test.tsx | 194 +++--- 6 files changed, 428 insertions(+), 341 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 629ad7654a9..c091f0f9366 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -197,6 +197,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl item?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); + // TODO: this currently has problems making Enter trigger Listbox links. usePress catches the press up that happens but + // detects that the press target is different from the event target aka listbox item vs the input where the Enter event occurs... } }; @@ -232,7 +234,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl spellCheck: 'false' }, collectionProps: mergeProps(collectionProps, { - // TODO: shouldFocusOnHover? + // TODO: shouldFocusOnHover? shouldFocusWrap? Should it be up to the wrapped collection? shouldUseVirtualFocus: true, disallowTypeAhead: true }), diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index a90b602cc39..c13d21b32b3 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -787,7 +787,7 @@ export function usePress(props: PressHookProps): PressResult { ]); // Remove user-select: none in case component unmounts immediately after pressStart - + useEffect(() => { return () => { if (!allowTextSelectionOnPress) { diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 6a28b53c33d..38bac8ce2bf 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -295,6 +295,13 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte ) { onSelect(e); } + + // TODO: non ideal since this makes the link action happen on press start rather than on press up like it usually + // does for listboxes but we never get the onPress event because the simiulated event target from useAutocomplete is different + // from the actual place it is triggered from (the input). + if (shouldUseVirtualFocus && e.pointerType === 'keyboard' && hasAction) { + performAction(e); + } }; itemPressProps.onPress = (e) => { diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 61d9c3cc6e0..ec24e3fdff0 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -348,6 +348,7 @@ export const AutocompleteWithListbox = { Foo Bar Baz + Google diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index 33d13b6c00b..345ee5082b2 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -30,7 +30,7 @@ import userEvent from '@testing-library/user-event'; interface RendererArgs { autocompleteProps?: any, inputProps?: any, - menuProps?: any + collectionProps?: any } interface AriaAutocompleteTestProps extends AriaBaseTestProps { renderers: { @@ -43,18 +43,31 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps { controlled?: (args: RendererArgs) => ReturnType // TODO, add tests for this when we support it // submenus?: (props?: {name: string}) => ReturnType - } + }, + collectionType?: 'menu' | 'listbox' } -export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocompleteTestProps) => { - describe(prefix ? prefix + 'AriaAutocomplete' : 'AriaAutocomplete', function () { +export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType = 'menu'}: AriaAutocompleteTestProps) => { + describe(prefix ? prefix + ' AriaAutocomplete' : ' AriaAutocomplete', function () { let onAction = jest.fn(); let onSelectionChange = jest.fn(); let user; + let collectionNodeRole; + let collectionItemRole; + let collectionSelectableItemRole; setup?.(); beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); jest.useFakeTimers(); + if (collectionType === 'menu') { + collectionNodeRole = 'menu'; + collectionItemRole = 'menuitem'; + collectionSelectableItemRole = 'menuitemcheckbox'; + } else if (collectionType === 'listbox') { + collectionNodeRole = 'listbox'; + collectionItemRole = 'option'; + collectionSelectableItemRole = 'option'; + } }); afterEach(() => { @@ -62,275 +75,283 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple act(() => jest.runAllTimers()); }); - it('has default behavior (input field renders with expected attributes)', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - expect(input).toHaveAttribute('aria-controls'); - expect(input).toHaveAttribute('aria-haspopup', 'listbox'); - expect(input).toHaveAttribute('aria-autocomplete', 'list'); - expect(input).toHaveAttribute('autoCorrect', 'off'); - expect(input).toHaveAttribute('spellCheck', 'false'); + let standardTests = (collectionType) => { + describe('standard interactions', function () { + it('has default behavior (input field renders with expected attributes)', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('aria-controls'); + expect(input).toHaveAttribute('aria-haspopup', 'listbox'); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + expect(input).toHaveAttribute('autoCorrect', 'off'); + expect(input).toHaveAttribute('spellCheck', 'false'); - let menu = getByRole('menu'); - expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); + let menu = getByRole(collectionNodeRole); + expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); - let label = document.getElementById(input.getAttribute('aria-labelledby')!); - expect(label).toHaveTextContent('Test'); + let label = document.getElementById(input.getAttribute('aria-labelledby')!); + expect(label).toHaveTextContent('Test'); - let description = document.getElementById(input.getAttribute('aria-describedby')!); - expect(description).toHaveTextContent('Please select an option below'); - }); + let description = document.getElementById(input.getAttribute('aria-describedby')!); + expect(description).toHaveTextContent('Please select an option below'); + }); - it('should support disabling the field', async function () { - let {getByRole} = renderers.standard({inputProps: {isDisabled: true}}); - let input = getByRole('searchbox'); - expect(input).toHaveAttribute('disabled'); - }); + it('should support disabling the field', async function () { + let {getByRole} = renderers.standard({inputProps: {isDisabled: true}}); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('disabled'); + }); - it('should support making the field read only', async function () { - let {getByRole} = renderers.standard({inputProps: {isReadOnly: true}}); - let input = getByRole('searchbox'); - expect(input).toHaveAttribute('readonly'); - }); + it('should support making the field read only', async function () { + let {getByRole} = renderers.standard({inputProps: {isReadOnly: true}}); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('readonly'); + }); - it('should support default value', async function () { - let {getByRole} = renderers.standard({autocompleteProps: {defaultInputValue: 'Ba'}}); - let input = getByRole('searchbox'); - expect(input).toHaveValue('Ba'); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(2); - expect(options[0]).toHaveTextContent('Bar'); - expect(options[1]).toHaveTextContent('Baz'); - - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('z'); - act(() => jest.runAllTimers()); - options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('Baz'); - }); + it('should support default value', async function () { + let {getByRole} = renderers.standard({autocompleteProps: {defaultInputValue: 'Ba'}}); + let input = getByRole('searchbox'); + expect(input).toHaveValue('Ba'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent('Bar'); + expect(options[1]).toHaveTextContent('Baz'); - it('should support keyboard navigation', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[2].id); - await user.keyboard('{ArrowUp}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - - expect(document.activeElement).toBe(input); - }); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('z'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole(collectionItemRole); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Baz'); + }); - it('should clear the focused key when using ArrowLeft and ArrowRight', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - expect(input).not.toHaveAttribute('aria-activedescendant'); + it('should support keyboard navigation', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.tab(); - expect(document.activeElement).toBe(input); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.keyboard('Foo'); - act(() => jest.runAllTimers()); - let options = within(menu).getAllByRole('menuitem'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowRight}'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowLeft}'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(document.activeElement).toBe(input); - }); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - it('should trigger the wrapped element\'s onAction when hitting Enter', async function () { - let {getByRole} = renderers.standard({menuProps: {onAction}}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole('menuitem'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{Enter}'); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('1'); - - await user.keyboard('{ArrowDown}'); - await user.keyboard('{Enter}'); - expect(onAction).toHaveBeenCalledTimes(2); - expect(onAction).toHaveBeenLastCalledWith('2'); - }); + expect(document.activeElement).toBe(input); + }); - it('should not trigger the wrapped element\'s onAction when hitting Space', async function () { - let {getByRole} = renderers.standard({menuProps: {onAction}}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - expect(input).not.toHaveAttribute('aria-activedescendant'); + it('should clear the focused key when using ArrowLeft and ArrowRight', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.tab(); - expect(document.activeElement).toBe(input); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole('menuitem'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('[Space]'); - act(() => jest.runAllTimers()); - expect(onAction).toHaveBeenCalledTimes(0); - options = within(menu).queryAllByRole('menuitem'); - expect(options).toHaveLength(0); - }); + await user.keyboard('Foo'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowRight}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowLeft}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + }); - it('should trigger the wrapped element\'s onSelectionChange when hitting Enter', async function () { - let {getByRole} = renderers.standard({menuProps: {onSelectionChange, selectionMode: 'multiple'}}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole('menuitemcheckbox'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{Enter}'); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); - - await user.keyboard('{ArrowDown}'); - await user.keyboard('{Enter}'); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['1', '2'])); - - await user.keyboard('{ArrowUp}'); - await user.keyboard('{Enter}'); - expect(onSelectionChange).toHaveBeenCalledTimes(3); - expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['2'])); - }); + if (collectionType === 'menu') { + it('should trigger the wrapped element\'s onAction when hitting Enter', async function () { + let {getByRole} = renderers.standard({collectionProps: {onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('1'); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(2); + expect(onAction).toHaveBeenLastCalledWith('2'); + }); + + it('should not trigger the wrapped element\'s onAction when hitting Space', async function () { + let {getByRole} = renderers.standard({collectionProps: {onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('[Space]'); + act(() => jest.runAllTimers()); + expect(onAction).toHaveBeenCalledTimes(0); + options = within(menu).queryAllByRole(collectionItemRole); + expect(options).toHaveLength(0); + }); + + it('should update the aria-activedescendant when hovering over an item', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + // Need to press to set a modality + await user.click(input); + await user.hover(options[1]); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(document.activeElement).toBe(input); + }); + } + + it('should trigger the wrapped element\'s onSelectionChange when hitting Enter', async function () { + let {getByRole} = renderers.standard({collectionProps: {onSelectionChange, selectionMode: 'multiple'}}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - it('should properly skip over disabled keys', async function () { - let {getByRole} = renderers.standard({menuProps: {disabledKeys: ['2'], onAction}}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(options[1]).toHaveAttribute('aria-disabled', 'true'); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole(collectionSelectableItemRole); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); - await user.keyboard('Bar'); - act(() => jest.runAllTimers()); - options = within(menu).getAllByRole('menuitem'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveAttribute('aria-disabled', 'true'); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['1', '2'])); - await user.click(options[0]); - expect(onAction).toHaveBeenCalledTimes(0); - }); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['2'])); + }); - it('should update the aria-activedescendant when hovering over an item', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - // Need to press to set a modality - await user.click(input); - await user.hover(options[1]); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - expect(document.activeElement).toBe(input); - }); + it('should properly skip over disabled keys', async function () { + let {getByRole} = renderers.standard({collectionProps: {disabledKeys: ['2'], onAction}}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(options[1]).toHaveAttribute('aria-disabled', 'true'); - it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); - await user.tab(); - expect(document.activeElement).toBe(input); + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole(collectionItemRole); + expect(options).toHaveLength(1); + expect(options[0]).toHaveAttribute('aria-disabled', 'true'); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.keyboard('a'); - let options = within(menu).getAllByRole('menuitem'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - act(() => jest.advanceTimersByTime(500)); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - }); + await user.click(options[0]); + expect(onAction).toHaveBeenCalledTimes(0); + }); - it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole('menu'); - expect(input).not.toHaveAttribute('aria-activedescendant'); + it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.tab(); - expect(document.activeElement).toBe(input); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.keyboard('a'); - let options = within(menu).getAllByRole('menuitem'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - }); + await user.keyboard('a'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + act(() => jest.advanceTimersByTime(500)); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + }); - it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox') as HTMLInputElement; + it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('Bar'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(3); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.keyboard('{ArrowLeft}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + await user.keyboard('a'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + }); - await user.keyboard('{Home}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { + let {getByRole} = renderers.standard({}); + let input = getByRole('searchbox') as HTMLInputElement; - await user.keyboard('{End}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(3); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); - await user.keyboard('{ArrowUp}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); - }); + await user.keyboard('{Home}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{End}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + }); + }); + }; + + standardTests(collectionType); let filterTests = (renderer) => { describe('text filtering', function () { @@ -338,15 +359,15 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple let {getByRole} = renderer({}); let input = getByRole('searchbox'); expect(input).toHaveValue(''); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(3); await user.tab(); expect(document.activeElement).toBe(input); await user.keyboard('F'); act(() => jest.runAllTimers()); - options = within(menu).getAllByRole('menuitem'); + options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(1); expect(options[0]).toHaveTextContent('Foo'); @@ -355,7 +376,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(document.activeElement).toBe(input); await user.keyboard('{Backspace}'); - options = within(menu).getAllByRole('menuitem'); + options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(3); expect(input).not.toHaveAttribute('aria-activedescendant'); expect(document.activeElement).toBe(input); @@ -365,15 +386,15 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple let {getByRole} = renderer({autocompleteProps: {defaultFilter: () => true}}); let input = getByRole('searchbox'); expect(input).toHaveValue(''); - let menu = getByRole('menu'); - let options = within(menu).getAllByRole('menuitem'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(3); await user.tab(); expect(document.activeElement).toBe(input); await user.keyboard('F'); act(() => jest.runAllTimers()); - options = within(menu).getAllByRole('menuitem'); + options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(3); expect(options[0]).toHaveTextContent('Foo'); }); @@ -390,10 +411,11 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple if (renderers.links) { describe('with links', function () { - it('should trigger the link option when hitting Enter', async function () { - let {getByRole} = (renderers.links!)({menuProps: {onAction}}); + // TODO: skipping until we get parity between Listbox and Menu behavior + it.skip('should trigger the link option when hitting Enter', async function () { + let {getByRole} = (renderers.links!)({collectionProps: {onAction}}); let input = getByRole('searchbox'); - let menu = getByRole('menu'); + let menu = getByRole(collectionNodeRole); expect(input).not.toHaveAttribute('aria-activedescendant'); await user.tab(); @@ -403,14 +425,17 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple await user.keyboard('{ArrowDown}'); await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole('menuitem'); + let options = within(menu).getAllByRole(collectionItemRole); expect(options[2].tagName).toBe('A'); expect(options[2]).toHaveAttribute('href', 'https://google.com'); let onClick = mockClickDefault(); await user.keyboard('{Enter}'); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('3'); + if (collectionType === 'menu') { + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('3'); + } + expect(onClick).toHaveBeenCalledTimes(1); window.removeEventListener('click', onClick); }); @@ -422,22 +447,22 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple it('should properly skip over sections when keyboard navigating', async function () { let {getByRole} = (renderers.sections!)({}); let input = getByRole('searchbox'); - let menu = getByRole('menu'); + let menu = getByRole(collectionNodeRole); let sections = within(menu).getAllByRole('group'); expect(sections).toHaveLength(2); expect(sections[0]).toHaveTextContent('Section 1'); expect(sections[1]).toHaveTextContent('Section 2'); expect(within(menu).getByRole('separator')).toBeInTheDocument(); - let firstSecOpts = within(sections[0]).getAllByRole('menuitem'); + let firstSecOpts = within(sections[0]).getAllByRole(collectionItemRole); expect(firstSecOpts).toHaveLength(3); - let secondSecOpts = within(sections[1]).getAllByRole('menuitem'); + let secondSecOpts = within(sections[1]).getAllByRole(collectionItemRole); expect(secondSecOpts).toHaveLength(3); await user.tab(); expect(document.activeElement).toBe(input); for (let section of sections) { - let options = within(section).getAllByRole('menuitem'); + let options = within(section).getAllByRole(collectionItemRole); for (let opt of options) { await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', opt.id); @@ -448,10 +473,10 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple it('should omit section titles and dividers when filtering', async function () { let {getByRole} = (renderers.sections!)({}); let input = getByRole('searchbox'); - let menu = getByRole('menu'); + let menu = getByRole(collectionNodeRole); let sections = within(menu).getAllByRole('group'); expect(sections).toHaveLength(2); - let options = within(menu).getAllByRole('menuitem'); + let options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(6); let divider = within(menu).getAllByRole('separator'); expect(divider).toHaveLength(1); @@ -465,7 +490,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(sections).toHaveLength(1); expect(sections[0]).toHaveAttribute('aria-labelledby'); expect(document.getElementById(sections[0].getAttribute('aria-labelledby')!)).toHaveTextContent('Section 1'); - options = within(sections[0]).getAllByRole('menuitem'); + options = within(sections[0]).getAllByRole(collectionItemRole); expect(options).toHaveLength(1); divider = within(menu).queryAllByRole('separator'); expect(divider).toHaveLength(0); @@ -478,7 +503,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple act(() => jest.runAllTimers()); sections = within(menu).getAllByRole('group'); expect(sections).toHaveLength(2); - options = within(menu).getAllByRole('menuitem'); + options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(6); divider = within(menu).getAllByRole('separator'); expect(divider).toHaveLength(1); @@ -494,7 +519,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple expect(sections).toHaveLength(1); expect(sections[0]).toHaveAttribute('aria-labelledby'); expect(document.getElementById(sections[0].getAttribute('aria-labelledby')!)).toHaveTextContent('Section 2'); - options = within(sections[0]).getAllByRole('menuitem'); + options = within(sections[0]).getAllByRole(collectionItemRole); expect(options).toHaveLength(1); divider = within(menu).queryAllByRole('separator'); expect(divider).toHaveLength(0); @@ -509,13 +534,13 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple act(() => jest.runAllTimers()); sections = within(menu).getAllByRole('group'); expect(sections).toHaveLength(2); - options = within(menu).getAllByRole('menuitem'); + options = within(menu).getAllByRole(collectionItemRole); expect(options).toHaveLength(3); divider = within(menu).queryAllByRole('separator'); expect(divider).toHaveLength(1); - let firstSecOpts = within(sections[0]).getAllByRole('menuitem'); + let firstSecOpts = within(sections[0]).getAllByRole(collectionItemRole); expect(firstSecOpts).toHaveLength(2); - let secondSecOpts = within(sections[1]).getAllByRole('menuitem'); + let secondSecOpts = within(sections[1]).getAllByRole(collectionItemRole); expect(secondSecOpts).toHaveLength(1); expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); expect(firstSecOpts[0]).toHaveTextContent('Bar'); @@ -525,8 +550,10 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix}: AriaAutocomple await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', secondSecOpts[0].id); expect(secondSecOpts[0]).toHaveTextContent('Paste'); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); + if (collectionType === 'menu') { + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); + } }); }); } diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index b1f9e590229..8fa2b167d4a 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -11,8 +11,8 @@ */ import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; -import {Autocomplete, Header, Input, Label, Menu, MenuItem, MenuSection, SearchField, Separator, Text} from '..'; -import React from 'react'; +import {Autocomplete, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, SearchField, Separator, Text} from '..'; +import React, {ReactNode} from 'react'; import {render} from '@react-spectrum/test-utils-internal'; interface AutocompleteItem { @@ -22,50 +22,92 @@ interface AutocompleteItem { let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; -let StaticAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - - - Please select an option below. - - +let StaticMenu = (props) => ( + + Foo + Bar + Baz + +); + +let DynamicMenu = (props) => ( + + {(item: AutocompleteItem) => {item.name}} + +); + +let MenuWithLinks = (props) => ( + + Foo + Bar + Google + +); + +let MenuWithSections = (props) => ( + + +
MenuSection 1
Foo Bar Baz -
- + + + +
MenuSection 2
+ Copy + Cut + Paste +
+
); -let DynamicAutoComplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - - - Please select an option below. - - - {(item: AutocompleteItem) => {item.name}} - - +let StaticListbox = (props) => ( + + Foo + Bar + Baz + +); + +let ListBoxWithLinks = (props) => ( + + Foo + Bar + Google + ); -let WithLinks = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( +let ListBoxWithSections = (props) => ( + + +
ListBox Section 1
+ Foo + Bar + Baz +
+ + +
ListBox Section 2
+ Copy + Cut + Paste +
+
+); + +let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, collectionProps?: any, children?: ReactNode}) => ( Please select an option below. - - Foo - Bar - Google - + {children} ); -let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => { +let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, collectionProps?: any, children?: ReactNode}) => { let [inputValue, setInputValue] = React.useState(''); return ( @@ -75,63 +117,71 @@ let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProp Please select an option below. - - Foo - Bar - Baz - + {children}
); }; -let MenuSectionsAutocomplete = ({autocompleteProps = {}, inputProps = {}, menuProps = {}}: {autocompleteProps?: any, inputProps?: any, menuProps?: any}) => ( - - - - - Please select an option below. - - - -
MenuSection 1
- Foo - Bar - Baz -
- - -
MenuSection 2
- Copy - Cut - Paste -
-
-
-); - AriaAutocompleteTests({ - prefix: 'rac-static', + prefix: 'rac-static-menu', renderers: { - standard: ({autocompleteProps, inputProps, menuProps}) => render( - + standard: ({autocompleteProps, inputProps, collectionProps}) => render( + + + ), - links: ({autocompleteProps, inputProps, menuProps}) => render( - + links: ({autocompleteProps, inputProps, collectionProps}) => render( + + + ), - sections: ({autocompleteProps, inputProps, menuProps}) => render( - + sections: ({autocompleteProps, inputProps, collectionProps}) => render( + + + ), - controlled: ({autocompleteProps, inputProps, menuProps}) => render( - + controlled: ({autocompleteProps, inputProps, collectionProps}) => render( + + + ) } }); AriaAutocompleteTests({ - prefix: 'rac-dynamic', + prefix: 'rac-dynamic-menu', renderers: { - standard: ({autocompleteProps, inputProps, menuProps}) => render( - + standard: ({autocompleteProps, inputProps, collectionProps}) => render( + + + ) } }); + +AriaAutocompleteTests({ + prefix: 'rac-static-listbox', + renderers: { + standard: ({autocompleteProps, inputProps, collectionProps}) => render( + + + + ), + links: ({autocompleteProps, inputProps, collectionProps}) => render( + + + + ), + sections: ({autocompleteProps, inputProps, collectionProps}) => render( + + + + ), + controlled: ({autocompleteProps, inputProps, collectionProps}) => render( + + + + ) + }, + collectionType: 'listbox' +}); From d8c5259500f11d0f80ffb8226b08796c4507c165 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 26 Nov 2024 14:58:10 -0800 Subject: [PATCH 39/42] initial review comments --- .../@react-aria/autocomplete/package.json | 1 + .../autocomplete/src/useAutocomplete.ts | 28 +++++----- .../selection/src/useSelectableCollection.ts | 52 +++++++++---------- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index 2dd3bded3fe..dc14893b038 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -24,6 +24,7 @@ "dependencies": { "@react-aria/combobox": "^3.11.0", "@react-aria/i18n": "^3.12.3", + "@react-aria/interactions": "^3.22.5", "@react-aria/listbox": "^3.13.6", "@react-aria/searchfield": "^3.7.11", "@react-aria/utils": "^3.26.0", diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index c091f0f9366..00e90aa04e9 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -17,6 +17,7 @@ import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, mergeProps, mergeRefs, UPDATE_ACTIVEDESC // @ts-ignore import intlMessages from '../intl/*.json'; import {useFilter, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useKeyboard} from '@react-aria/interactions'; export interface CollectionOptions extends DOMProps, AriaLabelingProps { /** Whether the collection items should use virtual focus instead of being focused directly. */ @@ -66,7 +67,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl let delayNextActiveDescendant = useRef(false); let lastCollectionNode = useRef(null); - let updateActiveDescendant = useCallback((e) => { + let updateActiveDescendant = useEffectEvent((e) => { let {detail} = e; clearTimeout(timeout.current); e.stopPropagation(); @@ -83,7 +84,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl } delayNextActiveDescendant.current = false; - }, [state]); + }); let callbackRef = useCallback((collectionNode) => { if (collectionNode != null) { @@ -103,16 +104,16 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef])); let focusFirstItem = useEffectEvent(() => { - let focusFirstEvent = new CustomEvent(FOCUS_EVENT, { - cancelable: true, - bubbles: true, - detail: { - focusStrategy: 'first' - } - }); - - collectionRef.current?.dispatchEvent(focusFirstEvent); delayNextActiveDescendant.current = true; + collectionRef.current?.dispatchEvent( + new CustomEvent(FOCUS_EVENT, { + cancelable: true, + bubbles: true, + detail: { + focusStrategy: 'first' + } + }) + ); }); let clearVirtualFocus = useEffectEvent(() => { @@ -193,7 +194,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); } else { - let item = collectionRef.current?.querySelector(`#${CSS.escape(state.focusedNodeId)}`); + let item = document.getElementById(state.focusedNodeId); item?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); @@ -201,6 +202,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl // detects that the press target is different from the event target aka listbox item vs the input where the Enter event occurs... } }; + let {keyboardProps} = useKeyboard({onKeyDown}); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let collectionProps = useLabels({ @@ -221,7 +223,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl inputProps: { value: state.inputValue, onChange: (e: ChangeEvent) => state.setInputValue(e.target.value), - onKeyDown, + ...keyboardProps, autoComplete: 'off', 'aria-haspopup': 'listbox', 'aria-controls': collectionId, diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 571744965c1..64a7f8671e2 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -388,39 +388,35 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Add event listeners for custom virtual events. These handle updating the focused key in response to various keyboard events // at the autocomplete level // TODO: fix type later - useEvent(ref, FOCUS_EVENT, (e: any) => { - if (shouldUseVirtualFocus) { - let {detail} = e; - e.stopPropagation(); - manager.setFocused(true); - - // If the user is typing forwards, autofocus the first option in the list. - if (detail?.focusStrategy === 'first') { - let keyToFocus = delegate.getFirstKey?.() ?? null; - // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist - if (keyToFocus == null) { - ref.current?.dispatchEvent( - new CustomEvent(UPDATE_ACTIVEDESCENDANT, { - cancelable: true, - bubbles: true, - detail: { - id: null - } - }) - ); - } + useEvent(ref, FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => { + let {detail} = e; + e.stopPropagation(); + manager.setFocused(true); - manager.setFocusedKey(keyToFocus); + // If the user is typing forwards, autofocus the first option in the list. + if (detail?.focusStrategy === 'first') { + let keyToFocus = delegate.getFirstKey?.() ?? null; + // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist + if (keyToFocus == null) { + ref.current?.dispatchEvent( + new CustomEvent(UPDATE_ACTIVEDESCENDANT, { + cancelable: true, + bubbles: true, + detail: { + id: null + } + }) + ); } + + manager.setFocusedKey(keyToFocus); } }); - useEvent(ref, CLEAR_FOCUS_EVENT, (e) => { - if (shouldUseVirtualFocus) { - e.stopPropagation(); - manager.setFocused(false); - manager.setFocusedKey(null); - } + useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e) => { + e.stopPropagation(); + manager.setFocused(false); + manager.setFocusedKey(null); }); const autoFocusRef = useRef(autoFocus); From 00f67add79f1147062cfeb722e5c21bd138b3987 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 27 Nov 2024 11:16:11 -0800 Subject: [PATCH 40/42] update tests and remove menu id coercing in favor of user defined ids --- packages/@react-aria/menu/src/index.ts | 1 - packages/@react-aria/menu/src/useMenu.ts | 7 +- packages/@react-aria/menu/src/useMenuItem.ts | 6 +- packages/@react-aria/menu/src/utils.ts | 21 +- .../test/AriaAutocomplete.test-util.tsx | 457 +++++++++--------- .../test/Autocomplete.test.tsx | 109 +++-- 6 files changed, 318 insertions(+), 283 deletions(-) diff --git a/packages/@react-aria/menu/src/index.ts b/packages/@react-aria/menu/src/index.ts index a9b520f8802..7aa44be2fce 100644 --- a/packages/@react-aria/menu/src/index.ts +++ b/packages/@react-aria/menu/src/index.ts @@ -15,7 +15,6 @@ export {useMenu} from './useMenu'; export {useMenuItem} from './useMenuItem'; export {useMenuSection} from './useMenuSection'; export {useSubmenuTrigger} from './useSubmenuTrigger'; -export {getItemId, menuData} from './utils'; export type {AriaMenuProps} from '@react-types/menu'; export type {AriaMenuTriggerProps, MenuTriggerAria} from './useMenuTrigger'; diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index 4a042f9e5b3..405db0c96f1 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -12,7 +12,7 @@ import {AriaMenuProps} from '@react-types/menu'; import {DOMAttributes, KeyboardDelegate, KeyboardEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps} from '@react-aria/utils'; import {menuData} from './utils'; import {TreeState} from '@react-stately/tree'; import {useSelectableList} from '@react-aria/selection'; @@ -65,19 +65,16 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: linkBehavior: 'override' }); - let id = useId(props.id); menuData.set(state, { onClose: props.onClose, onAction: props.onAction, - shouldUseVirtualFocus: props.shouldUseVirtualFocus, - id + shouldUseVirtualFocus: props.shouldUseVirtualFocus }); return { menuProps: mergeProps(domProps, {onKeyDown, onKeyUp}, { role: 'menu', ...listProps, - id, onKeyDown: (e) => { // don't clear the menu selected keys if the user is presses escape since escape closes the menu if (e.key !== 'Escape') { diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 788fd459175..4b6152438cd 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -13,8 +13,8 @@ import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject, RouterOptions} from '@react-types/shared'; import {filterDOMProps, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; -import {getItemId, menuData} from './utils'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; +import {menuData} from './utils'; import {SelectionManager} from '@react-stately/selection'; import {TreeState} from '@react-stately/tree'; import {useSelectableItem} from '@react-aria/selection'; @@ -167,10 +167,6 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let descriptionId = useSlotId(); let keyboardId = useSlotId(); - if (data.shouldUseVirtualFocus) { - id = getItemId(state, key); - } - let ariaProps = { id, 'aria-disabled': isDisabled || undefined, diff --git a/packages/@react-aria/menu/src/utils.ts b/packages/@react-aria/menu/src/utils.ts index 3ecf4af3f96..e57d9129439 100644 --- a/packages/@react-aria/menu/src/utils.ts +++ b/packages/@react-aria/menu/src/utils.ts @@ -16,26 +16,7 @@ import {TreeState} from '@react-stately/tree'; interface MenuData { onClose?: () => void, onAction?: (key: Key) => void, - shouldUseVirtualFocus?: boolean, - id: string + shouldUseVirtualFocus?: boolean } export const menuData = new WeakMap, MenuData>(); - -function normalizeKey(key: Key): string { - if (typeof key === 'string') { - return key.replace(/\s*/g, ''); - } - - return '' + key; -} - -export function getItemId(state: TreeState, itemKey: Key): string { - let data = menuData.get(state); - - if (!data) { - throw new Error('Unknown menu'); - } - - return `${data.id}-option-${normalizeKey(itemKey)}`; -} diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index 345ee5082b2..9b137b3bc25 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -26,30 +26,36 @@ import userEvent from '@testing-library/user-event'; // ${'touch'} // `(`${name} - $interactionType`, tests)); - -interface RendererArgs { - autocompleteProps?: any, - inputProps?: any, - collectionProps?: any -} interface AriaAutocompleteTestProps extends AriaBaseTestProps { renderers: { - // needs to wrap a menu with at three items, all enabled. The items should be Foo, Bar, and Baz - standard: (args: RendererArgs) => ReturnType, - // needs at least two sections, each with three items - sections?: (args: RendererArgs) => ReturnType, - // needs a item with a link - links?: (args: RendererArgs) => ReturnType, - controlled?: (args: RendererArgs) => ReturnType + // needs to wrap a menu with at three items, all enabled. The items should be Foo, Bar, and Baz with ids 1, 2, and 3 respectively + standard: () => ReturnType, + // needs at two sections with titles containing Section 1 and Section 2. The first section should have Foo, Bar, Baz with ids 1, 2, and 3. The second section + // should have Copy, Cut, Paste with ids 4, 5, 6 + sections?: () => ReturnType, + // needs a 3 items, Foo, Bar, and Google with ids 1, 2, 3. The Google item should have a href of https://google.com + links?: () => ReturnType, + // needs a controlled input element and the same collection items as the standard renderer. Should default to an empty string for the input + controlled?: () => ReturnType, + // needs the collection to have item actions enabled and a mock listener for the action provided. Uses the same collection items as the standard renderer + itemActions?: () => ReturnType, + // needs the collection to have multiple item selection enabled and a mock listener for the selection provided. Uses the same collection items as the standard renderer + multipleSelection?: () => ReturnType, + // needs the collection to have the item with key 2 disabled. Should include a item action mock listener. Uses the same collection items as the standard renderer + disabledItems?: () => ReturnType, + // should set a default value of "Ba" on the autocomplete. Uses the same collection items as the standard renderer + defaultValue?: () => ReturnType, + // should apply a custom filter that doesnt filter the list of items at all. Uses the same collection items as the standard renderer + customFiltering?: () => ReturnType // TODO, add tests for this when we support it // submenus?: (props?: {name: string}) => ReturnType }, - collectionType?: 'menu' | 'listbox' + ariaPattern?: 'menu' | 'listbox', + selectionListener?: jest.Mock, + actionListener?: jest.Mock } -export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType = 'menu'}: AriaAutocompleteTestProps) => { - describe(prefix ? prefix + ' AriaAutocomplete' : ' AriaAutocomplete', function () { - let onAction = jest.fn(); - let onSelectionChange = jest.fn(); +export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = 'menu', selectionListener, actionListener}: AriaAutocompleteTestProps) => { + describe(prefix ? prefix + ' AriaAutocomplete' : 'AriaAutocomplete', function () { let user; let collectionNodeRole; let collectionItemRole; @@ -59,11 +65,11 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); jest.useFakeTimers(); - if (collectionType === 'menu') { + if (ariaPattern === 'menu') { collectionNodeRole = 'menu'; collectionItemRole = 'menuitem'; collectionSelectableItemRole = 'menuitemcheckbox'; - } else if (collectionType === 'listbox') { + } else if (ariaPattern === 'listbox') { collectionNodeRole = 'listbox'; collectionItemRole = 'option'; collectionSelectableItemRole = 'option'; @@ -71,45 +77,157 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType }); afterEach(() => { - onAction.mockClear(); + actionListener?.mockClear(); + selectionListener?.mockClear(); act(() => jest.runAllTimers()); }); - let standardTests = (collectionType) => { - describe('standard interactions', function () { - it('has default behavior (input field renders with expected attributes)', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - expect(input).toHaveAttribute('aria-controls'); - expect(input).toHaveAttribute('aria-haspopup', 'listbox'); - expect(input).toHaveAttribute('aria-autocomplete', 'list'); - expect(input).toHaveAttribute('autoCorrect', 'off'); - expect(input).toHaveAttribute('spellCheck', 'false'); + describe('standard interactions', function () { + it('has default behavior (input field renders with expected attributes)', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + expect(input).toHaveAttribute('aria-controls'); + expect(input).toHaveAttribute('aria-haspopup', 'listbox'); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + expect(input).toHaveAttribute('autoCorrect', 'off'); + expect(input).toHaveAttribute('spellCheck', 'false'); + + let menu = getByRole(collectionNodeRole); + expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); + }); - let menu = getByRole(collectionNodeRole); - expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); + it('should support keyboard navigation', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + expect(document.activeElement).toBe(input); + }); - let label = document.getElementById(input.getAttribute('aria-labelledby')!); - expect(label).toHaveTextContent('Test'); + it('should clear the focused key when using ArrowLeft and ArrowRight', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('Foo'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowRight}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowLeft}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + }); - let description = document.getElementById(input.getAttribute('aria-describedby')!); - expect(description).toHaveTextContent('Please select an option below'); - }); + it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - it('should support disabling the field', async function () { - let {getByRole} = renderers.standard({inputProps: {isDisabled: true}}); - let input = getByRole('searchbox'); - expect(input).toHaveAttribute('disabled'); - }); + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('a'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + act(() => jest.advanceTimersByTime(500)); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + }); + + it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('a'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + }); + + it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox') as HTMLInputElement; + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(3); + + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{Home}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); - it('should support making the field read only', async function () { - let {getByRole} = renderers.standard({inputProps: {isReadOnly: true}}); + await user.keyboard('{End}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + }); + + if (ariaPattern === 'menu') { + it('should update the aria-activedescendant when hovering over an item', async function () { + let {getByRole} = renderers.standard(); let input = getByRole('searchbox'); - expect(input).toHaveAttribute('readonly'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + // Need to press to set a modality + await user.click(input); + await user.hover(options[1]); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(document.activeElement).toBe(input); }); + } + }); + if (renderers.defaultValue) { + describe('default text value', function () { it('should support default value', async function () { - let {getByRole} = renderers.standard({autocompleteProps: {defaultInputValue: 'Ba'}}); + let {getByRole} = (renderers.defaultValue!)(); let input = getByRole('searchbox'); expect(input).toHaveValue('Ba'); let menu = getByRole(collectionNodeRole); @@ -126,31 +244,35 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType expect(options).toHaveLength(1); expect(options[0]).toHaveTextContent('Baz'); }); + }); + } - it('should support keyboard navigation', async function () { - let {getByRole} = renderers.standard({}); + if (ariaPattern === 'menu' && renderers.itemActions) { + describe('item actions', function () { + it('should trigger the wrapped element\'s actionListener when hitting Enter', async function () { + let {getByRole} = (renderers.itemActions!)(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); - let options = within(menu).getAllByRole(collectionItemRole); expect(input).not.toHaveAttribute('aria-activedescendant'); await user.tab(); expect(document.activeElement).toBe(input); await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole(collectionItemRole); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[2].id); - await user.keyboard('{ArrowUp}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + await user.keyboard('{Enter}'); + expect(actionListener).toHaveBeenCalledTimes(1); + expect(actionListener).toHaveBeenLastCalledWith('1'); - expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + expect(actionListener).toHaveBeenCalledTimes(2); + expect(actionListener).toHaveBeenLastCalledWith('2'); }); - it('should clear the focused key when using ArrowLeft and ArrowRight', async function () { - let {getByRole} = renderers.standard({}); + it('should not trigger the wrapped element\'s actionListener when hitting Space', async function () { + let {getByRole} = renderers.standard(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); expect(input).not.toHaveAttribute('aria-activedescendant'); @@ -158,81 +280,22 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType await user.tab(); expect(document.activeElement).toBe(input); - await user.keyboard('Foo'); - act(() => jest.runAllTimers()); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowRight}'); - expect(input).not.toHaveAttribute('aria-activedescendant'); await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole(collectionItemRole); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowLeft}'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(document.activeElement).toBe(input); + await user.keyboard('[Space]'); + act(() => jest.runAllTimers()); + expect(actionListener).toHaveBeenCalledTimes(0); + options = within(menu).queryAllByRole(collectionItemRole); + expect(options).toHaveLength(0); }); + }); + } - if (collectionType === 'menu') { - it('should trigger the wrapped element\'s onAction when hitting Enter', async function () { - let {getByRole} = renderers.standard({collectionProps: {onAction}}); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{Enter}'); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('1'); - - await user.keyboard('{ArrowDown}'); - await user.keyboard('{Enter}'); - expect(onAction).toHaveBeenCalledTimes(2); - expect(onAction).toHaveBeenLastCalledWith('2'); - }); - - it('should not trigger the wrapped element\'s onAction when hitting Space', async function () { - let {getByRole} = renderers.standard({collectionProps: {onAction}}); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('[Space]'); - act(() => jest.runAllTimers()); - expect(onAction).toHaveBeenCalledTimes(0); - options = within(menu).queryAllByRole(collectionItemRole); - expect(options).toHaveLength(0); - }); - - it('should update the aria-activedescendant when hovering over an item', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - // Need to press to set a modality - await user.click(input); - await user.hover(options[1]); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - expect(document.activeElement).toBe(input); - }); - } - + if (renderers.multipleSelection) { + describe('supports multiple selection', function () { it('should trigger the wrapped element\'s onSelectionChange when hitting Enter', async function () { - let {getByRole} = renderers.standard({collectionProps: {onSelectionChange, selectionMode: 'multiple'}}); + let {getByRole} = (renderers.multipleSelection!)(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); expect(input).not.toHaveAttribute('aria-activedescendant'); @@ -244,22 +307,32 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType let options = within(menu).getAllByRole(collectionSelectableItemRole); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); await user.keyboard('{Enter}'); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); + if (selectionListener) { + expect(selectionListener).toHaveBeenCalledTimes(1); + expect(new Set(selectionListener.mock.calls[0][0])).toEqual(new Set(['1'])); + } await user.keyboard('{ArrowDown}'); await user.keyboard('{Enter}'); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['1', '2'])); + if (selectionListener) { + expect(selectionListener).toHaveBeenCalledTimes(2); + expect(new Set(selectionListener.mock.calls[1][0])).toEqual(new Set(['1', '2'])); + } await user.keyboard('{ArrowUp}'); await user.keyboard('{Enter}'); - expect(onSelectionChange).toHaveBeenCalledTimes(3); - expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['2'])); + if (selectionListener) { + expect(selectionListener).toHaveBeenCalledTimes(3); + expect(new Set(selectionListener.mock.calls[2][0])).toEqual(new Set(['2'])); + } }); + }); + } - it('should properly skip over disabled keys', async function () { - let {getByRole} = renderers.standard({collectionProps: {disabledKeys: ['2'], onAction}}); + if (renderers.disabledItems) { + describe('disabled items', function () { + it('should properly skip over disabled items', async function () { + let {getByRole} = (renderers.disabledItems!)(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); let options = within(menu).getAllByRole(collectionItemRole); @@ -282,81 +355,15 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType expect(input).not.toHaveAttribute('aria-activedescendant'); await user.click(options[0]); - expect(onAction).toHaveBeenCalledTimes(0); - }); - - it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('a'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - act(() => jest.advanceTimersByTime(500)); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - }); - - it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('a'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - }); - - it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { - let {getByRole} = renderers.standard({}); - let input = getByRole('searchbox') as HTMLInputElement; - - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('Bar'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(3); - - await user.keyboard('{ArrowLeft}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); - - await user.keyboard('{Home}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); - - await user.keyboard('{End}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); - - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); - - await user.keyboard('{ArrowUp}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + expect(actionListener).toHaveBeenCalledTimes(0); }); }); - }; - - standardTests(collectionType); + } let filterTests = (renderer) => { - describe('text filtering', function () { + describe('default text filtering', function () { it('should support filtering', async function () { - let {getByRole} = renderer({}); + let {getByRole} = renderer(); let input = getByRole('searchbox'); expect(input).toHaveValue(''); let menu = getByRole(collectionNodeRole); @@ -381,9 +388,21 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType expect(input).not.toHaveAttribute('aria-activedescendant'); expect(document.activeElement).toBe(input); }); + }); + }; + + filterTests(renderers.standard); + if (renderers.controlled) { + describe('controlled text value', function () { + filterTests(renderers.controlled); + }); + } + + if (renderers.customFiltering) { + describe('custom filter function', function () { it('should support custom filtering', async function () { - let {getByRole} = renderer({autocompleteProps: {defaultFilter: () => true}}); + let {getByRole} = (renderers.customFiltering!)(); let input = getByRole('searchbox'); expect(input).toHaveValue(''); let menu = getByRole(collectionNodeRole); @@ -399,21 +418,13 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType expect(options[0]).toHaveTextContent('Foo'); }); }); - }; - - filterTests(renderers.standard); - - if (renderers.controlled) { - describe('controlled', function () { - filterTests(renderers.controlled); - }); } if (renderers.links) { describe('with links', function () { // TODO: skipping until we get parity between Listbox and Menu behavior it.skip('should trigger the link option when hitting Enter', async function () { - let {getByRole} = (renderers.links!)({collectionProps: {onAction}}); + let {getByRole} = (renderers.links!)(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); expect(input).not.toHaveAttribute('aria-activedescendant'); @@ -431,9 +442,9 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType let onClick = mockClickDefault(); await user.keyboard('{Enter}'); - if (collectionType === 'menu') { - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('3'); + if (ariaPattern === 'menu') { + expect(actionListener).toHaveBeenCalledTimes(1); + expect(actionListener).toHaveBeenLastCalledWith('3'); } expect(onClick).toHaveBeenCalledTimes(1); @@ -445,7 +456,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType if (renderers.sections) { describe('with sections', function () { it('should properly skip over sections when keyboard navigating', async function () { - let {getByRole} = (renderers.sections!)({}); + let {getByRole} = (renderers.sections!)(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); let sections = within(menu).getAllByRole('group'); @@ -471,7 +482,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType }); it('should omit section titles and dividers when filtering', async function () { - let {getByRole} = (renderers.sections!)({}); + let {getByRole} = (renderers.sections!)(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); let sections = within(menu).getAllByRole('group'); @@ -550,7 +561,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, collectionType await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', secondSecOpts[0].id); expect(secondSecOpts[0]).toHaveTextContent('Paste'); - if (collectionType === 'menu') { + if (ariaPattern === 'menu') { await user.keyboard('{ArrowDown}'); expect(input).toHaveAttribute('aria-activedescendant', firstSecOpts[0].id); } diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 8fa2b167d4a..60366185179 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -21,6 +21,8 @@ interface AutocompleteItem { } let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; +let onAction = jest.fn(); +let onSelectionChange = jest.fn(); let StaticMenu = (props) => ( @@ -125,35 +127,62 @@ let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, children AriaAutocompleteTests({ prefix: 'rac-static-menu', renderers: { - standard: ({autocompleteProps, inputProps, collectionProps}) => render( - - + standard: () => render( + + ), - links: ({autocompleteProps, inputProps, collectionProps}) => render( - - + links: () => render( + + ), - sections: ({autocompleteProps, inputProps, collectionProps}) => render( - - + sections: () => render( + + ), - controlled: ({autocompleteProps, inputProps, collectionProps}) => render( - - + controlled: () => render( + + + ), + itemActions: () => render( + + + + ), + multipleSelection: () => render( + + + + ), + disabledItems: () => render( + + + + ), + defaultValue: () => render( + + + + ), + customFiltering: () => render( + true}}> + + ) - } + }, + actionListener: onAction, + selectionListener: onSelectionChange }); AriaAutocompleteTests({ prefix: 'rac-dynamic-menu', renderers: { - standard: ({autocompleteProps, inputProps, collectionProps}) => render( - - + standard: () => render( + + ) } @@ -162,26 +191,48 @@ AriaAutocompleteTests({ AriaAutocompleteTests({ prefix: 'rac-static-listbox', renderers: { - standard: ({autocompleteProps, inputProps, collectionProps}) => render( - - + standard: () => render( + + ), - links: ({autocompleteProps, inputProps, collectionProps}) => render( - - + links: () => render( + + ), - sections: ({autocompleteProps, inputProps, collectionProps}) => render( - - + sections: () => render( + + ), - controlled: ({autocompleteProps, inputProps, collectionProps}) => render( - - + controlled: () => render( + + + ), + multipleSelection: () => render( + + + + ), + disabledItems: () => render( + + + + ), + defaultValue: () => render( + + + + ), + customFiltering: () => render( + true}}> + + ) }, - collectionType: 'listbox' + ariaPattern: 'listbox', + actionListener: onAction, + selectionListener: onSelectionChange }); From 1a26d794f5e08b9127f99b6cf39e38d8f11b36e1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 27 Nov 2024 11:21:22 -0800 Subject: [PATCH 41/42] fix lock --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 729caa4a923..9c582a82c0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5865,6 +5865,7 @@ __metadata: dependencies: "@react-aria/combobox": "npm:^3.11.0" "@react-aria/i18n": "npm:^3.12.3" + "@react-aria/interactions": "npm:^3.22.5" "@react-aria/listbox": "npm:^3.13.6" "@react-aria/searchfield": "npm:^3.7.11" "@react-aria/utils": "npm:^3.26.0" From df033397e8fefac25882fbf2106960a3727354c9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 27 Nov 2024 14:51:05 -0800 Subject: [PATCH 42/42] update forward ref for react fast refresh --- .../react-aria-components/src/Autocomplete.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 4e18fd33afb..ddb5accc997 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -29,7 +29,12 @@ export const AutocompleteStateContext = createContext( // This context is to pass the register and filter down to whatever collection component is wrapped by the Autocomplete export const InternalAutocompleteContext = createContext(null); -function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { +/** + * A autocomplete combines a text input with a menu, allowing users to filter a list of options to items matching a query. + */ + + +export const Autocomplete = forwardRef(function Autocomplete(props: AutocompleteProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, AutocompleteContext); let {defaultFilter} = props; let state = useAutocompleteState(props); @@ -60,10 +65,4 @@ function Autocomplete(props: AutocompleteProps, ref: ForwardedRef ); -} - -/** - * A autocomplete combines a text input with a menu, allowing users to filter a list of options to items matching a query. - */ -const _Autocomplete = forwardRef(Autocomplete); -export {_Autocomplete as Autocomplete}; +});