Skip to content

Update trigger utilities to handle nested MenuTrigger/Tooltip#21495

Merged
behowell merged 9 commits into
microsoft:masterfrom
behowell:trigger-utilities
Feb 2, 2022
Merged

Update trigger utilities to handle nested MenuTrigger/Tooltip#21495
behowell merged 9 commits into
microsoft:masterfrom
behowell:trigger-utilities

Conversation

@behowell

@behowell behowell commented Jan 29, 2022

Copy link
Copy Markdown
Contributor

Current Behavior

Tooltip and MenuTrigger don't play nicely together. They both need to add props to their child element, but that does not currently work when they're nested like this:

<MenuTrigger>
  <Tooltip>
    <Button />
  </Tooltip>
</MenuTrigger>

New Behavior

This is an alternative approach to the useTriggerElement hook that was added here:

The goal of this approach is to have any trigger be able to "see through" other triggers and apply its props to the actual control that will do the triggering.

This introduces a few new types and helper functions:

  • FluentTriggerComponent - Allows components to tag themselves as trigger components, so other trigger components know to ignore them when looking for their child
  • getTriggerChild - Replaces the former onlyChild: looks for the first non-trigger descendant of the given element.
  • cloneTriggerTree - Applies new props by cloning the element that's found by getTriggerChild. Handles cloning any intermediate triggers as well, so they have the cloned element as their child.
  • useMergedEventCallbacks - Merges multiple callbacks into a single function that calls both.

This also removes the useTriggerElement function, since this replaces it.

Related Issues

@codesandbox-ci

codesandbox-ci Bot commented Jan 29, 2022

Copy link
Copy Markdown

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 49289be:

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

@fabricteam

fabricteam commented Jan 29, 2022

Copy link
Copy Markdown
Collaborator

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
165.87 kB
46.955 kB
165.697 kB
46.873 kB
-173 B
-82 B
react-menu
Menu (including children components)
103 kB
31.806 kB
102.827 kB
31.706 kB
-173 B
-100 B
react-menu
Menu (including selectable components)
105.251 kB
32.167 kB
105.078 kB
32.072 kB
-173 B
-95 B
react-tooltip
Tooltip
42.008 kB
14.478 kB
42.289 kB
14.589 kB
281 B
111 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-accordion
Accordion (including children components)
54.925 kB
17.57 kB
react-avatar
Avatar
43.056 kB
12.381 kB
react-badge
Badge
20.828 kB
6.533 kB
react-badge
CounterBadge
21.696 kB
6.832 kB
react-badge
PresenceBadge
21.79 kB
6.512 kB
react-button
Button
27.942 kB
8.023 kB
react-button
CompoundButton
33.192 kB
8.983 kB
react-button
MenuButton
29.596 kB
8.594 kB
react-button
SplitButton
35.682 kB
9.666 kB
react-button
ToggleButton
37.219 kB
8.634 kB
react-card
Card - All
48.488 kB
14.347 kB
react-card
Card
43.997 kB
13.195 kB
react-card
CardFooter
7.618 kB
3.232 kB
react-card
CardHeader
8.885 kB
3.673 kB
react-card
CardPreview
7.811 kB
3.361 kB
react-components
react-components: FluentProvider & webLightTheme
32.489 kB
10.636 kB
react-divider
Divider
15.269 kB
5.523 kB
react-image
Image
10.075 kB
3.942 kB
react-input
Input
21.474 kB
7.118 kB
react-label
Label
8.323 kB
3.479 kB
react-link
Link
11.072 kB
4.496 kB
react-make-styles
makeStaticStyles (runtime)
8.161 kB
3.564 kB
react-make-styles
makeStyles + mergeClasses (runtime)
19.554 kB
7.446 kB
react-make-styles
makeStyles + mergeClasses (build time)
2.793 kB
1.267 kB
react-popover
Popover
95.348 kB
29.017 kB
react-portal
Portal
6.249 kB
2.163 kB
react-positioning
usePopper
22.808 kB
7.935 kB
react-provider
FluentProvider
13.972 kB
5.237 kB
react-slider
Slider
22.936 kB
7.751 kB
react-spinner
Spinner
6.787 kB
2.887 kB
react-switch
Switch
25.397 kB
8.208 kB
react-text
Text - Default
10.769 kB
4.224 kB
react-text
Text - Wrappers
14.077 kB
4.565 kB
react-utilities
SSRProvider
189 B
161 B
🤖 This report was generated against 332d8f47ddea3daa577c95220a11f8bf5a7ac365

@size-auditor

size-auditor Bot commented Jan 29, 2022

Copy link
Copy Markdown

Asset size changes

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

Baseline commit: 332d8f47ddea3daa577c95220a11f8bf5a7ac365 (build)

@fabricteam

fabricteam commented Jan 29, 2022

Copy link
Copy Markdown
Collaborator

Perf Analysis (@fluentui/react)

No significant results to display.

All results

Scenario Render type Master Ticks PR Ticks Iterations Status
Avatar mount 690 782 5000
BaseButton mount 715 751 5000
Breadcrumb mount 2040 2123 1000
ButtonNext mount 452 451 5000
Checkbox mount 1324 1269 5000
CheckboxBase mount 1074 1131 5000
ChoiceGroup mount 3861 3935 5000
ComboBox mount 839 843 1000
CommandBar mount 8367 8837 1000
ContextualMenu mount 7220 6877 1000
DefaultButton mount 1005 949 5000
DetailsRow mount 3140 3200 5000
DetailsRowFast mount 3323 2913 5000
DetailsRowNoStyles mount 2806 3024 5000
Dialog mount 2150 2034 1000
DocumentCardTitle mount 131 141 1000
Dropdown mount 2675 2839 5000
FluentProviderNext mount 1586 1609 5000
FluentProviderWithTheme mount 132 144 10
FluentProviderWithTheme virtual-rerender 87 103 10
FluentProviderWithTheme virtual-rerender-with-unmount 168 190 10
FocusTrapZone mount 1511 1374 5000
FocusZone mount 1543 1493 5000
IconButton mount 1500 1496 5000
Label mount 320 287 5000
Layer mount 2466 2474 5000
Link mount 386 439 5000
MakeStyles mount 1351 1336 50000
MenuButton mount 1317 1180 5000
MessageBar mount 1775 1762 5000
Nav mount 2731 2778 1000
OverflowSet mount 977 969 5000
Panel mount 2008 2065 1000
Persona mount 731 704 1000
Pivot mount 1127 1233 1000
PrimaryButton mount 1070 1022 5000
Rating mount 6345 6582 5000
SearchBox mount 1103 1068 5000
Shimmer mount 1993 1991 5000
Slider mount 1586 1566 5000
SpinButton mount 4059 3959 5000
Spinner mount 399 412 5000
SplitButton mount 2692 2718 5000
Stack mount 427 468 5000
StackWithIntrinsicChildren mount 1891 1843 5000
StackWithTextChildren mount 4286 4153 5000
SwatchColorPicker mount 9633 9418 5000
TagPicker mount 2240 2193 5000
TeachingBubble mount 11340 11164 5000
Text mount 411 355 5000
TextField mount 1238 1170 5000
ThemeProvider mount 1049 1026 5000
ThemeProvider virtual-rerender 558 480 5000
ThemeProvider virtual-rerender-with-unmount 1623 1586 5000
Toggle mount 713 723 5000
buttonNative mount 118 133 5000

Perf Analysis (@fluentui/react-northstar)

⚠️ No perf measurements available

@behowell behowell marked this pull request as ready for review January 29, 2022 06:02
@behowell behowell requested review from a team and khmakoto as code owners January 29, 2022 06:02
@behowell behowell requested review from layershifter, ling1726 and spmonahan and removed request for spmonahan January 29, 2022 06:02
Comment thread packages/react-utilities/src/utils/applyTriggerPropsToChildren.ts Outdated

@layershifter layershifter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I requested changes because:

  • we should fix props priority (can be done in a separate PR)
  • there is no sense to support React.Fragment in traversal

In general it's not good idea to traverse React's tree, but as we don't rely on reading props - it's enough good for this scenario.

This approach is also better than existing as it removes event dispatching logic 🤗

outerProps: rest,
overrideProps,
}),
children: applyTriggerPropsToChildren(children, overrideProps),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As we discussed in offline the behavior of applyTriggerPropsToChildren() should be fixed to respect user's input:

<Tooltip content="bar" relationship="label">
  <Button aria-label="foo" />
</Tooltip>

In this case button should have foo as aria-label, but it has bar:

image


This change could be done either in this PR or in a follow up.

@behowell behowell Jan 31, 2022

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This needs to be handled by the individual trigger components to decide which props to override and which not to. applyTriggerPropsToChildren can't do it because for example, we want the merged event handlers on triggerProps to override the child's; but we don't want the aria props on triggerProps to override the child's.

I'll handle aria-label for tooltip in a separate PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can do something like that:

applyTriggerPropsToChildren(children, {
  defaultProps: { /* aria props there */},
  overrideProps: {/* handlers there */}
})

In this case, we could handle it in a single place. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It seems a little overkill IMO, and it's something that the caller could easily do if they wanted:

applyTriggerPropsToChildren(children, { 
  ...defaultProps, 
  ...child?.props, 
  ...overrideProps,
});

Also, it still doesn't help with the case where we don't want to set aria-label on the child if it has aria-labelledby.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

applyTriggerPropsToChildren(children, { 
  ...defaultProps, // aria props
  ...child?.props, 
  ...overrideProps, // merged event handlers
});

Also, it still doesn't help with the case where we don't want to set aria-label on the child if it has aria-labelledby.

Well, if overrideProps will contain only merged event handlers that it will work as expected (user's input wins).

Comment thread packages/react-utilities/src/utils/applyTriggerPropsToChildren.ts Outdated
Comment thread packages/react-utilities/src/utils/applyTriggerPropsToChildren.ts Outdated
Comment thread packages/react-utilities/src/utils/isFluentTriggerComponent.ts Outdated
Comment thread packages/react-utilities/src/hooks/useMergedEventCallbacks.ts
@layershifter

Copy link
Copy Markdown
Member

Add an example of composition

IMO we should mention this behavior in docs somehow, via an example or something else.

const CallButton = React.forwardRef((props, ref) => {
  return (
    <Tooltip content="bar" relationship="label">
      <Button
        {...props}
        ref={ref}
        onClick={(...args) => {
          props.onClick(...args);
          // custom logic
        }}
      >
        Toggle menu
      </Button>
    </Tooltip>
  );
});

export const MenuTriggerWithTooltip = () => (
  <Menu>
    <MenuTrigger>
      <CallButton />
    </MenuTrigger>

    <MenuPopover>
      <MenuList>
        <MenuItem>New </MenuItem>
        <MenuItem>New Window</MenuItem>
        <MenuItem disabled>Open File</MenuItem>
        <MenuItem>Open Folder</MenuItem>
      </MenuList>
    </MenuPopover>
  </Menu>
);

This example works fully with current approach, but there are few things that worth mentioning:

  • React.forwardRef() is required
    image
  • props & ref should be passed to a Button (or other component that behaves like a trigger)
  • custom callbacks should call matching handlers from props

@behowell

Copy link
Copy Markdown
Contributor Author

@layershifter

IMO we should mention this behavior in docs somehow, via an example or something else.

[...]

This example works fully with current approach, but there are few things that worth mentioning:

  1. React.forwardRef() is required
  2. props & ref should be passed to a Button (or other component that behaves like a trigger)
  3. custom callbacks should call matching handlers from props

We might want to document FluentTriggerComponent, and how to write a custom component that acts as a trigger (or, at least, mention that you have to add isFluentTriggerComponent = true if you extend Tooltip or something).

But I don't see anything in your example that needs explicit documentation IMO:

  1. React.forwardRef() is always required when forwarding a ref, regardless of whether they add a tooltip or not.
  2. That's the case for any higher order component that goes in place of the thing its wrapping: it needs to pass the props to the inner component that it wraps. There's nothing special about triggers here. One thing that actually helps is that the Tooltip component doesn't allow any of those props on it, so it at least makes the author think more about where they should be forwarding their props to.
  3. If they're ignoring some things on props, like the event handlers, then that's a bug in their component regardless of whether it's used as a trigger target or not.

I suppose if anything, the potential confusing part is that MenuTrigger and Tooltip "magically" set some HTMLElement props on their child, and those props need to work as expected. We could theoretically add something that mentions that to the documentation for those components.

@layershifter layershifter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved, thanks for addressing comments 👍
But we should solve #21495 (comment) in follow up PR for both Menu & Tooltip.

@behowell

behowell commented Feb 2, 2022

Copy link
Copy Markdown
Contributor Author

@layershifter I created PR #21532 to fix the aria-label overriding issue

@layershifter

Copy link
Copy Markdown
Member

We might want to document FluentTriggerComponent, and how to write a custom component that acts as a trigger (or, at least, mention that you have to add isFluentTriggerComponent = true if you extend Tooltip or something).

I am not sure that it is applicable to the example that I posted as CallButton does not have children 🙁

It will only work for scenarios when someone composes Tooltip or *Trigger components in this way:

const LabelTooltip = (props) => {
  return (
    <Tooltip {...props} relationship="label" />
   );
}

function App() {
  return (
    <LabelTooltip>
      <button />
   </LabelTooltip>
  )
}

I think that isFluentTriggerComponent should be documented anyway as components could also composed with hooks pattern.

@behowell

behowell commented Feb 2, 2022

Copy link
Copy Markdown
Contributor Author

@layershifter

I am not sure that it is applicable to the example that I posted as CallButton does not have children 🙁

That's right: I was mentioning documenting FluentTriggerComponent for something like the LabelTooltip example you gave.

In the CallButton example, it would be up to CallButton to handle prop merging and forwarding itself (like you're doing in the example). That's the same as with any higher-order component, and isn't special for triggers, or even special to our library. That's what I was explaining with the numbered list in my reply.

@behowell behowell merged commit f30e758 into microsoft:master Feb 2, 2022
@behowell behowell mentioned this pull request Feb 7, 2022
14 tasks
@behowell behowell deleted the trigger-utilities branch July 25, 2022 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

7 participants