Skip to content

Instantly share code, notes, and snippets.

@alanbsmith
Forked from NicholasBoll/session-3.md
Last active April 30, 2021 15:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alanbsmith/c86fd3ccdcc811834add039a52592eea to your computer and use it in GitHub Desktop.
Save alanbsmith/c86fd3ccdcc811834add039a52592eea 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:

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 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 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>
  );
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment