Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Storybook: How to define argTypes for properties that are objects?

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:

enter image description here

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:

enter image description here

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:

enter image description here

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:

enter image description here

The same happens if I edit label.text and/or label.toolTip:

enter image description here

like image 519
connexo Avatar asked Sep 10 '25 23:09

connexo


2 Answers

This 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.

like image 71
elliottregan Avatar answered Sep 12 '25 13:09

elliottregan


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",
  },
};
like image 30
ekimpl Avatar answered Sep 12 '25 12:09

ekimpl