Today we'll be covering creating compound components using models, behaviors, and utility functions in Canvas Kit that make composability easier. Please refer to the Create Compound Component docs for reference later.
Get the latest changes:
git fetch --all
Check out the session 3 start branch:
git checkout session-3-start
First, let's start by creating a new model. Create a new Disclosure
component:
touch src/components/Disclosure.tsx
Open the file. A model is an object that has a state
and events
property. At face value, models are pretty simple. We'll create a Disclosure
model that has a visible
state property and show
and hide
events and we'll see how they work.
Enter the following into the file we just created.
import React from 'react';
export const useDisclosureModel = () => {
const [visible, setVisible] = React.useState(false);
const state = {
visible,
};
const events = {
show() {
setVisible(true);
},
hide() {
setVisible(false);
},
};
return { state, events };
};
As we can see, the disclosure model is a simple React hook that uses React.useState
to maintain a boolean. So why all the boilerplate? We'll find out later!
Let's add a simple component update to see our Disclosure model in action. Modify the src/pages/Home/HomeSidebar.tsx
file to use our useDisclosureModel
. We'll add some styling later.
import React from "react";
import { Box } from "@workday/canvas-kit-labs-react/common";
import { useDisclosureModel } from "../../components/Disclosure";
export const HomeSidebar = () => {
const model = useDisclosureModel();
return (
<>
<button
onClick={() => {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
}}
>
Toggle
</button>
<Box hidden={model.state.visible ? undefined : true}>Foo</Box>
</>
);
};
Click the "Toggle" button to toggle the content. Notice clicking the button will toggle the content "Foo" on and off. Now let's add some styling. Update the HomeSidebar.tsx
file:
import React from "react";
import { chevronDownIcon } from "@workday/canvas-system-icons-web";
import { SystemIcon } from "@workday/canvas-kit-react/icon";
import { type } from "@workday/canvas-kit-react/tokens";
import { Box } from "@workday/canvas-kit-labs-react/common";
import { VStack, Flex } from "@workday/canvas-kit-labs-react/layout";
import { useDisclosureModel } from "../../components/Disclosure";
import { createComponent } from "@workday/canvas-kit-react/common";
import { Checkbox } from "@workday/canvas-kit-react/checkbox";
type ExpandableButtonProps = {
children: React.ReactNode;
visible: boolean;
};
const ExpandableButton = createComponent("button")({
displayName: "ExpandableButton",
Component: (
{ children, visible, ...props }: ExpandableButtonProps,
ref,
Element
) => {
return (
<>
<Flex
ref={ref}
as={Element}
width="100%"
border="none"
background="none"
padding="xs"
style={type.levels.heading.small}
{...props}
>
<SystemIcon
icon={chevronDownIcon}
style={{
transition: "transform 200ms ease-out",
transform: visible ? "" : "rotate(-90deg)"
}}
/>
<Box marginInlineStart="s">{children}</Box>
</Flex>
</>
);
}
});
export const HomeSidebar = () => {
const model = useDisclosureModel();
return (
<>
<ExpandableButton
visible={model.state.visible}
onClick={() => {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
}}
>
Roast Level
</ExpandableButton>
<Box
marginTop="s"
style={{
transition: "max-height 200ms ease, opacity 200ms ease-out",
maxHeight: model.state.visible ? "1000px" : "0px",
opacity: model.state.visible ? 1 : 0
}}
>
<VStack spacing="xs">
<Checkbox label="Light" />
<Checkbox label="Light-Medium" />
<Checkbox label="Medium" />
<Checkbox label="Medium-Dark" />
<Checkbox label="Dark" />
</VStack>
</Box>
</>
);
};
We've also introduced the createComponent
utility function. This function is used by Canvas Kit components to add the ref
and as
props to components. We've added an animation and it looks much better.
In this case, we might want the expandable containers to start visible, but our model starts the containers hidden. We'll also want to support guards and callbacks. Let's update our model to support these. Update src/components/Disclosure.tsx
import React from 'react';
type State = {
visible: boolean;
};
type DiscloseModelConfig = {
initialVisible?: boolean;
shouldShow?(event: { data: {}; state: State }): boolean;
shouldHide?(event: { data: {}; state: State }): boolean;
onShow?(event: { data: {}; prevState: State }): void;
onHide?(event: { data: {}; prevState: State }): void;
};
export const useDisclosureModel = (config: DiscloseModelConfig = {}) => {
const [visible, setVisible] = React.useState(config.initialVisible || false);
const state = {
visible,
};
const events = {
show() {
if (config.shouldShow?.({ data: {}, state }) === false) {
return;
}
setVisible(true);
config.onShow?.({ data: {}, prevState: state });
},
hide() {
if (config.shouldHide?.({ data: {}, state }) === false) {
return;
}
setVisible(false);
config.onHide?.({ data: {}, prevState: state });
},
};
return { state, events };
};
Now we can update our src/pages/Home/HomeSidebar.tsx
to set initialVisible
to true
.
const model = useDisclosureModel({
initialVisible: true,
});
The updates to the model allow for configuration of the model, but there are a few problems:
- Guards and callbacks duplicate the
data
attribute, making the types more cumbersome - We must remember to have the extra
if ... return
andconfig.on*?
code, increasing chances of user error. - The
events
object is created every render which is less efficient - Not very strong type checking between callback/guard and events
The Canvas Kit common module has several utility functions to help with all of these problems. Let's update to use these utility functions:
import React from "react";
import {
createEventMap,
Model,
ToModelConfig,
useEventMap
} from "@workday/canvas-kit-react/common";
export type DisclosureState = {
visible: boolean;
};
export type DisclosureEvents = {
show(): void;
hide(): void;
};
export type DisclosureModel = Model<DisclosureState, DisclosureEvents>;
const disclosureEventMap = createEventMap<DisclosureEvents>()({
guards: {
shouldShow: "show",
shouldHide: "hide"
},
callbacks: {
onShow: "show",
onHide: "hide"
}
});
export type DisclosureConfig = {
initialVisible?: boolean;
} & Partial<
ToModelConfig<DisclosureState, DisclosureEvents, typeof disclosureEventMap>
>;
export const useDisclosureModel = (config: DisclosureConfig = {}) => {
const [visible, setVisible] = React.useState(config.initialVisible || false);
const state = {
visible
};
const events = useEventMap(disclosureEventMap, state, config, {
show() {
setVisible(true);
},
hide() {
setVisible(false);
}
});
return { state, events };
};
The UI we made is not very reusable. It is also missing accessibility. We can make a more generic Disclosure
compound component that we can then compose in our UI to take care of the disclosure details and leave the application details to us. This helps separate concerns of the functionality of the disclosure behavior and our application's logic.
We'll create a compound component API like the following:
<Disclosure>
<Disclosure.Target>Toggle</Disclosure.Target>
<Disclosure.Content>Content</Disclosure.Content>
</Disclosure>
We'll add the following to the Disclosure file:
// add createComponent and useDefaultModel to existing import
import {createComponent, useDefaultModel} from '@workday/canvas-kit-react/common';
export const DisclosureModelContext = React.createContext(
{} as DisclosureModel
);
export interface DisclosureTargetProps {
children: React.ReactNode;
}
const DisclosureTarget = createComponent("button")({
displayName: "Disclosure.Target",
Component: (
{ children, ...elemProps }: DisclosureTargetProps,
ref,
Element
) => {
const model = React.useContext(DisclosureModelContext);
return (
<Element
ref={ref}
onClick={() => {
console.log("Disclosure.Target onClick");
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
}}
{...elemProps}
>
{children}
</Element>
);
}
});
export interface DisclosureContentProps {
children: React.ReactNode;
}
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.visible ? undefined : true}
{...elemProps}
>
{children}
</Element>
);
}
});
export interface DisclosureProps extends DisclosureConfig {
children: React.ReactNode;
model?: DisclosureModel;
}
export const Disclosure = createComponent()({
displayName: "Disclosure",
Component: ({ children, model, ...config }: DisclosureProps) => {
// useDefaultModel helps us conditionally use a passed in model, or fall back to creating our own
const value = useDefaultModel(model, config, useDisclosureModel);
return (
<DisclosureModelContext.Provider value={value}>
{children}
</DisclosureModelContext.Provider>
);
},
subComponents: {
Target: DisclosureTarget,
Content: DisclosureContent
}
});
Now we can update the src/pages/Home/HomeSidebar.tsx
file to only worry about styling and other application-specific behaviors.
export const HomeSidebar = () => {
const model = useDisclosureModel({
initialVisible: true
});
return (
<Disclosure model={model}>
<Disclosure.Target as={ExpandableButton} visible={model.state.visible}>
Roast Level
</Disclosure.Target>
<Disclosure.Content
as={Box}
marginTop="s"
style={{
transition: "max-height 200ms ease, opacity 200ms ease-out",
maxHeight: model.state.visible ? "1000px" : "0px",
opacity: model.state.visible ? 1 : 0
}}
hidden={undefined}
aria-hidden={model.state.visible ? undefined : true}
>
<VStack spacing="xs">
<Checkbox label="Light" />
<Checkbox label="Light-Medium" />
<Checkbox label="Medium" />
<Checkbox label="Medium-Dark" />
<Checkbox label="Dark" />
</VStack>
</Disclosure.Content>
</Disclosure>
);
};
There are some powerful concepts in this code. Both Disclosure.Target
and Disclosure.Content
are using the as
prop to control complete rendering of UI. The Disclosure.Target
is taking an visible
property even though the component doesn't define that prop. It is coming from ExpandableButton
. The createComponent
utility function to determine the final interface of a component based on the as
prop. So this example shows the ExpandableButton
can be left alone with it's own interface and we can compose it together with the Disclosure.Target
component. Pretty powerful stuff!
The Disclosure.Content
is being rendered as a Box
element. Since the Disclosure.Content
uses hidden
by default and we want animation, we disable hidden
in favor of aria-hidden
and animations.
Let's hook up some behavior to the checkboxes since they don't currently do anything.
import { useFilters, filterOptions } from '../../providers/CoffeeFilters';
import { Coffee } from '../../types';
// ...
export type CheckboxFilterProps = {
displayName: string;
name: Coffee['roastLevel'];
};
const CheckboxFilter = createComponent(Checkbox)({
displayName: "CheckboxFilter",
Component: ({ displayName, name }: CheckboxFilterProps) => {
const model = useFilters();
const checked = model.state.filters.roastLevel.indexOf(name) !== -1;
return (
<Checkbox
label={displayName}
checked={checked}
onChange={(event) => {
if (checked) {
model.events.removeRoast({ roast: name });
} else {
model.events.addRoast({ roast: name });
}
}}
/>
);
}
});
// ...
export const HomeSidebar = () => {
const model = useDisclosureModel({
initialVisible: true,
});
return (
<Disclosure model={model}>
<Disclosure.Target as={ExpandableButton} visible={model.state.visible}>
Roast Level
</Disclosure.Target>
<Disclosure.Content
as={Box}
marginTop="s"
style={{
transition: 'max-height 200ms ease, opacity 200ms ease-out',
maxHeight: model.state.visible ? '1000px' : '0px',
opacity: model.state.visible ? 1 : 0,
}}
hidden={undefined}
>
<VStack spacing="xs">
{filterOptions[0].options.map((option, index, options) => {
return (
<CheckboxFilter
displayName={option.displayName}
name={option.key}
/>
);
})}
</VStack>
</Disclosure.Content>
</Disclosure>
);
};
Behavior hooks are key pieces of functionality in components. A behavior hook is a React hook that takes in a model
, extra props
, and a ref
. It will return an object with merged props.
Let's refactor DisclosureTarget
to use a behavior hook instead of inlining props. This will make the behavior of DisclosureTarget
reusable independent of any styling.
const useDisclosureTarget = (model: DisclosureModel, props: {}, ref: React.Ref<any>) => {
return {
onClick() {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
},
...props,
ref,
}
}
We'll update DisclosureTarget
to use this hook.
const DisclosureTarget = createComponent("button")({
displayName: "Disclosure.Target",
Component: (
{ children, ...elemProps }: DisclosureTargetProps,
ref,
Element
) => {
const model = React.useContext(DisclosureModelContext);
const props = useDisclosureTarget(model, elemProps, ref)
return (
<Element
{...props}
>
{children}
</Element>
);
}
});
The functionality is the same, but now useDisclosureTarget
could be used in any other component.
There is a problem with useDisclosureTarget
though. If the application passes an onClick
to a Disclosure.Target
component, that onClick
will override our onClick
and the disclosure component will no longer toggle. This is not what we want. Let's refactor a bit to call an onClick
if it exists. This is a form of callback merging:
const useDisclosureTarget = (model: DisclosureModel, props: {}, ref: React.Ref<any>) => {
return {
...props,
onClick(e: React.MouseEvent) {
(props as any).onClick?.(e)
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
},
ref,
}
}
Notice how we had to move our onClick
after the ...props
so that a props.onClick
doesn't override, but will instead merge the callbacks so that both will be called. Our model has a guards like shouldShow
to prevent a click
from changing our model which should be used instead of event overriding. We merge event callbacks instead of overriding because model changes are semantic, while DOM events are not.
This is already getting messy and we could have many callbacks to merge and deal with and the Typescript is getting burdensome. Canvas Kit exports a mergeProps
utility function to handle callback merging as well as css
, style
, and other props.
The hook could be refactored to the following:
const useDisclosureTarget = (model: DisclosureModel, props: {}, ref: React.Ref<any>) => {
return mergeProps(
{
onClick() {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
},
ref
},
props
);
};
We used this pattern for a while, but always remembering the ref
(to always forward refs), and using mergeProps
became tedious. We made an additional utility for the common use-case of creating hooks. createHook
takes a function that returns props and returns a function that will merge everything properly. The above can be refactored into the following:
const useDisclosureTarget = createHook((model: DisclosureModel) => {
return {
onClick() {
if (model.state.visible) {
model.events.hide();
} else {
model.events.show();
}
}
};
});
The mergeProps
goes away and ref
handling is only needed if we need direct access to the ref
in the hook itself. createHook
will take care of calling mergeProps
for us and will make sure a ref
is always available to the component for proper ref forwarding. Notice there's less visible Typescript. createHook
is a generic function that creates a proper type signature that properly combines props. This is much more boilerplate without using createHook
.
A behavior hook can be created by composing multiple hooks together using composeHooks
. I won't make an example here because our component isn't complex enough, but the Dialog
component uses composeHooks
. Here's the source code of useDialogCard
:
import {composeHooks, createHook} from '@workday/canvas-kit-react/common';
import {usePopupCard, PopupModel} from '@workday/canvas-kit-react/popup';
export const useDialogCard = composeHooks(
usePopupCard,
createHook(({state}: PopupModel) => {
return {
id: `dialog-${state.id}`,
};
})
);
useDialogCard
is basically a hook merges props from usePopupCard
, an id
defined here, and any additional props passed into the component.