[combobox] New `Combobox` component
Closes #222
Preview: https://deploy-preview-2105--base-ui.netlify.app/react/components/combobox Examples: https://deploy-preview-2105--base-ui.netlify.app/react/components/combobox#examples
Todo:
- [x] Field/Form integration
- [ ] Interaction tests
- [ ] Add an uncontrolled API - an
itemsprop with built-in filtering logic - [x] Fix
gridnavigation when there's an uneven number of columns in the middle of rows - [ ] Tailwind demos
For Combobox inside Menu, this will likely be a separate component
pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/react@2105
pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/utils@2105
commit: 88e36a7
Bundle size report
Total Size Change: 🔺+301KB(+20.91%) - Total Gzip Change: 🔺+103KB(+20.17%) Files: 71 total (2 added, 0 removed, 19 changed)
Show details for 71 more bundles
@base-ui-components/react parsed: 🔺+36.7KB(+11.79%) gzip: 🔺+10.6KB(+10.66%) @base-ui-components/react/navigation-menu parsed: 🔺+1.76KB(+1.96%) gzip: 🔺+731B(+2.33%) @base-ui-components/react/menu parsed: 🔺+1.68KB(+1.48%) gzip: 🔺+660B(+1.68%) @base-ui-components/react/select parsed: 🔺+1.67KB(+1.42%) gzip: 🔺+658B(+1.61%) @base-ui-components/react/context-menu parsed: 🔺+1.67KB(+1.48%) gzip: 🔺+668B(+1.71%) @base-ui-components/react/tabs parsed: 🔺+808B(+3.09%) gzip: 🔺+326B(+3.46%) @base-ui-components/react/toolbar parsed: 🔺+806B(+4.12%) gzip: 🔺+344B(+4.92%) @base-ui-components/react/popover parsed: 🔺+776B(+0.91%) gzip: 🔺+319B(+1.06%) @base-ui-components/react/radio-group parsed: 🔺+753B(+3.55%) gzip: 🔺+324B(+4.03%) @base-ui-components/react/menubar parsed: 🔺+751B(+3.47%) gzip: 🔺+325B(+4.08%) @base-ui-components/react/toggle-group parsed: 🔺+751B(+5.03%) gzip: 🔺+331B(+5.83%) @base-ui-components/react/preview-card parsed: 🔺+489B(+0.85%) gzip: 🔺+212B(+1.03%) @base-ui-components/react/tooltip parsed: 🔺+482B(+0.76%) gzip: 🔺+202B(+0.90%) @base-ui-components/react/alert-dialog parsed: 🔺+287B(+0.57%) gzip: 🔺+89B(+0.50%) @base-ui-components/react/dialog parsed: 🔺+286B(+0.56%) gzip: 🔺+100B(+0.56%) @base-ui-components/react/accordion parsed: 🔺+55B(+0.25%) gzip: 🔺+15B(+0.19%) @base-ui-components/react/radio parsed: 🔺+55B(+0.38%) gzip: 🔺+5B(+0.09%) @base-ui-components/react/slider parsed: 🔺+55B(+0.21%) gzip: 🔺+27B(+0.27%) @base-ui-components/react/toggle parsed: 🔺+55B(+0.64%) gzip: 🔺+14B(+0.39%) @base-ui-components/react/combobox parsed: 🔺+128KB(new) gzip: 🔺+43.8KB(new) @base-ui-components/react/autocomplete parsed: 🔺+124KB(new) gzip: 🔺+42.9KB(new) @base-ui-components/react/avatar parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/checkbox parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/checkbox-group parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/collapsible parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/direction-provider parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/field parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/fieldset parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/form parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/input parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/merge-props parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/meter parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/number-field parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/progress parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/scroll-area parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/separator parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/switch parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/toast parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/types parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/unstable-no-ssr parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/unstable-use-media-query parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/use-render parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/detectBrowser parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/error parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/fastObjectShallowCompare parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/generateId parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/getReactElementRef parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/inertValue parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/isElementDisabled parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/isMouseWithinBounds parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/mergeObjects parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/owner parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/reactVersion parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/safeReact parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/store parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useAnimationFrame parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useControlled parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useEnhancedClickHandler parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useEventCallback parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useForcedRerendering parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useId parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useInterval parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useIsoLayoutEffect parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useLatestRef parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useMergedRefs parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useOnFirstRender parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useOnMount parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useRefWithInit parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useTimeout parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/visuallyHidden parsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/warn parsed: 0B(0.00%) gzip: 0B(0.00%)
Generated by :no_entry_sign: dangerJS against 88e36a72563b8bd9bad8981a52daf2a3c8c9f351
Deploy Preview for base-ui ready!
| Name | Link |
|---|---|
| Latest commit | 88e36a72563b8bd9bad8981a52daf2a3c8c9f351 |
| Latest deploy log | https://app.netlify.com/projects/base-ui/deploys/68b851b9d2f27d0008545e3a |
| Deploy Preview | https://deploy-preview-2105--base-ui.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify project configuration.
it would be really nice this component will also support autoHighlight, similar to MUI Autocomplete.
@michael-land in this current implementation, the selectable prop enables the selected item to be remembered as the value state, which then shows as the highlight upon opening (which can be styled with data-selected and/or data-highlighted)
This should enable creating the same effect as in the Material UI autocomplete autoHighlight prop:
Good morning! I am not sure where to best ask this so I apologize if this is the wrong location. I see this is actively being worked on. Do yall have a rough timeline you plan on it being merged in? Thank you!
@msholmesdev We don't operate on fixed timelines like that. But you should expect it in a couple months. Our Discord is the best place for questions like this.
A couple of issues I found:
- Add option demo: when you type something followed by a space, the suggestion list doesn't show the Add message. And when you type in an existing item followed by a space (for example "Angular "), the suggestion box is empty
- Grid navigation works inconsistently. Up/down arrows wrap around and remain in the same column while left/right go to the previous/next row.
- I don't find the basic example useful. I know it's similar to the ARIA one, but I don't think I've seen such combobox in the wild. IMO input autofill and select should be at the top, as they might be the most common
- I don't know how I did it, but I got the multiple selection example close whenever I pressed Enter. Can't reproduce it now, though.
- Input autofill: I intuitively expected the popup to close when I enter a text that isn't present on the list and I press enter.
Grid navigation works inconsistently. Up/down arrows wrap around and remain in the same column while left/right go to the previous/next row.
This is expected no? (Grids allow wrapping with ArrowRight/Left - it's how Calendar works for instance)
I don't find the basic example useful. I know it's similar to the ARIA one, but I don't think I've seen such combobox in the wild. IMO input autofill and select should be at the top, as they might be the most common
Yeah, fair enough
Input autofill: I intuitively expected the popup to close when I enter a text that isn't present on the list and I press enter.
The popup doesn't show when it's empty in this example? Unless you mean in general for the other demos
This is expected no? (Grids allow wrapping with ArrowRight/Left - it's how Calendar works for instance)
I don't think there's a gold standard for this. macOS emoji picker doesn't wrap vertically. Google calendar doesn't wrap at all and Slack's emoji picker works like your implementation. I likely wouldn't notice if I wasn't testing it specifically so maybe it's ok.
The popup doesn't show when it's empty in this example? Unless you mean in general for the other demos
I mean this:
https://github.com/user-attachments/assets/43d8e28b-b3ec-48f6-8f94-33b2f064416a
When no option is selected, Enter doesn't do anything. I'm not sure if there's any spec for this, but intuitively I expected the popup to close.
I just notice that the selected tags spacing is not the consistent
@mnajdova I'm guessing this is because the style uses gap: 0.1rem but should instead be an evenly divisible number (by 16 - 0.125rem), otherwise rounding artifacts can occur; will update
@atomiks Is it possible to add the "clear button" to the "inside popover" example? I ask because in the Select example, the interactive element is an input role="combobox" with buttons inside, while the Inside Popover example uses a button as the interactive element, and putting a button inside a button is a no-no from my understanding.
@Dakkers usually selects have an option specifically for deselection (with a null value), such as in the regular Select hero demo (Select font)
@atomiks yeah, I understand that – I was hoping it'd be possible to have an easy way for a user to clear the Combobox's selected value(s) when the popover is inside the menu. edit: this is supported for the other Combobox style (where the input is visible without clicking into the menu) and it's a good UX, especially for when the Combobox has multiple values
Small update for this Combobox PR, after some internal discussion (I'll be doing some refactoring here)
Names and actual behaviors are still in flux.
Comboboxwill change to only have filterable select behavior (in this PR, currentlyselectionMode="single"|"multiple"). The input is restricted to a predefined set of values.Autocomplete, a new component, will be the current Combobox implementation withselectionMode="none". Used when the input can have any value, not restricted to a predefined set. For example, Google Search.- New
Menufiltering behavior (possiblyFilterableMenu), where the items fire off comands when clicked, unrelated to the input, and there's no selection remembered. Things likecmd+k, Notion's menu widget, an emoji picker, etc. Similar toselectionMode="none"in this PR, but the input doesn't reflect the value chosen as it's just used for filtering menu items. - Textarea + Combobox (e.g. slash command / mentions widget) is currently in an unknown state. It would be great if it was supported externally for the time being using one of the given components, though.
This is because Combobox in its current form is taking on too many roles to the point of becoming overly monolithic (despite role="combobox" being flexible). It is hard to pin down exactly what a "Combobox" should be doing - we believe the select behavior is the most intuitive and common for this name
The docs pages look awesome! I found some things that I am not sure about based on the demos:
I was a bit surprised that clicking on x on one of the selected elements in https://deploy-preview-2105--base-ui.netlify.app/react/components/combobox#multiple-select closes the list, shouldn't it be kept open while interacting with the elements inside the input? Same while navigating between the selected items with the left/right arrow.
This jumps too much when searching: https://deploy-preview-2105--base-ui.netlify.app/react/components/filterable-menu#command-palette, can we make sure the input is always in the same location on the screen and only change the height of the dialog?
I will start reviewing the code next.
@mnajdova we can skip review of FilterableMenu demos/logic for now since it's not in the upcoming release. I will just hide it from the docs and package and work on it in a new PR.
@mnajdova we can skip review of FilterableMenu demos/logic for now since it's not in the upcoming release. I will just hide it from the docs and package and work on it in a new PR.
Yeah fair enough, do you agree for the behavior for the multiple combobox tough?
@mnajdova yeah I do agree
I think https://mui.com/material-ui/react-autocomplete/#multiple-values is a good reference for this behavior and it doesn't close when clicking the chips. It does however close in a multiple select when selecting a single item - I suppose this should be configurable
It does however close in a multiple select when selecting a single item - I suppose this should be configurable
It's up for discussion, I would keep it open in that case too, as otherwise it is inconsistent with the keyboard selection, if I press enter on an item it doesn't close the list, why would click close the list, it's just weird experience.
First of all: great work! Most of the functionality is flawless.
Random things I noticed:
- In the Creatable demo, pressing Enter should work the same as choosing to add a new item. Otherwise, you can end up having both chips and free text, which doesn't look right
- Similarly, the multiple example allows free text to be present. I'd expect invalid values to be cleared on blur (or is this somehow configurable?)
- The Input inside popup example could use typeahead on a closed combobox (similarly to how Select works), so for example, focusing the combobox and pressing "D" would select "Denmark" without opening the popup. That's how I usually operate with selects when I know what options are there.
- Why is the controlled value prop called
selectedValue, and notvalueas in Select? - I haven't found in the docs what the Row part is for and why we may need it around Item
- In Autocomplete, can we limit the number of displayed suggestions?
Why is the controlled value prop called selectedValue, and not value as in Select?
I originally had something like this but realized it was super confusing what value referred to. I saw different implementations used value for the selected value and others used it for the input value. So I decided that the slightly more verbose name was better to remove the ambiguity.
However, that may have been more of a concern when Combobox was monolithic. Now that they're split, value for selected value and inputValue for Combobox could work; while for Autocomplete, value could refer to the inputValue instead?
Yes, this is what I'd intuitively expect. It'll also make transitioning from Select to Combobox easier.
Did we look into the bundle size? Does it really need to be one of the heaviest? It's tiny in Material UI:
- Material UI unstyled autocomplete: 3.5kB gzipped https://bundlejs.com/?q=%40mui%2Fmaterial%2FuseAutocomplete&treeshake=%5B*%5D&text=%22export+%7B+useAutocomplete+%7D%3B%22. https://mui.com/material-ui/react-autocomplete/#customized-hook
- Downshift: 14.7kB gzipped: https://bundlejs.com/?q=downshift&treeshake=%5B*%5D&text=%22export+%7B+useCombobox%C2%A0%7D%22 https://www.downshift-js.com/use-combobox
- React Select: 34.6kB gzipped: https://bundlejs.com/?q=react-select&treeshake=%5B*%5D&text=%22export+%7BSelect%7D%22 https://react-select.com/home
- Headless UI ComboBox: 42.5kB gzipped: https://bundlejs.com/?q=%40headlessui%2Freact&treeshake=%5B*%5D&share=KYDwDg9gTgLgBAbzgYQgWwEYSyANC9LHAIQFcYYIA7fVTbCEASSrHNsIZAHkwYBLah3o5eA6gGc4AXyA https://headlessui.com/react/combobox
- Base UI ComboBox: 43.4kB gzipped https://frontend-public.mui.com/size-comparison/mui/base-ui/diff?prNumber=2105&baseRef=master&baseCommit=0f2b7a1e4220a61f496257c7497e926e2b69a03a&headCommit=ae29f2720c1c4b6670e2e0e68f3f751b448b997b&circleCIBuildNumber=135868
- React Aria ComboBox: 52.9kB gzipped https://bundlejs.com/?q=react-aria-components&treeshake=%5B*%5D&text=%22export+%7B%C2%A0ComboBox%7D%22 https://react-spectrum.adobe.com/react-aria/ComboBox.html
@oliviertassinari the upfront cost is substantial but once you're using it with e.g. Select, then the cost is only +5-10 kb roughly. If you use Dialog and add Combobox after, it's close to around half the cost. I mentioned the shared dependencies here a bit: https://github.com/mui/base-ui/issues/602#issuecomment-2345217478
And some things ours includes that are lacking in the smaller ones:
- Proper anchor positioning
Field/Formintegration- Animation API integration
- Grid list navigation (grid/gridcell/rows)
- The component API (not just a hook with some spreading), which has a bunch of overhead to make the DX nice
- Correctness and bug handling that is lacking in many of those. e.g. pressing Tab in Material UI loses focus to the body and doesn't support iOS VoiceOver, which ours does (adds nearly 5 kB to the total)
- Supports an open API not only closed
- The size presented is if you're using every component - if you only use some, treeshaking eliminates a bunch of the size
then the cost is only +5-10 kb roughly
@atomiks The size of a component once used with another component is something that feels like we should not look at much (but still looked at a bit to make sure we don't duplicate absurd stuff).
- As a user, what I care is the standalone size, I will weight the pros and cons with using another library. I think react-select is a proof that people are willing to do the effort to cherry pick. People's codebase is a mix of so many different libraries, where evergreen projet, starting from starch are the minority.
- The same argument of size with more components applies to Headless UI and Material UI. To be fair, the example of Material UI I used is the most barebone version, I think it has 80% of what people need in real projets, but still, it's missing the anchor position to work within dialogs. And maybe to fix https://github.com/mui/material-ui/issues/21661.
So I think success is about being clearly smaller than Headless UI, and I think smaller than React Select since they also bundle emotion. So it feels like 25-30KB should be the maximum JS size budget we allow ourselves, because a. competition won't be able to go a lot lower, and b. it shouldn't need more.
The size presented is if you're using every component - if you only use some, treeshaking eliminates a bunch of the size
Ok, so this size number needs to be updated to reflect a comparable canoncial use case as the other libraries.
Supports an open API not only closed
This makes me connect the dot that the demos in the docs feels wrong: that it shouldn't be about picking one value among 7 options, but 300 to reflect how people will evaluate their options.
-
I found it unexpected to have the clear and trigger buttons focusable in the hero demo. Usually, when I use the keyboard, I select the value and expect the Tab key to move to the next field immediately. Focusing the trigger seems unnecessary, especially since there is already an alternative way to open the list (using the down arrow). As for the clear button, the most similar example I could find is the clear button in native search fields. It also doesn't create a tab stop, and its hotkey is Esc.
-
In the creatable demo, there's a difference in behavior depending on whether you press Enter or select the "Create..." command - only the latter opens the Dialog. Since the item can theoretically be an object with multiple fields, the Dialog should be displayed in both cases.
-
I'm building a tag selector (quite similar to the Creatable demo, so multi-select with the ability to specify new options, but without a Dialog). One feature I'd find useful would be custom confirmation keys. In this specific scenario, users might type "red, blue" and have the comma act like Enter (either creating a new tag or selecting one from existing).
-
This is more general feedback about our demos - IMO some users might struggle to create reusable DS components based on the demos (especially complex components like the combobox). The main reason is that it's not immediately clear what parts of code are inherent to the reusable component and what are demo-specific (for example, I don't know without deeper analysis if state variables in the creatable demo support this specific scenario or can be used to create a generic component).
@mnajdova it's intentional - without autoHighlight, the highlight doesn't remain "trapped" in the listbox and can be removed (so virtual focus returns to the input). This also applies to opening the listbox for the first time (the Google Search combobox for example works like this). If there is a selection made in Combobox, then the highlight is always on the selected item though when opening
I feel this is wrong (at least for a default behavior). Based on ARIA:
Down Arrow: If the popup is available, moves focus into the popup:
- If the autocomplete behavior automatically selected a suggestion before Down Arrow was pressed, focus is placed on the suggestion following the automatically selected suggestion.
- Otherwise, places focus on the first focusable element in the popup.
React Aria's combobox also has that as a default behavior: https://react-spectrum.adobe.com/react-aria/ComboBox.html