diff --git a/addons/toolbars/README.md b/addons/toolbars/README.md new file mode 100644 index 000000000000..2583c2b721d6 --- /dev/null +++ b/addons/toolbars/README.md @@ -0,0 +1,177 @@ +
+ +
+ +

Storybook Addon Toolbars

+ +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 } }) => ( + {storyFn()} +); + +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. diff --git a/addons/toolbars/docs/hero.gif b/addons/toolbars/docs/hero.gif new file mode 100644 index 000000000000..80ca15be907c Binary files /dev/null and b/addons/toolbars/docs/hero.gif differ diff --git a/addons/toolbars/package.json b/addons/toolbars/package.json new file mode 100644 index 000000000000..557d0caef5db --- /dev/null +++ b/addons/toolbars/package.json @@ -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" + } +} diff --git a/addons/toolbars/preset.js b/addons/toolbars/preset.js new file mode 100644 index 000000000000..a83f95279e7f --- /dev/null +++ b/addons/toolbars/preset.js @@ -0,0 +1 @@ +module.exports = require('./dist/preset'); diff --git a/addons/toolbars/register.js b/addons/toolbars/register.js new file mode 100644 index 000000000000..06b5a5887266 --- /dev/null +++ b/addons/toolbars/register.js @@ -0,0 +1 @@ +export * from './dist/register'; diff --git a/addons/toolbars/src/components/MenuToolbar.tsx b/addons/toolbars/src/components/MenuToolbar.tsx new file mode 100644 index 000000000000..45ac06525739 --- /dev/null +++ b/addons/toolbars/src/components/MenuToolbar.tsx @@ -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 = ({ + 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 ( + { + 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 ; + }} + closeOnClick + > + + + + + ); +}; diff --git a/addons/toolbars/src/components/ToolbarManager.tsx b/addons/toolbars/src/components/ToolbarManager.tsx new file mode 100644 index 000000000000..b55857f6e11a --- /dev/null +++ b/addons/toolbars/src/components/ToolbarManager.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { useGlobalArgTypes } from '@storybook/api'; +import { Separator } from '@storybook/components'; + +import { 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 ( + <> + + {keys.map(key => { + const normalizedConfig = normalize(key, globalArgTypes[key] as ToolbarArgType); + return ; + })} + + ); +}; diff --git a/addons/toolbars/src/constants.ts b/addons/toolbars/src/constants.ts new file mode 100644 index 000000000000..1be8012f12e3 --- /dev/null +++ b/addons/toolbars/src/constants.ts @@ -0,0 +1,2 @@ +export const ID = 'addon-toolbars' as const; +export const PARAM = 'toolbars' as const; diff --git a/addons/toolbars/src/preset/index.ts b/addons/toolbars/src/preset/index.ts new file mode 100644 index 000000000000..0e20177db06c --- /dev/null +++ b/addons/toolbars/src/preset/index.ts @@ -0,0 +1,3 @@ +export function managerEntries(entry: any[] = []) { + return [...entry, require.resolve('../register')]; +} diff --git a/addons/toolbars/src/register.tsx b/addons/toolbars/src/register.tsx new file mode 100644 index 000000000000..739214fb5b50 --- /dev/null +++ b/addons/toolbars/src/register.tsx @@ -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: () => , + }) +); diff --git a/addons/toolbars/src/types.ts b/addons/toolbars/src/types.ts new file mode 100644 index 000000000000..6e9b38ddf9d0 --- /dev/null +++ b/addons/toolbars/src/types.ts @@ -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; +}; diff --git a/addons/toolbars/tsconfig.json b/addons/toolbars/tsconfig.json new file mode 100644 index 000000000000..eac4a67bed71 --- /dev/null +++ b/addons/toolbars/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["webpack-env", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["src/**.test.ts"] +} diff --git a/examples/official-storybook/main.js b/examples/official-storybook/main.js index f48040c27589..908363f9c7fa 100644 --- a/examples/official-storybook/main.js +++ b/examples/official-storybook/main.js @@ -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 }) => ({ diff --git a/examples/official-storybook/package.json b/examples/official-storybook/package.json index ab54f5f3a421..bd2d628abf33 100644 --- a/examples/official-storybook/package.json +++ b/examples/official-storybook/package.json @@ -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", diff --git a/examples/official-storybook/preview.js b/examples/official-storybook/preview.js index abd907d304c3..e4abcf92b22d 100644 --- a/examples/official-storybook/preview.js +++ b/examples/official-storybook/preview.js @@ -25,6 +25,18 @@ addHeadWarning('dotenv-file-not-loaded', 'Dotenv file not loaded'); addDecorator(withCssResources); +const themeDecorator = (storyFn, { globalArgs: { theme } }) => { + const selectedTheme = theme === 'dark' ? themes.dark : themes.light; + return ( + + + {storyFn()} + + ); +}; + +addDecorator(themeDecorator); + addDecorator(storyFn => ( @@ -69,4 +81,31 @@ export const globalArgs = { export const globalArgTypes = { foo: { defaultValue: 'fooDefaultValue' }, bar: { defaultValue: 'barDefaultValue' }, + theme: { + name: 'Theme', + description: 'Global theme for components', + defaultValue: null, + toolbar: { + icon: 'circlehollow', + // items: ['light', 'dark'], + items: [ + { value: 'light', icon: 'circlehollow', title: 'light' }, + { value: 'dark', icon: 'circle', title: 'dark' }, + ], + }, + }, + locale: { + name: 'Locale', + description: 'Internationalization locale', + defaultValue: 'en', + toolbar: { + icon: 'globe', + items: [ + { value: 'en', right: '🇺🇸', title: 'English' }, + { value: 'es', right: '🇪🇸', title: 'Español' }, + { value: 'zh', right: '🇨🇳', title: '中文' }, + { value: 'kr', right: '🇰🇷', title: '한국어' }, + ], + }, + }, }; diff --git a/examples/official-storybook/stories/addon-toolbars.stories.js b/examples/official-storybook/stories/addon-toolbars.stories.js new file mode 100644 index 000000000000..a09f5434ca9a --- /dev/null +++ b/examples/official-storybook/stories/addon-toolbars.stories.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { styled } from '@storybook/theming'; + +export default { + title: 'Addons/Toolbars', +}; + +const getCaptionForLocale = locale => { + switch (locale) { + case 'es': + return 'Hola!'; + case 'fr': + return 'Bonjour!'; + case 'zh': + return '你好!'; + case 'kr': + return '안녕하세요!'; + case 'en': + default: + return 'Hello'; + } +}; + +const Themed = styled.div(({ theme }) => ({ + color: theme.color.defaultText, + background: theme.background.content, +})); + +// eslint-disable-next-line react/prop-types +export const Locale = ({ globalArgs: { locale } }) => { + return ( + + Your locale is '{locale}', so I say: +
+ {getCaptionForLocale(locale)} +
+ ); +}; diff --git a/lib/api/src/index.tsx b/lib/api/src/index.tsx index 72b4fa773740..9e599bdfd2d2 100644 --- a/lib/api/src/index.tsx +++ b/lib/api/src/index.tsx @@ -119,6 +119,17 @@ export interface Args { [key: string]: any; } +export interface ArgType { + name?: string; + description?: string; + defaultValue?: any; + [key: string]: any; +} + +export interface ArgTypes { + [key: string]: ArgType; +} + type StatePartial = Partial; export type ManagerProviderProps = Children & RouterData & ProviderData & DocsModeData; @@ -421,3 +432,11 @@ export function useGlobalArgs(): [Args, (newGlobalArgs: Args) => void] { return [globalArgs, updateGlobalArgs]; } + +export function useArgTypes(): ArgTypes { + return useParameter('argTypes', {}); +} + +export function useGlobalArgTypes(): ArgTypes { + return useParameter('globalArgTypes', {}); +} diff --git a/lib/components/src/index.ts b/lib/components/src/index.ts index 96b5c0307fa9..42bd811e7e02 100644 --- a/lib/components/src/index.ts +++ b/lib/components/src/index.ts @@ -34,7 +34,7 @@ export { Bar, FlexBar } from './bar/bar'; export { AddonPanel } from './addon-panel/addon-panel'; // Graphics -export { Icons } from './icon/icon'; +export { Icons, IconsProps } from './icon/icon'; export { StorybookLogo } from './brand/StorybookLogo'; export { StorybookIcon } from './brand/StorybookIcon';