Create react-combobox package#21666
Conversation
|
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. Latest deployment of this branch, based on commit 692459f:
|
📊 Bundle size report
🤖 This report was generated against 9797c74483468ba919b66f4bdf7f4e728910416f |
Asset size changesSize Auditor did not detect a change in bundle size for any component! Baseline commit: 11a39f3c7fad9f159d82c50cf89fd4b722777acb (build) |
Perf Analysis (
|
| Scenario | Render type | Master Ticks | PR Ticks | Iterations | Status |
|---|---|---|---|---|---|
| Avatar | mount | 962 | 925 | 5000 | |
| BaseButton | mount | 969 | 995 | 5000 | |
| Breadcrumb | mount | 2787 | 2740 | 1000 | |
| ButtonNext | mount | 502 | 500 | 5000 | |
| Checkbox | mount | 1678 | 1624 | 5000 | |
| CheckboxBase | mount | 1369 | 1388 | 5000 | |
| ChoiceGroup | mount | 5085 | 5054 | 5000 | |
| ComboBox | mount | 1019 | 1048 | 1000 | |
| CommandBar | mount | 10764 | 10806 | 1000 | |
| ContextualMenu | mount | 8854 | 8794 | 1000 | |
| DefaultButton | mount | 1211 | 1191 | 5000 | |
| DetailsRow | mount | 3906 | 3990 | 5000 | |
| DetailsRowFast | mount | 3953 | 3937 | 5000 | |
| DetailsRowNoStyles | mount | 3685 | 3782 | 5000 | |
| Dialog | mount | 2306 | 2375 | 1000 | |
| DocumentCardTitle | mount | 212 | 194 | 1000 | |
| Dropdown | mount | 3382 | 3421 | 5000 | |
| FluentProviderNext | mount | 2005 | 1989 | 5000 | |
| FluentProviderWithTheme | mount | 180 | 173 | 10 | |
| FluentProviderWithTheme | virtual-rerender | 129 | 118 | 10 | |
| FluentProviderWithTheme | virtual-rerender-with-unmount | 226 | 205 | 10 | |
| FocusTrapZone | mount | 1963 | 1950 | 5000 | |
| FocusZone | mount | 1933 | 1936 | 5000 | |
| IconButton | mount | 1865 | 1868 | 5000 | |
| Label | mount | 393 | 396 | 5000 | |
| Layer | mount | 3129 | 3206 | 5000 | |
| Link | mount | 532 | 492 | 5000 | |
| MakeStyles | mount | 1795 | 1813 | 50000 | |
| MenuButton | mount | 1572 | 1581 | 5000 | |
| MessageBar | mount | 2114 | 2142 | 5000 | |
| Nav | mount | 3451 | 3496 | 1000 | |
| OverflowSet | mount | 1206 | 1175 | 5000 | |
| Panel | mount | 2254 | 2292 | 1000 | |
| Persona | mount | 899 | 896 | 1000 | |
| Pivot | mount | 1544 | 1536 | 1000 | |
| PrimaryButton | mount | 1368 | 1429 | 5000 | |
| Rating | mount | 8046 | 8100 | 5000 | |
| SearchBox | mount | 1461 | 1471 | 5000 | |
| Shimmer | mount | 2678 | 2656 | 5000 | |
| Slider | mount | 2079 | 2113 | 5000 | |
| SpinButton | mount | 5224 | 5264 | 5000 | |
| Spinner | mount | 484 | 490 | 5000 | |
| SplitButton | mount | 3300 | 3321 | 5000 | |
| Stack | mount | 573 | 571 | 5000 | |
| StackWithIntrinsicChildren | mount | 2451 | 2441 | 5000 | |
| StackWithTextChildren | mount | 5524 | 5568 | 5000 | |
| SwatchColorPicker | mount | 12056 | 11992 | 5000 | |
| TagPicker | mount | 2703 | 2726 | 5000 | |
| TeachingBubble | mount | 13754 | 13824 | 5000 | |
| Text | mount | 484 | 471 | 5000 | |
| TextField | mount | 1509 | 1548 | 5000 | |
| ThemeProvider | mount | 1307 | 1293 | 5000 | |
| ThemeProvider | virtual-rerender | 673 | 664 | 5000 | |
| ThemeProvider | virtual-rerender-with-unmount | 1999 | 2036 | 5000 | |
| Toggle | mount | 872 | 880 | 5000 | |
| buttonNative | mount | 169 | 161 | 5000 |
Perf Analysis (@fluentui/react-northstar)
Perf tests with no regressions
| Scenario | Current PR Ticks | Baseline Ticks | Ratio |
|---|---|---|---|
| SegmentMinimalPerf.default | 380 | 350 | 1.09:1 |
| DividerMinimalPerf.default | 399 | 371 | 1.08:1 |
| TreeWith60ListItems.default | 196 | 182 | 1.08:1 |
| ButtonMinimalPerf.default | 186 | 175 | 1.06:1 |
| PortalMinimalPerf.default | 196 | 185 | 1.06:1 |
| ListNestedPerf.default | 601 | 575 | 1.05:1 |
| ButtonSlotsPerf.default | 600 | 577 | 1.04:1 |
| ChatDuplicateMessagesPerf.default | 331 | 319 | 1.04:1 |
| MenuMinimalPerf.default | 889 | 858 | 1.04:1 |
| StatusMinimalPerf.default | 728 | 702 | 1.04:1 |
| TextMinimalPerf.default | 375 | 362 | 1.04:1 |
| GridMinimalPerf.default | 365 | 355 | 1.03:1 |
| ListWith60ListItems.default | 682 | 662 | 1.03:1 |
| SkeletonMinimalPerf.default | 368 | 358 | 1.03:1 |
| AccordionMinimalPerf.default | 172 | 168 | 1.02:1 |
| AttachmentSlotsPerf.default | 1126 | 1105 | 1.02:1 |
| AvatarMinimalPerf.default | 207 | 202 | 1.02:1 |
| HeaderSlotsPerf.default | 806 | 789 | 1.02:1 |
| ImageMinimalPerf.default | 399 | 393 | 1.02:1 |
| InputMinimalPerf.default | 1372 | 1345 | 1.02:1 |
| PopupMinimalPerf.default | 668 | 653 | 1.02:1 |
| AnimationMinimalPerf.default | 577 | 573 | 1.01:1 |
| CarouselMinimalPerf.default | 493 | 489 | 1.01:1 |
| CheckboxMinimalPerf.default | 2767 | 2742 | 1.01:1 |
| DatepickerMinimalPerf.default | 5808 | 5756 | 1.01:1 |
| DropdownManyItemsPerf.default | 695 | 691 | 1.01:1 |
| FormMinimalPerf.default | 429 | 425 | 1.01:1 |
| HeaderMinimalPerf.default | 390 | 387 | 1.01:1 |
| ItemLayoutMinimalPerf.default | 1230 | 1216 | 1.01:1 |
| ListCommonPerf.default | 670 | 666 | 1.01:1 |
| ListMinimalPerf.default | 534 | 527 | 1.01:1 |
| RadioGroupMinimalPerf.default | 474 | 469 | 1.01:1 |
| ReactionMinimalPerf.default | 403 | 399 | 1.01:1 |
| SliderMinimalPerf.default | 1759 | 1743 | 1.01:1 |
| TableManyItemsPerf.default | 2000 | 1978 | 1.01:1 |
| ToolbarMinimalPerf.default | 975 | 961 | 1.01:1 |
| TreeMinimalPerf.default | 861 | 851 | 1.01:1 |
| DialogMinimalPerf.default | 818 | 817 | 1:1 |
| DropdownMinimalPerf.default | 3186 | 3191 | 1:1 |
| EmbedMinimalPerf.default | 4309 | 4291 | 1:1 |
| LayoutMinimalPerf.default | 379 | 378 | 1:1 |
| LoaderMinimalPerf.default | 714 | 716 | 1:1 |
| RefMinimalPerf.default | 252 | 253 | 1:1 |
| SplitButtonMinimalPerf.default | 4491 | 4499 | 1:1 |
| TableMinimalPerf.default | 417 | 415 | 1:1 |
| AttachmentMinimalPerf.default | 162 | 164 | 0.99:1 |
| ButtonOverridesMissPerf.default | 1777 | 1790 | 0.99:1 |
| CardMinimalPerf.default | 584 | 592 | 0.99:1 |
| ChatWithPopoverPerf.default | 393 | 397 | 0.99:1 |
| ProviderMergeThemesPerf.default | 1804 | 1825 | 0.99:1 |
| ProviderMinimalPerf.default | 1163 | 1172 | 0.99:1 |
| IconMinimalPerf.default | 636 | 640 | 0.99:1 |
| CustomToolbarPrototype.default | 4230 | 4279 | 0.99:1 |
| TooltipMinimalPerf.default | 1076 | 1092 | 0.99:1 |
| VideoMinimalPerf.default | 659 | 664 | 0.99:1 |
| AlertMinimalPerf.default | 290 | 295 | 0.98:1 |
| BoxMinimalPerf.default | 357 | 363 | 0.98:1 |
| MenuButtonMinimalPerf.default | 1716 | 1746 | 0.98:1 |
| ChatMinimalPerf.default | 758 | 781 | 0.97:1 |
| FlexMinimalPerf.default | 300 | 310 | 0.97:1 |
| RosterPerf.default | 1202 | 1233 | 0.97:1 |
| TextAreaMinimalPerf.default | 505 | 530 | 0.95:1 |
| LabelMinimalPerf.default | 399 | 423 | 0.94:1 |
|
Can you please create the autogenerated things for this package in a separate PR to make reviews easier ? |
|
|
||
| return ( | ||
| <slots.root {...slotProps.root}> | ||
| <slots.content {...slotProps.content}>{slotProps.content.children}</slots.content> |
There was a problem hiding this comment.
Does slotProps.content.children need to be written as a child of <slots.content>? Shouldn't the props spread also spread children?
There was a problem hiding this comment.
haha you're right, I was messing around with the render of slots.content and forgot to change it back 😅
There was a problem hiding this comment.
I would suggest that you could use the root children as the children for the content slot. It's what is done for MenuItem and makes for a clearer API, especially if customers want extra dom markup in the content.
<ComboButton icon={<Icon />}>
<span>Some custom content <strong>that is bold</strong></span>
</ComboButton>Which is a lot better than
<ComboButton icon={<Icon />} content={<span>Some custom content <strong>that is bold</strong></span>} />The styling is a valid issue since to style the content slot only users would need to:
<ComboButton icon={<Icon />} content={{ className: styles.content }}>
<span>Some custom content <strong>that is bold</strong></span>
</ComboButton>In MenuItem we went with the assumption that this styling would not be that common and the trade off was worth the API clarity
There was a problem hiding this comment.
code in MenuItem for reference:
fluentui/packages/react-menu/src/components/MenuItem/useMenuItem.tsx
Lines 59 to 62 in e2e8307
There was a problem hiding this comment.
@ling1726 the content slot is the primary slot for the ComboButton control, so this would already be the way it would be used:
<ComboButton icon={<Icon />}>
<span>Some custom content <strong>that is bold</strong></span>
</ComboButton>ComboButton is a bit different than MenuItem in that any non-style props (e.g. aria-* attributes, disabled, readonly, etc.) should go on the content slot's button, which is the element with role="combobox". The root slot is a <div> for styling/layout, so having root as the primary slot and just putting its children in the content slot doesn't make as much sense here.
| @@ -0,0 +1,33 @@ | |||
| import { useControllableState } from '@fluentui/react-utilities'; | |||
There was a problem hiding this comment.
In response to your question about whether this should live outside of the ComboBox package my feeling is that we should keep it as an internal implementation detail of ComboBox for now. AFAIK we don't have any list controls for v9 yet and no one is working on any so we don't really know how we will handle selection in those cases. Keeping this private to ComboBox lets us solve the selection issue for this control while leaving us free to change the solution later to a more general selection implementation that works for all lists if necessary while avoiding adding/deprecating public APIs.
| * enum of actions available in any type of managed dropdown control | ||
| * e.g. combobox, select, datepicker, menu | ||
| */ | ||
| export enum DropdownActions { |
There was a problem hiding this comment.
In working on the SpinButton API I was told we are trying to avoid using TS enums in favor or string unions. Should this be changed?
There was a problem hiding this comment.
ah yeah, I should update this to a string union 👍
| } | ||
|
|
||
| let valueString = ''; | ||
| React.Children.map(children, child => { |
There was a problem hiding this comment.
I think you want to use forEach in this case rather than map since you don't need to the array that map produces.
| unRegisterOption: ctx.unRegisterOption, | ||
| }), | ||
| ); | ||
| const { id = `${idBase}-${props.fluentKey}`, fluentKey: key, disabled, value } = props; |
There was a problem hiding this comment.
Based on the guards used below (key ? ...) it looks like props.fluentKey can be undefined which means id might end up as ${idBase}-undefined.
There was a problem hiding this comment.
Instead of using id, keys or something better I suggest to make value a required prop.
valueshould be uniquevalueis an attribute on<option>valueis a string
There was a problem hiding this comment.
Unfortunately, since value is used as a way to define the string used in the actual <input>/<button> trigger element, it doesn't need to be unique and can't be a replacement for id or key.
An example of this is a combobox that has several pinned options at the top whose values are the same as the unpinned options in the full list (seen this in a Microsoft product in the past).
Another example is someone using options as, essentially, actions like "select all", "deselect", or whatever custom thing they're making. The value for each of those would likely be an empty string.
There was a problem hiding this comment.
An example of this is a combobox that has several pinned options at the top whose values are the same as the unpinned options in the full list (seen this in a Microsoft product in the past).
Do you I correctly understand that it's something like that (a snippet below)?
function App() {
return (
<Combobox>
<PinnedOptions>
<Option value="us">US</Option>
<Option value="canada">Canada</Option>
</PinnedOptions>
<AllOptions>
<Option value="us">US</Option>
<Option value="canada">Canada</Option>
<Option value="cz">Czech Republic</Option>
<Option value="de">Germany</Option>
</AllOptions>
</Combobox>
);
}- React
keys are not needed there as it's defined in JSX- We don't really need React keys in this snippet
- Let's assume that
ids will be autogenerated on everyOptionso they are also uniq- They can be used for
aria-activedescendant
- They can be used for
- I expect that values are not unique in this scope as if an option is selected in
PinnedOptionsit also should be selected inAllOptions
Is there sense in it?
There was a problem hiding this comment.
Yup, that's essentially the pinned options example I was thinking of.
| keys.push(...groupKeys); | ||
| return React.cloneElement(child, {}, groupChildren); | ||
| } else { | ||
| return child; |
There was a problem hiding this comment.
I wonder if we should also clone this child so that all children returned from this function are clones? Might be surprising to have some clones and some original children.
| function getValidOptions(children: React.ReactNode): { keys: string[]; children: React.ReactNode } { | ||
| const keys: string[] = []; | ||
|
|
||
| const clonedChildren = React.Children.map(children, (child, index) => { |
There was a problem hiding this comment.
+1 to eliminating the need for itemKey!
|
|
||
| // if the child is an option, add its key to the array | ||
| if (fluentComponentType === 'Option') { | ||
| const optionKey = child.props.fluentKey || child.key || child.props.id || `${index}`; |
There was a problem hiding this comment.
+1 to eliminating the need for itemKey on options.
| * @param ref - reference to root HTMLElement of Listbox | ||
| */ | ||
| export const useListbox_unstable = (props: ListboxProps, ref: React.Ref<HTMLElement>): ListboxState => { | ||
| export const useListbox_unstable = ( |
There was a problem hiding this comment.
Is the intention for the Listbox component that it can be used separately from Combobox ? It looks like a lot of the interactions and handlers are quite similar to the parent Combobox
There was a problem hiding this comment.
Yup, it can be used separately 👍
| // TODO: add themed styles | ||
| root: { | ||
| // TODO Add default styles for the root element | ||
| boxShadow: '0px 0px 2px 0px #0000001F, 0px 8px 16px 0px #00000024', |
There was a problem hiding this comment.
Are these box shadows available in our theme tokens ? if they are now I would check with design to see why because the hard coded values are not theme aware.
| // TODO Add default styles for the root element | ||
| boxShadow: '0px 0px 2px 0px #0000001F, 0px 8px 16px 0px #00000024', | ||
| ...shorthands.borderRadius('4px'), | ||
| backgroundColor: '#fff', |
There was a problem hiding this comment.
#fff is definitely not theme aware, and you should use a design token for this
|
This pull request has been automatically marked as stale because it was marked as requiring author feedback but has not had any activity for 7 days. It will be closed if no further activity occurs within 5 days of this comment. Thank you for your contributions to Fluent UI! |
change/@fluentui-react-combobox-ec7975c2-ac5a-4088-b599-5e3fb127c2cc.json
Outdated
Show resolved
Hide resolved
packages/react-combobox/src/components/ComboButton/renderComboButton.tsx
Show resolved
Hide resolved
| /* Unique id string that can be used as a base for default option ids */ | ||
| idBase: string; |
| /* The dropdown listbox slot */ | ||
| listbox: NonNullable<Slot<typeof Listbox>>; | ||
|
|
||
| /* The primary slot, the element with role="combobox" */ | ||
| trigger: NonNullable<Slot<typeof ComboButton>>; |
There was a problem hiding this comment.
This was discussed in other threads, but these slots are not aligned with the implementation of other components. Can you please create/link issue to follow up on this?
There was a problem hiding this comment.
Here it is, also created a checkbox for it in the convergence epic: #22331
| // if the child is an option, add its key to the array | ||
| if (fluentComponentType === 'Option') { | ||
| const optionKey = child.props.fluentKey || child.key || child.props.id || `${index}`; |
There was a problem hiding this comment.
@smhigley can we please link an issue to refactor this part and avoid cloning for the reasons mentioned in the comment?
| // update value based on selectedOptions | ||
| React.useEffect(() => { | ||
| let newValue; | ||
| if (multiselect) { | ||
| newValue = selectedOptions.map(option => option.value).join(', '); | ||
| } else { | ||
| newValue = selectedOptions[0]?.value; | ||
| } | ||
|
|
||
| setValue(newValue); | ||
| }, [multiselect, placeholder, selectedOptions, setValue]); |
There was a problem hiding this comment.
There was a problem hiding this comment.
It comes from a useControllableState hook in useSelection. There was a separate issue causing the multiple option re-renders you commented on earlier, which should've been fixed earlier. If you're still seeing multiple unnecessary renders somewhere, let me know & I'll open an issue for it
|
@smhigley I unblocked the PR, please check latest comments and ensure that we have matching issues for the problems that were discovered. I suggest to make PRs with more granular changes for next changes. |

Related to #20933, this is a draft implementation of the react-combobox package.
Things not included in this PR:
The main areas to pay attention to are:
Option registration
The option <> listbox/combobox communication happens primarily through the
useOptionCollectionhook, and theregisterOption/unRegisterOptionfunctions it provides that go on the context.The basic idea here is that
useOptionCollectioncreates an ordered array ofOptionkeys by iteratingReact.Children, then individual Options register themselves with the context with their key/id/value data, soids can be accessed by actual child index.I tried to structure it so that we could implement virtualization by just replacing the
useOptionCollectionentirely with one that supports virtualized children. As long as fulfills the same contract asuseOptionCollection, it can be passed in Combobox and Listbox like so:Current Combobox:
Virtualized Combobox:
Option
keyvs.itemKeySince the
keyprop isn't available within a component's render function, we discussed using a separateitemKeyprop vs. modifying the Option children to pass in thekeyvalue.This PR uses the second option, handled through the
getValidOptionsfunction inuseOptionCollection. As an alternative, this branch shows it using a requireditemKeyprop instead (the changes are primarily inuseOptionCollection,renderListbox, anduseOption).The
keyapproach relies onReact.cloneElement, but the benefit is that authors don't need to specify a second uniqueitemKeyprop for each option in addition tokey.useSelection
I'm not sure if this should live outside
react-combobox, since it seems like it could be reused in other future grid/list controls that support both single and multiple selection.