I have an input web component that has a very simple API - it has both a get state()
and a set state(model)
. This web component also handles the label
which is being used for the input, thus a simple model
looks like this:
{
"value": "Foobar",
"required": true,
"disabled": false,
"label": {
"text": "Label for input",
"toolTip": "Tooltip for the input's label",
"position": "east"
}
}
Now to describe the argTypes
for the stories, I tried this:
export default {
title: `Components/Input/text-input`,
argTypes: {
value: { control: 'text' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
label: {
text: { control: 'text' },
toolTip: { control: 'text' },
position: { control: 'select', options: ['north', 'south', 'east', 'west'] },
},
},
};
it renders in Storybook as follows:
As you can see, I'm not getting proper controls for the label
aspect, like e.g. a dropdown for label.position
. In fact, I'm getting the very same result even if I don't define argTypes.label
at all.
Sure, I could compromise on my state
structure and make all label
properties flat state
properties like labelText
, labelPosition
, and labelToolTip
. But as I understand, Storybook is not meant to influence design decisions this way.
This seems like a very basic requirement and I'm surprised I couldn't find anything on it in the docs.
Question: So how do I achieve that without changing my model structure?
Note: I'm using Storybook HTML v6.3.8.
Edit:
What I've tried so far to work around the current limitations:
Im using a TemplateFactory
function to replace the odd Template.bind({})
just to create a new instance. Our components each support setting the component state via an el.state
setter.
import { ArgsParser } from '../helper/ArgsParser.js';
export function TemplateFactory(tagName) {
return (args) => {
const el = document.createElement(tagName);
el.state = ArgsParser.expand(args);
return el;
};
}
// ArgsParser
export class ArgsParser {
static flat = (args) => {
const parsedArgs = {};
for (const [key, value] of Object.entries(args)) {
if (['string', 'boolean', 'number'].includes(typeof value)) {
parsedArgs[key] = value;
} else {
for (const innerKey in value) parsedArgs[`${key}.${innerKey}`] = value[innerKey];
}
}
return parsedArgs;
};
static expand(args) {
const parsedArgs = {};
for (const [key, value] of Object.entries(args)) {
const parsedKeys = key.split('.');
if (parsedKeys.length === 1) {
parsedArgs[key] = value;
} else {
const [parentKey, prop] = parsedKeys;
parsedArgs[parentKey] = parsedArgs[parentKey] ?? {};
parsedArgs[parentKey][prop] = value[prop];
}
}
return parsedArgs;
}
}
// custom-text.stories.js
import { TemplateFactory } from '../helper/TemplateFactory.js';
const TAG_NAME = 'custom-text';
export default {
title: `Components/Input/${TAG_NAME}`,
argTypes: {
value: { control: 'text' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
['label.text']: { control: 'text' },
['label.toolTip']: { control: 'text' },
['label.position']: {
control: { type: 'select', options: ['north', 'south', 'east', 'west'] },
},
},
};
export const EmptyEnabled = TemplateFactory(TAG_NAME);
EmptyEnabled.args = ArgsParser.flat({
value: '',
disabled: false,
label: {
text: 'Empty and Labeled',
toolTip: 'A beautiful tooltip',
position: 'north',
},
});
/* assigns
{
"value": "",
"disabled": false,
"label.text": "Empty and Labeled",
"label.toolTip": "A beautiful tooltip",
"label.position": "north"
}
*/
This results in:
If I modify the controls for the 3 label properties now, it won't affect the component. Also, the label in the initial state is gone.
If instead I assign the expanded model:
export const EmptyEnabled = TemplateFactory(TAG_NAME);
EmptyEnabled.args = {
value: '',
disabled: false,
label: {
text: 'Empty and Labeled',
position: 'north',
},
};
then I get this:
but when I try to use the radio buttons for label.position
it doesn't affect the component, but (only after picking twice) it results in the JSON suddenly displaying undefined
for position
:
The same happens if I edit label.text
and/or label.toolTip
:
I have an input web component that has a very simple API - it has both a get state()
and a set state(model)
. This web component also handles the label
which is being used for the input, thus a simple model
looks like this:
{
"value": "Foobar",
"required": true,
"disabled": false,
"label": {
"text": "Label for input",
"toolTip": "Tooltip for the input's label",
"position": "east"
}
}
Now to describe the argTypes
for the stories, I tried this:
export default {
title: `Components/Input/text-input`,
argTypes: {
value: { control: 'text' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
label: {
text: { control: 'text' },
toolTip: { control: 'text' },
position: { control: 'select', options: ['north', 'south', 'east', 'west'] },
},
},
};
it renders in Storybook as follows:
As you can see, I'm not getting proper controls for the label
aspect, like e.g. a dropdown for label.position
. In fact, I'm getting the very same result even if I don't define argTypes.label
at all.
Sure, I could compromise on my state
structure and make all label
properties flat state
properties like labelText
, labelPosition
, and labelToolTip
. But as I understand, Storybook is not meant to influence design decisions this way.
This seems like a very basic requirement and I'm surprised I couldn't find anything on it in the docs.
Question: So how do I achieve that without changing my model structure?
Note: I'm using Storybook HTML v6.3.8.
Edit:
What I've tried so far to work around the current limitations:
Im using a TemplateFactory
function to replace the odd Template.bind({})
just to create a new instance. Our components each support setting the component state via an el.state
setter.
import { ArgsParser } from '../helper/ArgsParser.js';
export function TemplateFactory(tagName) {
return (args) => {
const el = document.createElement(tagName);
el.state = ArgsParser.expand(args);
return el;
};
}
// ArgsParser
export class ArgsParser {
static flat = (args) => {
const parsedArgs = {};
for (const [key, value] of Object.entries(args)) {
if (['string', 'boolean', 'number'].includes(typeof value)) {
parsedArgs[key] = value;
} else {
for (const innerKey in value) parsedArgs[`${key}.${innerKey}`] = value[innerKey];
}
}
return parsedArgs;
};
static expand(args) {
const parsedArgs = {};
for (const [key, value] of Object.entries(args)) {
const parsedKeys = key.split('.');
if (parsedKeys.length === 1) {
parsedArgs[key] = value;
} else {
const [parentKey, prop] = parsedKeys;
parsedArgs[parentKey] = parsedArgs[parentKey] ?? {};
parsedArgs[parentKey][prop] = value[prop];
}
}
return parsedArgs;
}
}
// custom-text.stories.js
import { TemplateFactory } from '../helper/TemplateFactory.js';
const TAG_NAME = 'custom-text';
export default {
title: `Components/Input/${TAG_NAME}`,
argTypes: {
value: { control: 'text' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
['label.text']: { control: 'text' },
['label.toolTip']: { control: 'text' },
['label.position']: {
control: { type: 'select', options: ['north', 'south', 'east', 'west'] },
},
},
};
export const EmptyEnabled = TemplateFactory(TAG_NAME);
EmptyEnabled.args = ArgsParser.flat({
value: '',
disabled: false,
label: {
text: 'Empty and Labeled',
toolTip: 'A beautiful tooltip',
position: 'north',
},
});
/* assigns
{
"value": "",
"disabled": false,
"label.text": "Empty and Labeled",
"label.toolTip": "A beautiful tooltip",
"label.position": "north"
}
*/
This results in:
If I modify the controls for the 3 label properties now, it won't affect the component. Also, the label in the initial state is gone.
If instead I assign the expanded model:
export const EmptyEnabled = TemplateFactory(TAG_NAME);
EmptyEnabled.args = {
value: '',
disabled: false,
label: {
text: 'Empty and Labeled',
position: 'north',
},
};
then I get this:
but when I try to use the radio buttons for label.position
it doesn't affect the component, but (only after picking twice) it results in the JSON suddenly displaying undefined
for position
:
The same happens if I edit label.text
and/or label.toolTip
:
- 1 Hi @connexo. I run into the same problem. Did you manage somehow to find the solution for this? I've been trying out similar approach without any success... – Lazar Nikolic Commented Jan 27, 2022 at 11:22
- 3 @LazarNikolic Unfortunately, no. We have decided to live with what Storybook offers. – connexo Commented Jan 27, 2022 at 11:46
- @connexo Check out the answer I posted. Let me know if that works for you. – elliottregan Commented May 13, 2022 at 2:21
- @elliottregan where did you post it? – Design by Adrian Commented Mar 2, 2023 at 10:28
- Someone should write flatten prop types Storybook addon for that – d9k Commented Nov 10, 2023 at 15:08
2 Answers
Reset to default 4This was my approach in an Angular project:
type InputPropOverrides = {
'arg1.label': string,
};
export default {
title: 'Components/MyComponent',
component: MyComponent,
decorators: [
moduleMetadata({
imports: [MyModule],
}),
],
argTypes: {
arg1: { ... },
'arg1.label': {
control: {
type: 'text',
},
},
},
} as Meta;
const Template: Story<MyComponent & InputPropOverrides> = (args: MyComponent & InputPropOverrides) => {
const updatedArgs = args;
updatedArgs.menuItem.label = args['arg1.label'];
return { props: updatedArgs };
};
This will render an extra control labeled arg1.label
with a text field. When I input data into that field, the Story gets re-rendered with the label field gets replaced by that text.
I can manually customize any argument's property just by adding an extra argType, and passing that arg's value back to the real arg.
Apparently argTypes
for nested properties are not supported. But you can map custom arg to your component's property within render()
function.
Here is an example for react and typescript:
type MyComponentAndCustomArgs = React.ComponentProps<typeof MyComponent> & {
myCustomArg: string;
};
const meta = {
title: "Example/MyComponent",
component: MyComponent,
tags: ["autodocs"],
argTypes: {
myCustomArg: { control: "text" },
},
render: ({ myCustomArg, ...args }) => {
const props: MyComponentProps = { ...args };
props.axis.color = myCustomArg;
return <MyComponent {...props}></MyComponent>;
},
} satisfies Meta<MyComponentAndCustomArgs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
axis: {
color: "black",
},
myCustomArg: "red",
},
};