Skip to content

Create react-combobox package#21666

Merged
smhigley merged 40 commits intomicrosoft:masterfrom
smhigley:combobox-vnext
Apr 6, 2022
Merged

Create react-combobox package#21666
smhigley merged 40 commits intomicrosoft:masterfrom
smhigley:combobox-vnext

Conversation

@smhigley
Copy link
Contributor

@smhigley smhigley commented Feb 9, 2022

Related to #20933, this is a draft implementation of the react-combobox package.

Things not included in this PR:

  • Spec and docs
  • Tests
  • Stories, beyond a few single/multiple/grouped stories to show basic functionality
  • Theme styles (some basic styles for testing are included, but are not the final styles)
  • Some theme/style-specific props haven't been added yet
  • Some keyboard functionality (specifically typeahead) hasn't been implemented yet
  • Edit/filter functionality

The main areas to pay attention to are:

Option registration

The option <> listbox/combobox communication happens primarily through the useOptionCollection hook, and the registerOption/unRegisterOption functions it provides that go on the context.

The basic idea here is that useOptionCollection creates an ordered array of Option keys by iterating React.Children, then individual Options register themselves with the context with their key/id/value data, so ids can be accessed by actual child index.

I tried to structure it so that we could implement virtualization by just replacing the useOptionCollection entirely with one that supports virtualized children. As long as fulfills the same contract as useOptionCollection, it can be passed in Combobox and Listbox like so:

Current Combobox:

export const Combobox: ForwardRefComponent<ComboboxProps> = React.forwardRef((props, ref) => {
  const optionCollection = useOptionCollection(props.children);
  const state = useCombobox(props, optionCollection, ref);
  const contextValues = useComboboxContextValues(state);

  useComboboxStyles(state);
  return renderCombobox(state, contextValues);
});

Virtualized Combobox:

export const VirtualizedCombobox: ForwardRefComponent<VirtualizedComboboxProps> = React.forwardRef((props, ref) => {
  // change the collection hook out here:
  const optionCollection = useVirtualizedCollection(props.children);

  // and pass it into useCombobox:
  const state = useCombobox(props, optionCollection, ref);
  const contextValues = useComboboxContextValues(state);

  useComboboxStyles(state);
  return renderCombobox(state, contextValues);
});

Option key vs. itemKey

Since the key prop isn't available within a component's render function, we discussed using a separate itemKey prop vs. modifying the Option children to pass in the key value.

This PR uses the second option, handled through the getValidOptions function in useOptionCollection. As an alternative, this branch shows it using a required itemKey prop instead (the changes are primarily in useOptionCollection, renderListbox, and useOption).

The key approach relies on React.cloneElement, but the benefit is that authors don't need to specify a second unique itemKey prop for each option in addition to key.

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.

@smhigley smhigley requested review from a team February 9, 2022 06:31
@smhigley smhigley requested a review from a team as a code owner February 9, 2022 06:31
@codesandbox-ci
Copy link

codesandbox-ci bot commented Feb 9, 2022

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:

Sandbox Source
@fluentui/react 8 starter Configuration
@fluentui/react-components 9 starter Configuration

@fabricteam
Copy link
Collaborator

fabricteam commented Feb 9, 2022

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-combobox
Combobox
6.817 kB
2.901 kB
54.566 kB
18.884 kB
47.749 kB
15.983 kB

🤖 This report was generated against 9797c74483468ba919b66f4bdf7f4e728910416f

@size-auditor
Copy link

size-auditor bot commented Feb 9, 2022

Asset size changes

Size Auditor did not detect a change in bundle size for any component!

Baseline commit: 11a39f3c7fad9f159d82c50cf89fd4b722777acb (build)

@fabricteam
Copy link
Collaborator

fabricteam commented Feb 9, 2022

Perf Analysis (@fluentui/react)

No significant results to display.

All results

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

@ling1726
Copy link
Contributor

ling1726 commented Feb 9, 2022

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>
Copy link
Contributor

Choose a reason for hiding this comment

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

Does slotProps.content.children need to be written as a child of <slots.content>? Shouldn't the props spread also spread children?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

haha you're right, I was messing around with the render of slots.content and forgot to change it back 😅

Copy link
Contributor

@ling1726 ling1726 Feb 24, 2022

Choose a reason for hiding this comment

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

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

Copy link
Contributor

@ling1726 ling1726 Feb 24, 2022

Choose a reason for hiding this comment

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

code in MenuItem for reference:

content: resolveShorthand(props.content, {
required: !!props.children,
defaultProps: { children: props.children },
}),

/**
* Component children are placed in this slot
* Avoid using the `children` property in this slot in favour of Component children whenever possible
*/
content?: Slot<'span'>;

Copy link
Contributor Author

@smhigley smhigley Mar 18, 2022

Choose a reason for hiding this comment

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

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

Choose a reason for hiding this comment

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

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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah yeah, I should update this to a string union 👍

}

let valueString = '';
React.Children.map(children, child => {
Copy link
Contributor

Choose a reason for hiding this comment

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

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Based on the guards used below (key ? ...) it looks like props.fluentKey can be undefined which means id might end up as ${idBase}-undefined.

Copy link
Member

Choose a reason for hiding this comment

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

Instead of using id, keys or something better I suggest to make value a required prop.

Copy link
Contributor Author

@smhigley smhigley Mar 10, 2022

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

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 every Option so they are also uniq
    • They can be used for aria-activedescendant
  • I expect that values are not unique in this scope as if an option is selected in PinnedOptions it also should be selected in AllOptions

Is there sense in it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, that's essentially the pinned options example I was thinking of.

keys.push(...groupKeys);
return React.cloneElement(child, {}, groupChildren);
} else {
return child;
Copy link
Contributor

Choose a reason for hiding this comment

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

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) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

+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}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

+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 = (
Copy link
Contributor

@ling1726 ling1726 Feb 24, 2022

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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',
Copy link
Contributor

Choose a reason for hiding this comment

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

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',
Copy link
Contributor

Choose a reason for hiding this comment

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

#fff is definitely not theme aware, and you should use a design token for this

@msft-fluent-ui-bot
Copy link
Collaborator

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!

Comment on lines +81 to +82
/* Unique id string that can be used as a base for default option ids */
idBase: string;
Copy link
Member

Choose a reason for hiding this comment

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

@smhigley can you please provide context on this?

Comment on lines +13 to +17
/* The dropdown listbox slot */
listbox: NonNullable<Slot<typeof Listbox>>;

/* The primary slot, the element with role="combobox" */
trigger: NonNullable<Slot<typeof ComboButton>>;
Copy link
Member

Choose a reason for hiding this comment

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

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here it is, also created a checkbox for it in the convergence epic: #22331

Comment on lines +16 to +18
// 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}`;
Copy link
Member

Choose a reason for hiding this comment

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

@smhigley can we please link an issue to refactor this part and avoid cloning for the reasons mentioned in the comment?

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

Choose a reason for hiding this comment

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

This looks like a problem 💥

I am not sure how selectedOptions are generated, but they come from state/props, right?

image

As selectedOptions is an array React will trigger additional re-renders every time when a reference changes. This is probably one of the reasons why I have seen so many renders.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

@layershifter layershifter dismissed their stale review March 31, 2022 14:27

Unblock the PR

@layershifter
Copy link
Member

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

@smhigley smhigley merged commit fdf55e7 into microsoft:master Apr 6, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants