storybookjs / addon-svelte-csf

[Incubation] CSF using Svelte components.
MIT License
98 stars 29 forks source link

[Request] A simple way to bind component story state to args #164

Open spykr opened 5 months ago

spykr commented 5 months ago

Issue

I would like an easy way to have story state that is bound to the component but can still be controlled via args.

When writing stories for React it's very simple to have story state where the initial value is based on the story args, e.g.

const Template = (args) => {
  // Initial state is based on the args
  const [isOpen, setOpen] = useState(args.isOpen);

  return <Accordion {...args} isOpen={isOpen} onChange={setOpen} />;
};

export const Open = {
  render: Template,
  args: { isOpen: true },
};

export const Closed = {
  render: Template,
  args: { isOpen: false },
};

This allows you to have stories that 1) share a template, 2) show how the component reacts to different props, 3) are still fully interactive!

As a bonus, making it so the story state reacts to changes in the "Controls" panel is a bit more work but still straightforward:

const Template = (args) => {
  // Initial state is based on the args
  const [isOpen, setOpen] = useState(args.isOpen);
  // State updates when the args are changed
  useEffect(() => {
    setOpen(args.isOpen);
  }, [args.isOpen]);

  return <Accordion {...args} isOpen={isOpen} onChange={setOpen} />;
};

Reproducing this in addon-svelte-csf has proven difficult because I haven't found an easy way to access the story args in the initial render of the story. I've come up with a workaround (see below) but it feels very hacky and I would love to know if there's a built-in way to do it instead.

EDIT: See my comment below the OP, I ended up coming up with a much nicer workaround.

Example workaround

Note: This workaround has a limitation in that changes to state that happen inside the story code (e.g. form submission changing the loading state) will not update the story args, but this is the same as in my React example above and I think this is acceptable if not expected.

Form.svelte:

<script lang="ts">
    import { createEventDispatcher } from 'svelte';

    export let loading = false;
    export let error = '';

    const dispatch = createEventDispatcher();

    let email = '';

    function handleSubmit(event: SubmitEvent) {
        event.preventDefault();
        dispatch('submit', { email });
    }
</script>

<form on:submit={handleSubmit}>
    <div>
        <label for="email">Email address</label>
        <input bind:value={email} id="email" type="email" disabled={loading} required />
    </div>
    {#if error}
        <div>{error}</div>
    {/if}
    <button type="submit" disabled={loading}>Submit</button>
</form>

Form.stories.svelte:

<script context="module" lang="ts">
    import type { Meta } from '@storybook/svelte';

    import Form from './Form.svelte';

    export const meta = {
        title: 'Form',
        component: Form,
        argTypes: {
            loading: { control: 'boolean' },
            error: { control: 'text' },
        },
        args: {
            loading: false,
            error: '',
        },
    } satisfies Meta<Form>;
</script>

<script lang="ts">
    import { beforeUpdate, onMount } from 'svelte';
    import { Story, Template } from '@storybook/addon-svelte-csf';

    let loading = false;
    let error = '';

    async function handleSubmit() {
        loading = true;
        error = '';

        setTimeout(() => {
            loading = false;
            error = 'An error occurred, please try again.';
        }, 1000);
    }

    // WORKAROUND: Set the initial state from the initial story args
    let firstUpdate = true;
    beforeUpdate(() => {
        const id = __STORYBOOK_PREVIEW__.currentRender.id;
        const args = __STORYBOOK_PREVIEW__.currentRender.store.args.argsByStoryId[id];
        if (args && firstUpdate) {
            loading = args.loading;
            error = args.error;
            firstUpdate = false;
        }
    });

    // WORKAROUND: Listen for changes to the story args and update the state
    onMount(() => {
        function handleArgsUpdated({ args }) {
            loading = args.loading;
            error = args.error;
        }

        __STORYBOOK_PREVIEW__.channel.on('storyArgsUpdated', handleArgsUpdated);
        return () => __STORYBOOK_PREVIEW__.channel.off('storyArgsUpdated', handleArgsUpdated);
    });
</script>

<Template>
    <Form bind:loading bind:error on:submit={handleSubmit} />
</Template>

<Story name="Default" />

<Story name="Loading" args={{ loading: true }} />

<Story name="Error" args={{ error: "We couldn't find an account with that email address." }} />
spykr commented 5 months ago

Okay so I dug in to the source code a bit more and actually ended up coming up with a much nicer workaround than the one in the OP (4 lines of code instead of 18). However I am still curious to know if this is indeed a supported use case and/or if there's a nicer way to do it.


<script lang="ts">
    import { getContext } from 'svelte';
    import { Story, Template } from '@storybook/addon-svelte-csf';

    let loading = false;
    let error = '';

        // WORKAROUND: Update state to match the args on mount and when the args change
        // (Much more concise than the alternative in the OP)
    const { argsStore } = getContext('storybook-registration-context-component') || {};
    $: if (argsStore) {
        ({ loading, error } = $argsStore);
    }

    async function handleSubmit() {
        loading = true;
        error = '';

        setTimeout(() => {
            loading = false;
            error = 'An error occurred, please try again.';
        }, 1000);
    }
</script>

<Template>
    <Form bind:loading bind:error on:submit={handleSubmit} />
</Template>

EDIT: Third time's the charm? After playing with the above solution a bit more I realised that changes to the bound variables from inside the component could reset the state to match the initial args. This is my latest iteration which avoids that issue...

const { argsStore } = getContext('storybook-registration-context-component') || {};
argsStore?.subscribe((args) => {
    ({ loading, error } = args);
});
j3rem1e commented 5 months ago

You should probably use an intermediate component to do that. I don't think it's a good practice to mix args with test states..