@uxfront/layer-docs 0.4.1 → 0.6.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 (100) hide show
  1. package/README.md +42 -213
  2. package/app/app.config.ts +8 -92
  3. package/app/components/app/AppHeaderLeft.vue +84 -0
  4. package/app/components/content/FrameworkSwitcher.vue +66 -40
  5. package/app/components/docs/DocsAsideLeftTop.vue +9 -15
  6. package/app/components/docs/DocsFrameworkSelect.vue +4 -7
  7. package/app/composables/useFramework.ts +43 -38
  8. package/nuxt.config.ts +16 -170
  9. package/package.json +11 -77
  10. package/CHANGELOG.md +0 -192
  11. package/LICENSE +0 -21
  12. package/app/app.vue +0 -138
  13. package/app/assets/css/main.css +0 -15
  14. package/app/components/IconMenuToggle.vue +0 -92
  15. package/app/components/LanguageSelect.vue +0 -73
  16. package/app/components/MorphingGradientBackground.vue +0 -261
  17. package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
  18. package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
  19. package/app/components/app/AppFooter.vue +0 -13
  20. package/app/components/app/AppFooterCenter.vue +0 -17
  21. package/app/components/app/AppFooterLeft.vue +0 -21
  22. package/app/components/app/AppFooterRight.vue +0 -33
  23. package/app/components/app/AppHeader.vue +0 -123
  24. package/app/components/app/AppHeaderAttribution.vue +0 -45
  25. package/app/components/app/AppHeaderBody.vue +0 -14
  26. package/app/components/app/AppHeaderCTA.vue +0 -31
  27. package/app/components/app/AppHeaderCenter.vue +0 -10
  28. package/app/components/app/AppHeaderLogo.vue +0 -16
  29. package/app/components/app/AppOgDecoration.vue +0 -27
  30. package/app/components/app/AppOgLogo.vue +0 -19
  31. package/app/components/app/AppSearch.vue +0 -59
  32. package/app/components/app/AppSubHeader.vue +0 -21
  33. package/app/components/content/BrowserFrame.vue +0 -28
  34. package/app/components/content/GradientPageHero.vue +0 -35
  35. package/app/components/content/StorybookEmbed.vue +0 -160
  36. package/app/components/content/Video.vue +0 -103
  37. package/app/components/docs/DocsAsideLeftBody.vue +0 -20
  38. package/app/components/docs/DocsAsideRightBottom.vue +0 -15
  39. package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
  40. package/app/composables/useDocsSections.ts +0 -57
  41. package/app/composables/useDocusI18n.ts +0 -49
  42. package/app/constants/sections.ts +0 -25
  43. package/app/error.vue +0 -140
  44. package/app/layouts/default.vue +0 -24
  45. package/app/pages/[[lang]]/[...slug].vue +0 -58
  46. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
  47. package/app/plugins/i18n.ts +0 -21
  48. package/app/plugins/posthog.client.ts +0 -56
  49. package/app/types/non-route-categories.ts +0 -12
  50. package/app/utils/flattenNavigation.ts +0 -22
  51. package/app/utils/foldNonRouteCategories.ts +0 -47
  52. package/app/utils/prerender.ts +0 -9
  53. package/app/utils/storybookEmbed.test.ts +0 -98
  54. package/app/utils/storybookEmbed.ts +0 -93
  55. package/i18n/locales/ar.json +0 -24
  56. package/i18n/locales/be.json +0 -24
  57. package/i18n/locales/bn.json +0 -24
  58. package/i18n/locales/ca.json +0 -24
  59. package/i18n/locales/ckb.json +0 -24
  60. package/i18n/locales/cs.json +0 -24
  61. package/i18n/locales/da.json +0 -24
  62. package/i18n/locales/de.json +0 -24
  63. package/i18n/locales/el.json +0 -24
  64. package/i18n/locales/en.json +0 -24
  65. package/i18n/locales/et.json +0 -24
  66. package/i18n/locales/fr.json +0 -24
  67. package/i18n/locales/he.json +0 -24
  68. package/i18n/locales/hi.json +0 -24
  69. package/i18n/locales/hy.json +0 -24
  70. package/i18n/locales/it.json +0 -24
  71. package/i18n/locales/ja.json +0 -24
  72. package/i18n/locales/kk.json +0 -24
  73. package/i18n/locales/km.json +0 -24
  74. package/i18n/locales/ko.json +0 -24
  75. package/i18n/locales/ky.json +0 -24
  76. package/i18n/locales/lb.json +0 -24
  77. package/i18n/locales/ms.json +0 -24
  78. package/i18n/locales/nb.json +0 -24
  79. package/i18n/locales/pl.json +0 -24
  80. package/i18n/locales/ru.json +0 -24
  81. package/i18n/locales/sl.json +0 -24
  82. package/i18n/locales/sv.json +0 -24
  83. package/i18n/locales/uk.json +0 -24
  84. package/i18n/locales/ur.json +0 -24
  85. package/i18n/locales/vi.json +0 -24
  86. package/modules/config.ts +0 -144
  87. package/modules/optimizeDeps.ts +0 -45
  88. package/modules/routing.ts +0 -20
  89. package/nuxt.schema.ts +0 -374
  90. package/server/plugins/llms-redirect.ts +0 -60
  91. package/server/routes/raw/[...slug].md.get.ts +0 -74
  92. package/storybook/index.test.ts +0 -110
  93. package/storybook/index.ts +0 -362
  94. package/test/brand-palette.ts +0 -235
  95. package/test/no-brand-leakage.test.ts +0 -124
  96. package/tsconfig.json +0 -17
  97. package/utils/accent.ts +0 -80
  98. package/utils/content.ts +0 -193
  99. package/utils/git.ts +0 -114
  100. package/utils/meta.ts +0 -28
package/README.md CHANGED
@@ -1,252 +1,81 @@
1
1
  # @uxfront/layer-docs
2
2
 
3
- A neutral, brandable **Nuxt layer** documentation theme. Extend it, supply your
4
- own branding and content, and get a Docus-shaped docs site — header, footer,
5
- sidebar, SEO, i18n routing, OG images and analytics — without copying boilerplate
6
- between repos.
7
-
8
- The layer ships **no product branding**. Consuming apps provide title, logos,
9
- socials, GitHub, footer, table-of-contents links and colour palette via their own
10
- `app.config.ts`, merged over the layer's neutral defaults by Nuxt's `defu` layer
11
- merge. Site metadata, SEO and git info are auto-inferred from the consumer's
12
- `package.json` + git when not supplied.
3
+ The Nuxt layer for UXFront's documentation sites. It extends [Docus](https://docus.dev), which renders the markdown in `content/docs/` with a header, sidebar, search and table of contents, and adds a framework switcher: every reader sees the examples for their framework, on every page. It can also set the header's site name the way the UXFront homepages do, signed "by UXFront".
13
4
 
14
5
  ## Install
15
6
 
16
- ```bash
17
- npm install -D @uxfront/layer-docs
7
+ ```sh
8
+ pnpm add @uxfront/layer-docs
18
9
  ```
19
10
 
20
- Then install the peer dependencies the layer expects (the Nuxt documentation
21
- stack — see [`package.json`](./package.json) `peerDependencies` for pinned
22
- ranges): `nuxt`, `vue`, `@nuxt/ui`, `@nuxt/image`, `@nuxt/scripts`,
23
- `@nuxtjs/robots`, `nuxt-og-image`, `satori`, `@resvg/resvg-js`, `nuxt-llms`,
24
- `tailwindcss`, and — for content and translation — `@nuxt/content`,
25
- `@nuxtjs/i18n`, `@nuxtjs/mdc`.
26
-
27
- ## Usage
28
-
29
- Extend the layer from your app's `nuxt.config.ts`:
30
-
31
11
  ```ts
12
+ // nuxt.config.ts
32
13
  export default defineNuxtConfig({
33
14
  extends: ["@uxfront/layer-docs"],
34
15
  });
35
16
  ```
36
17
 
37
- Supply your branding from your app's `app.config.ts`:
18
+ Then list the frameworks the docs' examples come in, in display order. The first is the default:
38
19
 
39
20
  ```ts
21
+ // app/app.config.ts
40
22
  export default defineAppConfig({
41
- header: { title: "My Docs" },
42
- // logos, socials, footer, toc links, ui.colors …
43
- });
44
- ```
45
-
46
- ### Brand attribution
47
-
48
- `header.attribution` renders an optional credit beside the wordmark, where only
49
- the `label` is a link:
50
-
51
- ```ts
52
- export default defineAppConfig({
53
- header: {
54
- title: "uxd",
55
- attribution: { prefix: "by", label: "UXFront", to: "https://uxfront.com" },
23
+ docsTheme: {
24
+ frameworks: [
25
+ { value: "react", label: "React", icon: "i-simple-icons-react" },
26
+ { value: "vue", label: "Vue", icon: "i-simple-icons-vuedotjs" },
27
+ { value: "svelte", label: "Svelte", icon: "i-simple-icons-svelte" },
28
+ ],
56
29
  },
57
30
  });
58
31
  ```
59
32
 
60
- That renders `uxd by UXFront` — the wordmark still links home, `by` is plain
61
- text, and `UXFront` links out. Omit `attribution` (or leave `label` empty) to
62
- render the wordmark alone.
63
-
64
- ## Styling
65
-
66
- **The consumer owns the single Tailwind entry.** The layer ships a palette-free
67
- Tailwind base at `@uxfront/layer-docs/app/assets/css/main.css` and deliberately
68
- does not register it itself. A brand `@theme` only compiles into real `:root`
69
- custom properties when it lives inside a Tailwind pass, so your CSS file has to
70
- _import_ the layer base rather than sit beside it as a second entry — two
71
- entries each re-emit every base utility into the shipped stylesheet.
72
-
73
- Register one CSS entry:
74
-
75
- ```ts
76
- // nuxt.config.ts
77
- export default defineNuxtConfig({
78
- extends: ["@uxfront/layer-docs"],
79
- css: ["./app/assets/css/main.css"],
80
- });
81
- ```
82
-
83
- whose first line imports the layer base, followed by your palette:
84
-
85
- ```css
86
- /* app/assets/css/main.css */
87
- @import "@uxfront/layer-docs/app/assets/css/main.css";
33
+ `value` is the slot name pages write each framework's examples in, and `icon` any [Iconify](https://icones.js.org) icon. The layer ships no default list, so with none, the switcher and the select render nothing.
88
34
 
89
- /* The layer's own `@source` paths are package-relative (they resolve inside
90
- node_modules), so re-scan your content and config for utility classes. */
91
- @source "../../../content/**/*";
92
- @source "../../app.config.ts";
35
+ ## The header wordmark
93
36
 
94
- @theme static {
95
- --color-teal: hsl(189, 53%, 41%);
96
- /* … the rest of the scale … */
97
- }
98
-
99
- :root {
100
- --ui-primary: var(--color-teal);
101
- }
102
- ```
103
-
104
- The scale name must match `ui.colors.primary` in your `app.config.ts` for Nuxt
105
- UI to resolve component variants onto it.
106
-
107
- ### Guardrail tests
108
-
109
- Both halves of that contract fail silently, so the layer ships the guards.
110
- `vitest` is an optional peer dependency, needed only for these.
111
-
112
- ```ts
113
- // test/brand-palette-css.test.ts — source-level, no build required
114
- import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
115
-
116
- describeBrandPaletteCss({
117
- entry: new URL("../app/assets/css/main.css", import.meta.url),
118
- scale: "teal",
119
- });
120
- ```
37
+ Docus prints the site name in the header as plain text. Set `docsTheme.wordmark` to write it the way the UXFront homepages do, one part bold, and `docsTheme.byline` to sign it "by UXFront", linked to uxfront.com:
121
38
 
122
39
  ```ts
123
- // test/brand-palette-compiled.build.test.ts — requires a prior `nuxt build`
124
- import { describeSingleTailwindPass } from "@uxfront/layer-docs/test";
125
-
126
- describeSingleTailwindPass({
127
- output: new URL("../.output/public/_nuxt", import.meta.url),
128
- scale: "teal",
40
+ // app/app.config.ts
41
+ export default defineAppConfig({
42
+ docsTheme: {
43
+ // **Open**Components
44
+ wordmark: { bold: "Open", regular: "Components" },
45
+ byline: true,
46
+ },
129
47
  });
130
48
  ```
131
49
 
132
- The first asserts your CSS entry is a Tailwind entry and defines the palette.
133
- The second reads the built stylesheet and asserts every base utility is emitted
134
- **exactly once** — catching both a duplicate Tailwind pass (double payload) and
135
- a CSS entry that never reached the bundle (no base utilities at all). Run it in
136
- whichever CI job already builds the app; it needs `.output/` on disk.
137
-
138
- ## Storybook embeds
139
-
140
- `StorybookEmbed` renders a deployed Storybook story inside a docs page. Point it
141
- at your Storybook once, from env — the host is a deployment fact, not a code
142
- fact, so it lives in runtime config rather than `app.config`:
143
-
144
- ```bash
145
- # a single Storybook
146
- NUXT_PUBLIC_STORYBOOK_BASE_URL="https://storybook.example.com"
147
-
148
- # or one per framework — `{framework}` is substituted per embed
149
- NUXT_PUBLIC_STORYBOOK_BASE_URL="https://{framework}.storybook.example.com"
150
- ```
151
-
152
- Then use it from markdown:
153
-
154
- ```mdc
155
- :storybook-embed{story="components-actions-button--default"}
156
-
157
- :storybook-embed{story="components-actions-button--default" mode="panel"}
158
-
159
- :storybook-embed{story="components-actions-button--default" title="Button"}
160
- ```
161
-
162
- | Prop | Default | Notes |
163
- | ----------- | ---------------- | --------------------------------------------------------------------------------------------------- |
164
- | `story` | — | Storybook story id, e.g. `components-actions-button--default`. |
165
- | `framework` | `useFramework()` | Fills `{framework}` in the base URL. Follows the tab by default. |
166
- | `mode` | `"preview"` | `preview` (canvas only) · `full` (manager, no sidebar) · `panel` (manager with the controls panel). |
167
- | `height` | mode default | Number (px) or any CSS length. Set it and auto-height is off. |
168
- | `title` | — | Wraps the embed in `BrowserFrame` with this title. |
169
-
170
- The embed mounts its iframe only once it is 200px from the viewport, keeps the
171
- Storybook's colour mode in sync with the page's, and grows to fit its story.
172
- Height is always reserved, so none of that shifts layout.
173
-
174
- ### The Storybook side
175
-
176
- Auto-height and theme sync are a two-way `postMessage` contract, so the
177
- Storybook has to answer. Install the bridge — it is framework-free and imports
178
- nothing from Nuxt:
179
-
180
- ```ts
181
- // .storybook/preview.ts
182
- import { installDocsEmbedPreviewBridge } from "@uxfront/layer-docs/storybook";
183
-
184
- installDocsEmbedPreviewBridge({ onTheme: (theme) => applyTheme(theme) });
185
- ```
186
-
187
- ```ts
188
- // .storybook/manager.ts — only needed for `full` / `panel`
189
- import { installDocsEmbedManagerBridge } from "@uxfront/layer-docs/storybook";
190
-
191
- installDocsEmbedManagerBridge({ onTheme: (theme) => applyTheme(theme) });
192
- ```
50
+ The wordmark links home, and its accessible name is Docus's `header.title`, or the site name. The byline sits beside the link, not inside it, and phones leave it out so the header's buttons keep their room. With neither set, the header shows Docus's own title or logo.
193
51
 
194
- Without the bridge the embed still renders; it just holds its default height and
195
- does not follow the page's colour mode.
52
+ ## Writing examples
196
53
 
197
- ### Migrating an existing Storybook
54
+ Put one slot per framework in a `::framework-switcher`:
198
55
 
199
- If your Storybook already speaks a branded namespace (`<brand>:theme`,
200
- `<brand>:height`), name it and both sides accept **and** emit both spellings, so
201
- the docs site and the Storybook can deploy in either order:
56
+ ````md
57
+ ::framework-switcher
58
+ #react
202
59
 
203
- ```bash
204
- NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE="acme"
60
+ ```tsx [Button.tsx]
61
+ export const Button = () => <button>Save</button>;
205
62
  ```
206
63
 
207
- Unset it once both sides are on `@uxfront/layer-docs/storybook`.
208
-
209
- ## OG images
64
+ #vue
210
65
 
211
- Social share cards are on by default — the layer registers `nuxt-og-image` and
212
- calls `defineOgImage` from the docs and landing pages, so a consumer that sets up
213
- the CSS entry above gets branded 1200×630 cards without writing anything. Opt out
214
- with the module's own switch: `ogImage: { enabled: false }`.
215
-
216
- The accent is resolved to a **literal colour at build time** by following
217
- `--ui-primary` to the `--color-<scale>` value it aliases in your CSS entry.
218
- Satori — the renderer behind `.satori.vue` templates — has no CSS cascade and no
219
- custom properties, so `var(--ui-primary)` would render as nothing, and a utility
220
- class like `text-teal-500` would render Tailwind's teal rather than yours (your
221
- `@theme` shadows the stock scale name). Override it with a literal if you need
222
- something other than the brand colour:
223
-
224
- ```ts
225
- export default defineAppConfig({
226
- ogImage: { accent: "#a78bfa" },
227
- });
66
+ ```vue [Button.vue]
67
+ <template><button>Save</button></template>
228
68
  ```
229
69
 
230
- Two pieces of the card are components rather than props, because `defineOgImage`
231
- serialises its arguments and a Vue slot cannot cross that boundary. Shadow either
232
- by creating a file at the same path in your own `app/components/`:
233
-
234
- | Component | Default |
235
- | ------------------------- | ----------------------------------- |
236
- | `app/AppOgDecoration.vue` | A neutral radial-gradient flourish |
237
- | `app/AppOgLogo.vue` | A text wordmark from `header.title` |
238
-
239
- The wordmark is text, not `AppHeaderLogo`: that component renders
240
- `UColorModeImage`, and satori has neither a colour mode nor a browser to resolve
241
- relative image paths against. A brand that wants its mark on the card overrides
242
- `AppOgLogo.vue` with an `<img>` pointing at an **absolute** URL.
243
-
244
- ## Compatibility
70
+ ::
71
+ ````
245
72
 
246
- Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
247
- i18n 10.4, og-image 6, llms 0.2). TypeScript is pinned to the range the Nuxt
248
- stack supports (`^6.0.3`). See `peerDependencies` for the authoritative ranges.
73
+ ## What it adds
249
74
 
250
- ## License
75
+ - **`FrameworkSwitcher`.** One tab per framework, on Nuxt UI's tabs, so it follows the WAI-ARIA tabs pattern (arrow keys between tabs, panels labelled by their tab). A page doesn't have to cover every framework: a missing one shows the first one the page has, with a note saying so. When the tabs outgrow the width, the list scrolls.
76
+ - **A Framework select above the sidebar.** It replaces Docus's `DocsAsideLeftTop` and renders Docus's below it.
77
+ - **A header wordmark and byline.** It replaces Docus's `AppHeaderLeft`, and renders Docus's unless the app sets a wordmark.
78
+ - **`useFramework()`.** The reader's pick, shared by every switcher and the select, and kept in `localStorage` across visits. It's read once the page is mounted, so the prerendered HTML shows the default framework and hydrates cleanly.
79
+ - **Bundled icons.** Nuxt Icon bundles the icons named in app config too, so the select's icons don't come from the Iconify API.
251
80
 
252
- [MIT](./LICENSE)
81
+ Everything else is Docus's: configure it as its [docs](https://docus.dev) describe.
package/app/app.config.ts CHANGED
@@ -1,96 +1,12 @@
1
- export default defineAppConfig({
2
- /**
3
- * Neutral shell defaults. This layer ships NO product branding — consuming
4
- * apps supply title, logos, socials, GitHub, footer, TOC links and palette
5
- * via their own `app.config.ts` (merged over these defaults by Nuxt's `defu`
6
- * layer merge). `modules/config.ts` also fills `seo`/`header`/`github` from
7
- * the consumer's `package.json` + git as fallbacks.
8
- *
9
- * @docs https://www.docus.dev/concepts/configuration#global-configuration
10
- */
11
- toc: {
12
- // Title of the main table of contents
13
- title: "On this page",
14
- },
1
+ import type { FrameworkOption } from "./composables/useFramework";
15
2
 
16
- header: {
17
- /**
18
- * Optional brand attribution rendered beside the header wordmark.
19
- * `title: "uxd"` plus `{ prefix: "by", label: "UXFront", to: "https://uxfront.com" }`
20
- * renders "uxd by UXFront" with only "UXFront" linked. An empty `label`
21
- * renders nothing, which is the neutral default.
22
- */
23
- attribution: {
24
- prefix: "",
25
- label: "",
26
- to: "",
27
- },
28
- },
3
+ // Empty, so an app that lists none gets no framework switcher or select. Nuxt
4
+ // concatenates app config arrays across layers, so a list here would be added
5
+ // to the app's instead of replaced by it.
6
+ const frameworks: FrameworkOption[] = [];
29
7
 
30
- /**
31
- * Opt-out flags for the layer's client plugins. Defaults keep them on so an
32
- * existing consumer is unchanged; a consumer opts out by setting `false`.
33
- */
34
- analytics: {
35
- // PostHog analytics plugin (production only, requires a runtime key).
36
- enabled: true,
37
- },
38
- i18nRedirect: {
39
- // Redirect `/` to `/{locale}` (only fires when i18n is configured).
40
- enabled: true,
41
- },
42
-
43
- ui: {
44
- // Palette-free type discriminant — NOT branding. Nuxt UI's wide
45
- // `AppConfigUI` type (which permits the component slot overrides below)
46
- // only applies to `ui` when a `colors` key is present; without it the
47
- // narrow `nuxt.schema.ts` Studio type wins and the slots fail
48
- // excess-property checks. The empty object bakes in no palette; each
49
- // consumer supplies the real `colors` (merged over this by `defu`).
50
- colors: {},
51
- // Neutral Nuxt UI Pro component polish — reusable shell defaults every
52
- // consumer inherits, not product branding.
53
- commandPalette: {
54
- slots: {
55
- input: "[&_.iconify]:size-4 [&_.iconify]:mx-0.5",
56
- itemLeadingIcon: "size-4 mx-0.5",
57
- },
58
- },
59
- contentNavigation: {
60
- slots: {
61
- trigger: "font-normal text-muted data-[state=open]:text-muted cursor-pointer",
62
- linkLeadingIcon: "size-4 mr-1",
63
- linkTrailing: "hidden",
64
- },
65
- compoundVariants: [
66
- {
67
- variant: "link",
68
- active: false,
69
- disabled: false,
70
- class: {
71
- linkLeadingIcon: "group-data-[state=open]:text-dimmed",
72
- },
73
- },
74
- ],
75
- defaultVariants: {
76
- variant: "link",
77
- },
78
- },
79
- pageLinks: {
80
- slots: {
81
- linkLeadingIcon: "size-4",
82
- linkLabelExternalIcon: "size-2.5",
83
- },
84
- },
85
- pageCard: {
86
- slots: {
87
- root: "rounded-xl",
88
- },
89
- },
90
- pricingTable: {
91
- slots: {
92
- tierTitle: "text-highlighted text-2xl sm:text-3xl text-pretty font-semibold",
93
- },
94
- },
8
+ export default defineAppConfig({
9
+ docsTheme: {
10
+ frameworks,
95
11
  },
96
12
  });
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ // Replaces Docus's, which prints the site name as plain text, to set the app's
3
+ // wordmark (`docsTheme.wordmark`) and sign it "by UXFront" (`docsTheme.byline`),
4
+ // the way the UXFront homepage headers do. With neither, it renders Docus's own.
5
+ import UxFrontMark from "@uxfront/ui/components/icons/UxFrontMark.vue";
6
+ import DocusAppHeaderLeft from "docus/app/components/app/AppHeaderLeft.vue";
7
+
8
+ const appConfig = useAppConfig();
9
+ const site = useSiteConfig();
10
+ const { localePath } = useDocusI18n();
11
+
12
+ const wordmark = computed(() => appConfig.docsTheme.wordmark);
13
+ const ariaLabel = computed(() => appConfig.header?.title || site.name);
14
+ </script>
15
+
16
+ <template>
17
+ <div class="docs-header-left">
18
+ <NuxtLink v-if="wordmark" :to="localePath('/')" :aria-label="ariaLabel" class="docs-wordmark">
19
+ <strong>{{ wordmark.bold }}</strong
20
+ >{{ wordmark.regular }}
21
+ </NuxtLink>
22
+ <DocusAppHeaderLeft v-else />
23
+ <!-- Beside the home link, not inside it, since it holds a link of its own. -->
24
+ <span v-if="appConfig.docsTheme.byline" class="docs-byline">
25
+ by
26
+ <a href="https://uxfront.com">
27
+ <UxFrontMark />
28
+ <span><strong>UX</strong>Front</span>
29
+ </a>
30
+ </span>
31
+ </div>
32
+ </template>
33
+
34
+ <style scoped>
35
+ /* Styled here rather than with Tailwind utilities: Docus's stylesheet doesn't
36
+ list this layer among Tailwind's sources, so only utilities Docus happens to
37
+ use itself would be generated. */
38
+ .docs-header-left {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 0.75rem;
42
+ min-width: 0;
43
+ }
44
+
45
+ .docs-wordmark {
46
+ flex-shrink: 0;
47
+ }
48
+
49
+ /* Phones leave the byline out: beside the header's buttons, it pushes the menu
50
+ toggle off-screen. */
51
+ .docs-byline {
52
+ display: none;
53
+ align-items: center;
54
+ gap: 0.375rem;
55
+ font-size: 0.75rem;
56
+ line-height: 1rem;
57
+ white-space: nowrap;
58
+ color: var(--ui-text-muted);
59
+ }
60
+
61
+ .docs-byline a {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 0.25rem;
65
+ font-weight: 500;
66
+ color: var(--ui-text-toned);
67
+ transition: color 0.15s;
68
+ }
69
+
70
+ .docs-byline a:hover {
71
+ color: var(--ui-text-highlighted);
72
+ }
73
+
74
+ .docs-byline svg {
75
+ width: 0.875rem;
76
+ height: 0.875rem;
77
+ }
78
+
79
+ @media (min-width: 40rem) {
80
+ .docs-byline {
81
+ display: inline-flex;
82
+ }
83
+ }
84
+ </style>
@@ -1,48 +1,74 @@
1
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
- );
2
+ import type { FrameworkOption } from "../../composables/useFramework";
8
3
 
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
- });
4
+ /**
5
+ * Shows the reader's framework out of a page's per-framework examples:
6
+ *
7
+ * ```md
8
+ * ::framework-switcher
9
+ * #react
10
+ * …
11
+ * #vue
12
+ * …
13
+ * ::
14
+ * ```
15
+ *
16
+ * One tab per framework in `docsTheme.frameworks`. Picking one here picks it
17
+ * everywhere, the sidebar's select included. Nuxt UI's tabs bring the WAI-ARIA
18
+ * tabs pattern: arrow keys move between tabs, and each panel is labelled by its tab.
19
+ */
20
+ const { framework, frameworks, current } = useFramework();
21
+ const slots = useSlots();
22
+ const tabs = useTemplateRef("tabs");
16
23
 
17
- function select(value: string) {
18
- framework.value = value;
24
+ // A page can cover only some frameworks. The others show the first one it covers.
25
+ function shown(option: FrameworkOption) {
26
+ return slots[option.value] ? option : frameworks.value.find((f) => slots[f.value]);
19
27
  }
28
+
29
+ // When the tabs outgrow the width, the list scrolls. A pick made elsewhere (in
30
+ // another switcher, in the sidebar, or restored once mounted) scrolls the list
31
+ // to its tab. scrollIntoView() would scroll the page to the switcher too.
32
+ watch(
33
+ current,
34
+ (option) => {
35
+ const index = option ? frameworks.value.indexOf(option) : -1;
36
+ const tab: HTMLElement | undefined = tabs.value?.triggersRef[index]?.$el;
37
+ const list = tab?.parentElement;
38
+ if (!tab || !list) return;
39
+ const end = tab.offsetLeft + tab.offsetWidth - list.clientWidth;
40
+ list.scrollLeft = Math.min(tab.offsetLeft, Math.max(list.scrollLeft, end));
41
+ },
42
+ { flush: "post" },
43
+ );
20
44
  </script>
21
45
 
22
46
  <template>
23
- <div class="framework-switcher">
24
- <div
25
- v-if="frameworks.length"
26
- class="framework-switcher-tabs flex flex-wrap gap-1 mb-3 border-b border-default"
27
- role="tablist"
28
- >
29
- <UButton
30
- v-for="option in frameworks"
31
- :key="option.value"
32
- :icon="option.icon"
33
- :label="option.label"
34
- :color="option.value === current?.value ? 'primary' : 'neutral'"
35
- variant="ghost"
36
- size="sm"
37
- role="tab"
38
- :aria-selected="option.value === current?.value"
39
- @click="select(option.value)"
40
- />
41
- </div>
42
- <p v-if="fallback" class="text-sm text-muted mb-2">
43
- Not available for {{ current?.label }} — showing {{ fallback.label }}.
44
- </p>
45
- <slot v-if="!fallback" :name="framework" />
46
- <slot v-else :name="fallback.value" />
47
- </div>
47
+ <UTabs
48
+ v-if="current"
49
+ ref="tabs"
50
+ :model-value="current.value"
51
+ :items="frameworks"
52
+ color="primary"
53
+ variant="link"
54
+ size="sm"
55
+ class="framework-switcher"
56
+ :ui="{
57
+ root: 'my-5 gap-4',
58
+ // Scrolls when the tabs outgrow the width. The baseline and the selected
59
+ // tab's underline are drawn inside the list, where scrolling doesn't clip them.
60
+ list: 'overflow-x-auto border-b-0 mb-0 shadow-[inset_0_-1px_0_var(--ui-border)]',
61
+ indicator: 'bottom-0',
62
+ trigger: 'shrink-0',
63
+ content: '*:first:mt-0 *:last:mb-0',
64
+ }"
65
+ @update:model-value="framework = String($event)"
66
+ >
67
+ <template #content="{ item }">
68
+ <p v-if="!slots[item.value] && shown(item)" class="mb-2 text-sm text-muted">
69
+ Not available for {{ item.label }}, showing {{ shown(item)!.label }}.
70
+ </p>
71
+ <slot v-if="shown(item)" :name="shown(item)!.value" />
72
+ </template>
73
+ </UTabs>
48
74
  </template>
@@ -1,24 +1,18 @@
1
1
  <script setup lang="ts">
2
- /**
3
- * Persistent framework selector above the docs sidebar navigation.
4
- *
5
- * Renders nothing unless the consumer declares `docsTheme.frameworks` in its
6
- * `app.config.ts`. The in-content `FrameworkSwitcher` shares the same state,
7
- * but per-page tabs alone scale poorly past a handful of frameworks — this is
8
- * the always-visible control for the same preference.
9
- */
2
+ // Replaces Docus's own to put the framework select above the sidebar navigation,
3
+ // and renders Docus's below it, so its section links still show when enabled.
4
+ import DocusAsideLeftTop from "docus/app/components/docs/DocsAsideLeftTop.vue";
5
+
10
6
  const { frameworks } = useFramework();
11
- const { t } = useDocusI18n();
7
+ const labelId = useId();
12
8
  </script>
13
9
 
14
10
  <template>
15
11
  <div v-if="frameworks.length">
16
- <span
17
- class="group relative w-full pr-2.5 py-1.5 flex items-center gap-1.5 text-sm text-muted cursor-default hover:text-highlighted transition-colors font-normal"
18
- >
19
- <span class="truncate">{{ t("docs.framework") }}</span>
12
+ <span :id="labelId" class="w-full pr-2.5 py-1.5 flex items-center text-sm text-muted">
13
+ Framework
20
14
  </span>
21
-
22
- <DocsFrameworkSelect class="mb-2" />
15
+ <DocsFrameworkSelect :aria-labelledby="labelId" class="mb-2" />
23
16
  </div>
17
+ <DocusAsideLeftTop />
24
18
  </template>
@@ -1,18 +1,15 @@
1
1
  <script setup lang="ts">
2
- const { framework, frameworks } = useFramework();
3
-
4
- const current = computed(
5
- () => frameworks.value.find((option) => option.value === framework.value) ?? frameworks.value[0],
6
- );
2
+ const { framework, frameworks, current } = useFramework();
7
3
  </script>
8
4
 
9
5
  <template>
10
6
  <USelect
11
- v-model="framework"
12
- :items="[...frameworks]"
7
+ :model-value="current?.value"
8
+ :items="frameworks"
13
9
  variant="ghost"
14
10
  color="neutral"
15
11
  :icon="current?.icon"
16
12
  class="w-full"
13
+ @update:model-value="framework = $event"
17
14
  />
18
15
  </template>