Is your feature request related to a problem? Please describe.
Hello, I really like the idea behind the new @storybook/addon-toolbars, I cannot wait for 6.0.0!
I am just struggling a bit to understand how to properly use it yet :)
To contextualise, I am building a React UI library based on https://theme-ui.com/, which allows to customise UI components thanks to a <ThemeProvider /> component (I think it uses Emotion's theming under the hood)
Following the @storybook/addon-toolbar README, I would like to add a toolbar menu to switch themes, while being able to retrieve the current theme detail inside stories.
I have one main question and one main issue:
items only allow strings values, how would you approach that?Describe the solution you'd like
Theme UI exposes a hook called useThemeUI which allows to retrieve the current theme from the ThemeProvider context.
I wrote a global decorator wrapping all stories inside a ThemeProvider:
// withThemeProvider.tsx
/** @jsx jsx **/
import { jsx, ThemeProvider } from 'theme-ui';
import { makeDecorator } from '@storybook/addons';
import { getThemeByName } from '[...]/helpers/theme';
const withThemeProvider = makeDecorator({
name: 'withThemeProvider',
parameterName: 'theme',
wrapper: (storyFn, context) => {
const theme = getThemeByName(context.globalArgs.theme);
// Ternary condition required until https://github.com/storybookjs/storybook/issues/10239 is fixed
// (theme may be undefined during initial rendering)
return theme ? <ThemeProvider theme={theme}>{storyFn(context)}</ThemeProvider> : storyFn(context);
}
});
export default withThemeProvider;
// preview.ts
// themes is an array of { name: string, theme: object (Theme UI theme content), default: boolean }
export const globalArgTypes = {
theme: {
name: 'Theme',
description: 'Global UI theme',
defaultValue: themes.find(theme => !!theme.default)?.name,
toolbar: {
icon: 'cog',
items: themes.map(theme => ({
value: theme.name,
title: theme.name,
// Could we have an option here to store the actual theme object itself?
// Or would there a better way to achieve what I am after?
}))
},
}
};
addDecorator(withThemeProvider);
Then inside a story, I would like to do:
import { Story } from '@storybook/addon-docs/blocks';
import { select } from '@storybook/addon-knobs';
import { Heading, useThemeUI } from 'theme-ui';
<Story name="Heading">
{() => {
// why is theme undefined here?!
const { theme } = useThemeUI();
return (
<Heading
variant={select('Variant', theme ? Object.keys(theme.text || {}) : [])}
>
Almost before we knew it, we had left the ground.
</Heading>
);
}}
</Story>
Describe alternatives you've considered
Currently as a workaround, I can use the globalArgs API to retrieve the theme name, then reuse my getThemeByName helper function to reduce the theme based on that value:
import { Story } from '@storybook/addon-docs/blocks';
import { select } from '@storybook/addon-knobs';
import { Heading } from 'theme-ui';
import { getThemeByName } from '[...]/helpers/theme';
<Story name="Heading">
{(args, { globalArgs }) => {
const theme = getThemeByName(globalArgs.theme);
return (
<Heading
variant={select('Variant', theme ? Object.keys(theme.text || {}) : [])}
>
Almost before we knew it, we had left the ground.
</Heading>
);
}}
</Story>
But:
useThemeUI() does not work as expected in this context?Are you able to assist bring the feature to reality?
I would love to contribute, but I would need guidance to get started :)
Additional context
I run on the latest OS X version,
v12.16.1v1.22.4^6.0.0-alpha.45^3.0.0^0.3.1^16.13.1@flo-sch that's an interesting combination of globalArgs and knobs -- an interaction that hasn't been tested much but should work. (side note: we hope to replace knobs with args in the 6.x timeframe.)
re: object values, can look into supporting that for convenience, but it should be possible to do everything you want to do with strings and a lookup in your user code.
as for the missing theme context, i'm guessing that's a react hooks issue. try rewriting your decorator like this:
return theme ? <ThemeProvider theme={theme}><StoryFn /></ThemeProvider> : <StoryFn />;
You don't need to pass the context into the story function -- it's done automagically by a .bind() call higher up the stack.
Hi @shilman, thanks for your answer and reactivity :)
I am using TypeScript (3.8.0) and <Story /> resulted in type issues:
Type '{}' is missing the following properties from type 'StoryIdentifier': id, kind, name
However I managed to get it working by spreading the entire context (I probably could extract only what's needed but something may be added later on?):
/** @jsx jsx **/
import { jsx, ThemeProvider } from 'theme-ui';
import { makeDecorator } from '@storybook/addons';
import { getThemeByName } from '[...]/helpers/theme';
const withThemeProvider = makeDecorator({
name: 'withThemeProvider',
parameterName: 'theme',
wrapper: (Story, context) => {
const theme = getThemeByName(context.globalArgs.theme);
// Ternary condition required until https://github.com/storybookjs/storybook/issues/10239 is fixed
// (theme may be undefined during initial rendering)
return theme ? (
<ThemeProvider theme={theme.theme}>
<Story {...context} />
</ThemeProvider>
) : <Story {...context} />;
}
});
export default withThemeProvider;
And now it works like a charm, thanks a million!
(Actually, context.globalArgs() is undefined at initial rendering - most likely because what is explained in #10239) , so accessing the context using useThemeUI() is more reliable than reducing it from the args)
If you have the time to answer, I just have a few last(?) questions:
context.globalArgs and context.parameters.globalArgs?NOTE: the reason I always have a theme in that context is because my getThemeByName helper resolves to the default theme if context.globalArgs.theme is undefined (like at initial rendering)
what is the difference between context.globalArgs and context.parameters.globalArgs?
context.globalArgs is the dynamic data you should be inspecting. context.parameters.globalArgs is an implementation detail that we don't hide from you (cc @tmeasday )
can I access my decorator's theme context in other places such as a addon panel? Or should I listen to globalArgs instead there?
You should listen to globalArgs there and I think there's a hook for it, useGlobalArgs
I'm a little surprised that you needed to spread in the context into <Story/>. Does it still require you to do that if you just define the decorator directly as a function and don't use makeDecorator?
context.parameters.globalArgs is an implementation detail that we don't hide from you (cc @tmeasday )
Are you thinking we should pull it off @shilman? We could..
@tmeasday no i didn't have an opinion one way or another. just cc'ing you in case i missed something
I'm a little surprised that you needed to spread in the context into
. Does it still require you to do that if you just define the decorator directly as a function and don't use makeDecorator?
I am no TypeScript expert but I think this is due to the StoryGetter type: is takes a mandatory argument context of type StoryContext.
So as soon as I add types to the function, then providing that context is required as well (and types are added automatically by makeDecorator):
const withThemeProvider: StoryWrapper = (Story: StoryGetter, context: StoryContext) => {
const theme = getThemeByName(context.globalArgs.theme, true);
return theme ? (
<ThemeProvider theme={theme.theme}>
<Story {...context} />
</ThemeProvider>
) : Story;
};
I'm fine with that though, as a developer I do not really believe in tech-magic, so I do not expect anything to magically bind the context to the story, even though it's clearly nice to have :)
In the end I think it's all a matter of documentation :)
I know with the TypeScript migration and v6 milestone coming soon it's hard to keep documentation updated 馃槄
I would be willing to help and contribute to that part if it can help?
@flo-sch I think (1) lack of documentation, and (2) horrible duplication of types across the codebase.
In practice, I'd probably write it:
import { DecoratorFn } from '@storybook/react';
const withThemeProvider: DecoratorFn = (Story, { globalArgs }) => {
const theme = getThemeByName(globalArgs.theme, true);
return theme ? (
<ThemeProvider theme={theme.theme}>
<Story/>
</ThemeProvider>
) : Story;
};
@shilman I have 2 type issues with that:
First is the return itself
ERROR in /[...]/withThemeProvider.tsx
Type '(Story: StoryFn<StoryFnReactReturnType>, { globalArgs }: StoryContext) => LegacyStoryFn<StoryFnReactReturnType> | ArgsStoryFn<...> | Element' is not assignable to type 'DecoratorFunction<StoryFnReactReturnType>'.
Type 'LegacyStoryFn<StoryFnReactReturnType> | ArgsStoryFn<StoryFnReactReturnType> | Element' is not assignable to type 'StoryFnReactReturnType'.
Type 'LegacyStoryFn<StoryFnReactReturnType>' is missing the following properties from type 'ReactElement<unknown, string | ((props: any) => ReactElement<any, string | ... | (new (props: any) => Component<any, any, any>)> | null) | (new (props: any) => Component<any, any, any>)>': type, props, key
Second is the missing context:
ERROR in /[...]/withThemeProvider.tsx
TS2739: Type '{}' is missing the following properties from type 'StoryIdentifier': id, kind, name
But anyway, I think we can close this issue since I have a workaround thanks to your answers :)
I will open a PR for the documentation of addons-toolbar later today, we can continue the discussion there perhaps
Closing the issue, discussion about args can continue there :)
https://docs.google.com/document/d/1Mhp1UFRCKCsN8pjlfPdz8ZdisgjNXeMXpXvGoALjxYM/edit#
Most helpful comment
@flo-sch that's an interesting combination of
globalArgsandknobs-- an interaction that hasn't been tested much but should work. (side note: we hope to replaceknobswithargsin the 6.x timeframe.)re: object values, can look into supporting that for convenience, but it should be possible to do everything you want to do with strings and a lookup in your user code.
as for the missing theme context, i'm guessing that's a react hooks issue. try rewriting your decorator like this:
You don't need to pass the context into the story function -- it's done automagically by a
.bind()call higher up the stack.