<Meta title="Guides/Creating Compound Components" parameters={{ viewMode: 'docs', previewTabs: { canvas: {hidden: true}, }, }} />
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:
A model is composed of state and events. The shape of the model used by components looks like this:
type Model = {
state: Record<string, any>;
events: Record<string, (data?: any) => void>;
};
Our model hook will take a config for initialVisible
and return a model.
// useDisclosureModel.ts
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};
};
The model has 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:
type DisclosureConfig = {
initialVisible?: boolean;
// guards
shouldShow?(data: void, state: DisclosureState): boolean;
shouldHide?(data: void, state: DisclosureState): boolean;
// callbacks
onShow?(data: void, prevState: DisclosureState): void;
onHide?(data: void, prevState: DisclosureState): void;
};
We'll also have to add the runtime of the guards and actions:
const events = {
show() {
if (config.shouldShow?.(undefined, state) === false) {
return;
}
setVisible(true);
config.onShow?.(undefined, state);
},
hide() {
if (config.shouldHide?.(undefined, state) === false) {
return;
}
setVisible(false);
config.onHide?.(undefined, 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 the createModelHook
factory function to help
us reduce boilerplate and reduce the possibility of making mistakes.
createModelHook
creates a model and infers the config, state, and events. The callbacks and guard
types will automatically be inferred.
// useDisclosureModel.ts
import {createModelHook} from '@workday/canvas-kit-react/common';
export const useDisclosureModel = createModelHook({
defaultConfig: {
initialVisible: false,
},
})(config => {
const [visible, setVisible] = React.useState(config.initialVisible || false);
const state = {
visible,
};
const events = {
show() {
setVisible(true);
},
hide() {
setVisible(false);
},
};
return {state, events};
});
createModelHook
takes a config object to determine the default config and the required config. We
only need default config. This function returns a function with a config
object with all config
defaults applied. This is the body of the useDisclosureModel
hook from earlier. Notice we don't
need to implement guards and callbacks directly inside our event implementations. createModelHook
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
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.
First, let's create the <Disclosure>
container component:
// Disclosure.tsx
import React from 'react';
import {DisclosureTarget} from './DisclosureTarget';
import {DisclosureContent} from './DisclosureContent';
import {useDisclosureModel} from './useDisclosureModel';
type DisclosureConfig = typeof useDisclosureModel.TConfig;
export interface DisclosureProps extends DisclosureConfig {
children: React.ReactNode;
}
const DisclosureModelContext = useDisclosureModel.Context;
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 config of useDisclosureModel
.
createModelHook
exposes a TConfig
property to capture the config type. This allows us to pass
the 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.
The createModelHook
creates a React Context that can be used by the Disclosure
component to
expose the disclosure model to subcomponents without having to pass it via props. 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.tsx
import React from 'react';
import React from 'react';
import {useDisclosureModel} from './useDisclosureModel';
export interface DisclosureTargetProps {
children: React.ReactNode;
}
export const DisclosureTarget = ({children}: DisclosureTargetProps) => {
const model = React.useContext(useDisclosureModel.Context);
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.tsx
import React from 'react';
import {useDisclosureModel} from './useDisclosureModel';
export interface DisclosureContentProps {
children: React.ReactNode;
}
export const DisclosureContent = ({children}: DisclosureContentProps) => {
const model = React.useContext(useDisclosureModel.Context);
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 model
, ref
, as
, or extra
props as HTML attributes. Also, we have to use typeof
to create types and a DisclosureContext
variable (capitalized for JSX). We also have to worry about the model
prop. The boilerplate for
supporting all of this gets very complicated. For this reason, createContainer
and
createSubcomponent
were created to handle this boilerplate for you out of the box. Both functions
take 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 exampleDisclosure.Target
vsDisclosureTarget
.modelHook
: This is the model hook used by the compound component (useDisclosureModel
in our case). This model hook is used to determine proper prop types and seamlessly handle the optionmodel
prop. ForcreateContainer
, if amodel
is not passed, a model is created and added to React Context. ForcreateSubcomponent
, if amodel
is not passed, the model comes from React Context.elemPropsHook
: This is the elemPropsHook that takes a model and elemProps and returns elemProps.subComponents
: For container components. A list of sub components to add to the returned component. For example, a sub component calledDisclosureTarget
will be added to the export ofDisclosure
so that the user can import onlyDisclosure
and useDisclosure.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 thecreateComponent
factory function to infer the final interface of the returned component.
Finally, a generic function is returned that takes the component configuration. The first argument
is elemProps
with ref
and hook props already merged in with props handed to the component. The
model config props will already be filtered out. We'll worry about elemPropsHook
later. The second
is an Element
property. Element
is the value passed to the Component's as
prop. It will
default to the provided element. The last parameter is an optional model
reference. Ideally, the
model is used in elemPropsHook
and therefore not normally needed inside the render function.
Let's convert the Disclosure example to use the createContainer
utility function to get this extra
functionality:
// Disclosure.tsx
import React from 'react';
import {createContainer} from '@workday/canvas-kit-react/common';
import {DisclosureTarget} from './DisclosureTarget';
import {DisclosureContent} from './DisclosureContent';
import {useDisclosureModel} from './useDisclosureModel';
export interface DisclosureProps {}
export const Disclosure = createContainer()({
displayName: 'Disclosure',
modelHook: useDisclosureModel,
subComponents: {
Target: DisclosureTarget,
Content: DisclosureContent,
},
})<DisclosureProps>(({children}) => {
return <>{children}</>;
});
Notice we do not need to add children
or model
to our prop definition. createContainer
is
adding those prop types for us. The displayName
helps identify the component in React developer
tools. This is only needed by container components. The subComponents
automatically adds a
displayName
to subcomponents using the property key. For example, our DisclosureTarget
will have
a displayName
of Disclosure.Target
. You can still provide a displayName
to override this
naming convention.
// DisclosureTarget.tsx
import React from 'react';
import {createSubcomponent} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
export interface DisclosureTargetProps {}
export const DisclosureTarget = createSubcomponent('button')({
modelHook: useDisclosureModel,
})<DisclosureTargetProps>((elemProps, Element, model) => {
return (
<Element
onClick={() => {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
}}
{...elemProps}
/>
);
});
// DisclosureContent.tsx
import React from 'react';
import {createSubcomponent} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
export interface DisclosureContentProps {}
export const DisclosureContent = createSubcomponent('div')({
modelHook: useDisclosureModel,
})<DisclosureContentProps>(({children, ...elemProps}, Element, model) => {
return (
<Element hidden={model.state.visible ? undefined : true} {...elemProps}>
{children}
</Element>
);
});
The as
prop is being passed to the second 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);
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.
createContainer
and createSubcomponent
return a component with a type interface that includes
ref forwarding, the as
prop for changing the underlying element, the model
prop, and additional
attributes/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
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.ts
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.ts
import React from 'react';
import {createModelHook} from '@workday/canvas-kit-react/common';
import {useIDModel} from './useIDModel';
export const useDisclosureModel = createModelHook({
defaultConfig: {
...useIDModel.defaultConfig,
initialVisible: false,
},
})(config => {
const [visible, setVisible] = React.useState(config.initialVisible || false);
const idModel = useIDModel(config);
const state = {
...idModel.state,
visible,
};
const events = {
...idModel.events,
show() {
setVisible(true);
},
hide() {
setVisible(false);
},
};
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
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 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 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.ts
import {useDisclosureModel} from './useDisclosureModel';
export const useExpandableControls = (
{state}: ReturnType<typeof useDisclosureModel>,
elemProps = {},
ref?: React.Ref<any>
) => {
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.ts
import {mergeProps} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
export const useExpandableControls = (
{state}: ReturnType<typeof useDisclosureModel>,
elemProps = {},
ref?: React.Ref<any>
) => {
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.
This is still a lot of boilerplate. We need the return type of the model hook, we need to specify
that our hook can optionally accept elemProps
and a ref
, and we need to call mergeProps
.
createElemPropsHook
helps with a lot of this boilerplate:
import {createElemPropsHook} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
export const useExpandableControls = createElemPropsHook(useDisclosureModel)(({state}) => {
return {
'aria-controls': state.id,
'aria-expanded': state.visible,
};
});
createElemPropsHook
takes the model hook and an elem props hook body as arguments. The hook
function body doesn't need to call mergeProps
since createElemPropsHook
takes care of that for
us. Our logic can focus only on the props we need to add to an element!
Now we have a reusable elemProps hook that can be composed into other hooks or used on its own.
"expandable controls" could be used on a select component, a popup component, or any other type of
disclosure target component. We don't add the onClick
because how the disclosure is revealed
depends on the disclosure target type. In a Select
component, that could be by clicking on the
target, or using the down arrow. On a Tooltip
component, it could be revealed by a mouse hover or
focus event. Lets create a useDisclosureTarget
elemProps hook that merges in an onClick
with
useExpandableControls
:
// useDisclosureTarget.ts
import {createElemPropsHook, mergeProps} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
import {useExpandableControls} from './useExpandableControls';
export const useDisclosureTarget = createElemPropsHook(useDisclosureModel)(
(model, ref, elemProps) => {
const props = useExpandableControls(model, elemProps, ref);
return mergeProps(
{
onClick() {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
},
},
props
);
}
);
Notice we still need to use mergeProps
to compose the behavior of our two elemProps hooks?
composeHooks
was created to handle this common composition use case. composeHooks
takes two or
more elemProps hooks and returns a new hook with all props merged for us:
// useDisclosureTarget.ts
import {createElemPropsHook, composeHooks} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
import {useExpandableControls} from './useExpandableControls';
export const useDisclosureTarget = composeHooks(
createElemPropsHook(useDisclosureModel)(model => {
return {
onClick() {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
},
};
}),
useExpandableControls
);
We don't even need to declare elemProps
or ref
parameters if we don't use them!
Now we can use the behavior hook in the DiscloseTarget
component:
// DisclosureTarget.tsx
import React from 'react';
import {createSubcomponent} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
import {useDisclosureTarget} from './useDisclosureTarget';
export interface DisclosureTargetProps {}
export const DisclosureTarget = createSubcomponent('button')({
modelHook: useDisclosureModel,
})<DisclosureTargetProps>((elemProps, Element, model) => {
const props = useDisclosureTarget(model, elemProps);
return <Element {...props} />;
});
Note: We should never use createElemPropsHook
or composeHooks
inside a render function as that
would be slower. Always hoist the hook definition outside a render function.
It is very common to use an elemProps hook with a compound component, so createContainer
and
createSubcomponent
both take an elemPropsHook
configuration option. This way we don't have to
worry about the model
or using mergeProps
in our component definition. Here's the final code.
// DisclosureTarget.tsx
import React from 'react';
import {createSubcomponent} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
import {useDisclosureTarget} from './useDisclosureTarget';
export interface DisclosureTargetProps {}
export const DisclosureTarget = createSubcomponent('button')({
modelHook: useDisclosureModel,
elemPropsHook: useDisclosureTarget,
})<DisclosureTargetProps>((elemProps, Element) => {
return <Element {...elemProps} />;
});
We'll also make a useDisclosureContent
behavior hook for the hidden
attribute on the
Disclosure.Content
element:
// useDisclosureContent.ts
import {createElemPropsHook} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
export const useDisclosureContent = createElemPropsHook(useDisclosureModel)(model => {
return {
id: model.state.id,
hidden: model.state.visible ? undefined : true,
};
});
The Disclosure.Content
subcomponent can now be updated to use this hook:
// DisclosureContent.tsx
import React from 'react';
import {createSubcomponent} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
import {useDisclosureContent} from './useDisclosureContent';
export interface DisclosureContentProps {}
export const DisclosureContent = createSubcomponent('div')({
modelHook: useDisclosureModel,
elemPropsHook: useDisclosureContent,
})<DisclosureContentProps>(({children, ...elemProps}, Element) => {
return <Element {...elemProps}>{children}</Element>;
});
The full code can be found here: https://codesandbox.io/s/disclosure-composable-model-behavior-hooks-iwzl8
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.ts
import {createModelHook} from '@workday/canvas-kit-react/common';
import {useDisclosureModel} from './useDisclosureModel';
const {
initialVisible, // tooltips are never initially visible, so remove the option
...defaultConfig
} = useDisclosureModel.defaultConfig;
export const useTooltipModel = createModelHook({
defaultConfig,
requiredConfig: useDisclosureModel.requiredConfig,
})(config => {
return useDisclosureModel(config);
});
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 {createContainer} from '@workday/canvas-kit-react/common';
import {useTooltipModel} from './useTooltipModel';
import {TooltipTarget} from './TooltipTarget';
import {TooltipContent} from './TooltipContent';
export interface TooltipProps {
children?: React.ReactNode;
}
export const Tooltip = createContainer()({
displayName: 'Tooltip',
modelHook: useTooltipModel,
subComponents: {
Target: TooltipTarget,
Content: TooltipContent,
},
})(({children}: TooltipProps) => {
return <>{children}</>;
});
The Tooltip.Target
component is similar to the DisclosureTarget
component, but has different
behavior. The tooltip triggers on different events. Here's the code:
// TooltipTarget.tsx
import React from 'react';
import {createSubcomponent, createElemPropsHook} from '@workday/canvas-kit-react/common';
import {useTooltipModel} from './useTooltipModel';
export interface TooltipTargetProps {
children: React.ReactNode;
}
export const useTooltipTarget = createElemPropsHook(useTooltipModel)(({state, events}) => {
return {
onFocus(event: any) {
events.show();
},
onBlur() {
events.hide();
},
onMouseEnter() {
events.show();
},
onMouseLeave() {
events.hide();
},
'aria-describedby': state.id,
};
});
export const TooltipTarget = createSubcomponent('button')({
displayName: 'Tooltip.Target',
modelHook: useTooltipModel,
elemPropsHook: useTooltipTarget,
})<TooltipTargetProps>(({children, ...elemProps}, Element) => {
return <Element {...elemProps}>{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
useDisclosureContent
behavior hook in this component!
import React from 'react';
import ReactDOM from 'react-dom';
import {
createSubcomponent,
createElemPropsHook,
composeHooks,
} from '@workday/canvas-kit-react/common';
import {useDisclosureContent} from './useDisclosureContent';
import {useTooltipModel} from './useTooltipModel';
export interface TooltipContentProps {}
const useTooltipContent = composeHooks(
createElemPropsHook(useTooltipModel)(model => {
return {
style: {position: 'absolute', left: 80, top: 10},
};
}),
useDisclosureContent
);
export const TooltipContent = createSubcomponent('div')({
modelHook: useTooltipModel,
elemPropsHook: useTooltipContent,
})<TooltipContentProps>(({children, ...elemProps}, Element, model) => {
return ReactDOM.createPortal(
model.state.id ? <Element {...elemProps}>{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
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