@uxfront/layer-docs 0.1.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 (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -0
  4. package/app/app.config.ts +82 -0
  5. package/app/app.vue +138 -0
  6. package/app/assets/css/main.css +15 -0
  7. package/app/components/IconMenuToggle.vue +79 -0
  8. package/app/components/LanguageSelect.vue +73 -0
  9. package/app/components/MorphingGradientBackground.vue +261 -0
  10. package/app/components/app/AppFooter.vue +13 -0
  11. package/app/components/app/AppFooterCenter.vue +17 -0
  12. package/app/components/app/AppFooterLeft.vue +21 -0
  13. package/app/components/app/AppFooterRight.vue +33 -0
  14. package/app/components/app/AppHeader.vue +105 -0
  15. package/app/components/app/AppHeaderBody.vue +14 -0
  16. package/app/components/app/AppHeaderCTA.vue +31 -0
  17. package/app/components/app/AppHeaderCenter.vue +10 -0
  18. package/app/components/app/AppHeaderLogo.vue +16 -0
  19. package/app/components/app/AppSearch.vue +59 -0
  20. package/app/components/app/AppSubHeader.vue +21 -0
  21. package/app/components/content/BrowserFrame.vue +28 -0
  22. package/app/components/content/FrameworkSwitcher.vue +47 -0
  23. package/app/components/content/Video.vue +103 -0
  24. package/app/components/docs/DocsAsideLeftBody.vue +20 -0
  25. package/app/components/docs/DocsAsideRightBottom.vue +15 -0
  26. package/app/components/docs/DocsPageHeaderLinks.vue +74 -0
  27. package/app/composables/useDocsSections.ts +50 -0
  28. package/app/composables/useDocusI18n.ts +49 -0
  29. package/app/composables/useFramework.ts +70 -0
  30. package/app/constants/sections.ts +25 -0
  31. package/app/error.vue +140 -0
  32. package/app/layouts/default.vue +23 -0
  33. package/app/pages/[[lang]]/[...slug].vue +48 -0
  34. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +171 -0
  35. package/app/plugins/i18n.ts +21 -0
  36. package/app/plugins/posthog.client.ts +31 -0
  37. package/app/types/non-route-categories.ts +12 -0
  38. package/app/utils/flattenNavigation.ts +22 -0
  39. package/app/utils/foldNonRouteCategories.ts +47 -0
  40. package/app/utils/prerender.ts +9 -0
  41. package/i18n/locales/en.json +22 -0
  42. package/modules/config.ts +122 -0
  43. package/modules/routing.ts +20 -0
  44. package/nuxt.config.ts +130 -0
  45. package/package.json +98 -0
  46. package/server/plugins/llms-redirect.ts +60 -0
  47. package/server/routes/raw/[...slug].md.get.ts +74 -0
  48. package/tsconfig.json +17 -0
  49. package/utils/content.ts +118 -0
  50. package/utils/git.ts +114 -0
  51. package/utils/meta.ts +28 -0
@@ -0,0 +1,261 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, ref } from "vue";
3
+
4
+ const currentX = ref(0);
5
+ const currentY = ref(0);
6
+ const mouseX = ref(0);
7
+ const mouseY = ref(0);
8
+
9
+ let animationId: number | null = null;
10
+
11
+ const interactiveBubbleStyle = computed(() => {
12
+ return {
13
+ transform: `translate(${Math.round(currentX.value)}px, ${Math.round(currentY.value)}px)`,
14
+ };
15
+ });
16
+
17
+ function move() {
18
+ currentX.value += (mouseX.value - currentX.value) / 20;
19
+ currentY.value += (mouseY.value - currentY.value) / 20;
20
+ animationId = requestAnimationFrame(move);
21
+ }
22
+
23
+ function handleMouseMove(event: MouseEvent) {
24
+ mouseX.value = event.clientX;
25
+ mouseY.value = event.clientY;
26
+ }
27
+
28
+ onMounted(() => {
29
+ move();
30
+ });
31
+
32
+ onBeforeUnmount(() => {
33
+ if (animationId) {
34
+ cancelAnimationFrame(animationId);
35
+ }
36
+ });
37
+ </script>
38
+
39
+ <template>
40
+ <div class="morphing-gradient-background">
41
+ <svg xmlns="http://www.w3.org/2000/svg">
42
+ <defs>
43
+ <filter id="goo">
44
+ <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
45
+ <feBlend in="SourceGraphic" in2="goo" />
46
+ </filter>
47
+ </defs>
48
+ </svg>
49
+ <div class="gradients-container">
50
+ <div class="g1"></div>
51
+ <div class="g2"></div>
52
+ <div class="g3"></div>
53
+ <div class="g4"></div>
54
+ <div class="g5"></div>
55
+ <div class="interactive" :style="interactiveBubbleStyle"></div>
56
+ </div>
57
+ <slot :onMousemove="handleMouseMove" />
58
+ </div>
59
+ </template>
60
+
61
+ <style scoped>
62
+ @keyframes moveInCircle {
63
+ 0% {
64
+ transform: rotate(0deg);
65
+ }
66
+ 50% {
67
+ transform: rotate(180deg);
68
+ }
69
+ 100% {
70
+ transform: rotate(360deg);
71
+ }
72
+ }
73
+
74
+ @keyframes moveVertical {
75
+ 0% {
76
+ transform: translateY(-50%);
77
+ }
78
+ 50% {
79
+ transform: translateY(50%);
80
+ }
81
+ 100% {
82
+ transform: translateY(-50%);
83
+ }
84
+ }
85
+
86
+ @keyframes moveHorizontal {
87
+ 0% {
88
+ transform: translateX(-50%) translateY(-10%);
89
+ }
90
+ 50% {
91
+ transform: translateX(50%) translateY(10%);
92
+ }
93
+ 100% {
94
+ transform: translateX(-50%) translateY(-10%);
95
+ }
96
+ }
97
+
98
+ .morphing-gradient-background {
99
+ --color-bg1: var(--ui-bg);
100
+ --color-bg2: var(--ui-bg);
101
+ --color1: 18, 113, 255;
102
+ --color2: 221, 74, 255;
103
+ --color3: 100, 220, 255;
104
+ --color4: 200, 50, 50;
105
+ --color5: 180, 180, 50;
106
+ --color-interactive: 140, 100, 255;
107
+ --circle-size: 80%;
108
+ --blending: hard-light;
109
+ --intensity: 0.2;
110
+
111
+ position: relative;
112
+ background: linear-gradient(40deg, var(--color-bg1), var(--color-bg2));
113
+ overflow: hidden;
114
+ min-height: 100%;
115
+ width: 100%;
116
+
117
+ .gradients-container {
118
+ filter: url(#goo) blur(40px);
119
+ width: 100%;
120
+ height: 100%;
121
+ position: absolute;
122
+ top: 0;
123
+ left: 0;
124
+ right: 0;
125
+ bottom: 0;
126
+ overflow: hidden;
127
+ pointer-events: none;
128
+ }
129
+
130
+ svg {
131
+ position: absolute;
132
+ top: 0;
133
+ left: 0;
134
+ width: 0;
135
+ height: 0;
136
+ }
137
+
138
+ .g1 {
139
+ position: absolute;
140
+ background: radial-gradient(
141
+ circle at center,
142
+ rgba(var(--color1), var(--intensity)) 0,
143
+ rgba(var(--color1), 0) 50%
144
+ )
145
+ no-repeat;
146
+ mix-blend-mode: var(--blending);
147
+
148
+ width: var(--circle-size);
149
+ height: var(--circle-size);
150
+ top: calc(50% - var(--circle-size) / 2);
151
+ left: calc(50% - var(--circle-size) / 2);
152
+
153
+ transform-origin: center center;
154
+ animation: moveVertical 30s ease infinite;
155
+
156
+ opacity: 1;
157
+ }
158
+
159
+ .g2 {
160
+ position: absolute;
161
+ background: radial-gradient(
162
+ circle at center,
163
+ rgba(var(--color2), var(--intensity)) 0,
164
+ rgba(var(--color2), 0) 50%
165
+ )
166
+ no-repeat;
167
+ mix-blend-mode: var(--blending);
168
+
169
+ width: var(--circle-size);
170
+ height: var(--circle-size);
171
+ top: calc(50% - var(--circle-size) / 2);
172
+ left: calc(50% - var(--circle-size) / 2);
173
+
174
+ transform-origin: calc(50% - 400px);
175
+ animation: moveInCircle 20s reverse infinite;
176
+
177
+ opacity: 1;
178
+ }
179
+
180
+ .g3 {
181
+ position: absolute;
182
+ background: radial-gradient(
183
+ circle at center,
184
+ rgba(var(--color3), var(--intensity)) 0,
185
+ rgba(var(--color3), 0) 50%
186
+ )
187
+ no-repeat;
188
+ mix-blend-mode: var(--blending);
189
+
190
+ width: var(--circle-size);
191
+ height: var(--circle-size);
192
+ top: calc(50% - var(--circle-size) / 2 + 200px);
193
+ left: calc(50% - var(--circle-size) / 2 - 500px);
194
+
195
+ transform-origin: calc(50% + 400px);
196
+ animation: moveInCircle 40s linear infinite;
197
+
198
+ opacity: 1;
199
+ }
200
+
201
+ .g4 {
202
+ position: absolute;
203
+ background: radial-gradient(
204
+ circle at center,
205
+ rgba(var(--color4), var(--intensity)) 0,
206
+ rgba(var(--color4), 0) 50%
207
+ )
208
+ no-repeat;
209
+ mix-blend-mode: var(--blending);
210
+
211
+ width: var(--circle-size);
212
+ height: var(--circle-size);
213
+ top: calc(50% - var(--circle-size) / 2);
214
+ left: calc(50% - var(--circle-size) / 2);
215
+
216
+ transform-origin: calc(50% - 200px);
217
+ animation: moveHorizontal 40s ease infinite;
218
+
219
+ opacity: 0.7;
220
+ }
221
+
222
+ .g5 {
223
+ position: absolute;
224
+ background: radial-gradient(
225
+ circle at center,
226
+ rgba(var(--color5), var(--intensity)) 0,
227
+ rgba(var(--color5), 0) 50%
228
+ )
229
+ no-repeat;
230
+ mix-blend-mode: var(--blending);
231
+
232
+ width: calc(var(--circle-size) * 2);
233
+ height: calc(var(--circle-size) * 2);
234
+ top: calc(50% - var(--circle-size));
235
+ left: calc(50% - var(--circle-size));
236
+
237
+ transform-origin: calc(50% - 800px) calc(50% + 200px);
238
+ animation: moveInCircle 20s ease infinite;
239
+
240
+ opacity: 1;
241
+ }
242
+
243
+ .interactive {
244
+ position: absolute;
245
+ background: radial-gradient(
246
+ circle at center,
247
+ rgba(var(--color-interactive), var(--intensity)) 0,
248
+ rgba(var(--color-interactive), 0) 50%
249
+ )
250
+ no-repeat;
251
+ mix-blend-mode: var(--blending);
252
+
253
+ width: 100%;
254
+ height: 100%;
255
+ top: -50%;
256
+ left: -50%;
257
+
258
+ opacity: 0.7;
259
+ }
260
+ }
261
+ </style>
@@ -0,0 +1,13 @@
1
+ <template>
2
+ <UFooter>
3
+ <template #left>
4
+ <AppFooterLeft />
5
+ </template>
6
+
7
+ <AppFooterCenter />
8
+
9
+ <template #right>
10
+ <AppFooterRight />
11
+ </template>
12
+ </UFooter>
13
+ </template>
@@ -0,0 +1,17 @@
1
+ <script lang="ts" setup>
2
+ import type { NavigationMenuItem } from "@nuxt/ui";
3
+
4
+ /**
5
+ * Footer link row. Brand/legal links (Privacy, Terms, ...) are consumer data,
6
+ * read from `footer.links` in `app.config.ts`. This layer ships none.
7
+ */
8
+ const appConfig = useAppConfig();
9
+
10
+ const links = computed<NavigationMenuItem[]>(
11
+ () => (appConfig.footer?.links as NavigationMenuItem[] | undefined) ?? [],
12
+ );
13
+ </script>
14
+
15
+ <template>
16
+ <UNavigationMenu v-if="links.length" orientation="horizontal" :items="links" />
17
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Footer credit line. The consumer supplies the full string via
4
+ * `footer.credits` in `app.config.ts` (rendered verbatim). When absent, a
5
+ * sensible default is built from the header title so a minimally-configured app
6
+ * still shows a credit.
7
+ */
8
+ const appConfig = useAppConfig();
9
+
10
+ const credit = computed(() => {
11
+ if (appConfig.footer?.credits) return appConfig.footer.credits;
12
+ const name = appConfig.header?.title;
13
+ return name ? `© ${new Date().getFullYear()} ${name}. All rights reserved.` : "";
14
+ });
15
+ </script>
16
+
17
+ <template>
18
+ <div v-if="credit" class="text-sm text-muted">
19
+ {{ credit }}
20
+ </div>
21
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ const appConfig = useAppConfig();
3
+
4
+ const links = computed(() =>
5
+ [
6
+ ...Object.entries(appConfig.socials || {}).map(([key, url]) => ({
7
+ icon: `i-simple-icons-${key}`,
8
+ to: url,
9
+ target: "_blank",
10
+ "aria-label": `${key} social link`,
11
+ })),
12
+ appConfig.github &&
13
+ appConfig.github.url && {
14
+ icon: "i-simple-icons-github",
15
+ to: appConfig.github.url,
16
+ target: "_blank",
17
+ "aria-label": "GitHub repository",
18
+ },
19
+ ].filter(Boolean),
20
+ );
21
+ </script>
22
+
23
+ <template>
24
+ <template v-if="links.length">
25
+ <UButton
26
+ v-for="(link, index) of links"
27
+ :key="index"
28
+ size="sm"
29
+ v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
30
+ />
31
+ </template>
32
+ <UColorModeButton />
33
+ </template>
@@ -0,0 +1,105 @@
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
+ <template #title>
56
+ <AppHeaderLogo class="h-6 w-auto shrink-0" />
57
+ </template>
58
+
59
+ <template #right>
60
+ <AppHeaderCTA class="max-sm:hidden" />
61
+
62
+ <template v-if="isEnabled && locales.length > 1">
63
+ <ClientOnly>
64
+ <LanguageSelect />
65
+
66
+ <template #fallback>
67
+ <div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
68
+ </template>
69
+ </ClientOnly>
70
+
71
+ <USeparator orientation="vertical" class="h-8" />
72
+ </template>
73
+
74
+ <UContentSearchButton class="lg:hidden" />
75
+
76
+ <ClientOnly>
77
+ <UColorModeButton />
78
+
79
+ <template #fallback>
80
+ <div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
81
+ </template>
82
+ </ClientOnly>
83
+
84
+ <template v-if="links?.length">
85
+ <UButton
86
+ v-for="(link, index) of links"
87
+ :key="index"
88
+ v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
89
+ />
90
+ </template>
91
+ </template>
92
+
93
+ <template #toggle="{ open, toggle }">
94
+ <IconMenuToggle :open="open" class="lg:hidden" @click="toggle" />
95
+ </template>
96
+
97
+ <template #body>
98
+ <AppHeaderBody />
99
+ </template>
100
+
101
+ <template v-if="isDocs" #bottom>
102
+ <AppSubHeader />
103
+ </template>
104
+ </UHeader>
105
+ </template>
@@ -0,0 +1,14 @@
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>
@@ -0,0 +1,31 @@
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>
@@ -0,0 +1,10 @@
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>
@@ -0,0 +1,16 @@
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>
@@ -0,0 +1,59 @@
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>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import type { NavigationMenuItem } from "@nuxt/ui";
3
+
4
+ const { sections } = 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 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>
@@ -0,0 +1,28 @@
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>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ const { framework, frameworks } = useFramework();
3
+ const slots = useSlots();
4
+
5
+ const current = computed(
6
+ () => frameworks.value.find((f) => f.value === framework.value) ?? frameworks.value[0],
7
+ );
8
+
9
+ // When the selected framework has no matching slot on this page, fall back to
10
+ // the first framework that does. Keeps a page authored for a subset of the
11
+ // consumer's framework list usable under an N-tab switcher.
12
+ const fallback = computed(() => {
13
+ if (slots[framework.value]) return null;
14
+ return frameworks.value.find((f) => slots[f.value]) ?? null;
15
+ });
16
+
17
+ function select(value: string) {
18
+ framework.value = value;
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <div class="framework-switcher">
24
+ <div
25
+ class="framework-switcher-tabs flex flex-wrap gap-1 mb-3 border-b border-default"
26
+ role="tablist"
27
+ >
28
+ <UButton
29
+ v-for="option in frameworks"
30
+ :key="option.value"
31
+ :icon="option.icon"
32
+ :label="option.label"
33
+ :color="option.value === current?.value ? 'primary' : 'neutral'"
34
+ variant="ghost"
35
+ size="sm"
36
+ role="tab"
37
+ :aria-selected="option.value === current?.value"
38
+ @click="select(option.value)"
39
+ />
40
+ </div>
41
+ <p v-if="fallback" class="text-sm text-muted mb-2">
42
+ Not available for {{ current?.label }} — showing {{ fallback.label }}.
43
+ </p>
44
+ <slot v-if="!fallback" :name="framework" />
45
+ <slot v-else :name="fallback.value" />
46
+ </div>
47
+ </template>