Skip to content
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

Open
wants to merge 48 commits into
base: main
Choose a base branch
from

Conversation

LFDanLu
Copy link
Member

@LFDanLu LFDanLu commented Oct 11, 2024

Closes

#7248 has a version that always persists focus, to test on Monday

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

RSP

@rspbot
Copy link

rspbot commented Oct 11, 2024

@adobe adobe deleted a comment from rspbot Oct 22, 2024
@rspbot
Copy link

rspbot commented Oct 23, 2024

@rspbot
Copy link

rspbot commented Oct 24, 2024

@rspbot
Copy link

rspbot commented Oct 24, 2024

Comment on lines +212 to +216
// 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> {
Copy link
Member Author

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

@rspbot
Copy link

rspbot commented Nov 23, 2024

@LFDanLu LFDanLu marked this pull request as ready for review November 23, 2024 00:54
@LFDanLu LFDanLu changed the title (WIP) Autocomplete with menu Add Autocomplete wrapper for Menu and ListBox Nov 23, 2024
@LFDanLu LFDanLu changed the title Add Autocomplete wrapper for Menu and ListBox feat: Add Autocomplete wrapper for Menu and ListBox Nov 23, 2024
@rspbot
Copy link

rspbot commented Nov 23, 2024

spellCheck: 'false'
},
collectionProps: mergeProps(collectionProps, {
// TODO: shouldFocusOnHover?
Copy link
Member Author

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

@rspbot
Copy link

rspbot commented Nov 25, 2024

Comment on lines +299 to +304
// 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);
}
Copy link
Member Author

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

Copy link
Contributor

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?

Copy link
Member Author

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

Copy link
Contributor

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 😄

@rspbot
Copy link

rspbot commented Nov 26, 2024

@@ -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);
Copy link
Member Author

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

Copy link
Member

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...

Copy link
Member Author

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...

Copy link
Member

@devongovett devongovett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work! So excited about this.

packages/@react-aria/autocomplete/src/useAutocomplete.ts Outdated Show resolved Hide resolved
// 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(() => {
Copy link
Member

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).

Copy link
Member Author

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

Copy link
Member Author

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.

packages/@react-aria/autocomplete/src/useAutocomplete.ts Outdated Show resolved Hide resolved
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...
Copy link
Member

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?

Copy link
Member Author

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

Copy link
Member

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?

Copy link
Member Author

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,
Copy link
Member

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';
Copy link
Member

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?

Copy link
Member Author

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

Comment on lines +174 to +176
// 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]);
Copy link
Member

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);
Copy link
Member

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...

Copy link
Member

@devongovett devongovett left a 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!

packages/@react-aria/menu/src/index.ts Outdated Show resolved Hide resolved
packages/@react-aria/menu/src/utils.ts Outdated Show resolved Hide resolved
packages/@react-aria/menu/src/useMenuItem.ts Outdated Show resolved Hide resolved
} else if (document.activeElement !== ref.current && ref.current) {
focusSafely(ref.current);
if (isFocused && manager.isFocused) {
if (!shouldUseVirtualFocus) {
Copy link
Member

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.

Copy link
Member Author

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

@rspbot
Copy link

rspbot commented Nov 27, 2024

let options = within(menu).getAllByRole(collectionItemRole);
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
await user.keyboard('{Enter}');
expect(actionListener).toHaveBeenCalledTimes(1);
Copy link
Member

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.

Copy link
Member Author

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?

@rspbot
Copy link

rspbot commented Nov 27, 2024

@rspbot
Copy link

rspbot commented Nov 27, 2024

## 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
+}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants