@workday/canvas-kit-docs
Version:
Documentation components of Canvas Kit components
554 lines (442 loc) • 20.3 kB
text/mdx
# Compound Components
## What is a compound component?
> Compound components is a pattern where higher level components are composed using smaller
> components, and you retain access to all the semantic elements of the higher level component.
- [Container component](#container-components)
- [Subcomponents](#subcomponents)
- [Shared model (optional, advanced)](#models)
- [Behavior hooks (optional, advanced)](#behavior-hooks)
A compound component contrasts with a configuration component which instead configures from a single
interface, like a configuration object or multiple props in React. A configuration component might
be like choosing a desktop computer based on stats - how much RAM or how fast the CPU should be or
based on what you want to use it for. A compound component is more like buying the parts
individually and assembling yourself.
Configuration component:
```tsx
<Tabs
items={[
{title: 'First Tab', content: 'First Tab Contents'},
{title: 'Second Tab', content: 'Second Tab Contents'},
]}
/>
```
Compound component:
```tsx
<Tabs>
<Tabs.List>
<Tabs.Item>First Tab</Tabs.Item>
<Tabs.Item>Second Tab</Tabs.Item>
</Tabs.List>
<Tabs.Panel>First Tab Contents</Tabs.Panel>
<Tabs.Panel>Second Tab Contents</Tabs.Panel>
</Tabs>
```
In this example, `Tabs` is the container component and `Tabs.List` is a subcomponent.
Some compound components might not contain state or behavior. An example might be an `IconButton`
which is a button that contains an icon. It might be a compound component only for styling purposes,
but doesn't contain any special state or behaviors:
```tsx
<IconButton onClick={onClick}>
<IconButton.Icon icon={icon} />
<IconButton.Text>Button Text</IconButton.Text>
</IconButton>
```
## Container Components
A container component is the entry point to a compound component. A container component could
represent a real DOM element, or be a non-element container. For example, the `Pagination` component
has a container component that represents a `role=nav` element. The `Tabs` container component,
however, does not contain a semantic element.
If a compound component contains any state or behavior, it will also provide a shared model to
subcomponents via [React context](https://reactjs.org/docs/context.html). A container component
takes props for either the model or configuration for the model. In the `Tabs` compound component
example, it might look like this:
```tsx
export const TabsModelContext = React.createContext({});
const Tabs = ({children, model, ...config}) => {
// either a model is passed in, or we create one
const value = model || useTabsModel(config);
return <TabsModelContext.Provider value={value}>{children}</TabsModelContext.Provider>;
};
```
## Subcomponents
A subcomponent typically follows ARIA roles. For the `Tabs` example, these are the `tablist`, `tab`,
and `tabpanel` roles. A subcomponent provides direct access to semantic or key elements of a
compound component. In the `IconButton` example, the icon is not semantic and might be hidden from
screen readers while the `IconButton.Text` content is instead used for a tooltip and as the
accessible name while being visibly hidden.
## Why Compound Components?
Configurable components have a more terse implementation and tightly control component structure,
which make it a popular pattern. However, the trade-off of their rigid structure is losing direct
access to the markup. This is problematic for adding attributes to underlying elements, customizing
styles, and modifying the component's markup structure. Providing additional props can bypass those
issues, but that often leads to a bloated component API. And because these additional props are
often component-specific, it creates a less intuitive API for developers implementing the component.
For example, the Tabs interface might look like:
```tsx
interface Tab {
title: string;
contents: React.ReactNode;
tabProps: React.HTMLAttributes<HTMLElement>;
tabPanelProps: React.HTMLAttributes<HTMLElement>;
}
interface TabsProps {
tabListProps: React.HTMLAttributes<HTMLElement>;
items: Tab[];
}
```
Conversely, compound components have a more verbose implementation and loose control over structure.
This flexibility allows developers to have direct access to underlying elements which makes
manipulating attributes, styles, and markup structure much more natural and intuitive. From the
above example:
```tsx
<Tabs>
<Tabs.List>
<Tabs.Item>First Tab</Tabs.Item>
<Tabs.Item>Second Tab</Tabs.Item>
</Tabs.List>
<Tabs.Panel>First Tab Contents</Tabs.Panel>
<Tabs.Panel>Second Tab Contents</Tabs.Panel>
</Tabs>
```
The pattern provides an additional benefit as well: maintainability. An active client-side
application is constantly changing over the course of its lifecycle. However, not all parts change
at the same rate. The application-level business logic (authentication, authorization, data
fetching, et al) likely remains fairly intact over time. The UI logic layer (checkout flow steps,
modal logic, etc) will change more frequently as features evolves or are deprecated. The most
frequent changes happen at the markup structure and styling level. Configurable components are great
at meeting the needs of your application today, but are more difficult to update. Changing the
markup will often require changes to the component's code which will require library updates. These
library updates mean more UI logic and complexity or complete rewrite to support more use-cases.
For these reasons, we much prefer the compound component pattern. It allows our components to adapt
to your application's needs and evolve with it over time, often without changes to our code.
## Configuring Components
Components that directly wrap an element (most of them) will have the following properties:
- `ref`: This allows direct access to the underlying element.
```tsx
<Tabs.Item ref={myRef}>
```
- `as`: This allows overriding of the default element. The override can be a string representation
of a tag (i.e. `section`, `div`, `nav`, etc), or a Component that forwards attributes to an
element. If you use a component, you should forward the React `ref` and spread all extra props to
the element to ensure the API still works.
```tsx
// tag
<Tabs.List as="section" />
// Component
const Section = React.forwardRef(({children, ...elemProps}, ref) => (
<section ref={ref} {...elemProps}>{children}</section>
))
<Tabs.List as={Section}/>
```
Both will look like the following in the DOM:
```html
<section role="tablist"></section>
```
- Any extra props will be passed as HTML attributes to the underlying element.
```tsx
<Tabs.Item aria-label="Foobar" data-testid="tab1">
```
Compound components are also made up of [models](#models) that accept [guards](#guards) to
conditionally prevent state changes and [callbacks](#callbacks) to attach listeners. For example, in
our Tabs component clicking a Tab will select that tab. The `Tabs` container component will accept a
`shouldSelect` and a `onSelect` for the event called `select`.
```tsx
const MyComponent = () => {
// `data` is all event data from the `select` event
// `state` is the current state of the `Tabs` component
const shouldSelect = ({data, state}) => {
// for some reason, we only want to allow selection the 'first' tab
// Clicking on the first tab will select it, but clicking on the
// second tab will do nothing
return data.tab === 'first' ? true : false;
// returning true allows the event to trigger a state change and will
// also call the `onSelect` callback
};
// `prevState` is the previous state of the model. Callbacks are called _before_ state has resolved.
// This means the passed state hasn't updated yet. It also means it is safe to call `setState` without
// triggering extra renders. `setState` calls will add to React's batching system before a state changes
// are flushed and render functions are called.
const onSelect = ({data, prevState}) => {
// called any time the `select` event is triggered
console.log('onSelect', data, prevState);
};
return (
<Tabs shouldSelect={shouldSelect} onSelect={onSelect}>
<Tabs.List>
<Tabs.Item data-id="first">First</Tabs.Item>
<Tabs.Item data-id="second">Second</Tabs.Item>
</Tabs.List>
</Tabs>
);
};
```
This concludes basic compound components. If you'd like to know more about models, behavior hooks,
and more advanced composition techniques, read on.
## Models
### What is a Model?
If a compound component was stripped of all its markup, attributes, and styling, what would remain
is the model. The model is how we describe the state and supported state transitions. You could
completely swap out the underlying elements, attributes, and styles, and the model would remain the
same. The model is an object that is composed of two parts: `state` and a `events`. The model's
`state` describes the current snapshot in time of the component, and the `events` describes events
that can be sent to the model.
### Why Models?
Advantages of models:
- A common API structure to group state and behavior of components
- Atomic responsibilities
- Composable and shareable functionality
We use React Hooks to return models. An empty model would look like this:
```ts
const useEmptyModel = (config = {}) => {
const state = {};
const events = {};
return {state, events};
};
```
A model hooks takes in a configuration object. This object can contain anything like initial values,
configuration of behavior, etc. This is also where event behavior can be configured. Many model
events will have 2 optional configurable functions:
- Callbacks
- Guards
#### Callbacks
A callback of an event is similar to native event callbacks like `onClick`. Callbacks are a place to
handle events and by convention start with `on`. If the event is called `click`, the callback would
be called `onClick`. Callbacks can be used to handle side effects or used to produce additional
state changes. Callbacks are called synchronously which batches state changes, so any additional
state changes will not produce additional renders. This means callbacks are called with the previous
state since state has not resolved yet.
#### Guards
Guards are special functions that determine if an event should trigger a state change and a
callback. The function should return `true` or `false`. A `false` return value will effectively
cancel the event and state changes will not occur and callbacks will not be invoked. A guard allows
for a model's behavior to be modified without needing to produce a new model. Guard functions should
be pure functions. Side effects should be performed in callbacks. The convention of a guard function
is to start with a `should`. If an event is called `open`, the guard of the event would be called
`shouldOpen`.
Both guards and callbacks receive an object of event data (i.e. mouse position of a "click" event)
and the current `state` of the model.
Here's an example of a `DisclosureModel` that has an "open" event with a guard called "shouldOpen"
and a callback called "onOpen":
```ts
const useDisclosureModel = (config = {}) => {
const [opened, setOpened] = React.useState(false);
const state = {opened};
const events = {
open(data) {
if (config.shouldOpen?.({data, state}) === false) {
return;
}
setOpened(true);
config.onOpen?.({data, prevState: state});
},
};
return {state, events};
};
```
You can see the guard is called first, if defined, and the output is checked. If `false` is
returned, the event is canceled. If the guard is not defined or returns `true`, the `setOpened`
setter is called. Finally, if a callback is defined, it is called.
Guards allow configuration of state changes. A concrete example might be an `EllipsisTooltip` where
`mouseover` or `focus` DOM events call the model's `open` event. The `shouldOpen` guard would allow
conditional opening of the tooltip based on overflow (ellipsis) detection. For example:
```tsx
const useEllipsisTooltipModel = (config = {}) => {
return useTooltipModel({
...config,
shouldOpen({data}) {
// data has an `element` property
// `findOverflowElement` returns the element with an overflow style applied
const element = findOverflowElement(data.element);
// if the scrollWidth is greater than the clientWidth,
// then the content must be overflowed
return element.scrollWidth > element.clientWidth;
},
});
};
```
Models are meant to be composable. For example, a `TabsModel` uses a `CursorModel` (which itself
uses `ListModel`) and a `ListModel` for a list of panels. `TabsModel` also keeps track of which tab
is currently selected. This might look like the following:
```ts
const useTabsModel = (config = {}) => {
// id is used for ARIA attributes
const id = useUniqueId(config.id);
const [selectedTab, setSelectedTab] = React.useState('');
const cursor = useCursorModel(config);
const panels = useListModel(config);
const state = {
...cursor.state, // extend the CursorModel state
id,
selectedTab,
panels: panels.state.items, // we only care about
};
const events = {
...cursor.events, // extend the CursorModel events
registerPanel: panels.events.registerItem,
unregisterPanel: panels.events.unregisterItem,
select(data) {
if (config.shouldSelect?.({data, prevState: state}) === false) {
return;
}
setSelectedTab(data.tab);
config.onSelect?.({data, prevState: state});
},
};
return {state, events};
};
```
Model composition allows for components to share functionality with other components. In the Tabs
example, `ListModel` is in charge of maintaining a list of tab elements. The `CursorModel` is in
charge of maintaining a current cursor position of the tab list. The `Tabs.List` component uses the
cursor to allow keyboard navigation of the tabs. The `TabsModel` also maintains the currently
selected tab to ensure the correct `TabPanel` is visible. The `TabsModel` is also using a
`ListModel` to maintain a list of tab panels. The `TabsModel` is in charge of composing all this and
providing data and events to the `Tabs` compound component - coordination state between
subcomponents.
Many other components like `Select`, `Breadcrumbs`, or dropdown menus can also use the `ListModel`
and/or the `CursorModel`. These models could be thought of as abstract models where they do not
directly map to a compound component, but are instead used to create concrete models that do map to
compound components.
The Typescript interface of a model looks like this:
```ts
interface Model<
S extends Record<string, any>,
E extends Record<string, (...args: any[]) => void
> {
state: S
events: E
}
```
The Typescript interface of Callbacks and Guards looks like this:
```ts
type Callback<EventData, State> = ({data: EventData, prevState: State}) => void;
type Guard<EventData, State> = ({data: EventData, state: State}) => boolean;
```
## Behavior Hooks
### What is a Behavior Hook?
A behavior hook usually applies to a subcomponent and describes attributes that are applied to a
subcomponent's element (i.e. `aria-labelledby`, or `onClick`). A behavior hook takes in the model
and developer-defined DOM attributes and return a merged object of attributes.
`(Model, HTMLAttributs) => HTMLAttributes`.
### Why Behavior Hooks?
A behavior hook allows us to more easily reuse functionality between components with similar
subcomponents. They also provide another layer of composition to compound components.
For example, the `CursorModel` contains the model's internal state and events, but doesn't handle
external DOM events directly. The behavior hook is the glue between the model and DOM elements. A
`useKeyboardCursor` behavior hook might look like this:
```ts
const useKeyboardCursor = ({state, events}, elemProps = {}) => {
const focus = () => {
const items = state.items.find;
};
// effects on state changes
React.useEffect(() => {
const item = state.items.find(({id}) => state.currentId === id);
item.ref.current?.focus();
}, [state.currentId, state.items]);
return {
onKeyDown(event) {
// if onKeyDown was provided, call it first
elemProps.onKeyDown?.(event);
if (event.key === 'ArrowLeft' || event.key === 'Left') {
events.goToPrevious();
}
if (event.key === 'ArrowRight' || event.key === 'Right') {
events.goToNext();
}
},
...elemProps,
};
};
```
## Putting it all together
In the `Tabs` component example, there isn't a `Cursor` component. The `Tab.List` subcomponent uses
the `CursorModel` and the `useRovingFocus` behavior hook to produce the desired subcomponent. It
looks something like this:
```tsx
const TabList = ({children, ...elemProps}) => {
const model = React.useContext(TabsModelContext);
const props = useRovingFocus(model, elemProps);
// we could use other behavior hooks to further build `props`
return (
<div role="tablist" {...props}>
{children}
</div>
);
};
```
### Configuring a model
A container component can either accept model configuration _or_ a model. Passing model
configuration allows for simpler model configuration of guards, callbacks, or any other model
configuration. The following example provides an `onSelect` callback that fetches some data from the
server:
```tsx
<Tabs onSelect={({data}) => fetch('/api/selectTab' + data.id)}>...</Tabs>
```
If you need direct access to a model's state or events, you can hoist the model into your component
and pass the whole model to the container component. This allows you to use the model's state in
your render method or provide the model's events to other callbacks. In the `Tabs` example, it might
look like this:
```tsx
const MyTabs = () => {
const model = useTabsModel({
// we can still load data from the server
onSelect: ({data}) => fetch('/api/selectTab' + data.id),
});
return (
<>
<Tabs model={model}>...</Tabs>
// direct access to the model's state Currently selected tab: {model.state.selectedTab}
// Now we can send events directly to the model
<button onClick={() => model.events.select({tab: 'third'})}>Select third tab</button>
</>
);
};
```
### Composing a model
Models allow for very powerful composition without changing the UI at all. For example, if we have a
`Disclosure` component, but want to change the operating paradigm to be fully controlled by a parent
component, we can compose a `DisclosureModel` to do so. Normally a disclosure model has it's own
state, but we can override that behavior and make a controlled Disclosure component instead:
```tsx
const useControlledDisclosureModel = ({opened, onChange, ...config}) => {
const model = useDisclosureModel(config);
const state = {
...model.state,
opened,
};
const events = {
...model.events,
open(data) {
onChange(true);
},
close(data) {
onChange(false);
},
};
return {state, events};
};
const ControlledDisclosure = ({buttonText, children, opened, onChange}) => {
const model = useControlledDisclosureModel({opened, onChange});
return (
<Disclosure model={model}>
<Disclosure.Target>{buttonText}</Disclosure.Target>
<Disclosure.Content>{children}</Disclosure.Content>
</Disclosure>
);
};
const App = () => {
const [opened, setOpened] = React.useState(false);
return (
<ControlledDisclosure buttonText="Toggle" opened={opened} onChange={setOpened}>
Disclosed Content
</ControlledDisclosure>
);
};
```
### Conclusion
The compound component API is a powerful, incrementally composable way to create UI. The component
API is the highest level and offers a lot of functionality out of the box. But using models and
behavior hooks allow for creation of new components that share some functionality with other
components. An example of this is tabs and a dropdown menu both use a `CursorModel` and the
`useKeyboardCursor` to enable keyboard navigation even though the UI looks very different.