Build a simple component
We’ll build our UI following a Component-Driven Development (CDD) methodology. It’s a process that builds UIs from the “bottom-up”, starting with components and ending with screens. CDD helps you scale the amount of complexity you’re faced with as you build out the UI.
Task
Task
is the core component of our app. Each task displays slightly differently depending on exactly what state it’s in. We display a checked (or unchecked) checkbox, some information about the task, and a “pin” button, allowing us to move tasks up and down the list. Putting this together, we’ll need these props:
title
– a string describing the taskstate
- which list is the task currently in, and is it checked off?
As we start to build Task
, we first write our test states that correspond to the different types of tasks sketched above. Then we use Storybook to build the component in isolation using mocked data. We’ll “visual test” the component’s appearance given each state as we go.
Get set up
First, let’s create the task component and its accompanying story file: src/components/Task.svelte
and src/components/Task.stories.js
.
We’ll begin with a baseline implementation of the Task
, simply taking in the attributes we know we’ll need and the two actions you can take on a task (to move it between lists):
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
// event handler for Pin Task
function PinTask() {
dispatch('onPinTask', {
id: task.id,
});
}
// event handler for Archive Task
function ArchiveTask() {
dispatch('onArchiveTask', {
id: task.id,
});
}
// Task props
export let task = {
id: '',
title: '',
state: '',
};
</script>
<div class="list-item">
<label for="title" aria-label={task.title}>
<input type="text" value={task.title} name="title" readonly />
</label>
</div>
Above, we render straightforward markup for Task
based on the existing HTML structure of the Todos app.
Below we build out Task’s three test states in the story file:
import Task from './Task.svelte';
import { action } from '@storybook/addon-actions';
export const actionsData = {
onPinTask: action('onPinTask'),
onArchiveTask: action('onArchiveTask'),
};
export default {
component: Task,
title: 'Task',
excludeStories: /.*Data$/,
//👇 The argTypes are included so that they are properly displayed in the Actions Panel
argTypes: {
onPinTask: { action: 'onPinTask' },
onArchiveTask: { action: 'onArchiveTask' },
},
};
const Template = ({ onArchiveTask, onPinTask, ...args }) => ({
Component: Task,
props: args,
on: {
...actionsData,
},
});
export const Default = Template.bind({});
Default.args = {
task: {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
},
};
export const Pinned = Template.bind({});
Pinned.args = {
task: {
...Default.args.task,
state: 'TASK_PINNED',
},
};
export const Archived = Template.bind({});
Archived.args = {
task: {
...Default.args.task,
state: 'TASK_ARCHIVED',
},
};
There are two basic levels of organization in Storybook: the component and its child stories. Think of each story as a permutation of a component. You can have as many stories per component as you need.
- Component
- Story
- Story
- Story
To tell Storybook about the component we are documenting, we create a default
export that contains:
component
--the component itself,title
--how to refer to the component in the sidebar of the Storybook app,excludeStories
--information required by the story but should not be rendered by the Storybook app.argTypes
--specify the args behavior in each story.
To define our stories, we export a function for each of our test states to generate a story. The story is a function that returns a rendered element (i.e., a component class with a set of props) in a given state.
As we have multiple permutations of our component, assigning it to a Template
variable is convenient. Introducing this pattern in your stories will reduce the amount of code you need to write and maintain.
Template.bind({})
is a standard JavaScript technique for making a copy of a function. We use this technique to allow each exported story to set its own properties, but use the same implementation.
Arguments or args
for short, allow us to live-edit our components with the controls addon without restarting Storybook. Once an args
value changes, so does the component.
When creating a story, we use a base task
arg to build out the shape of the task the component expects, typically modeled from what the actual data looks like.
action()
allows us to create a callback that appears in the actions panel of the Storybook UI when clicked. So when we build a pin button, we’ll be able to determine if a button click is successful in the UI.
As we need to pass the same set of actions to all permutations of our component, it is convenient to bundle them up into a single actionsData
variable and pass them into our story definition each time.
Another nice thing about bundling the actionsData
that a component needs is that you can export
them and use them in stories for components that reuse this component, as we'll see later.
action()
to stub them in.
Config
We'll need to make a couple of changes to Storybook's configuration files so it notices our recently created stories and allows us to use the application's CSS file (located in src/index.css
).
Start by changing your Storybook configuration file (.storybook/main.js
) to the following:
// .storybook/main.js
module.exports = {
- stories: [
- '../src/**/*.stories.mdx',
- '../src/**/*.stories.@(js|jsx|ts|tsx)'
- ],
+ stories: ['../src/components/**/*.stories.js'],
staticDirs: ['../public'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-svelte-csf',
'@storybook/addon-interactions',
],
features: {
postcss: false,
interactionsDebugger: true,
},
framework: '@storybook/svelte',
core: {
builder: '@storybook/builder-webpack4',
},
};
After completing the change above, inside the .storybook
folder, change your preview.js
to the following:
+ import '../src/index.css';
//👇 Configures Storybook to log the actions( onArchiveTask and onPinTask ) in the UI.
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
parameters
are typically used to control the behavior of Storybook's features and addons. In our case, we're going to use them to configure how the actions
(mocked callbacks) are handled.
actions
allows us to create callbacks that appear in the actions panel of the Storybook UI when clicked. So when we build a pin button, we’ll be able to determine if a button click is successful in the UI.
Once we’ve done this, restarting the Storybook server should yield test cases for the three Task states:
Build out the states
Now that we have Storybook set up, styles imported, and test cases built out, we can quickly start implementing the HTML of the component to match the design.
The component is still rudimentary at the moment. First, write the code that achieves the design without going into too much detail:
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
// Event handler for Pin Task
function PinTask() {
dispatch("onPinTask", {
id: task.id,
});
}
// Event handler for Archive Task
function ArchiveTask() {
dispatch("onArchiveTask", {
id: task.id,
});
}
// Task props
export let task = {
id: "",
title: "",
state: "",
};
// Reactive declaration (computed prop in other frameworks)
$: isChecked = task.state === "TASK_ARCHIVED";
</script>
<div class="list-item {task.state}">
<label for="checked" class="checkbox" aria-label={`archiveTask-${task.id}`}>
<input
type="checkbox"
checked={isChecked}
disabled
name="checked"
id={`archiveTask-${task.id}`}
/>
<span class="checkbox-custom" on:click={ArchiveTask} />
</label>
<label for="title" aria-label={task.title} class="title">
<input
type="text"
value={task.title}
readonly
name="title"
placeholder="Input title"
/>
</label>
{#if task.state !== "TASK_ARCHIVED"}
<button
class="pin-button"
on:click|preventDefault={PinTask}
id={`pinTask-${task.id}`}
aria-label={`pinTask-${task.id}`}
>
<span class="icon-star" />
</button>
{/if}
</div>
The additional markup from above combined with the CSS we imported earlier yields the following UI:
Component built!
We’ve now successfully built out a component without needing a server or running the entire frontend application. The next step is to build out the remaining Taskbox components one by one in a similar fashion.
As you can see, getting started building components in isolation is easy and fast. We can expect to produce a higher-quality UI with fewer bugs and more polish because it’s possible to dig in and test every possible state.
Catch accessibility issues
Accessibility tests refer to the practice of auditing the rendered DOM with automated tools against a set of heuristics based on WCAG rules and other industry-accepted best practices. They act as the first line of QA to catch blatant accessibility violations ensuring that an application is usable for as many people as possible, including people with disabilities such as vision impairment, hearing problems, and cognitive conditions.
Storybook includes an official accessibility addon. Powered by Deque's axe-core, it can catch up to 57% of WCAG issues.
Let's see how it works! Run the following command to install the addon:
yarn add --dev @storybook/addon-a11y
Then, update your Storybook configuration file (.storybook/main.js
) to enable it:
module.exports = {
stories: ['../src/components/**/*.stories.js'],
staticDirs: ['../public'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-svelte-csf',
'@storybook/addon-interactions',
+ '@storybook/addon-a11y',
],
features: {
postcss: false,
interactionsDebugger: true,
},
framework: '@storybook/svelte',
core: {
builder: '@storybook/builder-webpack4',
},
};
Cycling through our stories, we can see that the addon found an accessibility issue with one of our test states. The message "Elements must have sufficient color contrast" essentially means there isn't enough contrast between the task title and the background. We can quickly fix it by changing the text color to a darker gray in our application's CSS (located in src/index.css
).
.list-item.TASK_ARCHIVED input[type="text"] {
- color: #a0aec0;
+ color: #4a5568;
text-decoration: line-through;
}
That's it! We've taken the first step to ensure that UI becomes accessible. As we continue to add complexity to our application, we can repeat this process for all other components without needing to spin up additional tools or testing environments.