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:
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 '../../components/common/primitives';
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. Now let's add some styling.
import React from 'react';
import styled from '@emotion/styled';
import { chevronDownIcon } from '@workday/canvas-system-icons-web';
import { SystemIcon } from '@workday/canvas-kit-react/icon';
import { type, space } from '@workday/canvas-kit-react/tokens';
import { Box } from '../../components/common/primitives';
import { VStack } from '../../components/common/layout';
import { useDisclosureModel } from '../../components/Disclosure';
import { createComponent, StyledType } from '@workday/canvas-kit-react/common';
import { Checkbox } from '@workday/canvas-kit-react/checkbox';
const StyledButton = styled('button')<StyledType>({
display: 'flex',
width: '100%',
border: 'none',
background: 'none',
padding: space.xs,
...type.h4,
});
type ExpandableButtonProps = {
children: React.ReactNode;
expanded: boolean;
};
const ExpandableButton = createComponent('button')({
displayName: 'ExpandableButton',
Component: (
{ children, expanded, ...props }: ExpandableButtonProps,
ref,
Element
) => {
return (
<StyledButton ref={ref} as={Element} {...props}>
<SystemIcon
icon={chevronDownIcon}
style={{
transition: 'transform 200ms ease-out',
transform: expanded ? '' : 'rotate(-90deg)'
}}
/>
<Box marginInlineStart="s">{children}</Box>
</StyledButton>
);
},
});
export const HomeSidebar = () => {
const model = useDisclosureModel();
return (
<>
<ExpandableButton
expanded={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. There's also the StyledType
that is meant to be added to Emotion 10's styled
function since the types don't include the as
prop for some reason, even though their documentation and runtime support it.
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 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 DisclosureModelContext = React.createContext(
{} as 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} expanded={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 expanded
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.includes(name);
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} expanded={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>
);
};