-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Add Autocomplete wrapper for Menu and ListBox #7181
base: main
Are you sure you want to change the base?
Conversation
issues outlined in various comments, basically boils down to ideally using the wrapped collection components state
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
// 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<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely specific to menu, will need to update when we handle other collection components. Collections is in alpha so that should shield us from any changes that may need to happen to this function
spellCheck: 'false' | ||
}, | ||
collectionProps: mergeProps(collectionProps, { | ||
// TODO: shouldFocusOnHover? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wonder if it might make sense to change focus on hover by default for the wrapped collection
// 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); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if there is a better way to go about this here. As described above the simulated event from useAutocomplete doesn't actually cause onPress
to be triggered because the tracked state.target
(listbox item) in usePress doesn't match/contain the event target's original target (the autocomplete input) but ideally we'd want links to be triggered on key up like they usually are for listboxes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, would there be issues with moving the focus briefly with requestAnimationFrame()
, similar to what we do with tabindex
in useGridCell
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As in moving focus to the virtually focused item on Enter
press, then dispatching the keydown event so that the event target for both the keydown/up is the focused item? That can certainly work but it does mean there will be a blur event on the input element that could be unexpected for the end user since this is supposed to be virtual focus.
Additionally, if there any cases where we'd want to return focus back to the input after the focus movement to the item, it might lead to some unexpected/noisy screenreader announcements in addition to these extra blur events, will have to test. As an aside, supporting wrapping a GridList/Table is also going to be interesting because we'd want grid navigation to work but left/right arrow is going to also move the text cursor in the field and clear virtual focus for NVDA announcements... It may turn out that ditching virtual focus is easier tbh, and perhaps the user should just tab between the input and the wrapped collection
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I intended to always reset the focus back to the input on the next frame 👍
The blur events would indeed be an annoying side-effect, but we might be able to early return if we delay onBlur
callback invocations to the next frame as well. Surely feels like fighting an uphill battle though.
Very interesting that autocomplete is planned to make its way over to grids eventually as well. Will follow the development on this closely 😄
@@ -55,6 +55,7 @@ function SearchField(props: SearchFieldProps, ref: ForwardedRef<HTMLDivElement>) | |||
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; | |||
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; | |||
let inputRef = useRef<HTMLInputElement>(null); | |||
let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note this opens up the ability for the user to influence the TextField's input from outside the textfield now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a bit weird, since this is the only place where we merge contexts instead of having the deepest one win, but otherwise we have to make Autocomplete
provide the contexts for SearchField, TextField, TextArea, and all other possible fields which doesn't seem so sustainable...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, I was a bit torn here. I guess one down side of the current approach is that you'd need to remember to clear the context if there were input props from a higher level context that you didn't want...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// 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<string | null>(null); | ||
useEffect(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any chance this can be done in an event (e.g. onChange) rather than an effect? Seems like that would be more explicit about the intent (the user typed something, not a result of some external state update).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when I changed this to onChange
in an attempt to have the input value be managed at the input level instead, I ran into the problem where focusFirstItem
was firing before the collection filtered itself in response and thus was getting the incorrect activedescendant id. I'll try this again now that the input text is managed at the top level again
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep, if I use onChange
here the focusFirstItem
happens before the collection is filtered in response to the change in input value.
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... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to forward onKeyUp to the target as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had thought that was the reason when I ran into this yesterday, but it didn't seem to change anything, the keyup event in usePress still detects that the event target of the keyup is the input. I assumed that stopping propagation on keydown would've allowed the dispatched keyup event to make it to usePress and fake that the keyup was happening on the option rather than the input but no luck
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess because the key up event on the input happens first. Maybe capturing listener would work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unfortunately making the onKeyUp a capturing listener didn't seem to help, the event on the input still happened first it seems. Changing the capturing listener for onKeyUp in usePress to non-capturing did fix it but we rely on it being capturing...
shouldUseVirtualFocus: true, | ||
disallowTypeAhead: true | ||
}), | ||
collectionRef: mergedCollectionRef, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a bit strange to return refs from a hook, but I guess in this case we need a callback ref so not sure how else we'd handle it...
// 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'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should these go in the @react-aria/selection
package since they are mainly specific to that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had considered that but it felt weird to import that package into @react-aria/autocomplete
when there isn't anything selection related going on per say. If anything, I wish there was a even lower level hook than useSelectableCollection/useSelectableItem to put all this virtual focus stuff in since they aren't explicitly selection related.
Happy to move these into @react-aria/selection
based on what people think but it just felt like @react-aria/utils
was broader place to store these for now
// 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]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was gonna ask about this. Seems less common for menu, but if someone wanted to create a SearchAutocomplete-like component with RAC, they'd probably want to be able to control the filtered items from the server.
@@ -55,6 +55,7 @@ function SearchField(props: SearchFieldProps, ref: ForwardedRef<HTMLDivElement>) | |||
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; | |||
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; | |||
let inputRef = useRef<HTMLInputElement>(null); | |||
let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a bit weird, since this is the only place where we merge contexts instead of having the deepest one win, but otherwise we have to make Autocomplete
provide the contexts for SearchField, TextField, TextArea, and all other possible fields which doesn't seem so sustainable...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops didn't mean to approve yet. Too excited!
} else if (document.activeElement !== ref.current && ref.current) { | ||
focusSafely(ref.current); | ||
if (isFocused && manager.isFocused) { | ||
if (!shouldUseVirtualFocus) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not for this PR, but possible improvement I'd like to discuss. since we now fire our own custom event, I wonder if we could always do that and make these hooks agnostic of focusing. I think we could do away with a lot of these checks for 'shouldUseVirtualFocus' in hooks that don't really need to know about virtualizers at all, or focus for that matter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do feel like useSelectableCollection handles too much for sure, definitely can discuss this further
packages/react-aria-components/test/AriaAutocomplete.test-util.tsx
Outdated
Show resolved
Hide resolved
packages/react-aria-components/test/AriaAutocomplete.test-util.tsx
Outdated
Show resolved
Hide resolved
packages/react-aria-components/test/AriaAutocomplete.test-util.tsx
Outdated
Show resolved
Hide resolved
packages/react-aria-components/test/AriaAutocomplete.test-util.tsx
Outdated
Show resolved
Hide resolved
packages/react-aria-components/test/AriaAutocomplete.test-util.tsx
Outdated
Show resolved
Hide resolved
let options = within(menu).getAllByRole(collectionItemRole); | ||
expect(input).toHaveAttribute('aria-activedescendant', options[0].id); | ||
await user.keyboard('{Enter}'); | ||
expect(actionListener).toHaveBeenCalledTimes(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
better, however, this is still tied to our API.
Someone could implement an autocomplete that is self contained and fires no event (I'm not sure why, but they could, maybe they fire a custom event on the dom instead of using props)
In addition, they may not include the id of the item, maybe they've created some mapping of the ids to some database uuid or something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The actionListener
here that they provided doesn't have to be passed to component via props though, they just need to provide us with whatever action listener mock that would get triggered on item action and we assert it has been properly called when hitting enter on the input while that item is focused right?
As for the id of the item, the id being checked above is the aria-activedescendant
which needs to be tied to the dom node they are virtually focusing so that should be fine as well right?
## API Changes
react-aria-components/react-aria-components:Autocomplete+Autocomplete {
+ children: ReactNode
+ defaultFilter?: (string, string) => boolean = contains
+ defaultInputValue?: string
+ inputValue?: string
+ onInputChange?: (string) => void
+ slot?: string | null
+} /react-aria-components:AutocompleteContext+AutocompleteContext {
+ UNTYPED
+} /react-aria-components:AutocompleteStateContext+AutocompleteStateContext {
+ UNTYPED
+} /react-aria-components:InternalAutocompleteContext+InternalAutocompleteContext {
+ UNTYPED
+} @react-aria/autocomplete/@react-aria/autocomplete:useAutocomplete+useAutocomplete {
+ props: AriaAutocompleteOptions
+ state: AutocompleteState
+ returnVal: undefined
+} /@react-aria/autocomplete:AriaAutocompleteProps+AriaAutocompleteProps {
+ children: ReactNode
+ defaultFilter?: (string, string) => boolean = contains
+ defaultInputValue?: string
+ inputValue?: string
+ onInputChange?: (string) => void
+} /@react-aria/autocomplete:AriaAutocompleteOptions+AriaAutocompleteOptions {
+ collectionRef: RefObject<HTMLElement | null>
+ defaultFilter?: (string, string) => boolean = contains
+ defaultInputValue?: string
+ inputValue?: string
+ onInputChange?: (string) => void
+} /@react-aria/autocomplete:AutocompleteAria+AutocompleteAria {
+ collectionProps: CollectionOptions
+ collectionRef: RefObject<HTMLElement | null>
+ filterFn: (string) => boolean
+ inputProps: InputHTMLAttributes<HTMLInputElement>
+} /@react-aria/autocomplete:CollectionOptions+CollectionOptions {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ disallowTypeAhead: boolean
+ id?: string
+ shouldUseVirtualFocus: boolean
+} @react-aria/collections/@react-aria/collections:BaseCollection BaseCollection <T> {
addNode: (CollectionNode<T>) => void
at: () => Node<T>
clone: () => this
commit: (Key | null, Key | null, any) => void
+ filter: ((string) => boolean) => BaseCollection<T>
getChildren: (Key) => Iterable<Node<T>>
getFirstKey: () => void
getItem: (Key) => Node<T> | null
getKeyAfter: (Key) => void
getKeys: () => void
getLastKey: () => void
removeNode: (Key) => void
size: any
undefined: () => void
} @react-aria/menu/@react-aria/menu:AriaMenuOptions AriaMenuOptions <T> {
aria-describedby?: string
aria-details?: string
aria-label?: string
aria-labelledby?: string
autoFocus?: boolean | FocusStrategy
defaultSelectedKeys?: 'all' | Iterable<Key>
disabledKeys?: Iterable<Key>
disallowEmptySelection?: boolean
id?: string
isVirtualized?: boolean
items?: Iterable<T>
keyboardDelegate?: KeyboardDelegate
onAction?: (Key) => void
onClose?: () => void
onKeyDown?: (KeyboardEvent) => void
onKeyUp?: (KeyboardEvent) => void
onSelectionChange?: (Selection) => void
selectedKeys?: 'all' | Iterable<Key>
selectionMode?: SelectionMode
shouldFocusWrap?: boolean
+ shouldUseVirtualFocus?: boolean
} @react-aria/selection/@react-aria/selection:SelectableItemOptions SelectableItemOptions {
allowsDifferentPressOrigin?: boolean
focus?: () => void
+ id?: string
isDisabled?: boolean
isVirtualized?: boolean
key: Key
linkBehavior?: 'action' | 'selection' | 'override' | 'none' = 'action'
ref: RefObject<FocusableElement | null>
selectionManager: MultipleSelectionManager
shouldSelectOnPressUp?: boolean
shouldUseVirtualFocus?: boolean
} @react-aria/utils/@react-aria/utils:CLEAR_FOCUS_EVENT+CLEAR_FOCUS_EVENT {
+ UNTYPED
+} /@react-aria/utils:FOCUS_EVENT+FOCUS_EVENT {
+ UNTYPED
+} /@react-aria/utils:UPDATE_ACTIVEDESCENDANT+UPDATE_ACTIVEDESCENDANT {
+ UNTYPED
+} @react-stately/autocomplete/@react-stately/autocomplete:useAutocompleteState+useAutocompleteState {
+ props: AutocompleteStateOptions
+ returnVal: undefined
+} /@react-stately/autocomplete:AutocompleteProps+AutocompleteProps {
+ children: ReactNode
+ defaultInputValue?: string
+ inputValue?: string
+ onInputChange?: (string) => void
+} /@react-stately/autocomplete:AutocompleteStateOptions+AutocompleteStateOptions {
+ defaultInputValue?: string
+ inputValue?: string
+ onInputChange?: (string) => void
+} /@react-stately/autocomplete:AutocompleteState+AutocompleteState {
+ focusedNodeId: string | null
+ inputValue: string
+ setFocusedNodeId: (string | null) => void
+ setInputValue: (string) => void
+} |
Closes
#7248 has a version that always persists focus, to test on Monday
✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project:
RSP