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

ComboBox with autofocus doesn't show its dropdown auto focused with menuTrigger='focus' #5464

Open
LFDanLu opened this issue Nov 23, 2023 Discussed in #5462 · 16 comments
Open
Labels
bug Something isn't working RAC

Comments

@LFDanLu
Copy link
Member

LFDanLu commented Nov 23, 2023

Discussed in #5462

Originally posted by austincrim November 22, 2023
I'm trying to build a CMD + K-like interface using a ComboBox inside of a Dialog. I want the ListBox options to always be visible, regardless if the Input has been typed in or manually focused (using the ComboBox menuTrigger prop).

I couldn't find a way to do this in the docs. Any recommendations?

Here's how it looks now, but I want the list of options to be visible as soon as the dialog is opened.

Stackblitz link

CleanShot.2023-11-22.at.15.14.50.mp4

As an aside, the React Spectrum support page still links to creating a new GitHub Issue, when it looks like you've switched to Discussions.

See #5462 (reply in thread) for more info. The gist is that the collection for the ComboBox is undefined/empty when the input gets autofocused since RAC collections need two renders to fully generate.

@LFDanLu LFDanLu added RAC bug Something isn't working labels Nov 23, 2023
@github-project-automation github-project-automation bot moved this to ✏️ To Groom in RSP Component Milestones Nov 23, 2023
@Spriithy
Copy link

Spriithy commented Dec 9, 2023

Exactly the same situation here ! Interested in any input from OP or anyone else on how to achieve this !

Thank you very much

@LFDanLu
Copy link
Member Author

LFDanLu commented Dec 11, 2023

Could you expand on how much control over the open state you need for the ComboBox here?

@Spriithy
Copy link

Spriithy commented Dec 11, 2023 via email

@LFDanLu
Copy link
Member Author

LFDanLu commented Dec 11, 2023

So would that mean the combobox is open at all times? If that is the case, I think you'd be better off using a searchfield + listbox (which I now see you've mentioned in the discussion) since the ComboBox's open overlay would hide the rest of the page from screenreaders when open. Depending on how much you'd want the experience to match ComboBox (aka would focus remain in the searchfield and up/down arrow keys move virtual focusing in the ListBox?), it would take some work extracting what you need from the hooks. It the searchfield was more of a standalone experience from the listbox (aka typing in it would simply change what the listbox displays) then it would be more straightforward since you'd simply control the searchfield's input and use that to fetch a list of items for the listbox.

@Spriithy
Copy link

Spriithy commented Dec 11, 2023 via email

@LFDanLu
Copy link
Member Author

LFDanLu commented Dec 11, 2023

What I really find difficult with the searchfield + listbox solution is
that, being fairly new to react aria, I wouldn't know how to keep the focus
on the input yet still be able to navigate options with the arrow keys.

So in short, the way this is handled is via tracking the "focusedKey" in state (aka the selectionManager), modifying it via the keyboard handlers attached to the input field (also see

let onKeyDown = (e: KeyboardEvent) => {
// Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes
if (e.altKey && e.key === 'Tab') {
e.preventDefault();
}
// Keyboard events bubble through portals. Don't handle keyboard events
// for elements outside the collection (e.g. menus).
if (!ref.current.contains(e.target as Element)) {
return;
}
const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => {
if (key != null) {
if (manager.isLink(key) && linkBehavior === 'selection' && selectOnFocus && !isNonContiguousSelectionModifier(e)) {
// Set focused key and re-render synchronously to bring item into view if needed.
flushSync(() => {
manager.setFocusedKey(key, childFocus);
});
let item = scrollRef.current.querySelector(`[data-key="${key}"]`);
router.open(item, e);
return;
}
manager.setFocusedKey(key, childFocus);
if (manager.isLink(key) && linkBehavior === 'override') {
return;
}
if (e.shiftKey && manager.selectionMode === 'multiple') {
manager.extendSelection(key);
} else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) {
manager.replaceSelection(key);
}
}
};
switch (e.key) {
case 'ArrowDown': {
if (delegate.getKeyBelow) {
e.preventDefault();
let nextKey = manager.focusedKey != null
? delegate.getKeyBelow(manager.focusedKey)
: delegate.getFirstKey?.();
if (nextKey == null && shouldFocusWrap) {
nextKey = delegate.getFirstKey?.(manager.focusedKey);
}
navigateToKey(nextKey);
}
break;
}
case 'ArrowUp': {
if (delegate.getKeyAbove) {
e.preventDefault();
let nextKey = manager.focusedKey != null
? delegate.getKeyAbove(manager.focusedKey)
: delegate.getLastKey?.();
if (nextKey == null && shouldFocusWrap) {
nextKey = delegate.getLastKey?.(manager.focusedKey);
}
navigateToKey(nextKey);
}
break;
}
for the specifics in useSelectableCollection), and then passing that same state (aka the collection + selection manager) with the appropriate options to the listbox so it will use virtual focus.

Also, I'm facing a bug with combobox where, with a not so large list,
typing then navigating with arrows then typing again makes the input lose
focus. I should probably open an issue with this, but I can't reproduce the
bug on a standalone page... (data is fetched from a React Context).

Not sure what would be causing this, definitely file an issue once you get a base reproduction!

@LFDanLu LFDanLu changed the title ComboBox with autofocus doesn't show its dropdown when inside a Modal ComboBox with autofocus doesn't show its dropdown auto focused with menuTrigger='focus' Dec 11, 2023
@Spriithy
Copy link

Well thank you very much for the detailed answer and suggestions. This makes a lot of digging and internals comprehension for my use case. I'll make sure to give it a try anyways.

@LFDanLu
Copy link
Member Author

LFDanLu commented Dec 12, 2023

Yeah, unfortunately the hooks as is come with a bit too much extra stuff, it would take some work pulling out the relevant code. Hopefully in the future we'll have a more base level useAutoComplete hook that only offers the collection filtering + focus handling without all the extra overlay open/closed state tracking

@Spriithy
Copy link

Spriithy commented Dec 12, 2023 via email

@Spriithy
Copy link

Spriithy commented Dec 12, 2023

So I've managed to control a ListBox using a search field like so :

But I cannot get the ListBox focus to wrap even though I've passed shouldFocusWrap: true. Also, how would I go setting up keyboard virtual focus between the input and the ListBox ?

function SearchableListBox() {
  const { globalSearch, setGlobalSearch } = useState("");
    const searchHistory = useState([
        { id: "a", search: "foo bar", date: new Date(), error: false },
        { id: "b", search: "foo baz", date: new Date(), error: false },
        { id: "c", search: "foo bar baz", date: new Date(), error: true },
        { id: "d", search: "bar baz", date: new Date(), error: false },
    ]); // replaced by data normally retrieved in IndexedDB using Dexie*/

    const getSearchEntryById = (id) => searchHistory.filter((query) => id === `${query.id}`).at(0);

    const listBoxItem = (query) => (
        <Item key={query.id} textValue={query.search} title={query.search} query={query} />
    );

    const listBoxState = useSingleSelectListState({
        onSelectionChange: (id) => setGlobalSearch(getSearchEntryById(id).search),
        items: searchHistory.filter((query) => query.search.indexOf(globalSearch) >= 0),
        children: listBoxItem,
    });

    // Get props for the listbox element
    let listBoxRef = React.useRef(null);
    let { listBoxProps } = useListBox({
        label: "Search history",
        shouldFocusWrap: true,
    }, listBoxState, listBoxRef);

    let inputRef = useRef(null);

    return (
        <>
                <input
                    value={globalSearch}
                    onChange={(e) => setGlobalSearch(e.target.value)}
                    ref={inputRef}
                    id="searchbar"
                    placeholder="Search"
                />

                <ListBox {...listBoxProps} listBoxRef={listBoxRef} state={listBoxState} />
        </>
    );
}

PS : the filter prop in the useSingleSelectListState is quite obscure to me since it doesn't match the ComboBox defaultFilter (this seems logical though, since the ListBox isn't related to a search field's input value).

@LFDanLu
Copy link
Member Author

LFDanLu commented Dec 14, 2023

Just chiming in that I haven't forgotten about this, but we're pretty busy with an upcoming release so I haven't been able to look at this in-depth.

the keyboard virtual focus can be setup by doing something like

// 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
});
and passing the keydown handlers to you input:
onKeyDown: !isReadOnly && chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown),
. You may need to add/remove some additional onKeyDown behavior similar to what we do in useComboBox but not having a open state to flip on/off should make that easier. I'm not sure about the shouldFocusWrap issue, would need to dig.

@apucacao
Copy link

apucacao commented May 2, 2024

What I really find difficult with the searchfield + listbox solution is
that, being fairly new to react aria, I wouldn't know how to keep the focus
on the input yet still be able to navigate options with the arrow keys.

So in short, the way this is handled is via tracking the "focusedKey" in state (aka the selectionManager), modifying it via the keyboard handlers attached to the input field (also see

let onKeyDown = (e: KeyboardEvent) => {
// Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes
if (e.altKey && e.key === 'Tab') {
e.preventDefault();
}
// Keyboard events bubble through portals. Don't handle keyboard events
// for elements outside the collection (e.g. menus).
if (!ref.current.contains(e.target as Element)) {
return;
}
const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => {
if (key != null) {
if (manager.isLink(key) && linkBehavior === 'selection' && selectOnFocus && !isNonContiguousSelectionModifier(e)) {
// Set focused key and re-render synchronously to bring item into view if needed.
flushSync(() => {
manager.setFocusedKey(key, childFocus);
});
let item = scrollRef.current.querySelector(`[data-key="${key}"]`);
router.open(item, e);
return;
}
manager.setFocusedKey(key, childFocus);
if (manager.isLink(key) && linkBehavior === 'override') {
return;
}
if (e.shiftKey && manager.selectionMode === 'multiple') {
manager.extendSelection(key);
} else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) {
manager.replaceSelection(key);
}
}
};
switch (e.key) {
case 'ArrowDown': {
if (delegate.getKeyBelow) {
e.preventDefault();
let nextKey = manager.focusedKey != null
? delegate.getKeyBelow(manager.focusedKey)
: delegate.getFirstKey?.();
if (nextKey == null && shouldFocusWrap) {
nextKey = delegate.getFirstKey?.(manager.focusedKey);
}
navigateToKey(nextKey);
}
break;
}
case 'ArrowUp': {
if (delegate.getKeyAbove) {
e.preventDefault();
let nextKey = manager.focusedKey != null
? delegate.getKeyAbove(manager.focusedKey)
: delegate.getLastKey?.();
if (nextKey == null && shouldFocusWrap) {
nextKey = delegate.getLastKey?.(manager.focusedKey);
}
navigateToKey(nextKey);
}
break;
}

for the specifics in useSelectableCollection), and then passing that same state (aka the collection + selection manager) with the appropriate options to the listbox so it will use virtual focus.

Also, I'm facing a bug with combobox where, with a not so large list,
typing then navigating with arrows then typing again makes the input lose
focus. I should probably open an issue with this, but I can't reproduce the
bug on a standalone page... (data is fetched from a React Context).

Not sure what would be causing this, definitely file an issue once you get a base reproduction!

Hi! I tried to follow your suggestion and am running into an issue: it appears like the collection is always empty.

It was all pretty straightforward to put together, thanks to the amazing composition API you all designed, and I feel like I'm close, but I also feel like I'm missing something. Curious if anything comes to mind with what I've documented over at #6281?

@LFDanLu
Copy link
Member Author

LFDanLu commented May 2, 2024

Just to double check, the code you added in #6281 your most up to date code? When you say the collection is always empty, I assume that means the ListBox itself isn't rendering anything at all? I assume the ListBox you are using is straight from RAC or is it a modified version?

EDIT:
Also @devongovett pointed out that if you are copying Collection.tsx + the related contexts but are also using ListBox as is from RAC, then it won't work because the contexts like CollectionDocumentContext/etc will be different. You'll have to essentially copy over ListBox as well so that it uses your version of the context.

@apucacao
Copy link

apucacao commented May 6, 2024

Just to double check, the code you added in #6281 your most up to date code? When you say the collection is always empty, I assume that means the ListBox itself isn't rendering anything at all? I assume the ListBox you are using is straight from RAC or is it a modified version?

EDIT: Also @devongovett pointed out that if you are copying Collection.tsx + the related contexts but are also using ListBox as is from RAC, then it won't work because the contexts like CollectionDocumentContext/etc will be different. You'll have to essentially copy over ListBox as well so that it uses your version of the context.

Correct -- that's the most up-to-date code. The ListBox does indeed render empty and the underlying collection is empty (even though I can see items going into useListState). And the ListBox I'm was using is our version that adds custom styles to the one from RAC, like this.

The update to that post about copying Collection.tsx didn't solve it, and I can try your suggestion to ensure the contexts are identical. The thing is that before I reached for that, the collection was empty too.

@apucacao
Copy link

apucacao commented May 8, 2024

I take it that it should have worked as-is with the off-the-shelf RAC ListBox? Is the Collection.tsx stuff actually required for what I'm trying to do?

@LFDanLu
Copy link
Member Author

LFDanLu commented May 8, 2024

The off the shelf ListBox should at the very least be able to render to items when used standalone so if that isn't working then that is unexpected. As for using RAC to build this autocomplete experience, yes you'll need useCollectionDocument so that the collection between the AutoCompleteListBox and the ListBox it wraps can be shared/filtered (similar to ComboBox's current implementation as you've noted already). However, still theoretical since I haven't tried building it with RAC yet, the conversation that happened early in the thread is using the old collection api prior to RAC that is still used in the hooks docs.

@LFDanLu LFDanLu moved this from ✏️ To Groom to 🩹 To Triage in RSP Component Milestones Oct 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working RAC
Projects
Status: 🩺 To Triage
Development

No branches or pull requests

3 participants