Skip to content

Instantly share code, notes, and snippets.

@NicholasBoll
Last active June 30, 2021 06:16
Show Gist options
  • Save NicholasBoll/c29fc3fe64e5933699abef8eb88558fb to your computer and use it in GitHub Desktop.
Save NicholasBoll/c29fc3fe64e5933699abef8eb88558fb to your computer and use it in GitHub Desktop.
Session 3

Session 3

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 and config.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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment