@uxfront/layer-docs 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +31 -220
  2. package/app/app.config.ts +8 -92
  3. package/app/components/content/FrameworkSwitcher.vue +66 -40
  4. package/app/components/docs/DocsAsideLeftTop.vue +9 -15
  5. package/app/components/docs/DocsFrameworkSelect.vue +4 -7
  6. package/app/composables/useFramework.ts +30 -38
  7. package/nuxt.config.ts +14 -170
  8. package/package.json +10 -77
  9. package/CHANGELOG.md +0 -180
  10. package/LICENSE +0 -21
  11. package/app/app.vue +0 -138
  12. package/app/assets/css/main.css +0 -15
  13. package/app/components/IconMenuToggle.vue +0 -92
  14. package/app/components/LanguageSelect.vue +0 -73
  15. package/app/components/MorphingGradientBackground.vue +0 -261
  16. package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
  17. package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
  18. package/app/components/app/AppFooter.vue +0 -13
  19. package/app/components/app/AppFooterCenter.vue +0 -17
  20. package/app/components/app/AppFooterLeft.vue +0 -21
  21. package/app/components/app/AppFooterRight.vue +0 -33
  22. package/app/components/app/AppHeader.vue +0 -123
  23. package/app/components/app/AppHeaderAttribution.vue +0 -45
  24. package/app/components/app/AppHeaderBody.vue +0 -14
  25. package/app/components/app/AppHeaderCTA.vue +0 -31
  26. package/app/components/app/AppHeaderCenter.vue +0 -10
  27. package/app/components/app/AppHeaderLogo.vue +0 -16
  28. package/app/components/app/AppOgDecoration.vue +0 -27
  29. package/app/components/app/AppOgLogo.vue +0 -19
  30. package/app/components/app/AppSearch.vue +0 -59
  31. package/app/components/app/AppSubHeader.vue +0 -21
  32. package/app/components/content/BrowserFrame.vue +0 -28
  33. package/app/components/content/GradientPageHero.vue +0 -35
  34. package/app/components/content/StorybookEmbed.vue +0 -160
  35. package/app/components/content/Video.vue +0 -103
  36. package/app/components/docs/DocsAsideLeftBody.vue +0 -20
  37. package/app/components/docs/DocsAsideRightBottom.vue +0 -15
  38. package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
  39. package/app/composables/useDocsSections.ts +0 -57
  40. package/app/composables/useDocusI18n.ts +0 -49
  41. package/app/constants/sections.ts +0 -25
  42. package/app/error.vue +0 -140
  43. package/app/layouts/default.vue +0 -24
  44. package/app/pages/[[lang]]/[...slug].vue +0 -58
  45. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
  46. package/app/plugins/i18n.ts +0 -21
  47. package/app/plugins/posthog.client.ts +0 -56
  48. package/app/types/non-route-categories.ts +0 -12
  49. package/app/utils/flattenNavigation.ts +0 -22
  50. package/app/utils/foldNonRouteCategories.ts +0 -47
  51. package/app/utils/prerender.ts +0 -9
  52. package/app/utils/storybookEmbed.test.ts +0 -98
  53. package/app/utils/storybookEmbed.ts +0 -93
  54. package/i18n/locales/ar.json +0 -24
  55. package/i18n/locales/be.json +0 -24
  56. package/i18n/locales/bn.json +0 -24
  57. package/i18n/locales/ca.json +0 -24
  58. package/i18n/locales/ckb.json +0 -24
  59. package/i18n/locales/cs.json +0 -24
  60. package/i18n/locales/da.json +0 -24
  61. package/i18n/locales/de.json +0 -24
  62. package/i18n/locales/el.json +0 -24
  63. package/i18n/locales/en.json +0 -24
  64. package/i18n/locales/et.json +0 -24
  65. package/i18n/locales/fr.json +0 -24
  66. package/i18n/locales/he.json +0 -24
  67. package/i18n/locales/hi.json +0 -24
  68. package/i18n/locales/hy.json +0 -24
  69. package/i18n/locales/it.json +0 -24
  70. package/i18n/locales/ja.json +0 -24
  71. package/i18n/locales/kk.json +0 -24
  72. package/i18n/locales/km.json +0 -24
  73. package/i18n/locales/ko.json +0 -24
  74. package/i18n/locales/ky.json +0 -24
  75. package/i18n/locales/lb.json +0 -24
  76. package/i18n/locales/ms.json +0 -24
  77. package/i18n/locales/nb.json +0 -24
  78. package/i18n/locales/pl.json +0 -24
  79. package/i18n/locales/ru.json +0 -24
  80. package/i18n/locales/sl.json +0 -24
  81. package/i18n/locales/sv.json +0 -24
  82. package/i18n/locales/uk.json +0 -24
  83. package/i18n/locales/ur.json +0 -24
  84. package/i18n/locales/vi.json +0 -24
  85. package/modules/config.ts +0 -144
  86. package/modules/optimizeDeps.ts +0 -45
  87. package/modules/routing.ts +0 -20
  88. package/nuxt.schema.ts +0 -374
  89. package/server/plugins/llms-redirect.ts +0 -60
  90. package/server/routes/raw/[...slug].md.get.ts +0 -74
  91. package/storybook/index.test.ts +0 -110
  92. package/storybook/index.ts +0 -362
  93. package/test/brand-palette.ts +0 -235
  94. package/test/no-brand-leakage.test.ts +0 -124
  95. package/tsconfig.json +0 -17
  96. package/utils/accent.ts +0 -80
  97. package/utils/content.ts +0 -193
  98. package/utils/git.ts +0 -114
  99. package/utils/meta.ts +0 -28
@@ -1,123 +0,0 @@
1
- <script setup lang="ts">
2
- import { useResizeObserver } from "@vueuse/core";
3
- import { useDocusI18n } from "../../composables/useDocusI18n";
4
-
5
- const appConfig = useAppConfig();
6
- const site = useSiteConfig();
7
-
8
- const { localePath, isEnabled, locales } = useDocusI18n();
9
-
10
- const route = useRoute();
11
- const isDocs = computed(() => route.path.startsWith("/docs"));
12
-
13
- const headerEl = ref<HTMLElement | null>(null);
14
-
15
- onMounted(() => {
16
- headerEl.value = document.querySelector<HTMLElement>("header");
17
- });
18
-
19
- useResizeObserver(headerEl, ([entry]) => {
20
- if (!isDocs.value || !entry) return;
21
- const height = entry.contentRect.height;
22
- if (height > 0) {
23
- document.documentElement.style.setProperty("--ui-header-height", `${height}px`);
24
- }
25
- });
26
-
27
- watch(isDocs, (docs) => {
28
- if (!docs) {
29
- document.documentElement.style.removeProperty("--ui-header-height");
30
- }
31
- });
32
-
33
- const links = computed(() =>
34
- appConfig.github && appConfig.github.url
35
- ? [
36
- {
37
- icon: "i-simple-icons-github",
38
- to: appConfig.github.url,
39
- target: "_blank",
40
- "aria-label": "GitHub",
41
- },
42
- ]
43
- : [],
44
- );
45
- </script>
46
-
47
- <template>
48
- <UHeader
49
- :ui="{ center: 'flex-1', root: isDocs ? 'h-auto' : '', container: 'h-16' }"
50
- :to="localePath('/')"
51
- :title="appConfig.header?.title || site.name"
52
- >
53
- <AppHeaderCenter />
54
-
55
- <!--
56
- `#left` rather than `#title`: UHeader wraps the `#title` slot in its own
57
- `<ULink :to>` home link, so an attribution link rendered there would be an
58
- anchor nested in an anchor — invalid HTML that browsers unnest at parse
59
- time. Owning `#left` lets the wordmark and the attribution sit as
60
- siblings. The link classes restate UHeader's `title` slot theme, which the
61
- slot itself does not expose.
62
- -->
63
- <template #left>
64
- <div class="flex shrink-0 items-baseline gap-1.5">
65
- <ULink
66
- :to="localePath('/')"
67
- :aria-label="appConfig.header?.title || site.name"
68
- class="flex shrink-0 items-end gap-1.5 rounded-xs text-xl font-bold text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
69
- >
70
- <AppHeaderLogo class="h-6 w-auto shrink-0" />
71
- </ULink>
72
-
73
- <AppHeaderAttribution />
74
- </div>
75
- </template>
76
-
77
- <template #right>
78
- <AppHeaderCTA class="max-sm:hidden" />
79
-
80
- <template v-if="isEnabled && locales.length > 1">
81
- <ClientOnly>
82
- <LanguageSelect />
83
-
84
- <template #fallback>
85
- <div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
86
- </template>
87
- </ClientOnly>
88
-
89
- <USeparator orientation="vertical" class="h-8" />
90
- </template>
91
-
92
- <UContentSearchButton class="lg:hidden" />
93
-
94
- <ClientOnly>
95
- <UColorModeButton />
96
-
97
- <template #fallback>
98
- <div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
99
- </template>
100
- </ClientOnly>
101
-
102
- <template v-if="links?.length">
103
- <UButton
104
- v-for="(link, index) of links"
105
- :key="index"
106
- v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
107
- />
108
- </template>
109
- </template>
110
-
111
- <template #toggle="{ open, toggle }">
112
- <IconMenuToggle :open="open" class="lg:hidden" @click="toggle" />
113
- </template>
114
-
115
- <template #body>
116
- <AppHeaderBody />
117
- </template>
118
-
119
- <template v-if="isDocs" #bottom>
120
- <AppSubHeader />
121
- </template>
122
- </UHeader>
123
- </template>
@@ -1,45 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Optional brand attribution rendered next to the header wordmark, e.g.
4
- * "uxd by UXFront" where only "UXFront" is a link.
5
- *
6
- * Declared by the consuming app as:
7
- *
8
- * ```ts
9
- * header: {
10
- * title: "uxd",
11
- * attribution: { prefix: "by", label: "UXFront", to: "https://uxfront.com" },
12
- * }
13
- * ```
14
- *
15
- * `prefix` stays plain text so the link's accessible name is exactly the brand
16
- * it points at — "by UXFront" would read as the link text otherwise.
17
- */
18
- type Attribution = { prefix?: string; label?: string; to?: string };
19
-
20
- const appConfig = useAppConfig();
21
-
22
- // Read through a local type, as `AppHeaderCTA` does for `header.links`: Nuxt's
23
- // generated `AppConfig` prefers the shape inferred from the merged
24
- // `app.config.ts` values over the `nuxt.schema.ts` type, so a consumer that
25
- // declares `header` without `attribution` erases the key from the type even
26
- // though the layer's own defaults supply it at runtime.
27
- const attribution = computed(
28
- () => (appConfig.header as { attribution?: Attribution } | undefined)?.attribution,
29
- );
30
- </script>
31
-
32
- <template>
33
- <p v-if="attribution?.label" class="shrink-0 text-sm text-muted">
34
- <!-- Non-breaking space: Vue's `condense` whitespace handling drops a plain
35
- trailing space before a newline, and the credit should not wrap. -->
36
- <template v-if="attribution.prefix">{{ attribution.prefix }}&nbsp;</template>
37
- <ULink
38
- v-if="attribution.to"
39
- :to="attribution.to"
40
- class="rounded-xs font-medium text-toned underline underline-offset-2 hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
41
- >{{ attribution.label }}</ULink
42
- >
43
- <template v-else>{{ attribution.label }}</template>
44
- </p>
45
- </template>
@@ -1,14 +0,0 @@
1
- <script setup lang="ts">
2
- import type { ContentNavigationItem } from "@nuxt/content";
3
-
4
- const navigationMap = inject<Ref<Record<string, ContentNavigationItem[]> | null>>("navigation");
5
-
6
- const navigation = computed<ContentNavigationItem[]>(() =>
7
- navigationMap?.value ? Object.values(navigationMap.value).flat() : [],
8
- );
9
- </script>
10
-
11
- <template>
12
- <AppHeaderCTA highlight variant="link" orientation="vertical" class="min-sm:hidden mb-4 -ml-2" />
13
- <UContentNavigation highlight variant="link" :navigation="navigation" />
14
- </template>
@@ -1,31 +0,0 @@
1
- <script setup lang="ts">
2
- import type { NavigationMenuItem } from "@nuxt/ui";
3
-
4
- /**
5
- * Neutral header CTA menu. The links are brand data, so they come from the
6
- * consumer's `app.config.ts` under `header.links` — this layer ships none.
7
- * Each entry is a Nuxt UI `NavigationMenuItem`; a `startsWith` string on
8
- * `activeMatch` marks the item active for the current route prefix.
9
- */
10
- type HeaderLink = NavigationMenuItem & { activeMatch?: string };
11
-
12
- const route = useRoute();
13
- const appConfig = useAppConfig();
14
-
15
- // `header` is seeded partially in `modules/config.ts`, so defu's resolved
16
- // AppConfig type narrows it to `{ title, logo }` and drops the array-valued
17
- // `links`. Cast `header` itself (not the `.links` result) to read it safely.
18
- const items = computed<NavigationMenuItem[]>(() =>
19
- ((appConfig.header as { links?: HeaderLink[] } | undefined)?.links ?? []).map((link) => {
20
- const { activeMatch, ...rest } = link;
21
- return {
22
- ...rest,
23
- active: rest.active ?? (activeMatch ? route.path.startsWith(activeMatch) : undefined),
24
- };
25
- }),
26
- );
27
- </script>
28
-
29
- <template>
30
- <UNavigationMenu v-if="items.length" :items="items" />
31
- </template>
@@ -1,10 +0,0 @@
1
- <template>
2
- <UContentSearchButton
3
- :collapsed="false"
4
- class="w-full"
5
- variant="soft"
6
- :ui="{
7
- leadingIcon: 'size-4 mr-1',
8
- }"
9
- />
10
- </template>
@@ -1,16 +0,0 @@
1
- <script setup lang="ts">
2
- const appConfig = useAppConfig();
3
- </script>
4
-
5
- <template>
6
- <UColorModeImage
7
- v-if="appConfig.header?.logo?.dark || appConfig.header?.logo?.light"
8
- :light="appConfig.header?.logo?.light || appConfig.header?.logo?.dark"
9
- :dark="appConfig.header?.logo?.dark || appConfig.header?.logo?.light"
10
- :alt="appConfig.header?.logo?.alt || appConfig.header?.title"
11
- class="h-6 w-auto shrink-0"
12
- />
13
- <span v-else>
14
- {{ appConfig.header?.title || "{appConfig.header.title}" }}
15
- </span>
16
- </template>
@@ -1,27 +0,0 @@
1
- <script lang="ts" setup>
2
- /**
3
- * Decorative flourish behind the OG card — the "slot" of the OG templates.
4
- *
5
- * `defineOgImage(name, props)` serialises its props into the payload, so a real
6
- * Vue slot cannot cross that boundary. The Nuxt-native equivalent is app-dir
7
- * component precedence: a consumer shadows this file at the same path to supply
8
- * its own decoration, or ships an empty template to render nothing. That is the
9
- * same override mechanism consumers already use for `DocsAsideLeftBody`.
10
- *
11
- * Geometry only, no brand mark: a soft radial wash in the top-right corner.
12
- * Deliberately not the source SVG blob — satori implements a subset of SVG and
13
- * does not apply `filter` / `feGaussianBlur`, so the blurred shape would render
14
- * as a hard-edged starburst. A `radial-gradient` background is satori-native
15
- * and preserves what the blur was there for.
16
- *
17
- * Bound rather than written as a `style` attribute so it stays on one line:
18
- * satori's gradient parser rejects a value containing newlines, and a formatter
19
- * will wrap an attribute this long across lines given the chance.
20
- */
21
- const backgroundImage =
22
- "radial-gradient(circle at 75% 15%, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.1) 35%, rgba(255, 255, 255, 0) 70%)";
23
- </script>
24
-
25
- <template>
26
- <div class="absolute right-0 top-0 h-[593px] w-[629px]" :style="{ backgroundImage }" />
27
- </template>
@@ -1,19 +0,0 @@
1
- <script lang="ts" setup>
2
- /**
3
- * Wordmark for the OG card. Overridable at the same path, like
4
- * {@link AppOgDecoration}.
5
- *
6
- * Text, not `AppHeaderLogo`: that component renders `UColorModeImage`, which
7
- * needs a colour mode and resolves relative image paths against the browser —
8
- * satori has neither, so it needs an absolute URL and no colour mode at all. A
9
- * brand that wants its mark here overrides this file with an `<img>` pointing
10
- * at an absolute URL; stating that limit rather than half-solving it.
11
- */
12
- const appConfig = useAppConfig();
13
- </script>
14
-
15
- <template>
16
- <span class="text-[32px] font-semibold text-white">
17
- {{ appConfig.header?.title }}
18
- </span>
19
- </template>
@@ -1,59 +0,0 @@
1
- <script setup lang="ts">
2
- import type { ContentNavigationItem, PageCollections } from "@nuxt/content";
3
-
4
- const props = defineProps<{
5
- navigation?: ContentNavigationItem[];
6
- }>();
7
-
8
- const { locale, isEnabled } = useDocusI18n();
9
-
10
- // FTS5 indexes one collection at a time; our docs are split across a
11
- // collection per top-level section (and per locale), so we hand
12
- // `useSearchCollection` the whole set and let it index and query them together.
13
- const collections = computed(() =>
14
- DOCS_SECTIONS.map(
15
- (section) =>
16
- (isEnabled.value
17
- ? `docs_${section.key}_${locale.value}`
18
- : `docs_${section.key}`) as keyof PageCollections,
19
- ),
20
- );
21
-
22
- const {
23
- search,
24
- status: searchStatus,
25
- init,
26
- } = useSearchCollection(collections, {
27
- immediate: false,
28
- ignoredTags: ["style"],
29
- });
30
-
31
- // Build the SQLite FTS5 index lazily: only when the palette first opens, so
32
- // the search payload never loads on pages the reader never searches from.
33
- const { open } = useContentSearch();
34
- watch(open, (value) => {
35
- if (value && searchStatus.value === "idle") {
36
- init();
37
- }
38
- });
39
-
40
- // Empty-state shortcuts shown before the reader types anything.
41
- const links = computed(() =>
42
- props.navigation
43
- ?.filter((item) => item.children?.length)
44
- .map((item) => ({
45
- label: item.title,
46
- icon: item.icon as string | undefined,
47
- to: item.children![0]!.path,
48
- })),
49
- );
50
- </script>
51
-
52
- <template>
53
- <LazyUContentSearch
54
- :search="search"
55
- :search-status="searchStatus"
56
- :links="links"
57
- :navigation="navigation"
58
- />
59
- </template>
@@ -1,21 +0,0 @@
1
- <script setup lang="ts">
2
- import type { NavigationMenuItem } from "@nuxt/ui";
3
-
4
- const { sections, hasSectionSwitcher } = useDocsSections();
5
-
6
- const items = computed<NavigationMenuItem[]>(() =>
7
- sections.value.map((section) => ({
8
- label: section.label,
9
- to: section.to,
10
- active: section.active,
11
- })),
12
- );
13
- </script>
14
-
15
- <template>
16
- <div v-if="hasSectionSwitcher" class="border-t border-default max-lg:hidden">
17
- <UContainer class="py-1">
18
- <UNavigationMenu :items="items" :ui="{ root: 'overflow-x-auto no-scrollbar' }" />
19
- </UContainer>
20
- </div>
21
- </template>
@@ -1,28 +0,0 @@
1
- <script setup lang="ts">
2
- interface Props {
3
- title?: string;
4
- }
5
-
6
- defineProps<Props>();
7
- </script>
8
-
9
- <template>
10
- <div class="p-1 border border-default rounded text-sm bg-default/70">
11
- <div
12
- v-if="title"
13
- class="flex items-center bg-slate-100 dark:bg-slate-800 p-2 rounded font-bold"
14
- >
15
- <span class="flex gap-1 absolute">
16
- <span class="rounded-full bg-error inline-flex w-3 h-3" />
17
- <span class="rounded-full bg-warning inline-flex w-3 h-3" />
18
- <span class="rounded-full bg-success inline-flex w-3 h-3" />
19
- </span>
20
- <span class="flex w-full align-center justify-center">
21
- {{ title }}
22
- </span>
23
- </div>
24
- <div class="px-3 overflow-auto">
25
- <slot mdc-unwrap="p" />
26
- </div>
27
- </div>
28
- </template>
@@ -1,35 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * `UPageHero` on the layer's `MorphingGradientBackground`, usable from MDC:
4
- *
5
- * ```mdc
6
- * ::gradient-page-hero
7
- * ---
8
- * ---
9
- * #title
10
- * Your headline
11
- * ::
12
- * ```
13
- *
14
- * Ships with `MorphingGradientBackground` rather than beside it: a package that
15
- * hands you the ingredient should hand you the recipe too. The gradient's own
16
- * base surface follows `--ui-bg`; its blob colours are the background
17
- * component's own fixed palette, overridable per consumer by restating the
18
- * `--color1..5` custom properties on an ancestor.
19
- */
20
- defineOptions({
21
- inheritAttrs: false,
22
- });
23
- </script>
24
-
25
- <template>
26
- <MorphingGradientBackground v-slot="{ onMousemove }">
27
- <UPageHero v-bind="$attrs" @mousemove="onMousemove">
28
- <slot />
29
- <template #title><slot name="title" /></template>
30
- <template #description><slot name="description" /></template>
31
- <template #headline><slot name="headline" /></template>
32
- <template #links><slot name="links" /></template>
33
- </UPageHero>
34
- </MorphingGradientBackground>
35
- </template>
@@ -1,160 +0,0 @@
1
- <script setup lang="ts">
2
- import { useIntersectionObserver } from "@vueuse/core";
3
- // Resolved through `#components` rather than imported by path so a consumer
4
- // that ships its own `BrowserFrame` overrides this one, as with any other
5
- // component in the layer.
6
- import { BrowserFrame } from "#components";
7
- import { docsEmbedMessageNames, readDocsEmbedHeight } from "../../../storybook";
8
- import {
9
- buildStorybookEmbedUrl,
10
- STORYBOOK_EMBED_DEFAULT_HEIGHTS,
11
- storybookEmbedCssHeight,
12
- type StorybookEmbedMode,
13
- } from "../../utils/storybookEmbed";
14
-
15
- /**
16
- * Embeds a single story from the consumer's deployed Storybook.
17
- *
18
- * The Storybook host is a consumer fact, never a layer one: it comes from
19
- * `runtimeConfig.public.storybookBaseUrl`, which is empty here and set per
20
- * consumer — in `nuxt.config.ts` or, without a rebuild,
21
- * `NUXT_PUBLIC_STORYBOOK_BASE_URL`.
22
- *
23
- * ```md
24
- * :storybook-embed{story="components-actions-button--default"}
25
- * :storybook-embed{story="components-forms-input--default" mode="panel"}
26
- * ```
27
- *
28
- * Three behaviours are always on, and all three degrade rather than fail:
29
- *
30
- * - **Lazy mount.** The iframe is not created until the embed comes near the
31
- * viewport, so a page with twenty embeds loads one Storybook, not twenty.
32
- * - **Theme sync.** The page's colour mode is posted to the story on load and
33
- * on every change, so an embed never sits light inside a dark page.
34
- * - **Auto-height.** The story reports what it needs and the frame follows.
35
- *
36
- * Theme and height both need `@uxfront/layer-docs/storybook` installed in the
37
- * Storybook config. Without it the frame keeps `height` (or the mode's
38
- * default) and the story renders in its own theme — degraded, not broken.
39
- */
40
- interface Props {
41
- /** Storybook story id, e.g. `components-actions-button--default`. */
42
- story: string;
43
- /**
44
- * Framework substituted into the base URL's `{framework}` placeholder.
45
- * Defaults to the reader's selected framework, so an embed inside a
46
- * `FrameworkSwitcher` tab needs it and a standalone one does not.
47
- */
48
- framework?: string;
49
- /** Storybook surface to embed. */
50
- mode?: StorybookEmbedMode;
51
- /**
52
- * Fixed height. Set this and auto-height is off — the story's own report is
53
- * ignored, which is what you want for a story whose height oscillates.
54
- */
55
- height?: number | string;
56
- /** Caption for the browser frame. Omit to render the story unframed. */
57
- title?: string;
58
- }
59
-
60
- const props = withDefaults(defineProps<Props>(), {
61
- framework: undefined,
62
- mode: "preview",
63
- height: undefined,
64
- title: undefined,
65
- });
66
-
67
- const config = useRuntimeConfig();
68
- const storybookBaseUrl = computed(() => (config.public.storybookBaseUrl as string) ?? "");
69
- const messageNames = computed(() =>
70
- docsEmbedMessageNames(config.public.storybookLegacyMessageNamespace as string),
71
- );
72
-
73
- const { framework: selectedFramework } = useFramework();
74
- const framework = computed(() => props.framework ?? selectedFramework.value);
75
-
76
- const colorMode = useColorMode();
77
- const containerRef = useTemplateRef<HTMLElement>("container");
78
- const iframeRef = useTemplateRef<HTMLIFrameElement>("iframe");
79
-
80
- // Mount the iframe just before it is scrolled to, then stop observing — the
81
- // answer cannot change back.
82
- const visible = ref(false);
83
- const { stop } = useIntersectionObserver(
84
- containerRef,
85
- ([entry]) => {
86
- if (!entry?.isIntersecting) return;
87
- visible.value = true;
88
- stop();
89
- },
90
- { rootMargin: "200px" },
91
- );
92
-
93
- const src = computed(() =>
94
- buildStorybookEmbedUrl({
95
- template: storybookBaseUrl.value,
96
- story: props.story,
97
- framework: framework.value,
98
- mode: props.mode,
99
- }),
100
- );
101
-
102
- const reportedHeight = ref<number | null>(null);
103
- const cssHeight = computed(() =>
104
- storybookEmbedCssHeight(
105
- props.height ?? reportedHeight.value ?? STORYBOOK_EMBED_DEFAULT_HEIGHTS[props.mode],
106
- ),
107
- );
108
-
109
- // An iframe is an unlabelled frame to a screen reader. The caption names it
110
- // when there is one; otherwise say what it holds rather than leaving it silent.
111
- const frameTitle = computed(
112
- () => props.title ?? `Storybook preview: ${props.story.replace(/-+/g, " ")}`,
113
- );
114
-
115
- const wrapper = computed(() => (props.title ? BrowserFrame : "div"));
116
- const wrapperProps = computed(() => (props.title ? { title: props.title } : {}));
117
-
118
- function sendTheme() {
119
- const target = iframeRef.value?.contentWindow;
120
- if (!target) return;
121
-
122
- const theme = colorMode.value === "dark" ? "dark" : "light";
123
- for (const type of messageNames.value.theme) {
124
- target.postMessage({ type, theme }, "*");
125
- }
126
- }
127
-
128
- function onMessage(event: MessageEvent) {
129
- // Scope by source, not by origin: the Storybook host is consumer-configured
130
- // and may differ per framework, but only one window can be this iframe.
131
- if (event.source !== iframeRef.value?.contentWindow) return;
132
-
133
- const height = readDocsEmbedHeight(event.data, messageNames.value.height);
134
- if (height !== null) reportedHeight.value = height;
135
- }
136
-
137
- onMounted(() => window.addEventListener("message", onMessage));
138
- onUnmounted(() => window.removeEventListener("message", onMessage));
139
-
140
- watch(() => colorMode.value, sendTheme);
141
- </script>
142
-
143
- <template>
144
- <component :is="wrapper" v-bind="wrapperProps">
145
- <div
146
- ref="container"
147
- class="storybook-embed border border-default rounded overflow-hidden min-h-25 resize-y transition-[height] duration-150 ease-out motion-reduce:transition-none"
148
- :style="{ height: cssHeight }"
149
- >
150
- <iframe
151
- v-if="visible"
152
- ref="iframe"
153
- :src="src"
154
- :title="frameTitle"
155
- class="block w-full h-full border-0"
156
- @load="sendTheme"
157
- />
158
- </div>
159
- </component>
160
- </template>