From 99b20893baccb9f3a4bf0470f68877e810ce43db Mon Sep 17 00:00:00 2001 From: Lars Rickert Date: Thu, 23 May 2024 10:58:45 +0200 Subject: [PATCH] fix: improve Storybook code snippets (#1134) Relates to #1078, #414 - simplified some Stories - refactor oynx icon import code transformer to work globally so we don't need to add it to every component that uses icons - temporarily copy over improved source code generation until it is released in Storybook itself (see https://github.com/storybookjs/storybook/pull/27194) --- .changeset/serious-comics-fly.md | 5 + .changeset/seven-carpets-exist.md | 8 + apps/docs/src/.vitepress/utils-icons.ts | 9 +- packages/icons/src/metadata.ts | 28 +- packages/icons/tsconfig.json | 3 +- .../OnyxAppLayout/OnyxAppLayout.stories.ts | 105 ++++--- .../components/OnyxBadge/OnyxBadge.stories.ts | 14 +- .../OnyxButton/OnyxButton.stories.ts | 14 +- .../components/OnyxEmpty/OnyxEmpty.stories.ts | 11 +- .../OnyxFlyoutMenu/OnyxFlyoutMenu.stories.ts | 20 +- .../components/OnyxIcon/OnyxIcon.stories.ts | 15 +- .../OnyxIconButton/OnyxIconButton.stories.ts | 17 +- .../OnyxListboxOption.stories.ts | 14 +- .../OnyxNavItem/OnyxNavItem.stories.ts | 2 +- .../OnyxPageLayout/OnyxPageLayout.stories.ts | 98 ++----- .../components/OnyxTable/OnyxTable.stories.ts | 65 ++-- .../src/components/OnyxTag/OnyxTag.stories.ts | 13 +- .../OnyxTooltip/OnyxTooltip.stories.ts | 2 +- .../OnyxUserMenu/OnyxUserMenu.stories.ts | 2 +- packages/sit-onyx/src/utils/storybook.spec.ts | 29 -- packages/sit-onyx/src/utils/storybook.ts | 21 +- packages/storybook-utils/package.json | 2 + packages/storybook-utils/src/preview.spec.ts | 31 +- packages/storybook-utils/src/preview.ts | 80 +++-- .../src/source-code-generator.spec.ts | 141 +++++++++ .../src/source-code-generator.ts | 277 ++++++++++++++++++ packages/storybook-utils/tsconfig.json | 2 +- pnpm-lock.yaml | 6 + 28 files changed, 684 insertions(+), 350 deletions(-) create mode 100644 .changeset/serious-comics-fly.md create mode 100644 .changeset/seven-carpets-exist.md delete mode 100644 packages/sit-onyx/src/utils/storybook.spec.ts create mode 100644 packages/storybook-utils/src/source-code-generator.spec.ts create mode 100644 packages/storybook-utils/src/source-code-generator.ts diff --git a/.changeset/serious-comics-fly.md b/.changeset/serious-comics-fly.md new file mode 100644 index 000000000..1b8bacbcf --- /dev/null +++ b/.changeset/serious-comics-fly.md @@ -0,0 +1,5 @@ +--- +"@sit-onyx/icons": minor +--- + +feat: add `getIconImportName` utility diff --git a/.changeset/seven-carpets-exist.md b/.changeset/seven-carpets-exist.md new file mode 100644 index 000000000..754788e61 --- /dev/null +++ b/.changeset/seven-carpets-exist.md @@ -0,0 +1,8 @@ +--- +"@sit-onyx/storybook-utils": major +--- + +use experimental source code generator + +- port the improved source code generator from [this Storybook PR](https://github.com/storybookjs/storybook/pull/27194). +- globally replace onyx icons with their corresponding imports from `@sit-onyx/icons` diff --git a/apps/docs/src/.vitepress/utils-icons.ts b/apps/docs/src/.vitepress/utils-icons.ts index 11f7f26bf..009cce996 100644 --- a/apps/docs/src/.vitepress/utils-icons.ts +++ b/apps/docs/src/.vitepress/utils-icons.ts @@ -1,4 +1,4 @@ -import { ICON_CATEGORIES } from "@sit-onyx/icons"; +import { ICON_CATEGORIES, getIconImportName } from "@sit-onyx/icons"; import { capitalize } from "vue"; export type EnrichedIcon = { @@ -23,12 +23,7 @@ const getIconContextData = (iconName: string, allIconContents: Record `Bell Disabled` const tooltipName = parts.map((word) => capitalize(word)).join(" "); // bell-disabled => `bellDisabled` - const importName = parts - .map((word, index) => { - if (index === 0) return word; - return capitalize(word); - }) - .join(""); + const importName = getIconImportName(iconName); // svg content for OnyxIcon `icon` prop const content = allIconContents[`../../../node_modules/@sit-onyx/icons/src/assets/${iconName}.svg`]; diff --git a/packages/icons/src/metadata.ts b/packages/icons/src/metadata.ts index 885942deb..c83050fd2 100644 --- a/packages/icons/src/metadata.ts +++ b/packages/icons/src/metadata.ts @@ -1,4 +1,4 @@ -import { IconMetadata, groupIconsByCategory } from "./utils.js"; +import { groupIconsByCategory, type IconMetadata } from "./utils.js"; /** * Metadata for all available onyx icons. @@ -1787,3 +1787,29 @@ export const ICON_METADATA = { * Categories and icons will be sorted alphabetically. */ export const ICON_CATEGORIES = groupIconsByCategory(ICON_METADATA); + +/** + * Transform an icon name to its corresponding JavaScript import name. + * + * @example + * ```ts + * "bell-disabled" => "bellDisabled" + * // e.g. used as 'import bellDisabled from "@sit-onyx/icons/bell-disabled.svg?raw"' + * ``` + */ +export const getIconImportName = (iconName: string) => { + return iconName + .split("-") + .map((word, index) => { + if (index === 0) return word; + return capitalize(word); + }) + .join(""); +}; + +/** + * Capitalizes the first character of the given string. + */ +const capitalize = (value: string) => { + return value.charAt(0).toUpperCase() + value.slice(1); +}; diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json index 4c40ff572..9c9f540bd 100644 --- a/packages/icons/tsconfig.json +++ b/packages/icons/tsconfig.json @@ -4,6 +4,7 @@ "compilerOptions": { "rootDir": "./src", "baseUrl": ".", - "types": ["node"] + "types": ["node"], + "verbatimModuleSyntax": true } } diff --git a/packages/sit-onyx/src/components/OnyxAppLayout/OnyxAppLayout.stories.ts b/packages/sit-onyx/src/components/OnyxAppLayout/OnyxAppLayout.stories.ts index 449daec93..fe992ffa1 100644 --- a/packages/sit-onyx/src/components/OnyxAppLayout/OnyxAppLayout.stories.ts +++ b/packages/sit-onyx/src/components/OnyxAppLayout/OnyxAppLayout.stories.ts @@ -1,5 +1,6 @@ import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; +import { h } from "vue"; import OnyxAppLayout from "./OnyxAppLayout.vue"; /** @@ -37,10 +38,6 @@ const meta: Meta = { `, }), ], - render: (args) => ({ - setup: () => ({ args }), - ...getAppTemplate(args.navBarAlignment === "left"), - }), }), }; @@ -49,14 +46,33 @@ type Story = StoryObj; /** Standard app layout with a nav bar and some content. */ export const Default = { - args: {}, + args: { + default: () => h("div", "This is the page content."), + navBar: () => + h( + "header", + { + style: "border-bottom: var(--onyx-1px-in-rem) solid var(--onyx-color-base-neutral-300);", + }, + "Nav bar", + ), + }, } satisfies Story; /** App layout where the nav bar is left aligned. */ export const LeftNav = { args: { - ...Default.args, navBarAlignment: "left", + default: () => h("div", "This is the page content."), + navBar: () => + h( + "header", + { + style: + "border-right: var(--onyx-1px-in-rem) solid var(--onyx-color-base-neutral-300); height: 100%;", + }, + "Nav bar", + ), }, } satisfies Story; @@ -64,60 +80,41 @@ export const LeftNav = { export const AppOverlay = { args: { ...Default.args, + appOverlay: () => + h( + "div", + { + style: `background-color: var(--onyx-color-base-background-tinted); + position: absolute; + inset: 10rem; + min-width: 5rem; + min-height: 1rem; + `, + }, + "This is an overlay that covers the whole app.", + ), }, - render: (args) => ({ - setup: () => ({ args }), - ...getAppTemplate( - args.navBarAlignment === "left", - ``, - ), - }), } satisfies Story; /** Example of an overlay that covers the whole page section of an application. */ export const PageOverlay = { args: { ...Default.args, + pageOverlay: () => + h( + "div", + { + style: `backdrop-filter: blur(4px); + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + align-items: center;`, + }, + [ + h("div", "This is an overlay that covers the page content."), + h("div", "The nav bar is excluded."), + ], + ), }, - render: (args) => ({ - setup: () => ({ args }), - ...getAppTemplate( - args.navBarAlignment === "left", - ``, - ), - }), } satisfies Story; - -const getAppTemplate = (alignNavLeft: boolean, otherSlotContent?: string) => ({ - components: { OnyxAppLayout }, - template: ` - - -
This is the page content.
- ${otherSlotContent ?? ""} -
-`, -}); diff --git a/packages/sit-onyx/src/components/OnyxBadge/OnyxBadge.stories.ts b/packages/sit-onyx/src/components/OnyxBadge/OnyxBadge.stories.ts index 131ddd4e7..de05530ec 100644 --- a/packages/sit-onyx/src/components/OnyxBadge/OnyxBadge.stories.ts +++ b/packages/sit-onyx/src/components/OnyxBadge/OnyxBadge.stories.ts @@ -1,11 +1,7 @@ import placeholder from "@sit-onyx/icons/placeholder.svg?raw"; import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; -import { - createIconSourceCodeTransformer, - createTruncationDecorator, - defineIconSelectArgType, -} from "../../utils/storybook"; +import { createTruncationDecorator, defineIconSelectArgType } from "../../utils/storybook"; import OnyxBadge from "./OnyxBadge.vue"; /** @@ -29,14 +25,6 @@ const meta: Meta = { default: { control: { type: "text" } }, }, }), - parameters: { - docs: { - source: { - // improve code snippet by adding the icon import - transform: createIconSourceCodeTransformer("icon"), - }, - }, - }, }; export default meta; diff --git a/packages/sit-onyx/src/components/OnyxButton/OnyxButton.stories.ts b/packages/sit-onyx/src/components/OnyxButton/OnyxButton.stories.ts index 8fcaf0f6b..49634978e 100644 --- a/packages/sit-onyx/src/components/OnyxButton/OnyxButton.stories.ts +++ b/packages/sit-onyx/src/components/OnyxButton/OnyxButton.stories.ts @@ -1,11 +1,7 @@ import checkSmall from "@sit-onyx/icons/check-small.svg?raw"; import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; -import { - createIconSourceCodeTransformer, - createTruncationDecorator, - defineIconSelectArgType, -} from "../../utils/storybook"; +import { createTruncationDecorator, defineIconSelectArgType } from "../../utils/storybook"; import OnyxButton from "./OnyxButton.vue"; /** @@ -26,14 +22,6 @@ const meta: Meta = { icon: defineIconSelectArgType(), }, }), - parameters: { - docs: { - source: { - // improve code snippet by adding the icon import - transform: createIconSourceCodeTransformer("icon"), - }, - }, - }, }; export default meta; diff --git a/packages/sit-onyx/src/components/OnyxEmpty/OnyxEmpty.stories.ts b/packages/sit-onyx/src/components/OnyxEmpty/OnyxEmpty.stories.ts index 58cb8f27a..471e5d3f7 100644 --- a/packages/sit-onyx/src/components/OnyxEmpty/OnyxEmpty.stories.ts +++ b/packages/sit-onyx/src/components/OnyxEmpty/OnyxEmpty.stories.ts @@ -42,12 +42,11 @@ export const Default = { export const CustomContent = { args: { icon: () => h(OnyxIcon, { icon: emojiSad, size: "32px", color: "warning" }), - default: () => - h("div", [ - "No data found. Go to ", - h(OnyxLink, { href: "#" }, () => "this page"), - " to add some data.", - ]), + default: () => [ + "No data found. Go to ", + h(OnyxLink, { href: "#" }, () => "this page"), + " to add some data.", + ], }, } satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxFlyoutMenu/OnyxFlyoutMenu.stories.ts b/packages/sit-onyx/src/components/OnyxFlyoutMenu/OnyxFlyoutMenu.stories.ts index fd40e3960..d0eaad521 100644 --- a/packages/sit-onyx/src/components/OnyxFlyoutMenu/OnyxFlyoutMenu.stories.ts +++ b/packages/sit-onyx/src/components/OnyxFlyoutMenu/OnyxFlyoutMenu.stories.ts @@ -1,17 +1,25 @@ import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; -import OnyxFlyoutMenu from "./OnyxFlyoutMenu.vue"; import { h } from "vue"; import OnyxListItem from "../OnyxListItem/OnyxListItem.vue"; +import OnyxFlyoutMenu from "./OnyxFlyoutMenu.vue"; const meta: Meta = { title: "support/FlyoutMenu", ...defineStorybookActionsAndVModels({ component: OnyxFlyoutMenu, events: [], + argTypes: { + default: { control: { disable: true } }, + header: { control: { disable: true } }, + footer: { control: { disable: true } }, + }, }), }; +export default meta; +type Story = StoryObj; + const listAnimals = [ { label: "Cat" }, { label: "Dog" }, @@ -25,19 +33,11 @@ const listAnimals = [ { label: "Owl" }, ]; -export default meta; -type Story = StoryObj; - /** * This example shows a basic OnyxFlyoutMenu */ export const Default = { args: { - default: () => - h( - "div", - { style: { display: "contents" } }, - listAnimals.map(({ label }) => h(OnyxListItem, label)), - ), + default: () => listAnimals.map(({ label }) => h(OnyxListItem, label)), }, } satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxIcon/OnyxIcon.stories.ts b/packages/sit-onyx/src/components/OnyxIcon/OnyxIcon.stories.ts index 6213a5fb7..15d14d953 100644 --- a/packages/sit-onyx/src/components/OnyxIcon/OnyxIcon.stories.ts +++ b/packages/sit-onyx/src/components/OnyxIcon/OnyxIcon.stories.ts @@ -1,11 +1,7 @@ import happyIcon from "@sit-onyx/icons/emoji-happy-2.svg?raw"; import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; -import { - createIconSourceCodeTransformer, - defineIconSelectArgType, - textColorDecorator, -} from "../../utils/storybook"; +import { defineIconSelectArgType, textColorDecorator } from "../../utils/storybook"; import OnyxIcon from "./OnyxIcon.vue"; /** @@ -24,14 +20,7 @@ const meta: Meta = { icon: defineIconSelectArgType(), }, }), - parameters: { - docs: { - source: { - // improve code snippet by adding the icon import - transform: createIconSourceCodeTransformer("icon"), - }, - }, - }, + decorators: [textColorDecorator], }; diff --git a/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.stories.ts b/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.stories.ts index 9a944c974..fca385cf2 100644 --- a/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.stories.ts +++ b/packages/sit-onyx/src/components/OnyxIconButton/OnyxIconButton.stories.ts @@ -2,7 +2,7 @@ import trash from "@sit-onyx/icons/trash.svg?raw"; import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; import { h } from "vue"; -import { createIconSourceCodeTransformer, defineIconSelectArgType } from "../../utils/storybook"; +import { defineIconSelectArgType } from "../../utils/storybook"; import OnyxIconButton from "./OnyxIconButton.vue"; /** @@ -18,14 +18,6 @@ const meta: Meta = { default: { control: { disable: true } }, }, }), - parameters: { - docs: { - source: { - // improve code snippet by adding the icon import - transform: createIconSourceCodeTransformer("icon"), - }, - }, - }, }; export default meta; @@ -69,12 +61,7 @@ export const Danger = { export const Custom = { args: { label: "Button", - default: () => - h( - "figure", - { style: "margin: 0; width: 24px; height: 24px; display: grid; place-items: center" }, - "🎉", - ), + default: () => h("figure", { style: "width: 1.5rem; height: 1.5rem;" }, "🎉"), }, } satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxListboxOption/OnyxListboxOption.stories.ts b/packages/sit-onyx/src/components/OnyxListboxOption/OnyxListboxOption.stories.ts index 8a1dc1adb..6f192125d 100644 --- a/packages/sit-onyx/src/components/OnyxListboxOption/OnyxListboxOption.stories.ts +++ b/packages/sit-onyx/src/components/OnyxListboxOption/OnyxListboxOption.stories.ts @@ -2,11 +2,7 @@ import settings from "@sit-onyx/icons/settings.svg?raw"; import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; import type { AriaAttributes } from "vue"; -import { - createIconSourceCodeTransformer, - createTruncationDecorator, - defineIconSelectArgType, -} from "../../utils/storybook"; +import { createTruncationDecorator, defineIconSelectArgType } from "../../utils/storybook"; import OnyxListboxOption from "./OnyxListboxOption.vue"; /** @@ -22,14 +18,6 @@ const meta: Meta = { default: { control: { type: "text" } }, icon: defineIconSelectArgType(), }, - parameters: { - docs: { - source: { - // improve code snippet by adding the icon import - transform: createIconSourceCodeTransformer("icon"), - }, - }, - }, }), }; diff --git a/packages/sit-onyx/src/components/OnyxNavItem/OnyxNavItem.stories.ts b/packages/sit-onyx/src/components/OnyxNavItem/OnyxNavItem.stories.ts index 253cabc55..469b21aab 100644 --- a/packages/sit-onyx/src/components/OnyxNavItem/OnyxNavItem.stories.ts +++ b/packages/sit-onyx/src/components/OnyxNavItem/OnyxNavItem.stories.ts @@ -65,7 +65,7 @@ export const WithOptions = { export const WithCustomContent = { args: { ...Default.args, - default: () => ["custom label", h(OnyxBadge, { dot: true, variation: "warning" })], + default: () => ["Custom label", h(OnyxBadge, { dot: true, variation: "warning" })], }, } satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxPageLayout/OnyxPageLayout.stories.ts b/packages/sit-onyx/src/components/OnyxPageLayout/OnyxPageLayout.stories.ts index 2564055d0..5d58de857 100644 --- a/packages/sit-onyx/src/components/OnyxPageLayout/OnyxPageLayout.stories.ts +++ b/packages/sit-onyx/src/components/OnyxPageLayout/OnyxPageLayout.stories.ts @@ -1,5 +1,6 @@ import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; +import { h } from "vue"; import OnyxPageLayout from "./OnyxPageLayout.vue"; /** @@ -40,10 +41,6 @@ const meta: Meta = { `, }), ], - render: (args) => ({ - setup: () => ({ args }), - ...getPageRenderContent(), - }), }; export default meta; @@ -51,97 +48,66 @@ type Story = StoryObj; /** A standard page with some content. */ export const Default = { - args: {}, + args: { + default: () => h("div", "This is the page content."), + }, } satisfies Story; /** A standard page with a fixed sidebar. */ export const WithSidebar = { args: { ...Default.args, + sidebar: () => + h( + "div", + { + style: + "height: 100%; border-right: var(--onyx-1px-in-rem) solid var(--onyx-color-base-neutral-300);", + }, + "Side Bar Content", + ), }, - render: (args) => ({ - setup: () => ({ args }), - ...getPageRenderContent({ sidebar: true }), - }), } satisfies Story; /** A standard page with a footer. */ export const WithFooter = { args: { ...Default.args, + footer: () => + h( + "div", + { style: "border-top: var(--onyx-1px-in-rem) solid var(--onyx-color-base-neutral-300);" }, + "Footer Content", + ), }, - render: (args) => ({ - setup: () => ({ args }), - ...getPageRenderContent({ footer: true }), - }), } satisfies Story; /** A page that shows a sidebar and a footer next to it. */ export const WithPartialFooter = { args: { - ...Default.args, + ...WithSidebar.args, + footer: WithFooter.args.footer, footerAsideSidebar: true, }, - render: (args) => ({ - setup: () => ({ args }), - ...getPageRenderContent({ sidebar: true, footer: true }), - }), } satisfies Story; /** A page that shows a toast. */ export const WithToast = { args: { - ...Default.args, - footerAsideSidebar: true, - }, - render: (args) => ({ - setup: () => ({ args }), - ...getPageRenderContent( - { sidebar: true, footer: true }, - ` - - `, - ), - }), + margin: auto;`, + }, + "This is the place for a toast", + ), + }, } satisfies Story; - -const getPageRenderContent = ( - options?: { sidebar?: boolean; footer?: boolean }, - otherSlotContent?: string, -) => ({ - components: { OnyxPageLayout }, - template: ` - - ${ - options?.sidebar - ? `` - : "" - } -
This is the page content.
- ${ - options?.footer - ? `` - : "" - } - ${otherSlotContent ?? ""} -
-`, -}); diff --git a/packages/sit-onyx/src/components/OnyxTable/OnyxTable.stories.ts b/packages/sit-onyx/src/components/OnyxTable/OnyxTable.stories.ts index a7f4eb525..4024bf656 100644 --- a/packages/sit-onyx/src/components/OnyxTable/OnyxTable.stories.ts +++ b/packages/sit-onyx/src/components/OnyxTable/OnyxTable.stories.ts @@ -1,5 +1,6 @@ import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; +import { h } from "vue"; import OnyxTable from "./OnyxTable.vue"; /** @@ -15,45 +16,38 @@ const meta: Meta = { control: { disable: true }, }, }, - render: (args) => ({ - setup: () => ({ args }), - components: { OnyxTable }, - template: ` - - - - Fruit Price (€/kg) Inventory (kg) - - - - - Strawberry 4.50 200 - - - Apple 1.99 3000 - - - Banana 3.75 18000 - - - `, - }), }), }; export default meta; type Story = StoryObj; +const getTableBody = () => { + return h("tbody", [ + h("tr", [h("td", "Strawberry"), h("td", "4.50"), h("td", "200")]), + h("tr", [h("td", "Apple"), h("td", "1.99"), h("td", "3000")]), + h("tr", [h("td", "Banana"), h("td", "3.75"), h("td", "18000")]), + ]); +}; + /** * This example shows a default table. */ -export const Default = { args: {} } satisfies Story; +export const Default = { + args: { + default: () => [ + h("thead", [h("tr", [h("th", "Fruit"), h("th", "Price (€/kg)"), h("th", "Inventory (kg)")])]), + getTableBody(), + ], + }, +} satisfies Story; /** * This example shows a striped table. */ export const Striped = { args: { + ...Default.args, striped: true, }, } satisfies Story; @@ -63,6 +57,7 @@ export const Striped = { */ export const GridBorders = { args: { + ...Default.args, grid: true, }, } satisfies Story; @@ -71,23 +66,7 @@ export const GridBorders = { * This example shows a table without a header. */ export const WithoutHeader = { - args: {}, - render: (args) => ({ - setup: () => ({ args }), - components: { OnyxTable }, - template: ` - - - - Strawberry 4.50 200 - - - Apple 1.99 3000 - - - Banana 3.75 18000 - - - `, - }), + args: { + default: () => getTableBody(), + }, } satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxTag/OnyxTag.stories.ts b/packages/sit-onyx/src/components/OnyxTag/OnyxTag.stories.ts index 70b0b7ce1..6a08ffd3c 100644 --- a/packages/sit-onyx/src/components/OnyxTag/OnyxTag.stories.ts +++ b/packages/sit-onyx/src/components/OnyxTag/OnyxTag.stories.ts @@ -1,11 +1,7 @@ import check from "@sit-onyx/icons/check.svg?raw"; import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; -import { - createIconSourceCodeTransformer, - createTruncationDecorator, - defineIconSelectArgType, -} from "../../utils/storybook"; +import { createTruncationDecorator, defineIconSelectArgType } from "../../utils/storybook"; import OnyxTag from "./OnyxTag.vue"; /** @@ -20,13 +16,6 @@ const meta: Meta = { icon: defineIconSelectArgType(), }, }), - parameters: { - docs: { - source: { - transform: createIconSourceCodeTransformer("icon"), - }, - }, - }, }; export default meta; diff --git a/packages/sit-onyx/src/components/OnyxTooltip/OnyxTooltip.stories.ts b/packages/sit-onyx/src/components/OnyxTooltip/OnyxTooltip.stories.ts index 71cce369d..28e59f56c 100644 --- a/packages/sit-onyx/src/components/OnyxTooltip/OnyxTooltip.stories.ts +++ b/packages/sit-onyx/src/components/OnyxTooltip/OnyxTooltip.stories.ts @@ -113,6 +113,6 @@ export const Danger = { export const CustomContent = { args: { ...Default.args, - tooltip: () => h("div", [h("span", "This is "), h("b", "custom content")]), + tooltip: () => ["This is ", h("strong", "custom content")], }, } satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxUserMenu/OnyxUserMenu.stories.ts b/packages/sit-onyx/src/components/OnyxUserMenu/OnyxUserMenu.stories.ts index 60b5f2784..95afdff32 100644 --- a/packages/sit-onyx/src/components/OnyxUserMenu/OnyxUserMenu.stories.ts +++ b/packages/sit-onyx/src/components/OnyxUserMenu/OnyxUserMenu.stories.ts @@ -40,7 +40,7 @@ export const Default = { { value: "/settings", label: "Settings", icon: settings }, { value: "logout", label: "Logout", icon: logout, color: "danger" }, ], - footer: () => h(() => ["App version", h("span", { class: "onyx-text--monospace" }, "1.0.0")]), + footer: () => ["App version", h("span", { class: "onyx-text--monospace" }, "1.0.0")], }, } satisfies Story; diff --git a/packages/sit-onyx/src/utils/storybook.spec.ts b/packages/sit-onyx/src/utils/storybook.spec.ts deleted file mode 100644 index da5a6d63e..000000000 --- a/packages/sit-onyx/src/utils/storybook.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import mockIcon from "@sit-onyx/icons/emoji-happy-2.svg?raw"; -import * as storybookUtils from "@sit-onyx/storybook-utils"; -import type { StoryContext } from "@storybook/vue3"; -import { describe, expect, test, vi } from "vitest"; -import { createIconSourceCodeTransformer } from "./storybook"; - -describe("storybook.ts", () => { - test("should add import statement for icon property", () => { - const transformerSpy = vi.spyOn(storybookUtils, "sourceCodeTransformer"); - - const transformer = createIconSourceCodeTransformer("icon"); - const originalSourceCode = ``; - - const mockContext: Pick = { - args: { - icon: mockIcon, - }, - }; - - const sourceCode = transformer(originalSourceCode, mockContext); - - expect(sourceCode).toBe(` -`); - - expect(transformerSpy).toHaveBeenCalledWith(originalSourceCode); - }); -}); diff --git a/packages/sit-onyx/src/utils/storybook.ts b/packages/sit-onyx/src/utils/storybook.ts index df399b753..0d06ff242 100644 --- a/packages/sit-onyx/src/utils/storybook.ts +++ b/packages/sit-onyx/src/utils/storybook.ts @@ -1,5 +1,4 @@ -import { sourceCodeTransformer } from "@sit-onyx/storybook-utils"; -import type { ArgTypes, Decorator, StoryContext } from "@storybook/vue3"; +import type { ArgTypes, Decorator } from "@storybook/vue3"; /** * Defines the control for a Storybook argType to be a select/dropdown of @@ -42,24 +41,6 @@ export const defineIconSelectArgType = () => { } satisfies ArgTypes[number]; }; -export const createIconSourceCodeTransformer = (propertyName: string) => { - const iconArgType = defineIconSelectArgType(); - - return (sourceCode: string, ctx: Pick) => { - // using this custom transformer would override the default one - // so we are calling the default transformer here - const code = sourceCodeTransformer(sourceCode); - if (!ctx.args[propertyName] || typeof ctx.args[propertyName] !== "string") return code; - - const iconName = iconArgType.control.labels[ctx.args[propertyName] as string]; - - return ` -${code.replace(new RegExp(` ${propertyName}=['"].*['"]`), ` :${propertyName}="icon"`)}`; - }; -}; - /** * Storybook decorator that wraps the story with a
that sets the text color * to neutral intense. diff --git a/packages/storybook-utils/package.json b/packages/storybook-utils/package.json index 054eaf126..884f7b5ac 100644 --- a/packages/storybook-utils/package.json +++ b/packages/storybook-utils/package.json @@ -28,7 +28,9 @@ "test:coverage": "vitest run --coverage" }, "peerDependencies": { + "@sit-onyx/icons": "workspace:^", "@storybook/core-events": ">= 8.0.0", + "@storybook/docs-tools": ">= 8.0.0", "@storybook/preview-api": ">= 8.0.0", "@storybook/theming": ">= 8.0.0", "@storybook/vue3": ">= 8.0.0", diff --git a/packages/storybook-utils/src/preview.spec.ts b/packages/storybook-utils/src/preview.spec.ts index 769bf74e4..79c2619e7 100644 --- a/packages/storybook-utils/src/preview.spec.ts +++ b/packages/storybook-utils/src/preview.spec.ts @@ -1,18 +1,31 @@ -import { describe, expect, test } from "vitest"; -import { sourceCodeTransformer } from "./preview"; +import bellRing from "@sit-onyx/icons/bell-ring.svg?raw"; +import calendar from "@sit-onyx/icons/calendar.svg?raw"; +import placeholder from "@sit-onyx/icons/placeholder.svg?raw"; +import { describe, expect, test, vi } from "vitest"; +import { replaceAll, sourceCodeTransformer } from "./preview"; +import * as sourceCodeGenerator from "./source-code-generator"; describe("preview.ts", () => { - test("should transform source code", () => { + test("should transform source code and add icon imports", () => { // ARRANGE - // mix double and single quotes to test that both are supported - const originalSourceCode = ``; - - const expectedOutput = ``; + const generatorSpy = vi.spyOn(sourceCodeGenerator, "generateSourceCode") + .mockReturnValue(``); // ACT - const output = sourceCodeTransformer(originalSourceCode); + const sourceCode = sourceCodeTransformer("", { title: "OnyxTest", args: {} }); // ASSERT - expect(output).toBe(expectedOutput); + expect(generatorSpy).toHaveBeenCalledOnce(); + expect(sourceCode).toBe(` + +`); }); }); diff --git a/packages/storybook-utils/src/preview.ts b/packages/storybook-utils/src/preview.ts index 7a3b58278..325183393 100644 --- a/packages/storybook-utils/src/preview.ts +++ b/packages/storybook-utils/src/preview.ts @@ -1,11 +1,13 @@ import { DOCS_RENDERED } from "@storybook/core-events"; import { addons } from "@storybook/preview-api"; import { type ThemeVars } from "@storybook/theming"; -import { type Preview } from "@storybook/vue3"; +import { type Preview, type StoryContext } from "@storybook/vue3"; import { deepmerge } from "deepmerge-ts"; import { DARK_MODE_EVENT_NAME } from "storybook-dark-mode"; +import { getIconImportName } from "@sit-onyx/icons"; import { requiredGlobalType, withRequired } from "./required"; +import { generateSourceCode } from "./source-code-generator"; import { ONYX_BREAKPOINTS, createTheme } from "./theme"; const themes = { @@ -116,32 +118,68 @@ export const createPreview = (overrides?: T) => { }; /** - * Custom transformer for the story source code to better fit to our - * Vue.js code because storybook per default does not render it exactly how - * we want it to look. + * Custom transformer for the story source code to support improved source code generation. + * and add imports for all used onyx icons so icon imports are displayed in the source code + * instead of the the raw SVG content. + * * @see https://storybook.js.org/docs/react/api/doc-block-source */ -export const sourceCodeTransformer = (sourceCode: string): string => { - const replacements = [ - // replace event bindings with shortcut - { searchValue: "v-on:", replaceValue: "@" }, - // remove empty event handlers, e.g. @click="()=>({})" will be removed - { searchValue: / @\S*['"]\(\)=>\({}\)['"]/g, replaceValue: "" }, - // // remove empty v-binds, e.g. v-bind="{}" will be removed - { searchValue: / v-bind=['"]{}['"]/g, replaceValue: "" }, - // // replace boolean shortcuts for true, e.g. disabled="true" will be changed to just disabled - { searchValue: /:?(\S*)=['"]true['"]/g, replaceValue: "$1" }, - ]; - - return replacements.reduce((code, replacement) => { - return replaceAll(code, replacement.searchValue, replacement.replaceValue); - }, sourceCode); +export const sourceCodeTransformer = ( + sourceCode: string, + ctx: Pick, +): string => { + const RAW_ICONS = import.meta.glob("../node_modules/@sit-onyx/icons/src/assets/*.svg", { + query: "?raw", + import: "default", + eager: true, + }); + + /** + * Mapping between icon SVG content (key) and icon name (value). + * Needed to display a labelled dropdown list of all available icons. + */ + const ALL_ICONS = Object.entries(RAW_ICONS).reduce>( + (obj, [filePath, content]) => { + obj[filePath.split("/").at(-1)!.replace(".svg", "")] = content as string; + return obj; + }, + {}, + ); + + let code = generateSourceCode(ctx); + + const iconImports: string[] = []; + + // add icon imports to the source code for all used onyx icons + Object.entries(ALL_ICONS).forEach(([iconName, iconContent]) => { + const importName = getIconImportName(iconName); + const singleQuotedIconContent = `'${replaceAll(iconContent, '"', "\\'")}'`; + + if (code.includes(iconContent)) { + code = code.replace(new RegExp(` (\\S+)=['"]${iconContent}['"]`), ` :$1="${importName}"`); + iconImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`); + } else if (code.includes(singleQuotedIconContent)) { + // support icons inside objects + code = code.replace(singleQuotedIconContent, importName); + iconImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`); + } + }); + + if (iconImports.length > 0) { + return ` + +${code}`; + } + + return code; }; /** * Custom String.replaceAll implementation using a RegExp * because String.replaceAll() is not available in our specified EcmaScript target in tsconfig.json */ -const replaceAll = (message: string, searchValue: string | RegExp, replaceValue: string) => { - return message.replace(new RegExp(searchValue, "gi"), replaceValue); +export const replaceAll = (value: string, searchValue: string | RegExp, replaceValue: string) => { + return value.replace(new RegExp(searchValue, "gi"), replaceValue); }; diff --git a/packages/storybook-utils/src/source-code-generator.spec.ts b/packages/storybook-utils/src/source-code-generator.spec.ts new file mode 100644 index 000000000..2e42c9f68 --- /dev/null +++ b/packages/storybook-utils/src/source-code-generator.spec.ts @@ -0,0 +1,141 @@ +// +// This file is only a temporary copy of the improved source code generation for Storybook. +// It is intended to be deleted once its officially released in Storybook itself, see: +// https://github.com/storybookjs/storybook/pull/27194 +// +import { expect, test } from "vitest"; +import { h } from "vue"; +import { + extractSlotNames, + generatePropsSourceCode, + generateSlotSourceCode, +} from "./source-code-generator"; + +test("should generate source code for props", () => { + const slots = ["default", "testSlot"]; + + const code = generatePropsSourceCode( + { + a: "foo", + b: '"I am double quoted"', + c: 42, + d: true, + e: false, + f: [1, 2, 3], + g: { + g1: "foo", + b2: 42, + }, + h: undefined, + i: null, + j: "", + k: BigInt(9007199254740991), + l: Symbol(), + m: Symbol("foo"), + default: "default slot", + testSlot: "test slot", + }, + slots, + ); + + expect(code).toBe( + `a="foo" b='"I am double quoted"' :c="42" d :e="false" :f="[1,2,3]" :g="{'g1':'foo','b2':42}" :k="BigInt(9007199254740991)" :l="Symbol()" :m="Symbol('foo')"`, + ); +}); + +test("should generate source code for slots", () => { + // slot code generator should support primitive values (string, number etc.) + // but also VNodes (e.g. created using h()) so custom Vue components can also be used + // inside slots with proper generated code + + const slots = { + default: "default content", + a: "a content", + b: 42, + c: true, + // single VNode without props + d: h("div", "d content"), + // VNode with props and single child + e: h("div", { style: "color:red" }, "e content"), + // VNode with props and single child returned as getter + f: h("div", { style: "color:red" }, () => "f content"), + // VNode with multiple children + g: h("div", { style: "color:red" }, [ + "child 1", + h("span", { style: "color:green" }, "child 2"), + ]), + // VNode multiple children but returned as getter + h: h("div", { style: "color:red" }, () => [ + "child 1", + h("span", { style: "color:green" }, "child 2"), + ]), + // VNode with multiple and nested children + i: h("div", { style: "color:red" }, [ + "child 1", + h("span", { style: "color:green" }, ["nested child 1", h("p", "nested child 2")]), + ]), + j: ["child 1", "child 2"], + k: null, + l: { foo: "bar" }, + m: BigInt(9007199254740991), + }; + + const expectedCode = `default content + + + + + + + + + + + + + + + + + + + + + + + +`; + + let actualCode = generateSlotSourceCode(slots, Object.keys(slots)); + expect(actualCode).toBe(expectedCode); + + // should generate the same code if getters/functions are used to return the slot content + const slotsWithGetters = Object.entries(slots).reduce< + Record (typeof slots)[keyof typeof slots]> + >((obj, [slotName, value]) => { + obj[slotName] = () => value; + return obj; + }, {}); + + actualCode = generateSlotSourceCode(slotsWithGetters, Object.keys(slotsWithGetters)); + expect(actualCode).toBe(expectedCode); +}); + +test.each([ + { __docgenInfo: "invalid-value", slotNames: [] }, + { __docgenInfo: {}, slotNames: [] }, + { __docgenInfo: { slots: "invalid-value" }, slotNames: [] }, + { __docgenInfo: { slots: ["invalid-value"] }, slotNames: [] }, + { + __docgenInfo: { slots: [{ name: "slot-1" }, { name: "slot-2" }, { notName: "slot-3" }] }, + slotNames: ["slot-1", "slot-2"], + }, +])("should extract slots names from __docgenInfo", ({ __docgenInfo, slotNames }) => { + const actualNames = extractSlotNames({ __docgenInfo }); + expect(actualNames).toStrictEqual(slotNames); +}); diff --git a/packages/storybook-utils/src/source-code-generator.ts b/packages/storybook-utils/src/source-code-generator.ts new file mode 100644 index 000000000..664e8b579 --- /dev/null +++ b/packages/storybook-utils/src/source-code-generator.ts @@ -0,0 +1,277 @@ +// +// This file is only a temporary copy of the improved source code generation for Storybook. +// It is intended to be deleted once its officially released in Storybook itself, see: +// https://github.com/storybookjs/storybook/pull/27194 +// +import { SourceType } from "@storybook/docs-tools"; +import type { Args, StoryContext } from "@storybook/vue3"; +import type { VNode } from "vue"; +import { isVNode } from "vue"; +import { replaceAll } from "./preview"; + +/** + * Generate Vue source code for the given Story. + * @returns Source code or empty string if source code could not be generated. + */ +export const generateSourceCode = ( + ctx: Pick, +): string => { + const componentName = ctx.component?.__name || ctx.title.split("/").at(-1)!; + + const slotNames = extractSlotNames(ctx.component); + const slotSourceCode = generateSlotSourceCode(ctx.args, slotNames); + const propsSourceCode = generatePropsSourceCode(ctx.args, slotNames); + + if (slotSourceCode) { + return ``; + } + + // prefer self closing tag if no slot content exists + return ``; +}; + +/** + * Checks if the source code generation should be skipped for the given Story context. + * Will be true if one of the following is true: + * - view mode is not "docs" + * - story is no arg story + * - story has set custom source code via parameters.docs.source.code + * - story has set source type to "code" via parameters.docs.source.type + */ +export const shouldSkipSourceCodeGeneration = (context: StoryContext): boolean => { + const sourceParams = context?.parameters.docs?.source; + if (sourceParams?.type === SourceType.DYNAMIC) { + // always render if the user forces it + return false; + } + + const isArgsStory = context?.parameters.__isArgsStory; + const isDocsViewMode = context?.viewMode === "docs"; + + // never render if the user is forcing the block to render code, or + // if the user provides code, or if it's not an args story. + return ( + !isDocsViewMode || !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE + ); +}; + +/** + * Gets all slot names from the `__docgenInfo` of the given component if available. + * Requires Storybook docs addon to be enabled. + * Default slot will always be sorted first, remaining slots are sorted alphabetically. + */ +export const extractSlotNames = ( + component?: StoryContext["component"] & { __docgenInfo?: unknown }, +): string[] => { + if (!component || !("__docgenInfo" in component)) return []; + + // type check __docgenInfo to prevent errors + if (!component.__docgenInfo || typeof component.__docgenInfo !== "object") return []; + if ( + !("slots" in component.__docgenInfo) || + !component.__docgenInfo.slots || + !Array.isArray(component.__docgenInfo.slots) + ) { + return []; + } + + return component.__docgenInfo.slots + .map((slot) => slot.name) + .filter((i): i is string => typeof i === "string") + .sort((a, b) => { + if (a === "default") return -1; + if (b === "default") return 1; + return a.localeCompare(b); + }); +}; + +/** + * Generates the source code for the given Vue component properties. + * + * @param args Story args / property values. + * @param slotNames All slot names of the component. Needed to not generate code for args that are slots. + * Can be extracted using `extractSlotNames()`. + */ +export const generatePropsSourceCode = ( + args: Record, + slotNames: string[], +): string => { + const props: string[] = []; + + Object.entries(args).forEach(([propName, value]) => { + // ignore slots + if (slotNames.includes(propName)) return; + + switch (typeof value) { + case "string": + if (value === "") return; // do not render empty strings + + if (value.includes('"')) { + props.push(`${propName}='${value}'`); + } else { + props.push(`${propName}="${value}"`); + } + + break; + case "number": + props.push(`:${propName}="${value}"`); + break; + case "bigint": + props.push(`:${propName}="BigInt(${value.toString()})"`); + break; + case "boolean": + props.push(value === true ? propName : `:${propName}="false"`); + break; + case "object": + if (value === null) return; // do not render null values + props.push(`:${propName}="${replaceAll(JSON.stringify(value), '"', "'")}"`); + break; + case "symbol": { + const symbol = `Symbol(${value.description ? `'${value.description}'` : ""})`; + props.push(`:${propName}="${symbol}"`); + break; + } + case "function": + // TODO: check if functions should be rendered in source code + break; + } + }); + + return props.join(" "); +}; + +/** + * Generates the source code for the given Vue component slots. + * + * @param args Story args. + * @param slotNames All slot names of the component. Needed to only generate slots and ignore props etc. + * Can be extracted using `extractSlotNames()`. + */ +export const generateSlotSourceCode = (args: Args, slotNames: string[]): string => { + /** List of slot source codes (e.g. ) */ + const slotSourceCodes: string[] = []; + + slotNames.forEach((slotName) => { + const arg = args[slotName]; + if (!arg) return; + + const slotContent = generateSlotChildrenSourceCode([arg]); + if (!slotContent) return; // do not generate source code for empty slots + + // TODO: support generating bindings + const bindings = ""; + + if (slotName === "default" && !bindings) { + // do not add unnecessary "