base-ui icon indicating copy to clipboard operation
base-ui copied to clipboard

[combobox] New `Combobox` component

Open atomiks opened this issue 6 months ago • 5 comments

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 items prop with built-in filtering logic
  • [x] Fix grid navigation 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

atomiks avatar Jun 12 '25 08:06 atomiks

Open in StackBlitz

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

pkg-pr-new[bot] avatar Jun 12 '25 08:06 pkg-pr-new[bot]

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/reactparsed: 🔺+36.7KB(+11.79%) gzip: 🔺+10.6KB(+10.66%) @base-ui-components/react/navigation-menuparsed: 🔺+1.76KB(+1.96%) gzip: 🔺+731B(+2.33%) @base-ui-components/react/menuparsed: 🔺+1.68KB(+1.48%) gzip: 🔺+660B(+1.68%) @base-ui-components/react/selectparsed: 🔺+1.67KB(+1.42%) gzip: 🔺+658B(+1.61%) @base-ui-components/react/context-menuparsed: 🔺+1.67KB(+1.48%) gzip: 🔺+668B(+1.71%) @base-ui-components/react/tabsparsed: 🔺+808B(+3.09%) gzip: 🔺+326B(+3.46%) @base-ui-components/react/toolbarparsed: 🔺+806B(+4.12%) gzip: 🔺+344B(+4.92%) @base-ui-components/react/popoverparsed: 🔺+776B(+0.91%) gzip: 🔺+319B(+1.06%) @base-ui-components/react/radio-groupparsed: 🔺+753B(+3.55%) gzip: 🔺+324B(+4.03%) @base-ui-components/react/menubarparsed: 🔺+751B(+3.47%) gzip: 🔺+325B(+4.08%) @base-ui-components/react/toggle-groupparsed: 🔺+751B(+5.03%) gzip: 🔺+331B(+5.83%) @base-ui-components/react/preview-cardparsed: 🔺+489B(+0.85%) gzip: 🔺+212B(+1.03%) @base-ui-components/react/tooltipparsed: 🔺+482B(+0.76%) gzip: 🔺+202B(+0.90%) @base-ui-components/react/alert-dialogparsed: 🔺+287B(+0.57%) gzip: 🔺+89B(+0.50%) @base-ui-components/react/dialogparsed: 🔺+286B(+0.56%) gzip: 🔺+100B(+0.56%) @base-ui-components/react/accordionparsed: 🔺+55B(+0.25%) gzip: 🔺+15B(+0.19%) @base-ui-components/react/radioparsed: 🔺+55B(+0.38%) gzip: 🔺+5B(+0.09%) @base-ui-components/react/sliderparsed: 🔺+55B(+0.21%) gzip: 🔺+27B(+0.27%) @base-ui-components/react/toggleparsed: 🔺+55B(+0.64%) gzip: 🔺+14B(+0.39%) @base-ui-components/react/comboboxparsed: 🔺+128KB(new) gzip: 🔺+43.8KB(new) @base-ui-components/react/autocompleteparsed: 🔺+124KB(new) gzip: 🔺+42.9KB(new) @base-ui-components/react/avatarparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/checkboxparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/checkbox-groupparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/collapsibleparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/direction-providerparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/fieldparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/fieldsetparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/formparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/inputparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/merge-propsparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/meterparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/number-fieldparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/progressparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/scroll-areaparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/separatorparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/switchparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/toastparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/typesparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/unstable-no-ssrparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/unstable-use-media-queryparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/react/use-renderparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/detectBrowserparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/errorparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/fastObjectShallowCompareparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/generateIdparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/getReactElementRefparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/inertValueparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/isElementDisabledparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/isMouseWithinBoundsparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/mergeObjectsparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/ownerparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/reactVersionparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/safeReactparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/storeparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useAnimationFrameparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useControlledparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useEnhancedClickHandlerparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useEventCallbackparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useForcedRerenderingparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useIdparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useIntervalparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useIsoLayoutEffectparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useLatestRefparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useMergedRefsparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useOnFirstRenderparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useOnMountparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useRefWithInitparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/useTimeoutparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/visuallyHiddenparsed: 0B(0.00%) gzip: 0B(0.00%) @base-ui-components/utils/warnparsed: 0B(0.00%) gzip: 0B(0.00%)

Details of bundle changes

Generated by :no_entry_sign: dangerJS against 88e36a72563b8bd9bad8981a52daf2a3c8c9f351

mui-bot avatar Jun 12 '25 08:06 mui-bot

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

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

netlify[bot] avatar Jun 12 '25 08:06 netlify[bot]

it would be really nice this component will also support autoHighlight, similar to MUI Autocomplete.

michael-land avatar Jun 12 '25 16:06 michael-land

@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:

Screenshot 2025-06-13 at 9 15 28 am

atomiks avatar Jun 12 '25 23:06 atomiks

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 avatar Jul 02 '25 14:07 msholmesdev

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

colmtuite avatar Jul 02 '25 21:07 colmtuite

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.

michaldudak avatar Jul 11 '25 08:07 michaldudak

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

atomiks avatar Jul 11 '25 09:07 atomiks

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.

michaldudak avatar Jul 14 '25 07:07 michaldudak

I just notice that the selected tags spacing is not the consistent

Screenshot 2025-07-14 at 13 16 26

mnajdova avatar Jul 14 '25 11:07 mnajdova

@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 avatar Jul 14 '25 12:07 atomiks

@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 avatar Jul 31 '25 15:07 Dakkers

@Dakkers usually selects have an option specifically for deselection (with a null value), such as in the regular Select hero demo (Select font)

atomiks avatar Aug 01 '25 01:08 atomiks

@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

Dakkers avatar Aug 01 '25 14:08 Dakkers

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.

  • Combobox will change to only have filterable select behavior (in this PR, currently selectionMode="single"|"multiple"). The input is restricted to a predefined set of values.
  • Autocomplete, a new component, will be the current Combobox implementation with selectionMode="none". Used when the input can have any value, not restricted to a predefined set. For example, Google Search.
  • New Menu filtering behavior (possibly FilterableMenu), where the items fire off comands when clicked, unrelated to the input, and there's no selection remembered. Things like cmd+k, Notion's menu widget, an emoji picker, etc. Similar to selectionMode="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

atomiks avatar Aug 13 '25 03:08 atomiks

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 avatar Aug 19 '25 11:08 mnajdova

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

atomiks avatar Aug 19 '25 11:08 atomiks

@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 avatar Aug 19 '25 12:08 mnajdova

@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

atomiks avatar Aug 19 '25 12:08 atomiks

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.

mnajdova avatar Aug 19 '25 16:08 mnajdova

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 image
  • 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 not value as 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?

michaldudak avatar Aug 20 '25 16:08 michaldudak

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?

atomiks avatar Aug 21 '25 08:08 atomiks

Yes, this is what I'd intuitively expect. It'll also make transitioning from Select to Combobox easier.

michaldudak avatar Aug 21 '25 10:08 michaldudak

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 avatar Aug 27 '25 23:08 oliviertassinari

@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/Form integration
  • 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

atomiks avatar Aug 28 '25 06:08 atomiks

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

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

oliviertassinari avatar Aug 28 '25 18:08 oliviertassinari

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

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

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

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

michaldudak avatar Aug 29 '25 10:08 michaldudak

@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

atomiks avatar Sep 02 '25 08:09 atomiks

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

mnajdova avatar Sep 02 '25 08:09 mnajdova