Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Svelte: Fix decorators with slots #19987

Merged
merged 6 commits into from
Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 55 additions & 49 deletions code/renderers/svelte/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,95 @@
import type { DecoratorFunction, StoryContext, LegacyStoryFn } from '@storybook/types';
import { sanitizeStoryContextUpdate } from '@storybook/preview-api';
import SlotDecorator from '../templates/SlotDecorator.svelte';
// ! DO NOT change this SlotDecorator import to a relative path, it will break it.
// ! A relative import will be compiled at build time, and Svelte will be unable to
// ! render the component together with the user's Svelte components
// ! importing from @storybook/svelte will make sure that it is compiled at runtime
// ! with the same bundle as the user's Svelte components
// eslint-disable-next-line import/no-extraneous-dependencies
import SlotDecorator from '@storybook/svelte/templates/SlotDecorator.svelte';
import type { SvelteRenderer } from './types';

/**
* Check if an object is a svelte component.
* @param obj Object
*/
function isSvelteComponent(obj: any) {
Copy link
Contributor Author

@JReinhold JReinhold Nov 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work in dev mode with Vite (and maybe with Webpack) because the component is an instance of an empty ProxyComponent instead.
I asked on the Svelte Discord for alternatives, but we haven't come up with any yet.
https://discord.com/channels/457912077277855764/1046174450749558895/1046174450749558895

return obj.prototype && obj.prototype.$destroy !== undefined;
}

/**
* Handle component loaded with esm or cjs.
* Handle component loaded with ESM or CJS,
* by getting the 'default' property of the object if it exists.
* @param obj object
*/
function unWrap(obj: any) {
return obj && obj.default ? obj.default : obj;
function unWrap<T>(obj: { default: T } | T): T {
return obj && typeof obj === 'object' && 'default' in obj ? obj.default : obj;
}

/**
* Transform a story to be compatible with the PreviewRender component.
* Prepare a story to be compatible with the PreviewRender component.
*
* - `() => MyComponent` is translated to `() => ({ Component: MyComponent })`
* - `() => ({})` is translated to `() => ({ Component: <from context.component> })`
* - A decorator component is wrapped with SlotDecorator. The decorated component is inject through
* a <slot/>
* - `() => ({ Component: MyComponent, props: ...})` is already prepared, kept as-is
* - `() => MyComponent` is transformed to `() => ({ Component: MyComponent })`
* - `() => ({})` is transformed to component from context with `() => ({ Component: context.component })`
* - A decorator component is wrapped with SlotDecorator, injecting the decorated component in a <slot />
*
* @param context StoryContext
* @param story the current story
* @param originalStory the story decorated by the current story
* @param innerStory the story decorated by the current story
*/
function prepareStory(context: StoryContext<SvelteRenderer>, story: any, originalStory?: any) {
let result = unWrap(story);
if (isSvelteComponent(result)) {
// wrap the component
result = {
Component: result,
function prepareStory(
context: StoryContext<SvelteRenderer>,
rawStory: SvelteRenderer['storyResult'],
rawInnerStory?: SvelteRenderer['storyResult']
) {
const story = unWrap(rawStory);
const innerStory = rawInnerStory && unWrap(rawInnerStory);

let preparedStory;

if (!story || Object.keys(story).length === 0) {
// story is empty or an empty object, use the component from the context
preparedStory = {
Component: context.component,
};
} else if (story.Component) {
// the story is already prepared
preparedStory = story;
} else {
// we must assume that the story is a Svelte component
preparedStory = {
Component: story,
};
}

if (originalStory) {
// inject the new story as a wrapper of the original story
result = {
if (innerStory) {
// render a SlotDecorator with innerStory as its regular component,
// and the prepared story as the decorating component
return {
Component: SlotDecorator,
props: {
decorator: unWrap(result.Component),
decoratorProps: result.props,
component: unWrap(originalStory.Component),
props: originalStory.props,
on: originalStory.on,
// inner stories will already have been prepared, keep as is
...innerStory,
decorator: preparedStory,
},
};
} else {
let cpn = result.Component;
if (!cpn) {
// if the component is not defined, get it the context
cpn = context.component;
}
result.Component = unWrap(cpn);
}
return result;

return preparedStory;
}

export function decorateStory(storyFn: any, decorators: any[]) {
return decorators.reduce(
(
previousStoryFn: LegacyStoryFn<SvelteRenderer>,
decorator: DecoratorFunction<SvelteRenderer>
) =>
(decorated: LegacyStoryFn<SvelteRenderer>, decorator: DecoratorFunction<SvelteRenderer>) =>
(context: StoryContext<SvelteRenderer>) => {
let story;
const decoratedStory = decorator((update) => {
story = previousStoryFn({
let story: SvelteRenderer['storyResult'] | undefined;

const decoratedStory: SvelteRenderer['storyResult'] = decorator((update) => {
story = decorated({
...context,
...sanitizeStoryContextUpdate(update),
});
return story;
}, context);

if (!story) {
story = previousStoryFn(context);
story = decorated(context);
}

if (!decoratedStory || decoratedStory === story) {
if (decoratedStory === story) {
return story;
}

Expand Down
6 changes: 5 additions & 1 deletion code/renderers/svelte/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/* eslint-disable no-param-reassign */
import type { Store_RenderContext, ArgsStoryFn } from '@storybook/types';
import type { SvelteComponentTyped } from 'svelte';

// ! DO NOT change this PreviewRender import to a relative path, it will break it.
// ! A relative import will be compiled at build time, and Svelte will be unable to
// ! render the component together with the user's Svelte components
// ! importing from @storybook/svelte will make sure that it is compiled at runtime
// ! with the same bundle as the user's Svelte components
// eslint-disable-next-line import/no-extraneous-dependencies
import PreviewRender from '@storybook/svelte/templates/PreviewRender.svelte';

Expand Down
1 change: 1 addition & 0 deletions code/renderers/svelte/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ export interface SvelteStoryResult<
? Record<string, (event: CustomEvent) => void>
: { [K in keyof Events as string extends K ? never : K]?: (event: Events[K]) => void };
props?: Props;
decorator?: ComponentType<Props>;
}
42 changes: 42 additions & 0 deletions code/renderers/svelte/template/stories/slot-decorators.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import ButtonView from './views/ButtonView.svelte';
import BorderDecoratorRed from './views/BorderDecoratorRed.svelte';
import BorderDecoratorBlue from './views/BorderDecoratorBlue.svelte';
import BorderDecoratorProps from './views/BorderDecoratorProps.svelte';

export default {
component: ButtonView,
decorators: [() => BorderDecoratorRed],
args: {
primary: true,
},
};

export const WithDefaultRedBorder = {};
export const WithBareBlueBorder = {
decorators: [() => BorderDecoratorBlue],
};
export const WithPreparedBlueBorder = {
decorators: [
() => ({
Component: BorderDecoratorBlue,
}),
],
};
export const WithPropsBasedBorder = {
decorators: [
() => ({
Component: BorderDecoratorProps,
props: { color: 'green' },
}),
],
};
export const WithArgsBasedBorderUnset = {
argTypes: {
color: { control: 'color' },
},
decorators: [(_, { args }) => ({ Component: BorderDecoratorProps, props: args })],
};
export const WithArgsBasedBorder = {
...WithArgsBasedBorderUnset,
args: { color: 'lightblue' },
};
13 changes: 13 additions & 0 deletions code/renderers/svelte/template/stories/svelte-mdx.stories.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import globalThis from 'global';
import { Meta, Story, Canvas } from '@storybook/addon-docs';
import ButtonView from './views/ButtonView.svelte';
import BorderDecoratorRed from './views/BorderDecoratorRed.svelte';

<Meta title="stories/renderers/svelte/svelte-mdx" />

Expand Down Expand Up @@ -33,3 +34,15 @@ import ButtonView from './views/ButtonView.svelte';
}}
</Story>
</Canvas>

<Canvas>
<Story name="WithDecorator" decorators={[() => BorderDecoratorRed]}>
{{
Component: ButtonView,
props: {
primary: false,
text: 'Secondary text',
},
}}
</Story>
</Canvas>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div style="border: 3px solid blue; padding: 10px; margin: 10px;">
<slot />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
export let color = 'violet';
</script>

<div style="border: 3px solid {color}; padding: 10px; margin: 10px;">
<slot />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div style="border: 3px solid red; padding: 10px; margin: 10px;">
<slot />
</div>
16 changes: 3 additions & 13 deletions code/renderers/svelte/templates/PreviewRender.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,27 @@
props = {},
/** @type {{[string]: () => {}}} Attach svelte event handlers */
on,
Wrapper,
WrapperData = {},
} = storyFn();

// reactive, re-render on storyFn change
$: ({ Component, props = {}, on, Wrapper, WrapperData = {} } = storyFn());
Copy link
Contributor Author

@JReinhold JReinhold Nov 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe these Wrapper and WrapperData were causing the issue at #19671 , because they're never set, in decorators.ts they were called decorator and decoratorProps

$: ({ Component, props = {}, on } = storyFn());

const eventsFromArgTypes = Object.fromEntries(
Object.entries(storyContext.argTypes)
.filter(([k, v]) => v.action && props[k] != null)
.map(([k, v]) => [v.action, props[k]])
);

const events = { ...eventsFromArgTypes, ...on };

if (!Component) {
showError({
title: `Expecting a Svelte component from the story: "${name}" of "${kind}".`,
description: dedent`
Did you forget to return the Svelte component configuration from the story?
Use "() => ({ Component: YourComponent, data: {} })"
Use "() => ({ Component: YourComponent, props: {} })"
when defining the story.
`,
});
}
</script>

<SlotDecorator
decorator={Wrapper}
decoratorProps={WrapperData}
component={Component}
{props}
on={events}
/>
<SlotDecorator {Component} {props} on={{ ...eventsFromArgTypes, ...on }} />
14 changes: 7 additions & 7 deletions code/renderers/svelte/templates/SlotDecorator.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script>
import { onMount } from 'svelte';
export let decorator;
export let decoratorProps = {};
export let component;

export let decorator = undefined;
export let Component;
export let props = {};
export let on;
export let on = undefined;

let instance;
let decoratorInstance;
Expand All @@ -23,9 +23,9 @@
</script>

{#if decorator}
<svelte:component this={decorator} {...decoratorProps} bind:this={decoratorInstance}>
<svelte:component this={component} {...props} bind:this={instance} />
<svelte:component this={decorator.Component} {...decorator.props} bind:this={decoratorInstance}>
<svelte:component this={Component} {...props} bind:this={instance} />
</svelte:component>
{:else}
<svelte:component this={component} {...props} bind:this={instance} />
<svelte:component this={Component} {...props} bind:this={instance} />
{/if}