Skip to content

Instantly share code, notes, and snippets.

@NicholasBoll
Last active April 29, 2021 18:20
Show Gist options
  • Save NicholasBoll/8420c08393fa4a7ee4a02f35120c9220 to your computer and use it in GitHub Desktop.
Save NicholasBoll/8420c08393fa4a7ee4a02f35120c9220 to your computer and use it in GitHub Desktop.
Session 2 notes

Session 2

fetch latest:

git fetch --all

Checkout the session 2 branch

git checkout session-2-start

Pull latest

yarn install

Once that's done, start the application:

yarn start

You should see the application with tabs at the top. Clicking tabs will switch the coffee list.

Go to src/pages/Home/index.tsx. There's Tabs with an items prop which is an array of objects with the shape of { title: string, content: React.ReactNode }. We call the style of this type of complex component a "configuration component" because you configure the component, but you don't have any direct access to the internals of the component without extra prop config.

Read the Compound Components documentation to learn the advantages of compound components. Refactor the Home component to use the Tabs compound component API. Remember to import Tabs from @workday/canvas-kit-labs-react/tabs. Below is the final code.

// ...
import { Tabs } from '@workday/canvas-kit-labs-react/tabs';
//...

export const Home: React.FC = () => {
  const { coffee } = useAllCoffee();
  const [newCoffee, popularCoffee, staffCoffee] = splitCoffee(coffee);

  return (
    <Tabs>
      <Tabs.List>
        <Tabs.Item>All</Tabs.Item>
        <Tabs.Item>Popular</Tabs.Item>
        <Tabs.Item>New & Interesting</Tabs.Item>
        <Tabs.Item>Staff Favorites</Tabs.Item>
      </Tabs.List>

      <Tabs.Panel>
        <CoffeeList coffee={coffee} />
      </Tabs.Panel>
      <Tabs.Panel>
        <CoffeeList coffee={popularCoffee} />
      </Tabs.Panel>
      <Tabs.Panel>
        <CoffeeList coffee={newCoffee} />
      </Tabs.Panel>
      <Tabs.Panel>
        <CoffeeList coffee={staffCoffee} />
      </Tabs.Panel>
    </Tabs>
  );
};

The application should look exactly the same as it did before.

All sub components of a Compound Component API can have the element adjusted: https://github.com/Workday/canvas-kit/blob/6491da3eace1996a1e4e8e9d9271214b3d6e2b88/COMPOUND_COMPONENTS.md#configuring-components

Go ahead and change the element of the Tabs.List component to a section.

Let's explore the TabsModel to see how we can change or observe the behavior of the Tabs model.

If we want to observe when tabs change, we can add the onActivate callback of the Tabs Model. The Compound Component API allows us to configure the model through the container component. Go ahead and add the following to the Tabs element:

<Tabs onActivate={({ data, prevState }) => {
  console.log('onActivate', data, prevState)
}}>

Now when you click on a tab, you'll see the tab event data and the previous state in the dev console. You'll notice the data contains an object with a tab property. The tab will output something like "0". This is because we didn't specify a name for the tab items or tab panels. By default the name will be the index of the item or panel converted to a string. Let's go ahead and name our items and panels.

<Tabs.Item name="all">

The final code will look like:

export const Home: React.FC = () => {
  const { coffee } = useAllCoffee();
  const [newCoffee, popularCoffee, staffCoffee] = splitCoffee(coffee);

  return (
    <Tabs
      onActivate={({ data, prevState }) => {
        console.log('onActivate', data, prevState);
      }}
    >
      <Tabs.List as="section">
        <Tabs.Item name="all">All</Tabs.Item>
        <Tabs.Item name="popular">Popular</Tabs.Item>
        <Tabs.Item name="new">New & Interesting</Tabs.Item>
        <Tabs.Item name="alan">Staff Favorites</Tabs.Item>
      </Tabs.List>

      <Tabs.Panel name="all">
        <CoffeeList coffee={coffee} />
      </Tabs.Panel>
      <Tabs.Panel name="popular">
        <CoffeeList coffee={popularCoffee} />
      </Tabs.Panel>
      <Tabs.Panel name="new">
        <CoffeeList coffee={newCoffee} />
      </Tabs.Panel>
      <Tabs.Panel name="alan">
        <CoffeeList coffee={staffCoffee} />
      </Tabs.Panel>
    </Tabs>
  );
};

Now you will see the tab name in the event data in the dev console output.

There are also guards that allow us to prevent certain events from happening under any condition we want.

<Tabs
  shouldActivate={({ data }) => data.tab !== 'alan'}
>

This will prevent the alan tab from being activated. This might be useful if you have an "Add Tab" tab that adds a new tab. It is meant to be a button and not an actual tab, so there would be no panel content.

Now we will hoist the model so that we have access to the state and events in our application:

Update the import:

import { Tabs, useTabsModel } from '@workday/canvas-kit-labs-react/tabs';

Update the Home component:

export const Home: React.FC = () => {
  const { coffee } = useAllCoffee();
  const [newCoffee, popularCoffee, staffCoffee] = splitCoffee(coffee);

  const model = useTabsModel({
    onActivate: ({ data, prevState }) => {
      console.log('onActivate', data, prevState);
    },
  });

  return (
    <Tabs model={model}>
      <Tabs.List as="section">
        <Tabs.Item name="all">All</Tabs.Item>
        <Tabs.Item name="popular">Popular</Tabs.Item>
        <Tabs.Item name="new">New & Interesting</Tabs.Item>
        <Tabs.Item name="alan">Staff Favorites</Tabs.Item>
      </Tabs.List>

      <Tabs.Panel name="all">
        <CoffeeList coffee={coffee} />
      </Tabs.Panel>
      <Tabs.Panel name="popular">
        <CoffeeList coffee={popularCoffee} />
      </Tabs.Panel>
      <Tabs.Panel name="new">
        <CoffeeList coffee={newCoffee} />
      </Tabs.Panel>
      <Tabs.Panel name="alan">
        <CoffeeList coffee={staffCoffee} />
      </Tabs.Panel>
    </Tabs>
  );
};

We're using the Tabs as more like filters than individual tab panels. It would be more efficient to update our list of coffee vs having many lists since Tabs retains everything in the DOM. We'll start by removing all but a single tab:

export const Home: React.FC = () => {
  const { coffee } = useAllCoffee();
  const [newCoffee, popularCoffee, staffCoffee] = splitCoffee(coffee);

  const model = useTabsModel({
    onActivate: ({ data, prevState }) => {
      console.log('onActivate', data, prevState);
    },
  });

  return (
    <Tabs model={model}>
      <Tabs.List as="section">
        <Tabs.Item name="all">All</Tabs.Item>
        <Tabs.Item name="popular">Popular</Tabs.Item>
        <Tabs.Item name="new">New & Interesting</Tabs.Item>
        <Tabs.Item name="alan">Staff Favorites</Tabs.Item>
      </Tabs.List>

      <Tabs.Panel name="all">
        <CoffeeList coffee={coffee} />
      </Tabs.Panel>
    </Tabs>
  );
};

We notice that all is the only tab panel that will ever show. If you activate other tabs, the panel will be blank. The tabs component works by connecting a tab item with a tab panel by the name. It uses a hidden attribute on tab panels to handle which tab is "active". If we want a single panel, we'll have to override this mechanism. Luckily, CK components will take any props handed to it and use those attributes directly, overriding default functionality. We'll update our example to a single panel.

First, let's add a mapping of coffee data:

// We use `Record` to make TS happy later
const coffees: Record<string, Coffee[]> = {
  all: coffee,
  popular: popularCoffee,
  new: newCoffee,
  alan: staffCoffee,
};

We'll update the Tabs.Panel to render a CoffeeList with coffee from this map.

<Tabs.Panel hidden={undefined}>
  <CoffeeList coffee={coffees[model.state.activeTab] || []} />
</Tabs.Panel>

We have the || [] as a fallback. There's a short time when when the active tab isn't set yet. This will send an empty array to the CoffeeList.

We ended up breaking some accessibility though. The tab item has an aria-controls that maps to the id of a tab panel. We'll need to add back that functionality:

<Tabs.Item name="all" aria-controls="my-panel">
  All
</Tabs.Item>
<Tabs.Panel hidden={undefined} id="my-panel">
...

The final code looks like:

export const Home: React.FC = () => {
  const { coffee } = useAllCoffee();
  const [newCoffee, popularCoffee, staffCoffee] = splitCoffee(coffee);

  const model = useTabsModel({
    onActivate: ({ data, prevState }) => {
      console.log('onActivate', data, prevState);
    },
  });

  const coffees: Record<string, Coffee[]> = {
    all: coffee,
    popular: popularCoffee,
    new: newCoffee,
    alan: staffCoffee,
  };

  return (
    <>
      <Tabs model={model}>
        <Tabs.List>
          <Tabs.Item name="all" aria-controls="my-panel">
            All
          </Tabs.Item>
          <Tabs.Item name="popular" aria-controls="my-panel">
            Popular
          </Tabs.Item>
          <Tabs.Item name="new" aria-controls="my-panel">
            New & Interesting
          </Tabs.Item>
          <Tabs.Item name="alan" aria-controls="my-panel">
            Staff Favorites
          </Tabs.Item>
        </Tabs.List>

        <Tabs.Panel hidden={undefined} id="my-panel">
          <CoffeeList coffee={coffees[model.state.activeTab] || []} />
        </Tabs.Panel>
      </Tabs>
    </>
  );
};

Now we have a fully accessible single-tab implementation. We could extend or change base functionality without creating a new mode on the Tabs component. This showcases some of the power of the Compound Component API. More use-cases can be supported without creating new components from scratch.

Since we have access to the model, we can also send events to it. We might want to control which tab is activated external via a route or an external button. Let's create a button that can switch our tab to the Staff Favorites tab.

Here's the button:

import { Button } from '@workday/canvas-kit-react/button';

// ...

<Button onClick={() => model.events.activate({ tab: 'alan' })}>
  Activate Staff Favorites
</Button>

The onClick of the button calls model.events.activate with the alan tab as the event data. This will activate the tab in the exact same way the tabs is updated internally. If we manually updated the coffee data, but didn't update the model, the model would be out of sync. This way the Tabs model remains the source of truth for the state of the component.

Here's the final Home component:

export const Home: React.FC = () => {
  const { coffee } = useAllCoffee();
  const [newCoffee, popularCoffee, staffCoffee] = splitCoffee(coffee);

  const model = useTabsModel({
    onActivate: ({ data, prevState }) => {
      console.log('onActivate', data, prevState);
    },
  });

  const coffees: Record<string, Coffee[]> = {
    all: coffee,
    popular: popularCoffee,
    new: newCoffee,
    alan: staffCoffee,
  };

  return (
    <>
      <Button onClick={() => model.events.activate({ tab: 'alan' })}>
        Activate Staff Favorites
      </Button>
      {model.state.activeTab}
      <Tabs model={model}>
        <Tabs.List>
          <Tabs.Item name="all" aria-controls="my-panel">
            All
          </Tabs.Item>
          <Tabs.Item name="popular" aria-controls="my-panel">
            Popular
          </Tabs.Item>
          <Tabs.Item name="new" aria-controls="my-panel">
            New & Interesting
          </Tabs.Item>
          <Tabs.Item name="alan" aria-controls="my-panel">
            Staff Favorites
          </Tabs.Item>
        </Tabs.List>

        <Tabs.Panel hidden={undefined} id="my-panel">
          <CoffeeList coffee={coffees[model.state.activeTab] || []} />
        </Tabs.Panel>
      </Tabs>
    </>
  );
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment