Skip to content

Tooltip: add support for composition with other *Trigger components#21245

Closed
layershifter wants to merge 3 commits into
microsoft:masterfrom
layershifter:prototype/tooltip-api-change
Closed

Tooltip: add support for composition with other *Trigger components#21245
layershifter wants to merge 3 commits into
microsoft:masterfrom
layershifter:prototype/tooltip-api-change

Conversation

@layershifter

@layershifter layershifter commented Jan 11, 2022

Copy link
Copy Markdown
Member

PR changes

Tooltip in this PR uses useTriggerElement() hook introduced in #21225. This adds support for composition with *Trigger components:

<>
  <Menu>
    <MenuTrigger>
      <Tooltip content="opens a menu">
        <Button>Menu only</Button>
      </Tooltip>
    </MenuTrigger>
  </Menu>
  <Menu>
    <Tooltip content="opens a menu">
      <MenuTrigger>
        <Button>Menu only</Button>
      </MenuTrigger>
    </Tooltip>
  </Menu>
</>

With changes in this PR the order of MenuTrigger & Tooltip in markup does matter anymore.

@fabricteam

fabricteam commented Jan 11, 2022

Copy link
Copy Markdown
Collaborator

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-tooltip
Tooltip
42.008 kB
14.478 kB
42.593 kB
14.693 kB
585 B
215 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
166.089 kB
47.059 kB
react-components
react-components: FluentProvider & webLightTheme
32.489 kB
10.636 kB
🤖 This report was generated against eba257f5078792e52dbf8c71b1fc342d607668bc

@size-auditor

size-auditor Bot commented Jan 11, 2022

Copy link
Copy Markdown

Asset size changes

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

Baseline commit: eba257f5078792e52dbf8c71b1fc342d607668bc (build)

@fabricteam

fabricteam commented Jan 11, 2022

Copy link
Copy Markdown
Collaborator

Perf Analysis (@fluentui/react)

Scenario Render type Master Ticks PR Ticks Iterations Status
ContextualMenu mount 16662 8366 1000 Possible regression
All results

Scenario Render type Master Ticks PR Ticks Iterations Status
Avatar mount 944 961 5000
BaseButton mount 912 918 5000
Breadcrumb mount 2576 2587 1000
ButtonNext mount 501 498 5000
Checkbox mount 1529 1522 5000
CheckboxBase mount 1290 1260 5000
ChoiceGroup mount 4838 4695 5000
ComboBox mount 1008 1014 1000
CommandBar mount 10404 10288 1000
ContextualMenu mount 16662 8366 1000 Possible regression
DefaultButton mount 1146 1185 5000
DetailsRow mount 3794 3806 5000
DetailsRowFast mount 3807 3950 5000
DetailsRowNoStyles mount 3614 3609 5000
Dialog mount 2538 2583 1000
DocumentCardTitle mount 177 184 1000
Dropdown mount 3179 3164 5000
FluentProviderNext mount 1913 1895 5000
FluentProviderWithTheme mount 170 177 10
FluentProviderWithTheme virtual-rerender 124 127 10
FluentProviderWithTheme virtual-rerender-with-unmount 211 202 10
FocusTrapZone mount 1869 1856 5000
FocusZone mount 1835 1878 5000
IconButton mount 1791 1775 5000
Label mount 375 392 5000
Layer mount 3147 3052 5000
Link mount 508 499 5000
MakeStyles mount 1735 1705 50000
MenuButton mount 1533 1508 5000
MessageBar mount 2066 2085 5000
Nav mount 3341 3335 1000
OverflowSet mount 1190 1155 5000
Panel mount 2541 2559 1000
Persona mount 885 885 1000
Pivot mount 1504 1484 1000
PrimaryButton mount 1333 1337 5000
Rating mount 7763 7809 5000
SearchBox mount 1356 1322 5000
Shimmer mount 2544 2549 5000
Slider mount 1966 1931 5000
SpinButton mount 4875 4934 5000
Spinner mount 448 459 5000
SplitButton mount 3195 3070 5000
Stack mount 544 545 5000
StackWithIntrinsicChildren mount 2349 2299 5000
StackWithTextChildren mount 5422 5360 5000
SwatchColorPicker mount 11500 11584 5000
TagPicker mount 2719 2742 5000
TeachingBubble mount 13340 13478 5000
Text mount 454 453 5000
TextField mount 1439 1441 5000
ThemeProvider mount 1254 1251 5000
ThemeProvider virtual-rerender 654 651 5000
ThemeProvider virtual-rerender-with-unmount 1952 1923 5000
Toggle mount 877 854 5000
buttonNative mount 153 153 5000

Perf Analysis (@fluentui/react-northstar)

Perf tests with no regressions
Scenario Current PR Ticks Baseline Ticks Ratio
SkeletonMinimalPerf.default 373 340 1.1:1
PortalMinimalPerf.default 197 180 1.09:1
TableMinimalPerf.default 414 388 1.07:1
AttachmentMinimalPerf.default 162 153 1.06:1
FormMinimalPerf.default 425 403 1.05:1
GridMinimalPerf.default 355 337 1.05:1
LayoutMinimalPerf.default 387 367 1.05:1
TextMinimalPerf.default 350 333 1.05:1
ToolbarMinimalPerf.default 970 926 1.05:1
ButtonSlotsPerf.default 566 546 1.04:1
HeaderMinimalPerf.default 381 367 1.04:1
ListCommonPerf.default 635 612 1.04:1
ListWith60ListItems.default 665 642 1.04:1
PopupMinimalPerf.default 633 610 1.04:1
BoxMinimalPerf.default 347 336 1.03:1
ImageMinimalPerf.default 400 389 1.03:1
ReactionMinimalPerf.default 391 380 1.03:1
AttachmentSlotsPerf.default 1051 1035 1.02:1
DropdownManyItemsPerf.default 691 677 1.02:1
EmbedMinimalPerf.default 4168 4106 1.02:1
FlexMinimalPerf.default 305 298 1.02:1
HeaderSlotsPerf.default 768 756 1.02:1
ListMinimalPerf.default 529 518 1.02:1
ListNestedPerf.default 573 561 1.02:1
LoaderMinimalPerf.default 691 676 1.02:1
MenuMinimalPerf.default 862 845 1.02:1
RadioGroupMinimalPerf.default 470 463 1.02:1
TreeMinimalPerf.default 804 789 1.02:1
ButtonOverridesMissPerf.default 1658 1646 1.01:1
DialogMinimalPerf.default 767 763 1.01:1
InputMinimalPerf.default 1312 1293 1.01:1
ProviderMergeThemesPerf.default 1736 1727 1.01:1
SegmentMinimalPerf.default 355 351 1.01:1
StatusMinimalPerf.default 671 665 1.01:1
IconMinimalPerf.default 606 600 1.01:1
TextAreaMinimalPerf.default 507 500 1.01:1
TreeWith60ListItems.default 178 176 1.01:1
AvatarMinimalPerf.default 199 200 1:1
CardMinimalPerf.default 537 536 1:1
ChatDuplicateMessagesPerf.default 310 310 1:1
DatepickerMinimalPerf.default 5493 5478 1:1
DropdownMinimalPerf.default 3033 3047 1:1
ItemLayoutMinimalPerf.default 1177 1172 1:1
LabelMinimalPerf.default 388 389 1:1
SliderMinimalPerf.default 1655 1655 1:1
CustomToolbarPrototype.default 3960 3979 1:1
TooltipMinimalPerf.default 1022 1018 1:1
AlertMinimalPerf.default 269 272 0.99:1
ChatMinimalPerf.default 716 724 0.99:1
CheckboxMinimalPerf.default 2650 2667 0.99:1
MenuButtonMinimalPerf.default 1688 1701 0.99:1
ProviderMinimalPerf.default 1144 1153 0.99:1
TableManyItemsPerf.default 1818 1838 0.99:1
AnimationMinimalPerf.default 535 547 0.98:1
CarouselMinimalPerf.default 467 477 0.98:1
DividerMinimalPerf.default 363 370 0.98:1
SplitButtonMinimalPerf.default 4161 4255 0.98:1
VideoMinimalPerf.default 623 635 0.98:1
ButtonMinimalPerf.default 166 171 0.97:1
ChatWithPopoverPerf.default 368 381 0.97:1
RefMinimalPerf.default 238 248 0.96:1
RosterPerf.default 1165 1226 0.95:1
AccordionMinimalPerf.default 151 165 0.92:1

@codesandbox-ci

codesandbox-ci Bot commented Jan 11, 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 306f677:

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

@layershifter layershifter force-pushed the prototype/tooltip-api-change branch 2 times, most recently from 5ad0ec3 to b69be69 Compare January 19, 2022 11:38
const defaultOptions: Partial<IsConformantOptions<TProps>> = {
asPropHandlesRef: true,
componentPath: module!.parent!.filename.replace('.test', ''),
extraTests: makeStylesTests as TestObject<TProps>,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As our tests currently perform asserts only for root slot these tests are not applicable.

Comment on lines +52 to +54
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
'data-testid': 'the-tooltip',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As our typings are strict we don't allow to pass data- attributes:

src/components/Tooltip/Tooltip.test.tsx:52:11 - error TS2322: Type '{ children: JSX.Element; id: string; 'data-testid': string; }' is not assignable to type 'ShorthandProps<{ as?: "div" | undefined; } & Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<...>> & { ...; } & { ...; }>'.
  Object literal may only specify known properties, and ''data-testid'' does not exist in type 'ReactElement<any, string | JSXElementConstructor<any>> | ReactNodeArray | ReactPortal | ({ as?: "div" | undefined; } & Pick<...> & { ...; } & { ...; })'.

52           'data-testid': 'the-tooltip',
             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This probably should be turned into an issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FYI, I'm removing the data attributes from the tests with this PR: #21331.

I agree it's still an issue that our types don't allow for data attributes, but at least it won't be an issue in this particular case.

/**
* A tooltip provides light weight contextual information on top of its target element.
*/
export const Tooltip: ForwardRefComponent<TooltipProps> = React.forwardRef((props, ref) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping it as it is impossible as it causes a set of TS issues:

src/components/Tooltip/Tooltip.tsx:11:14 - error TS2322: Type 'ForwardRefExoticComponent<RefAttributes<unknown>>' is not assignable to type 'never'.

11 export const Tooltip: ForwardRefComponent<TooltipProps> = React.forwardRef((props, ref) => {
                ~~~~~~~
src/components/Tooltip/Tooltip.tsx:12:28 - error TS2345: Argument of type '{ children?: ReactNode; }' is not assignable to parameter of type 'TooltipProps'.
  Type '{ children?: ReactNode; }' is not assignable to type '{ children?: (ReactElement<any, string | JSXElementConstructor<any>> & { ref?: Ref<unknown> | undefined; }) | ((props: TooltipTriggerProps) => ReactNode) | undefined; }'.
    Types of property 'children' are incompatible.
      Type 'ReactNode' is not assignable to type '(ReactElement<any, string | JSXElementConstructor<any>> & { ref?: Ref<unknown> | undefined; }) | ((props: TooltipTriggerProps) => ReactNode) | undefined'.
        Type 'null' is not assignable to type '(ReactElement<any, string | JSXElementConstructor<any>> & { ref?: Ref<unknown> | undefined; }) | ((props: TooltipTriggerProps) => ReactNode) | undefined'.

12   const state = useTooltip(props, ref);
                              ~~~~~
src/components/Tooltip/Tooltip.tsx:18:9 - error TS2339: Property 'displayName' does not exist on type 'never'.

Comment on lines +193 to +196
onPointerEnter: onEnterTrigger,
onPointerLeave: onLeaveTrigger,
onFocus: onEnterTrigger,
onBlur: onLeaveTrigger,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Merging is not longer requires as it's done in useTriggerElement().

@layershifter layershifter marked this pull request as ready for review January 19, 2022 14:38
@layershifter layershifter requested review from a team, behowell and khmakoto as code owners January 19, 2022 14:38
@layershifter layershifter requested a review from smhigley January 19, 2022 14:38
@layershifter layershifter changed the title Tooltip: prototype API changes Tooltip: align API with other components that use triggers Jan 19, 2022

@behowell behowell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! Just one comment about not using forwardRef with no root slot.

* A tooltip provides light weight contextual information on top of its target element.
*/
export const Tooltip: ForwardRefComponent<TooltipProps> = React.forwardRef((props, ref) => {
export const Tooltip = React.forwardRef<HTMLElement, TooltipProps>((props, ref) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since we don't have a root slot, this probably shouldn't use forwardRef. If someone needs a ref to the content slot, they can pass it as an attribute to that slot.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Well, it should as we also forwarding refs for composition purpose to trigger:

<MenuTrigger>
  <Tooltip>
    <button />
  </Tooltip>
</MenuTrigger>

In this example MenuTrigger still need to pass ref to button, without ref forwarding it's not possible. As an option we can use cast to a regular React.FunctionComponent to hide this contract. WDYT?

@behowell behowell Jan 21, 2022

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just to make sure I understand, if I write the following, you're saying myRef be the <button />?

<Tooltip ref={myRef} content="...">
   <button />
</Tooltip>

That seems weird to me... first that the ref on Tooltip goes to another element other than the Tooltip, and second if we're forwarding ref, why aren't we forwarding other props?


Stepping back a minute, I wonder if another way to solve the problem of applying props to the trigger would be to update onlyChild to know to drill through to the children of Tooltip and MenuTrigger the same way that it currently drills through to the children of React.Fragment. That way both MenuTrigger and Tooltip would be able to "see through" each other to the trigger element, and they could apply props and refs to the trigger directly, instead of relying on the other to forward them.

E.g. maybe we could have an attribute e.g. isFluentTrigger (except with a better name) on the Tooltip and MenuTrigger types like:

// in Tooltip.tsx:
Tooltip.isFluentTrigger = true;

// in MenuTrigger.tsx:
MenuTrigger.isFluentTrigger = true;

// New utility function isFluentTrigger:
const isFluentTrigger = <P>(type: React.ReactElement<P>['type'] & { isFluentTrigger?: boolean }) => {
  return typeof type !== 'string' && 'isFluentTrigger' in type && type.isFluentTrigger;
};

// in onlyChild.ts:
return child.type === React.Fragment || isFluentTrigger(child.type) ? onlyChild(child.props.children) : child;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Just to make sure I understand, if I write the following, you're saying myRef be the <button />?

<Tooltip ref={myRef} content="...">
   <button />
</Tooltip>

That seems weird to me... first that the ref on Tooltip goes to another element other than the Tooltip, and second if we're forwarding ref, why aren't we forwarding other props?

Correct and we are forwarding props. It's how composition of triggers works.

@layershifter layershifter Jan 24, 2022

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Stepping back a minute, I wonder if another way to solve the problem of applying props to the trigger would be to update onlyChild to know to drill through to the children of Tooltip and MenuTrigger the same way that it currently drills through to the children of React.Fragment. That way both MenuTrigger and Tooltip would be able to "see through" each other to the trigger element, and they could apply props and refs to the trigger directly, instead of relying on the other to forward them.

We had similar approach in N*, but with React you cannot predict element's children:

function MyTrigger(props) {
  const data = useData();

  return (
    <MenuTrigger {...props}>
      <CustomButton>{data.count}</CustomButton>
    </MenuTrigger>
  );
}

In this example MyTrigger.isFluentTrigger will be undefined, but it still valid code.

@layershifter layershifter force-pushed the prototype/tooltip-api-change branch from b69be69 to bef7414 Compare January 28, 2022 10:35
@layershifter layershifter changed the title Tooltip: align API with other components that use triggers Tooltip: add support for composition with other *Trigger components Jan 28, 2022
useTooltipStyles_unstable(state);
return renderTooltip_unstable(state);
};
}) as React.FC<TooltipProps>;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This type cast allows to hide ref from Tooltip props.

@layershifter layershifter marked this pull request as ready for review January 28, 2022 10:58
@layershifter

Copy link
Copy Markdown
Member Author

Closing in favour of #21495.

@layershifter layershifter deleted the prototype/tooltip-api-change branch January 31, 2022 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants