Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addon-Toolbars: Global args support in the toolbar #10028

Merged
merged 14 commits into from
Mar 13, 2020
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions addons/toolbars/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<center>
<img src="./docs/hero.gif" width="100%" />
</center>

<h1>Storybook Addon Toolbars</h1>

The Toolbars addon controls global story rendering options from Storybook's toolbar UI. It's a general purpose addon that can be used to:

- set a theme for your components
- set your components' internationalization (i18n) locale
- configure just about anything in Storybook that makes use of a global variable

Toolbars is implemented using Storybook Args (SB6.0+): dynamic variables that trigger a story re-render when they are set.

- [Get started](#get-started)
- [Installation](#installation)
- [Configure menu UI](#configure-menu-ui)
- [Create a decorator](#create-a-decorator)
- [Advanced usage](#advanced-usage)
- [Advanced menu configuration](#advanced-menu-configuration)
- [Consuming global args from within a story](#consuming-global-args-from-within-a-story)
- [FAQs](#faqs)
- [How does this compare to `addon-contexts`?](#how-does-this-compare-to-addon-contexts)

## Get started

To get started with `addon-toolbars`: (1) [install the addon](#installation), (2) [configure the menu UI](#configure-menu-ui), and (3) [Create a decorator to implement custom logic](#create-a-decorator).

### Installation

First, install the package:

```sh
npm install @storybook/addon-toolbars -D # or yarn
```

Then add it to your `.storybook/main.js` config:

```js
module.exports = {
addons: ['@storybook/addon-toolbars'],
};
```

### Configure menu UI

Addon-toolbars has a simple, declarative syntax for configuring toolbar menus. You can add toolbars by adding `globalArgTypes` with a `toolbar` annotation, in `.storybook/preview.js`:

```js
export const globalArgTypes = {
theme: {
name: 'Theme'
description: 'Global theme for components',
defaultValue: 'light',
toolbar: { icon: 'box', options: ['light','dark', 'medium'] },
}
}
```

You should see a dropdown in your toolbar with options `light`, `dark`, and `medium`.

### Create a decorator

Now, let's wire it up! We can consume our new `theme` global arg in a decorator using the `context.globalArgs.theme` value.

For example, suppose you are using`styled-components`. You can add a theme provider decorator to your `.storybook/preview.js` config:

```js
const styledComponentsThemeDecorator = (storyFn, { globalArgs: { theme } }) => (
<ThemeProvider {...getTheme(theme)}>{storyFn()}</ThemeProvider>
);

export const decorators = [styledComponentsThemeDecorator];
```

## Advanced usage

The previous section shows the common case. There are two advanced use cases: (1) [advanced menu configurations](#advanced-menu-configuration), (2) [consuming global args inside a story](#consuming-global-args-from-within-a-story).

### Advanced menu configuration

The default menu configuration is simple: everything's a string! However, the Toolbars addon also support configuration options to tweak the appearance of the menu:

```ts
type MenuItem {
/**
* The string value of the menu that gets set in the global args
*/
value: string,
/**
* The main text of the title
*/
title: string,
/**
* A string that gets shown in left side of the menu, if set
*/
left?: string,
/**
* A string that gets shown in right side of the menu, if set
*/
right?: string,
/**
* An icon that gets shown in the toolbar if this item is selected
*/
icon?: icon,
}
```

Thus if you want to show right-justified flags for an internationalization locale, you might set up the following configuration in `.storybook/preview.js`:

```js
export const globalArgTypes = {
locale: {
name: 'Locale',
description: 'Internationalization locale',
defaultValue: 'en',
toolbar: {
icon: 'globe',
items: [
{ value: 'en', right: '🇺🇸', title: 'English' },
{ value: 'fr', right: '🇫🇷', title: 'Français' },
{ value: 'es', right: '🇪🇸', title: 'Español' },
{ value: 'zh', right: '🇨🇳', title: '中文' },
{ value: 'kr', right: '🇰🇷', title: '한국어' },
],
}
},
},
};
```

### Consuming global args from within a story

The recommended usage, as shown in the examples above, is to consume global args from within a decorator and implement a global setting that applies to all stories. But sometimes it's useful to use toolbar options inside individual stories.

Storybook's `globalArgs` are available via the story context:

```js
const getCaptionForLocale = (locale) => {
switch(locale) {
case 'es': return 'Hola!';
case 'fr': return 'Bonjour!';
case 'kr': return '안녕하세요!';
case 'zh': return '你好!';
default:
return 'Hello!',
}
}

export const StoryWithLocale = ({ globalArgs: { locale } }) => {
const caption = getCaptionForLocale(locale);
return <>{caption}</>
};
```

**NOTE:** In Storybook 6.0, if you set the global option `passArgsFirst`, the story context is passes as the second argument:

```js
export const StoryWithLocale = (args, { globalArgs: { locale } }) => {
const caption = getCaptionForLocale(locale);
return <>{caption}</>;
};
```

## FAQs

### How does this compare to `addon-contexts`?

`Addon-toolbars` is the successor to `addon-contexts`, which provided convenient global toolbars in Storybook's toolbar.

The primary difference between the two packages is that `addon-toolbars` makes use of Storybook's new **Story Args** feature, which has the following advantages:

- **Standardization**. Args are built into Storybook in 6.x. Since `addon-toolbars` is based on args, you don't need to learn any addon-specific APIs to use it.

- **Ergonomics**. Global args are easy to consume [in stories](#consuming-global-args-from-within-a-story), in [Storybook Docs](https://github.com/storybookjs/storybook/tree/master/addons/docs), or even in other addons.

* **Framework compatibility**. Args are completely framework-independent, so `addon-toolbars` is compatible with React, Vue, Angular, etc. out of the box with no framework logic needed in the addon.
Binary file added addons/toolbars/docs/hero.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions addons/toolbars/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@storybook/addon-toolbars",
"version": "6.0.0-alpha.26",
"description": "Storybook Addon Controls",
"repository": {
"type": "git",
"url": "https://github.com/storybookjs/storybook.git",
"directory": "addons/toolbars"
},
"license": "MIT",
"main": "dist/register.js",
"files": [
"dist/**/*",
"README.md",
"*.js",
"*.d.ts"
],
"scripts": {
"prepare": "node ../../scripts/prepare.js"
},
"dependencies": {
"@storybook/addons": "6.0.0-alpha.26",
"@storybook/api": "6.0.0-alpha.26",
"@storybook/client-api": "6.0.0-alpha.26",
"@storybook/components": "6.0.0-alpha.26"
},
"peerDependencies": {
"react": "*"
},
"publishConfig": {
"access": "public"
}
}
1 change: 1 addition & 0 deletions addons/toolbars/preset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dist/preset');
1 change: 1 addition & 0 deletions addons/toolbars/register.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dist/register';
47 changes: 47 additions & 0 deletions addons/toolbars/src/components/MenuToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { FC } from 'react';
import { useGlobalArgs } from '@storybook/api';
import { Icons, IconButton, WithTooltip, TooltipLinkList } from '@storybook/components';
import { NormalizedToolbarArgType } from '../types';

export type MenuToolbarProps = NormalizedToolbarArgType & { id: string };

export const MenuToolbar: FC<MenuToolbarProps> = ({
id,
name,
description,
toolbar: { icon, items },
}) => {
const [globalArgs, updateGlobalArgs] = useGlobalArgs();
const selectedValue = globalArgs[id];
const active = selectedValue != null;
const selectedItem = active && items.find(item => item.value === selectedValue);

return (
<WithTooltip
placement="top"
trigger="click"
tooltip={({ onHide }) => {
const links = items.map(item => {
const { value, left, title, right } = item;
return {
id: value,
left,
title,
right,
active: selectedValue === value,
onClick: () => {
updateGlobalArgs({ [id]: value });
onHide();
},
};
});
return <TooltipLinkList links={links} />;
}}
closeOnClick
>
<IconButton key={name} active={active} title={description}>
<Icons icon={(selectedItem && selectedItem.icon) || icon} />
</IconButton>
</WithTooltip>
);
};
37 changes: 37 additions & 0 deletions addons/toolbars/src/components/ToolbarManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { FC } from 'react';
import { useGlobalArgTypes } from '@storybook/api';
import { Separator } from '@storybook/components';

import { ToolbarConfig, ToolbarArgType } from '../types';
import { MenuToolbar } from './MenuToolbar';

const normalize = (key: string, argType: ToolbarArgType) => ({
...argType,
name: argType.name || key,
description: argType.description || key,
toolbar: {
...argType.toolbar,
items: argType.toolbar.items.map(item =>
typeof item === 'string' ? { value: item, title: item } : item
),
},
});

/**
* A smart component for handling manager-preview interactions.
*/
export const ToolbarManager: FC = () => {
const globalArgTypes = useGlobalArgTypes();
const keys = Object.keys(globalArgTypes).filter(key => !!globalArgTypes[key].toolbar);
if (!keys.length) return null;

return (
<>
<Separator />
{keys.map(key => {
const normalizedConfig = normalize(key, globalArgTypes[key] as ToolbarArgType);
return <MenuToolbar key={key} id={key} {...normalizedConfig} />;
})}
</>
);
};
2 changes: 2 additions & 0 deletions addons/toolbars/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const ID = 'addon-toolbars' as const;
export const PARAM = 'toolbars' as const;
3 changes: 3 additions & 0 deletions addons/toolbars/src/preset/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function managerEntries(entry: any[] = []) {
return [...entry, require.resolve('../register')];
}
13 changes: 13 additions & 0 deletions addons/toolbars/src/register.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import addons, { types } from '@storybook/addons';
import { ToolbarManager } from './components/ToolbarManager';
import { ID } from './constants';

addons.register(ID, api =>
addons.add(ID, {
title: ID,
type: types.TOOL,
match: ({ viewMode }) => viewMode === 'story',
render: () => <ToolbarManager />,
})
);
27 changes: 27 additions & 0 deletions addons/toolbars/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IconsProps } from '@storybook/components';
import { ArgType } from '@storybook/api';

export interface ToolbarItem {
value: string;
icon?: IconsProps['icon'];
left?: string;
right?: string;
title?: string;
}

export interface NormalizedToolbarConfig {
icon?: IconsProps['icon'];
items: ToolbarItem[];
}

export type NormalizedToolbarArgType = ArgType & {
toolbar: NormalizedToolbarConfig;
};

export type ToolbarConfig = NormalizedToolbarConfig & {
items: string[] | ToolbarItem[];
};

export type ToolbarArgType = ArgType & {
toolbar: ToolbarConfig;
};
9 changes: 9 additions & 0 deletions addons/toolbars/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["webpack-env", "jest"]
},
"include": ["src/**/*"],
"exclude": ["src/**.test.ts"]
}
1 change: 1 addition & 0 deletions examples/official-storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module.exports = {
'@storybook/addon-viewport',
'@storybook/addon-graphql',
'@storybook/addon-contexts',
'@storybook/addon-toolbars',
'@storybook/addon-queryparams',
],
webpackFinal: async (config, { configType }) => ({
Expand Down
1 change: 1 addition & 0 deletions examples/official-storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@storybook/addon-storyshots": "6.0.0-alpha.26",
"@storybook/addon-storyshots-puppeteer": "6.0.0-alpha.26",
"@storybook/addon-storysource": "6.0.0-alpha.26",
"@storybook/addon-toolbars": "6.0.0-alpha.26",
"@storybook/addon-viewport": "6.0.0-alpha.26",
"@storybook/addons": "6.0.0-alpha.26",
"@storybook/components": "6.0.0-alpha.26",
Expand Down
Loading