Skip to content

Latest commit

 

History

History
1049 lines (837 loc) · 30.7 KB

CREATING_COMPOUND_COMPONENTS.mdx

File metadata and controls

1049 lines (837 loc) · 30.7 KB

import {Meta} from '@storybook/addon-docs/blocks';

Building a Compound Component

Refer to the Compound Component documentation document to learn about what a compound component is.

This document will go through building a simplified Disclosure component to help solidify the concepts. We will cover:

Models

A model is composed of state and events. State and Event shapes are as follows:

type State = Record<string, any>;
type Events = Record<string, (data?: object) => void>;

The shape of a model is as follows:

interface Model<S extends State, E extends Events> {
  state: S;
  events: E;
}

The @workday/canvas-kit-react/common module exports a Model type for us.

Let's start by defining our state and events:

// useDisclosureModel.tsx
import React from 'react';

import {Model} from '@workday/canvas-kit-react/common';

export type DisclosureState = {
  visible: boolean;
};

export type DisclosureEvents = {
  show(data?: {}): void;
  hide(data?: {}): void;
};

export type DisclosureModel = Model<DisclosureState, DisclosureEvents>;

Let's add an initialVisible config and export a model hook:

// useDisclosureModel.tsx
// ...

export type DisclosureConfig = {
  initialVisible?: boolean;
};

export const useDisclosureModel = (config: DisclosureConfig = {}) => {
  const [visible, setVisible] = React.useState(config.initialVisible || false);

  const state = {
    visible,
  };

  const events = {
    show() {
      setVisible(true);
    },
    hide() {
      setVisible(false);
    },
  };

  return {state, events};
};

Models aren't very complicated so far. We have a single visible state property and show and hide events we can send to the model. So far using the model might look like this:

const Test = () => {
  const model = useDisclosureModel();

  return (
    <>
      <button
        onClick={() => {
          if (model.state.visible) {
            model.events.hide();
          } else {
            model.events.show();
          }
        }}
      >
        Toggle
      </button>
      <div hidden={model.state.visible ? undefined : true}>Content</div>
    </>
  );
};

You can find a working example here: https://codesandbox.io/s/basic-disclosure-model-5gold

It would be nice to add guards and callbacks to our events. Let's add configuration to our model:

export type DisclosureConfig = {
  initialVisible?: boolean;
  // guards
  shouldShow?(event: {data?: {}; state: DisclosureState}): boolean;
  shouldHide?(event: {data?: {}; state: DisclosureState}): boolean;
  // callbacks
  onShow?(event: {data?: {}; prevState: DisclosureState}): void;
  onHide?(event: {data?: {}; prevState: DisclosureState}): void;
};

We'll also have to add the runtime of the guards and actions:

const events: DisclosureEvents = {
  show(data) {
    if (config.shouldShow?.({data, state}) === false) {
      return;
    }
    setVisible(true);
    config.onShow?.({data, prevState: state});
  },
  hide(data) {
    if (config.shouldHide?.({data, state}) === false) {
      return;
    }
    setVisible(false);
    config.onHide?.({data, prevState: state});
  },
};

Now we should be able to configure the model via the guards and do something in the callbacks:

const Test = () => {
  const [should, setShould] = React.useState(true);
  const model = useDisclosureModel({
    shouldShow({data, state}) {
      console.log('shouldShow', data, state, should);
      return should;
    },
    shouldHide({data, state}) {
      console.log('shouldHide', data, state, should);
      return should;
    },
    onShow({data, prevState}) {
      console.log('onShow', data, prevState);
    },
    onHide({data, prevState}) {
      console.log('onHide', data, prevState);
    },
  });

  return (
    <>
      <button
        onClick={() => {
          setShould(!should);
        }}
      >
        Toggle "should"
      </button>{' '}
      Buttons below should {should ? '' : 'NOT'} work
      <br />
      <button
        onClick={() => {
          model.events.show();
        }}
      >
        Show
      </button>
      <button
        onClick={() => {
          model.events.hide();
        }}
      >
        Hide
      </button>
      <div hidden={model.state.visible ? undefined : true}>Content</div>
      <br />
      Check the console output
    </>
  );
};

You can see it in action here: https://codesandbox.io/s/basic-configurable-disclosure-model-nuteg

That's a lot of extra boilerplate code for actions and callbacks. Our events don't have any data, but if they did, we'd have to keep the event + guard and callback data types in sync. We are also creating the events object every render. We could use React refs and React.useMemo to decrease extra object creation. Luckily the common module has createEventMap and useEventMap functions to help us reduce boilerplate and reduce possibility of making mistakes.

First we need to create an event map - a map of guard and callback functions to the events they need to be paired with. We'll use createEventMap to do this:

import {createEventMap} from '@workday/canvas-kit-react/common';

const disclosureEventMap = createEventMap<DisclosureEvents>()({
  guards: {
    shouldShow: 'show',
    shouldHide: 'hide',
  },
  callbacks: {
    onShow: 'show',
    onHide: 'hide',
  },
});

This part is a little weird: createEventMap<DisclosureEvents>()({. The reason for this is a Typescript issue: microsoft/TypeScript#26242. The gist is a function with generics requires the caller to specify none of the generics or all of them. In this case, the only generic that cannot be inferred is the DisclosureEvents. Everything else can be inferred. The only way to separate defined generics vs inferred generics is to have separate, chained functions.

We see that createEventMap takes two optional keys: guards and callbacks. The names of these functions are arbitrary, but should follow the convention of should* for guards and on* for callbacks. The event name that is passed in is type checked against the DisclosureEvents interface.

Now that we have an event map, we'll need to use it for our DisclosureConfig:

import {ToModelConfig} from '@workday/canvas-kit-react/common';

export type DisclosureConfig = {
  initialVisible?: boolean;
} & Partial<ToModelConfig<DisclosureState, DisclosureEvents, typeof disclosureEventMap>>;

The ToModelConfig type takes in our State type, Events type, and our EventMap type (the event map type is extracted from the event map: typeof disclosureEventMap). This will give us the proper shape of our config object.

The disclosureEventMap will also be used to create the events object using the useEventMap utility hook:

const events = useEventMap(disclosureEventMap, state, config, {
  show(data) {
    setVisible(true);
  },
  hide(data) {
    setVisible(false);
  },
});

You can see useEventMap takes in our disclosureEventMap, state, config objects as well as a list of event implementations. This is all type checked, decreasing the chances we make a mistake. Also notice we don't need to implement guards and callbacks directly inside our event implementations. useEventMap will return an object that has that functionality built right in! Neat!

The full working implementation is here: https://codesandbox.io/s/configurable-disclosure-model-3y5qh

Components

Now that our model is figured out, we can work on the container component and sub-components. An external API might look something like this:

<Disclosure>
  <Disclosure.Target>Toggle</Disclosure.Target>
  <Disclosure.Content>Content</Disclosure.Content>
</Disclosure>

The <Disclosure> is our container component and will be responsible for creating a DisclosureModel if a model isn't passed in. The <Disclosure.Target> and <Disclosure.Content> components are sub-components with specific functionality built into them. The Target controls the visibility of the Content. We already created a simplified render function for our model, now let's create the real components.

Disclosure Component

First, let's create the <Disclosure> container component:

// Disclosure.tsx
import React from 'react';

import {DisclosureTarget} from './DisclosureTarget';
import {DisclosureContent} from './DisclosureContent';

import {DisclosureConfig, DisclosureModel, useDisclosureModel} from './useDisclosureModel';

export interface DisclosureProps extends DisclosureConfig {
  children: React.ReactNode;
}

export const DisclosureModelContext = React.createContext({} as DisclosureModel);

export const Disclosure = ({children, ...config}: DisclosureProps) => {
  const model = useDisclosureModel(config);

  return (
    <DisclosureModelContext.Provider value={model}>{children}</DisclosureModelContext.Provider>
  );
};

Disclosure.Target = DisclosureTarget;
Disclosure.Content = DisclosureContent;

We can see that the DisclosureProps interface extends the DisclosureConfig interface. This allows us to pass model config directly to the <Disclosure> component. A user of this <Disclosure> component might want to register a callback when the show event is called, for instance.

Next, a React Context object is created to represent the model of the compound component. This context will be used to pass the model to sub-components implicitly. This allows our compound component API to remain clean for consumers of compound components.

In this particular compound component, the container component doesn't have a real element. Accessibility specifications have no role for this component, so an element is not required.

Let's go ahead and finish out our sub-components.

DisclosureTarget Component

// DisclosureTarget.tsx
import React from 'react';
import {DisclosureModelContext} from './Disclosure';

export interface DisclosureTargetProps {
  children: React.ReactNode;
}

export const DisclosureTarget = ({children}: DisclosureTargetProps) => {
  const model = React.useContext(DisclosureModelContext);

  return (
    <button
      onClick={() => {
        if (model.state.visible) {
          model.events.hide();
        } else {
          model.events.show();
        }
      }}
    >
      {children}
    </button>
  );
};

The DisclosureTarget component is in charge of the toggle button and it calls the show or hide event on the model.

DisclosureContent Component

// DisclosureContent.tsx
import React from 'react';
import {DisclosureModelContext} from './Disclosure';

export interface DisclosureContentProps {
  children: React.ReactNode;
}

export const DisclosureContent = ({children}: DisclosureContentProps) => {
  const model = React.useContext(DisclosureModelContext);

  return <div hidden={model.state.visible ? undefined : true}>{children}</div>;
};

The DisclosureContent component is in charge of the content. It uses the visible state value to set a hidden attribute.

The working example can be found here: https://codesandbox.io/s/configurable-disclosure-model-components-nvhtv

These components are not fully compliant yet. They do not support ref, as, or extra props as HTML attributes. The boilerplate to supporting all this gets very complicated. For this reason, a createComponent utility function was created to support all this out of the box. createComponent takes a default React.ElementType which can be an element string like div or button or a component like Button. It also takes a config object containing the following:

  • displayName: This will be the name of the component when shown by the React Dev tools. By convention, we make that name be the same as typed in a render function. For example Disclosure.Target vs DisclosureTarget.
  • Component: A forward ref component function with an added Element property. Element is the value passed to the Component's as prop. It will default to the provided element.
  • subComponents: For container components. A list of sub components to add to the returned component. For example, a sub component called DisclosureTarget will be added to the export of Disclosure so that the user can import only Disclosure and use Disclosure.Target. subComponents is needed for Typescript because static properties cannot be added to predefined interfaces. Disclosure.Target = DisclosureTarget will caused a type error. This property allows the createComponent factory function to infer the final interface of the returned component.

Let's convert the Disclosure example to use the createComponent utility function to get this extra functionality:

// Disclosure.tsx
import React from 'react';
import {createComponent} from '@workday/canvas-kit-react/common';
import {DisclosureTarget} from './DisclosureTarget';
import {DisclosureContent} from './DisclosureContent';

import {DisclosureConfig, DisclosureModel, useDisclosureModel} from './useDisclosureModel';

export interface DisclosureProps extends DisclosureConfig {
  children: React.ReactNode;
}

export const DisclosureModelContext = React.createContext({} as DisclosureModel);

export const Disclosure = createComponent()({
  displayName: 'Disclosure',
  Component: ({children, ...config}: DisclosureProps) => {
    const model = useDisclosureModel(config);

    return (
      <DisclosureModelContext.Provider value={model}>{children}</DisclosureModelContext.Provider>
    );
  },
  subComponents: {
    Target: DisclosureTarget,
    Content: DisclosureContent,
  },
});
// DisclosureTarget.tsx
import React from 'react';
import {createComponent} from '@workday/canvas-kit-react/common';
import {DisclosureModelContext} from './Disclosure';

export interface DisclosureTargetProps {
  children: React.ReactNode;
}

export const DisclosureTarget = createComponent('button')({
  displayName: 'Disclosure.Target',
  Component: ({children, ...elemProps}: DisclosureTargetProps, ref, Element) => {
    const model = React.useContext(DisclosureModelContext);

    return (
      <Element
        ref={ref}
        onClick={() => {
          if (model.state.visible) {
            model.events.hide();
          } else {
            model.events.show();
          }
        }}
        {...elemProps}
      >
        {children}
      </Element>
    );
  },
});
// DisclosureContent.tsx
import React from 'react';
import {createComponent} from '@workday/canvas-kit-react/common';
import {DisclosureModelContext} from './Disclosure';

export interface DisclosureContentProps {
  children: React.ReactNode;
}

export const DisclosureContent = createComponent('div')({
  displayName: 'Disclosure.Content',
  Component: ({children, ...elemProps}: DisclosureContentProps, ref, Element) => {
    const model = React.useContext(DisclosureModelContext);

    return (
      <Element ref={ref} hidden={model.state.open ? undefined : true} {...elemProps}>
        {children}
      </Element>
    );
  },
});

The displayName of the components helps properly identify the sub-components by their used name. For example Disclose.Target instead of DiscloseTarget.

The as prop is being passed to the 3rd argument in the and we're calling it Element. The variable is passed to JSX as <Element>. Element is capitalized because the JSX parser treats capitalized elements as variables and lower case elements as strings:

() => <Div />;
() => <div />;

// transpiled output:
() => React.createElement(Div, null);
() => React.createElement('div', null);

Typescript Playground

In our example, there are no styles associated with Target or Content sub-components, so we render as as an element. If we were using Emotion's styled components, we'd pass the as like <StyledElement as={Element}>. Using the as prop this way retains styles while <Element> does not. Use <Element> when styling should come from the passed in element and use <StyledElement as={Element}> when the component handles styling.

createComponent returns a component with a type interface that includes ref forwarding, the as prop for changing the underlying element, and additional props the element type normally takes.

For example, we can now do the following:

<Disclosure>
  <Disclosure.Target ref={targetRef} data-testid="target-button">
    Toggle
  </Disclosure.Target>
  <Disclosure.Content as="section">Content</Disclosure.Content>
</Disclosure>

In this example, we added a data-testid to the Disclosure Target element and rendered the Content element as a section tag.

The full code can be found here: https://codesandbox.io/s/configurable-disclosure-model-components-utility-pk9s6

Model Composition

Our example isn't fully accessible yet. The Disclosure target needs a aria-controls attribute to tie the target and content in the accessibility tree. This is done by the use of id references (string IDs that starts with a letter). We could add an id to our model, but it is extremely common so let's make a new model and compose from it instead. We'll later use this model in a reusable behavioral hook.

// useIDModel.tsx
import {Model, useUniqueId} from '@workday/canvas-kit-react/common';

export type IDState = {
  id: string;
};

export type IDEvents = {};

export type IDModel = Model<IDState, IDEvents>;

export type IDConfig = {
  id?: string;
};

export const useIDModel = (config: IDConfig = {}) => {
  const id = useUniqueId(config.id);

  const state = {
    id,
  };

  const events = {};

  return {state, events};
};

This model only provides an id since that's all that is needed for id reference functionality. Also later we'll add behavioral hook that will require this model.

Let's update the DisclosureModel to compose the IDModel:

// useDisclosureModel.tsx
// ...
import {IDState, IDConfig, useIDModel} from './useIDModel';

export type DisclosureState = IDState & {
  visible: boolean;
};

// ...

export type DisclosureConfig = IDConfig & {
  initialVisible?: boolean;
} & Partial<ToModelConfig<DisclosureState, DisclosureEvents, typeof disclosureEventMap>>;

export const useDisclosureModel = (config: DisclosureConfig = {}) => {
  const [visible, setVisible] = React.useState(config.initialVisible || false);
  const idModel = useIDModel(config);

  const state = {
    ...idModel.state,
    visible,
  };

  // ...

  return {state, events};
};

We can now add aria-controls to DisclosureTarget and id to DisclosureContent. We'll also add aria-expanded to DisclosureTarget to finish off the accessibility specifications:

// DisclosureTarget.tsx

// ...

return (
  <Element
    ref={ref}
    aria-controls={model.state.id}
    aria-expanded={model.state.visible}
    onClick={() => {
      if (model.state.visible) {
        model.events.hide();
      } else {
        model.events.show();
      }
    }}
    {...elemProps}
  >
    {children}
  </Element>
);

// ...
// DisclosureContent.tsx

// ...

return (
  <Element
    ref={ref}
    id={model.state.id}
    hidden={model.state.visible ? undefined : true}
    {...elemProps}
  >
    {children}
  </Element>
);

// ...

Here's the working example now: https://codesandbox.io/s/disclosure-composable-model-9shjn

At this point, we have an accessible disclosure compound component that composes 2 models. But the disclosure pattern is more than just the component level. For example, a tooltip uses the disclosure pattern as well. Let's extract out some behaviors into hooks.

Behavior Hooks

Behavior hooks allow us to reuse pieces of functionality in difference components. For example, the Tabs component utilizes a cursor hook for keyboard navigation even though the UI of tabs and the UI of a dropdown menu look very different!

We'll build a behavior hook for the DisclosureTarget component:

// useExpandableControls.tsx
import {DisclosureModel} from './useDisclosureModel';

export const useExpandableControls = ({state}: DisclosureModel, elemProps: {}) => {
  return {
    'aria-controls': state.id,
    'aria-expanded': state.visible,
    ...elemProps,
  };
};

At this point, we should reiterate that compound components should always merge passed in props properly. If the prop is a primitive prop, it should override the props of the component. If the prop is a callback function like onClick, the style tag or the css prop, they should be merged properly. Luckily, the common package has a mergeProps utility function that takes care of this for us. Hooks can use an optional 3rd parameter that is a ref if they need to fork the ref. We won't get into that here, but it is useful and works with composeHooks that is available via the common module. Let's refactor the above to use that function:

// useExpandableControls.tsx
import {mergeProps} from '@workday/canvas-kit-react/common';
import {DisclosureModel} from './useDisclosureModel';

export const useExpandableControls = ({state}: DisclosureModel, elemProps: {}) => {
  return mergeProps(
    {
      'aria-controls': state.id,
      'aria-expanded': state.visible,
    },
    elemProps
  );
};

Even though the useExpandableControls did not use any special props that need special merging, it is a good habit to use mergeProps anytime you define props.

Now we can use the behavior hook in the DiscloseTarget component:

// DisclosureTarget.tsx
import {createComponent, mergeProps} from '@workday/canvas-kit-react/common';
import {useExpandableControls} from './useExpandableControls';

// ...
const model = React.useContext(DisclosureModelContext);
const props = mergeProps(
  {
    onClick() {
      if (model.state.visible) {
        model.events.hide();
      } else {
        model.events.show();
      }
    },
  },
  useExpandableControls(model, elemProps)
);

return (
  <Element ref={ref} {...props}>
    {children}
  </Element>
);
// ...

We used mergeProps for the onClick. This way, if the user passes their own onClick, their onClick will be called in addition to the onClick we've defined here. Without this, if the user passes an onClick, it will override ours and break functionality. Definitely not what we want!

We'll also make a useHidden behavior hook for the hidden attribute on the Disclosure.Content element:

// useHidden.tsx
import {mergeProps} from '@workday/canvas-kit-react/common';
import {DisclosureModel} from './useDisclosureModel';

export const useHidden = ({state}: DisclosureModel, elemProps: {}) => {
  return mergeProps(
    {
      hidden: state.visible ? undefined : true,
    },
    elemProps
  );
};

The Disclosure.Content subcomponent can now be updated to use this hook:

// DisclosureContent.tsx
import {useHidden} from './useHidden';
// ..

const model = React.useContext(DisclosureModelContext);
const props = useHidden(model, elemProps);

return (
  <Element ref={ref} id={model.state.id} {...props}>
    {children}
  </Element>
);

The full code can be found here: https://codesandbox.io/s/disclosure-composable-model-behavior-hooks-iwzl8

Composing Compound Components

Having composable models, behaviors, and components means we can reuse parts of other compound components. For example, let's make a simple tooltip component that has a target and content, similar to the disclosure component, but behaves differently. A tooltip shows and hides based on mouse and focus events.

Here's a tooltip model composing the disclosure model:

// useTooltipModel.tsx
import {Model} from '@workday/canvas-kit-react/common';

import {
  DisclosureConfig,
  DisclosureEvents,
  DisclosureState,
  useDisclosureModel,
} from './useDisclosureModel';

export type TooltipState = DisclosureState;
export type TooltipEvents = DisclosureEvents;
export type TooltipModel = Model<TooltipState, TooltipEvents>;

export type TooltipConfig = DisclosureConfig & {
  initialVisible?: never; // tooltips never start showing
};

export const useTooltipModel = (config: TooltipConfig = {}) => {
  const disclosure = useDisclosureModel(config);

  const state = disclosure.state;
  const events = disclosure.events;

  return {state, events};
};

Not much interesting is happening here. We're not adding additional state or events, but we're removing the initialVisible config option from the model.

The final Tooltip compound component API will look something like this when we're done:

<Tooltip>
  <Tooltip.Target>Target</Tooltip.Target>
  <Tooltip.Content>The content of the Tooltip</Tooltip>
</Tooltip>

The Tooltip container component looks almost exactly like the Disclosure component:

// Tooltip.tsx
import React from 'react';
import {createComponent} from '@workday/canvas-kit-react/common';

import {useTooltipModel, TooltipModel, TooltipConfig} from './useTooltipModel';
import {TooltipTarget} from './TooltipTarget';
import {TooltipContent} from './TooltipContent';

export const TooltipModelContext = React.createContext<TooltipModel>({} as any);

export interface TooltipProps extends TooltipConfig {
  children: React.ReactNode;
}

export const Tooltip = createComponent()({
  displayName: 'Tooltip',
  Component: ({children, ...config}: TooltipProps) => {
    const model = useTooltipModel(config);

    return <TooltipModelContext.Provider value={model}>{children}</TooltipModelContext.Provider>;
  },
  subComponents: {
    Target: TooltipTarget,
    Content: TooltipContent,
  },
});

The Tooltip.Target component is similar to the DisclosureTarget component, but has different behavior. The tooltip triggers on different events. Here's the code:

import React from 'react';
import {createComponent, mergeProps} from '@workday/canvas-kit-react/common';

import {TooltipModelContext} from './Tooltip';
import {TooltipModel} from './useTooltipModel';

export interface TooltipTargetProps {
  children: React.ReactNode;
}

export const useTooltipTarget = ({state, events}: TooltipModel, elemProps = {}) => {
  return mergeProps(
    {
      onFocus(event: any) {
        events.show();
      },
      onBlur() {
        events.hide();
      },
      onMouseEnter() {
        events.show();
      },
      onMouseLeave() {
        events.hide();
      },
      'aria-describedby': state.id,
    },
    elemProps
  );
};

export const TooltipTarget = createComponent('button')({
  displayName: 'Tooltip.Target',
  Component: ({children, ...elemProps}: TooltipTargetProps, ref, Element) => {
    const model = React.useContext(TooltipModelContext);
    const props = useTooltipTarget(model, elemProps);

    return (
      <Element ref={ref} {...props}>
        {children}
      </Element>
    );
  },
});

The Tooltip.Target component also uses the aria-described for accessibility. The state.id comes from the IDModel.

The Tooltip.Content component is similar to the Disclosure.Content component, except that it uses a ReactDOM portal to ensure the content appears on top of other content. This example doesn't include a positional library and instead hard-codes positional values. Notice we can reuse our useHidden behavior hook in this component!

import React from 'react';
import ReactDOM from 'react-dom';
import {createComponent, mergeProps} from '@workday/canvas-kit-react/common';

import {TooltipModelContext} from './Tooltip';
import {useHidden} from './useHidden';

export interface TooltipContentProps {
  children: React.ReactNode;
}

export const TooltipContent = createComponent('div')({
  displayName: 'Tooltip.Content',
  Component: ({children, ...elemProps}: TooltipContentProps, ref, Element) => {
    const model = React.useContext(TooltipModelContext);
    const props = mergeProps(
      {
        id: model.state.id,
        style: {position: 'absolute', left: 80, top: 10},
      },
      useHidden(model, elemProps)
    );

    return ReactDOM.createPortal(
      model.state.id ? (
        <Element ref={ref} {...props}>
          {children}
        </Element>
      ) : null,
      document.body
    );
  },
});

The tooltip target could be anything. By default it is a button element since tooltips need to receive focus. What if we want a tooltip around the disclosure target element without introducing another button element? This is where the as prop comes in handy:

<Disclosure>
  <Tooltip>
    <Tooltip.Target as={Disclosure.Target}>Toggle</Tooltip.Target>
    <Tooltip.Content>Tooltip!</Tooltip.Content>
  </Tooltip>
  <Disclosure.Content>Content</Disclosure.Content>
</Disclosure>

In the example, we can see the Tooltip.Target element will be the Disclosure.Target element.

Here's the working example: https://codesandbox.io/s/disclosure-composable-model-behavior-hooks-tooltip-df7ht

Wrap it up

Hopefully, by now, you have a much better idea how compound components work internally and how to create your own. Model composition is a powerful way to create more complex models out of smaller parts. Compound components can be composed to make much more complicated UIs.

This API seems more verbose, but it is extremely flexible. The nice thing about a compound component API is we can create more terse components out of them. We expect applications to create wrapper components the have a more tightly controlled interface. For example, if we wanted an expandable component with a tooltip baked in, we could create a component API like this:

<Expandable tooltipText="Tooltip!" targetText="Toggle">
  Content
</Expandable>

We'll make an Expandable component that abstracts the compound component API for re-use in applications (expandable components are so in these days!):

// Expandable.tsx
import React from 'react';

import {Disclosure} from './Disclosure';
import {Tooltip} from './Tooltip';

export interface ExpandableProps {
  tooltipText: string;
  targetText: string;
  children: React.ReactNode;
}

export const Expandable = ({tooltipText, targetText, children}: ExpandableProps) => {
  return (
    <Disclosure>
      <Tooltip>
        <Tooltip.Target as={Disclosure.Target}>{targetText}</Tooltip.Target>
        <Tooltip.Content>{tooltipText}</Tooltip.Content>
      </Tooltip>
      <Disclosure.Content>{children}</Disclosure.Content>
    </Disclosure>
  );
};

This configuration API has lost the flexibility of the compound component API, but it is simpler to use. Applications can create these APIs for internal components since they know more about the context that a component will live in. Things like how to do translations, if there's any additional attributes to add (test ids or analytics metadata).

The full working code can be found here: https://codesandbox.io/s/disclosure-composable-model-behavior-hooks-tooltip-wrapped-2u8mk