Back to Fluentui

RFC: Align API of Tooltip component to allow composition

docs/react-v9/contributing/rfcs/react-components/components/tooltip-api-alignment.md

4.40.2-hotfix24.2 KB
Original Source

RFC: Align API of Tooltip component to allow composition

@layershifter

Summary

This RFC proposes API changes to Tooltip component. The goal is to unblock composition of components that follow "trigger" pattern.

Background

In Fluent UI React v9 we have "trigger" pattern that is used in components that usually render out of DOM render (popovers, menus, dialogs). The snippet below shows a usage of Menu component:

tsx
function App() {
  return (
    <Menu>
      <MenuTrigger>
        <button />
      </MenuTrigger>
      <MenuList />
    </Menu>
  );
}

Popover component also follow this pattern. Keynotes from an example above:

  • Menu (aka host component) manages connection between *Trigger and content (in this case MenuList) & controls state
  • MenuTrigger (aka trigger) clones a passed React element and adds additional handlers

Tooltip component also follows "trigger" pattern, but the implementation is more similar to what we have in Fluent UI React Northstar as it does not have "host" <-> "trigger" <-> "content" connection:

tsx
function App() {
  return (
    <Tooltip>
      <button />
    </Tooltip>
  );
}

Problem statement

We noticed in microsoft/fluentui#21115 that we have a problem with composition of components that are using "trigger" pattern:

tsx
function App() {
  return (
    <Menu>
      <Tooltip content="Some content">
        <MenuTrigger>
          <button>Opens only a tooltip</button>
        </MenuTrigger>
      </Tooltip>
    </Menu>
  );
}

In the snippet above consumers are trying to have a menu and a tooltip on the same React element, but they are not able to do it. microsoft/fluentui#21225 proposes a solution to solve that issue and relies on a waterfall model to pass props & handlers down:

tsx
function App() {
  return (
    //  1️⃣ "Tooltip":
    //      - clones its trigger ("MenuTrigger")
    //      - adds custom handlers/props to a trigger
    <Tooltip content="Some content">
      <MenuTrigger>
        <button />
      </MenuTrigger>
    </Tooltip>
  );
}

In this case props that are needed are passed down from Tooltip to button and everything works like a charm 💎

tsx
function App() {
  return (
    //  1️⃣ "MenuTrigger":
    //      - clones its trigger ("Tooltip")
    //      - adds custom handlers/props to a trigger
    <MenuTrigger>
      <Tooltip content="Some content">
        <button />
      </Tooltip>
    </MenuTrigger>
  );
}

The snippet above looks almost the same as a previous, but it does not work 🙃 Tooltip passes props to div instead of a trigger, so Menu will be coupled with tooltip's content instead of a trigger.

This highlights the major difference in APIs between Tooltip and other *Triggers:

tsx
function App() {
  return (
    <>
      <MenuTrigger onClick={() => {}} />
      <Tooltip onClick={() => {}} />
      <Tooltip content="Some content" />
    </>
  );
}

Detailed Design or Proposal

The proposal is to update API shape of Tooltip:

  • turn content to a real slot
  • do not accept DOM props in TS interface
tsx
function App() {
  return (
    <>
      <Tooltip content="Foo" />
      <Tooltip content="Foo" className="bar" />
      <Tooltip content="Foo" />
      <Tooltip content={{ children: 'Foo', className: 'bar' }} />
    </>
  );
}

These changes were prototyped in microsoft/fluentui#21245 (CI is green 🟢) and reuse shared functionality introduced in microsoft/fluentui#21225.

Pros and Cons

  • 👍 API alignment, content becomes a real slot
  • 👍 composition works

Discarded Solutions

Another option is to convert Tooltip to follow "host" <-> "trigger" <-> "content":

tsx
// ⚠️ not a real proposal
function App() {
  return (
    <Tooltip>
      <TooltipTrigger>
        <button />
      </TooltipTrigger>
      <TooltipContent />
    </Tooltip>
  );
}

But this will make API too verbose and complicated to use for composition.

Open Issues