@poveste/app 0.20.0 → 0.21.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 (39) hide show
  1. package/dist/bundled/app.css +55 -27
  2. package/dist/bundled/components/layout/LayoutModal.vue_vue_type_script_setup_true_lang.js +44 -5
  3. package/dist/bundled/components/panel/ComponentDocs.js +1 -1
  4. package/dist/bundled/components/panel/ComponentDocs.vue_vue_type_script_setup_true_lang.js +72 -40
  5. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +34 -10
  6. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  7. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +2 -2
  8. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  9. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +5 -3
  10. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  11. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +5 -3
  12. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +3 -2
  13. package/dist/bundled/index.js +5 -1
  14. package/dist/bundled/sandbox.js +2 -0
  15. package/dist/bundled/util/component-docs-data.js +5 -1
  16. package/dist/bundled/util/component-docs.js +90 -14
  17. package/dist/bundled/util/dark.js +3 -1
  18. package/dist/bundled/util/docs.js +12 -2
  19. package/dist/bundled/util/highlighter.js +1 -1
  20. package/dist/bundled/util/icons.generated.js +1 -0
  21. package/dist/bundled/util/keyboard.js +5 -4
  22. package/dist/style.css +317 -150
  23. package/package.json +7 -7
  24. package/src/app/components/layout/LayoutModal.vue +55 -2
  25. package/src/app/components/panel/ComponentDocs.vue +83 -17
  26. package/src/app/components/panel/StoryDocs.vue +30 -3
  27. package/src/app/components/panel/StorySourceCode.vue +2 -2
  28. package/src/app/components/story/StoryResponsivePreview.vue +4 -1
  29. package/src/app/components/story/StoryVariantGridItem.vue +4 -1
  30. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +6 -4
  31. package/src/app/index.ts +12 -3
  32. package/src/app/sandbox.ts +5 -0
  33. package/src/app/util/component-docs-data.ts +6 -3
  34. package/src/app/util/component-docs.ts +102 -11
  35. package/src/app/util/dark.ts +6 -0
  36. package/src/app/util/docs.ts +18 -2
  37. package/src/app/util/highlighter.ts +2 -1
  38. package/src/app/util/icons.generated.ts +1 -1
  39. package/src/app/util/keyboard.ts +8 -4
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@poveste/app",
3
3
  "type": "module",
4
- "version": "0.20.0",
4
+ "version": "0.21.0",
5
5
  "description": "Pre-bundled UI for Poveste",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan",
@@ -36,9 +36,9 @@
36
36
  "node": ">=22.22.2"
37
37
  },
38
38
  "dependencies": {
39
- "@poveste/controls": "^0.20.0",
40
- "@poveste/shared": "^0.20.0",
41
- "@poveste/vendors": "^0.20.0",
39
+ "@poveste/controls": "^0.21.0",
40
+ "@poveste/shared": "^0.21.0",
41
+ "@poveste/vendors": "^0.21.0",
42
42
  "fuse.js": "^7.0.0",
43
43
  "shiki": "^4.5.0"
44
44
  },
@@ -61,13 +61,13 @@
61
61
  "fs-extra": "^11.4.1",
62
62
  "globby": "^16.2.4",
63
63
  "pathe": "^2.0.3",
64
- "pinia": "^4.0.3",
65
- "postcss": "^8.5.23",
64
+ "pinia": "^4.1.0",
65
+ "postcss": "^8.5.31",
66
66
  "postcss-cli": "^12.0.0",
67
67
  "tailwindcss": "^4.3.3",
68
68
  "typescript": "6.0.3",
69
69
  "vite": "^8.3.4",
70
- "vitest": "^5.0.4",
70
+ "vitest": "^5.0.5",
71
71
  "vue": "^3.5.44",
72
72
  "vue-router": "^5.4.0",
73
73
  "vue-tsc": "^3.3.13"
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { Icon } from '@iconify/vue'
3
+ import { nextTick, ref, watch } from 'vue'
3
4
  import { onKeyboardShortcut } from '../../util/keyboard'
4
5
  import LayoutPane from './LayoutPane.vue'
5
6
 
@@ -23,6 +24,48 @@ onKeyboardShortcut(['escape'], () => {
23
24
  close()
24
25
  }
25
26
  })
27
+
28
+ // A dialog to the keyboard as well: focus moves in when it opens, Tab stays inside,
29
+ // and closing hands focus back to whatever opened it, the layout chip or the page
30
+ // behind a shortcut. Focus used to stay on the chip, behind the pane (#1372).
31
+ const panel = ref<HTMLElement>()
32
+ let opener: HTMLElement | null = null
33
+
34
+ const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
35
+
36
+ function focusables() {
37
+ return [...panel.value?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []]
38
+ .filter(element => !element.hasAttribute('disabled') && element.getClientRects().length > 0)
39
+ }
40
+
41
+ watch(() => props.shown, async (shown) => {
42
+ if (shown) {
43
+ opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
44
+ await nextTick()
45
+ focusables()[0]?.focus()
46
+ }
47
+ else if (opener?.isConnected) {
48
+ opener.focus()
49
+ opener = null
50
+ }
51
+ })
52
+
53
+ function keepFocusInside(event: KeyboardEvent) {
54
+ const elements = focusables()
55
+ const first = elements[0]
56
+ const last = elements.at(-1)
57
+ if (!first || !last) {
58
+ return
59
+ }
60
+ if (event.shiftKey && document.activeElement === first) {
61
+ event.preventDefault()
62
+ last.focus()
63
+ }
64
+ else if (!event.shiftKey && document.activeElement === last) {
65
+ event.preventDefault()
66
+ first.focus()
67
+ }
68
+ }
26
69
  </script>
27
70
 
28
71
  <template>
@@ -35,9 +78,19 @@ onKeyboardShortcut(['escape'], () => {
35
78
  class="absolute inset-0"
36
79
  @click="close()"
37
80
  />
38
- <div class="bg-white dark:bg-gray-900 md:mt-16 md:mx-auto w-screen max-w-[512px] shadow-xl border border-gray-200 dark:border-gray-750 rounded-lg relative divide-y divide-gray-200 dark:divide-gray-850">
81
+ <div
82
+ ref="panel"
83
+ role="dialog"
84
+ aria-modal="true"
85
+ aria-labelledby="poveste-layout-modal-title"
86
+ class="bg-white dark:bg-gray-900 md:mt-16 md:mx-auto w-screen max-w-[512px] shadow-xl border border-gray-200 dark:border-gray-750 rounded-lg relative divide-y divide-gray-200 dark:divide-gray-850"
87
+ @keydown.tab="keepFocusInside"
88
+ >
39
89
  <div class="flex items-center px-6 py-3 text-gray-900 dark:text-gray-100">
40
- <h2 class="flex-1 text-sm font-medium m-0">
90
+ <h2
91
+ id="poveste-layout-modal-title"
92
+ class="flex-1 text-sm font-medium m-0"
93
+ >
41
94
  Customize Layout
42
95
  </h2>
43
96
  <button
@@ -1,12 +1,17 @@
1
1
  <script lang="ts" setup>
2
2
  import type { DocProp } from '@poveste/shared'
3
3
  import type { ComponentDocsEntry } from '../../util/component-docs'
4
- import { tagHref, tagText } from '../../util/component-docs'
4
+ import { computed } from 'vue'
5
+ import { modelLabel, OPEN_PARTS, rootFirst, tagHref, tagText } from '../../util/component-docs'
5
6
 
6
- defineProps<{
7
+ const props = defineProps<{
7
8
  components: ComponentDocsEntry[]
9
+ /** The story's file, which names the family whose root opens first. */
10
+ storyFile?: string | undefined
8
11
  }>()
9
12
 
13
+ const ordered = computed(() => rootFirst(props.components, props.storyFile))
14
+
10
15
  function shownDefault(prop: DocProp) {
11
16
  return prop.default ?? prop.defaultTag
12
17
  }
@@ -17,15 +22,22 @@ function shownDefault(prop: DocProp) {
17
22
  class="poveste-component-docs"
18
23
  data-testid="component-docs"
19
24
  >
20
- <section
21
- v-for="component of components"
25
+ <details
26
+ v-for="(component, index) of ordered"
22
27
  :key="component.file"
28
+ :open="ordered.length <= OPEN_PARTS || index === 0"
23
29
  data-slot="component"
24
30
  :data-component="component.name"
25
31
  >
26
- <h3 data-slot="component-name">
27
- {{ component.name }}
28
- </h3>
32
+ <summary>
33
+ <h3 data-slot="component-name">
34
+ {{ component.name }}<code
35
+ v-if="component.doc?.generic"
36
+ title="The component's type parameters, as its script declares them"
37
+ data-slot="generic"
38
+ >&lt;{{ component.doc.generic }}&gt;</code>
39
+ </h3>
40
+ </summary>
29
41
 
30
42
  <p
31
43
  v-if="component.error"
@@ -61,6 +73,11 @@ function shownDefault(prop: DocProp) {
61
73
  title="Required"
62
74
  data-slot="required"
63
75
  >*</span>
76
+ <code
77
+ v-if="modelLabel(component.doc, prop.name)"
78
+ :title="`Bound two ways with ${modelLabel(component.doc, prop.name)}, through the update:${prop.name} event`"
79
+ data-slot="model"
80
+ >{{ modelLabel(component.doc, prop.name) }}</code>
64
81
  </td>
65
82
  <td><code>{{ prop.type }}</code></td>
66
83
  <td>
@@ -114,15 +131,26 @@ function shownDefault(prop: DocProp) {
114
131
  </tr>
115
132
  </thead>
116
133
  <tbody>
117
- <tr
134
+ <template
118
135
  v-for="slot of component.doc.slots"
119
136
  :key="slot.name"
120
- :data-slot-name="slot.name"
121
137
  >
122
- <td><code>{{ slot.name }}</code></td>
123
- <td><code v-if="slot.type">{{ slot.type }}</code></td>
124
- <td>{{ slot.description }}</td>
125
- </tr>
138
+ <tr :data-slot-name="slot.name">
139
+ <td><code>{{ slot.name }}</code></td>
140
+ <!-- Its props are listed below, one row each; the type string would only repeat them. -->
141
+ <td><code v-if="slot.type && !slot.props?.length">{{ slot.type }}</code></td>
142
+ <td>{{ slot.description }}</td>
143
+ </tr>
144
+ <tr
145
+ v-for="slotProp of slot.props ?? []"
146
+ :key="`${slot.name}.${slotProp.name}`"
147
+ :data-slot-prop="slotProp.name"
148
+ >
149
+ <td><code>{{ slotProp.name }}</code></td>
150
+ <td><code>{{ slotProp.type }}</code></td>
151
+ <td>{{ slotProp.description }}</td>
152
+ </tr>
153
+ </template>
126
154
  </tbody>
127
155
  </table>
128
156
 
@@ -143,14 +171,21 @@ function shownDefault(prop: DocProp) {
143
171
  :key="event.name"
144
172
  :data-event="event.name"
145
173
  >
146
- <td><code>{{ event.name }}</code></td>
174
+ <td>
175
+ <code>{{ event.name }}</code>
176
+ <code
177
+ v-if="modelLabel(component.doc, event.name)"
178
+ :title="`Bound two ways with ${modelLabel(component.doc, event.name)}, with the ${event.name.slice('update:'.length)} prop`"
179
+ data-slot="model"
180
+ >{{ modelLabel(component.doc, event.name) }}</code>
181
+ </td>
147
182
  <td><code v-if="event.type">{{ event.type }}</code></td>
148
183
  <td>{{ event.description }}</td>
149
184
  </tr>
150
185
  </tbody>
151
186
  </table>
152
187
  </template>
153
- </section>
188
+ </details>
154
189
  </div>
155
190
  </template>
156
191
 
@@ -166,14 +201,19 @@ function shownDefault(prop: DocProp) {
166
201
  }
167
202
  }
168
203
 
169
- section + section {
204
+ details + details {
170
205
  margin-top: 1.5rem;
171
206
  }
172
207
 
208
+ summary {
209
+ cursor: pointer;
210
+ margin-bottom: .5rem;
211
+ }
212
+
173
213
  h3 {
214
+ display: inline;
174
215
  font-weight: 600;
175
216
  font-size: 1rem;
176
- margin-bottom: .5rem;
177
217
  }
178
218
 
179
219
  table {
@@ -221,11 +261,37 @@ pre code {
221
261
  text-decoration: line-through;
222
262
  }
223
263
 
264
+ /* Its own line: a constraint wrapped beside the name broke mid-phrase in the narrow panel. */
265
+ /* A prop a slot passes its content, under the slot it belongs to. */
266
+ [data-slot-prop] td:first-child {
267
+ padding-inline-start: 1rem;
268
+ }
269
+
270
+ [data-slot='generic'] {
271
+ display: block;
272
+ width: fit-content;
273
+ margin-top: .25rem;
274
+ font-size: .75rem;
275
+ font-weight: 400;
276
+ cursor: help;
277
+ }
278
+
224
279
  [data-slot='required'] {
225
280
  color: var(--color-red-500);
226
281
  margin-inline-start: .125rem;
227
282
  }
228
283
 
284
+ [data-slot='model'] {
285
+ display: inline-block;
286
+ margin-inline-start: .375rem;
287
+ color: var(--color-primary-600);
288
+ cursor: help;
289
+
290
+ &:where(.ptw-dark, .ptw-dark *) {
291
+ color: var(--color-primary-400);
292
+ }
293
+ }
294
+
229
295
  [data-slot='deprecated'],
230
296
  [data-slot='default-conflict'] {
231
297
  color: var(--color-amber-600);
@@ -5,7 +5,7 @@ import { Icon } from '@iconify/vue'
5
5
  import { markdownFiles } from 'virtual:$poveste-markdown-files'
6
6
  import { computed, nextTick, ref, toRefs, watch, watchEffect } from 'vue'
7
7
  import { useRoute, useRouter } from 'vue-router'
8
- import { useComponentDocs } from '../../util/component-docs.js'
8
+ import { onlySections, useComponentDocs } from '../../util/component-docs.js'
9
9
  import { povesteConfig } from '../../util/config.js'
10
10
  import BaseEmpty from '../base/BaseEmpty.vue'
11
11
  import DevOnlyToolbarOpenInEditor from '../toolbar/DevOnlyToolbarOpenInEditor.vue'
@@ -79,7 +79,8 @@ const { story } = toRefs(props)
79
79
  const { renderedDoc } = useStoryDoc(story)
80
80
 
81
81
  // Opening the docs is what starts auto-docs extraction (#1159).
82
- const { components } = useComponentDocs(computed(() => story.value?.id))
82
+ const { components: allComponents, pending, off } = useComponentDocs(computed(() => story.value?.id))
83
+ const components = computed(() => allComponents.value && onlySections(allComponents.value, story.value?.autoDocs))
83
84
 
84
85
  // Markdown links to other stories
85
86
  const router = useRouter()
@@ -189,8 +190,33 @@ const filePath = computed(() => story.value.file?.docsFilePath ?? (props.standal
189
190
  />
190
191
  </div>
191
192
 
193
+ <!--
194
+ The same empty panel said "still reading" and "off, for a reason" alike (#1400).
195
+ A built book's data is a chunk away, so it shows nothing until then, not a spinner.
196
+ -->
192
197
  <BaseEmpty
193
- v-if="!renderedDoc && !components?.length"
198
+ v-if="__POVESTE_DEV__ && !renderedDoc && !components?.length && pending"
199
+ data-testid="docs-pending"
200
+ >
201
+ <Icon
202
+ icon="carbon:circle-dash"
203
+ class="w-8 h-8 opacity-50 mb-6 animate-spin"
204
+ />
205
+ Reading component types…
206
+ <span class="text-sm opacity-70 mt-2">The first time can take a while.</span>
207
+ </BaseEmpty>
208
+ <BaseEmpty
209
+ v-else-if="!pending && !renderedDoc && !components?.length && off"
210
+ data-testid="docs-off"
211
+ >
212
+ <Icon
213
+ icon="carbon:document-unknown"
214
+ class="w-8 h-8 opacity-50 mb-6"
215
+ />
216
+ {{ off }}
217
+ </BaseEmpty>
218
+ <BaseEmpty
219
+ v-else-if="!pending && !renderedDoc && !components?.length"
194
220
  >
195
221
  <Icon
196
222
  icon="carbon:document-unknown"
@@ -208,6 +234,7 @@ const filePath = computed(() => story.value.file?.docsFilePath ?? (props.standal
208
234
  <ComponentDocs
209
235
  v-if="components?.length"
210
236
  :components="components"
237
+ :story-file="story.file?.filePath"
211
238
  />
212
239
  </div>
213
240
  </template>
@@ -6,7 +6,7 @@ import { Icon } from '@iconify/vue'
6
6
  import { HstCopyIcon, HstTooltip } from '@poveste/controls'
7
7
  import { computed, markRaw, nextTick, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
8
8
  import { isDark } from '../../util/dark'
9
- import { getDynamicSourceCode, getStaticSourceCode, loadGenerateSourceCode } from '../../util/docs'
9
+ import { getDynamicSourceCode, getStaticSourceCode, loadGenerateSourceCode, sourceLang } from '../../util/docs'
10
10
  import { getHighlighter } from '../../util/highlighter'
11
11
  import BaseEmpty from '../base/BaseEmpty.vue'
12
12
 
@@ -112,7 +112,7 @@ onMounted(async () => {
112
112
 
113
113
  const sourceHtml = computed(() => displayedSourceCode.value
114
114
  ? highlighter.value?.codeToHtml(displayedSourceCode.value, {
115
- lang: 'html',
115
+ lang: sourceLang(props.story),
116
116
  theme: isDark.value ? 'github-dark' : 'github-light-high-contrast',
117
117
  })
118
118
  : '')
@@ -6,6 +6,7 @@ import { HstTooltip } from '@poveste/controls'
6
6
  import { useEventListener } from '@vueuse/core'
7
7
  import { computed, onUnmounted, ref } from 'vue'
8
8
  import { usePreviewSettingsStore } from '../../stores/preview-settings'
9
+ import { usePreviewDark } from '../../util/color-scheme'
9
10
  import CheckerboardPattern from '../misc/CheckerboardPattern.vue'
10
11
  import HatchedPattern from '../misc/HatchedPattern.vue'
11
12
 
@@ -21,6 +22,7 @@ const props = withDefaults(defineProps<{
21
22
  })
22
23
 
23
24
  const settings = usePreviewSettingsStore().currentSettings
25
+ const previewDark = usePreviewDark(settings)
24
26
 
25
27
  // Resize
26
28
 
@@ -183,7 +185,8 @@ const sizeTooltip = computed(() => `${responsiveWidth.value ?? 'Auto'} × ${resp
183
185
  // padding (#264). Gated on `responsive`, not `isResponsiveEnabled`:
184
186
  // a single-view story with `responsiveDisabled` still needs its own
185
187
  // chrome, it just loses the draggers.
186
- responsive ? 'bg-white dark:bg-gray-700' : '',
188
+ // The preview's scheme, not the chrome's, which `dark:` would follow (#1449).
189
+ responsive ? (previewDark ? 'bg-gray-700' : 'bg-white') : '',
187
190
  isResponsiveEnabled ? {
188
191
  'w-fit': !!finalWidth,
189
192
  'h-fit': !!finalHeight,
@@ -148,8 +148,11 @@ const useIframe = computed(() => {
148
148
 
149
149
  <!-- Body -->
150
150
  <div
151
- class="border bg-white dark:bg-gray-700 rounded flex-1 p-4 relative"
151
+ class="border rounded flex-1 p-4 relative"
152
152
  :class="{
153
+ // The preview's scheme, not the chrome's, which `dark:` would follow (#1449).
154
+ 'bg-gray-700': previewDark,
155
+ 'bg-white': !previewDark,
153
156
  'border-gray-100 dark:border-gray-800': !isActive,
154
157
  'border-primary-200 dark:border-primary-900': isActive,
155
158
  }"
@@ -50,7 +50,7 @@ function postState(changes: Record<string, any>) {
50
50
  iframe.value?.contentWindow?.postMessage({
51
51
  type: STATE_SYNC,
52
52
  state: changes,
53
- })
53
+ }, window.location.origin)
54
54
  }
55
55
 
56
56
  let bridge = createStateBridge(postState)
@@ -105,9 +105,11 @@ function fromCurrentOccupant(data: { storyId?: string, variantId?: string }) {
105
105
  useEventListener(window, 'message', (event) => {
106
106
  // With many grid iframes mounted, every sandbox postMessage hits every
107
107
  // parent listener. Skip events that didn't originate from this iframe.
108
- // Paired with the origin check in `sandbox.ts`, which guards the other
109
- // direction (#379).
110
108
  if (event.source !== iframe.value?.contentWindow) return
109
+ // And from the book's own origin: the frame keeps its window when a story
110
+ // navigates it off-site, and the site it lands on could post here (#1494).
111
+ // The other direction is `sandbox.ts`'s check (#379).
112
+ if (event.origin !== window.location.origin) return
111
113
  switch (event.data?.type) {
112
114
  case STATE_SYNC:
113
115
  if (!fromCurrentOccupant(event.data)) break
@@ -279,7 +281,7 @@ function syncSettings() {
279
281
  iframe.value.contentWindow.postMessage({
280
282
  type: PREVIEW_SETTINGS_SYNC,
281
283
  settings: toRaw(settings),
282
- })
284
+ }, window.location.origin)
283
285
  }
284
286
  }
285
287
 
package/src/app/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { addCollection } from '@iconify/vue'
2
2
  import { createPinia } from 'pinia'
3
- import { generation } from 'virtual:$poveste-stories'
3
+ import { generation, onUpdate, icons as storyIcons } from 'virtual:$poveste-stories'
4
4
  import { createApp, watch } from 'vue'
5
5
  import App from './App.vue'
6
6
  import { setupPluginApi } from './plugin.js'
@@ -13,11 +13,20 @@ import 'virtual:$poveste-theme'
13
13
  export async function mountMainApp() {
14
14
  // The chrome's icon data ships with the app. Without this every Icon fetched
15
15
  // its data from api.iconify.design at runtime — a handful of CDN requests per
16
- // boot, and no toolbar offline (#219). Story icons a user sets still resolve
17
- // through the runtime lookup.
16
+ // boot, and no toolbar offline (#219).
18
17
  for (const collection of iconCollections) {
19
18
  addCollection(collection)
20
19
  }
20
+ // And the icons a book's stories name, where the book has the collection
21
+ // installed; the rest still resolve through the runtime lookup (#255).
22
+ for (const collection of storyIcons) {
23
+ addCollection(collection)
24
+ }
25
+ onUpdate((_files: unknown, _tree: unknown, icons: typeof storyIcons = []) => {
26
+ for (const collection of icons) {
27
+ addCollection(collection)
28
+ }
29
+ })
21
30
 
22
31
  // The chrome's own scheme, for the browser: it paints the chrome's native
23
32
  // controls and scrollbars, and reads no class (#991). Here rather than in the
@@ -18,6 +18,8 @@ import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } fro
18
18
  import { createStateBridge } from './util/state-bridge.js'
19
19
  import { toRawDeep } from './util/state.js'
20
20
  import { isBenignResizeObserverEvent } from './util/uncaught.js'
21
+ // The book's `theme.colors`, under the private names the controls' tokens read (#1443).
22
+ import 'virtual:$poveste-theme'
21
23
 
22
24
  const query = parseQuery(window.location.search)
23
25
 
@@ -115,6 +117,9 @@ watch(previewDark, (value) => {
115
117
  el.classList.toggle(className, value)
116
118
  }
117
119
  }
120
+ // A builtin control in the story keys its dark rules on `.ptw-dark`, so it follows
121
+ // the preview it sits in, not the chrome around it (#1443).
122
+ document.documentElement.classList.toggle('ptw-dark', value)
118
123
  }, {
119
124
  immediate: true,
120
125
  })
@@ -1,3 +1,6 @@
1
- // What `poveste build` extracted, in a chunk of its own: loaded when a reader first
2
- // opens a story's docs, so a book whose docs nobody opens never downloads it.
3
- export { componentDocs } from 'virtual:$poveste-component-docs'
1
+ import type { ComponentDoc } from '@poveste/shared'
2
+ import { componentDocs as data } from 'virtual:$poveste-component-docs'
3
+
4
+ // What `poveste build` extracted: a small map from each story to its components, each
5
+ // in a chunk of its own, so a reader downloads only the tables of the stories they open (#1429).
6
+ export const componentDocs: Record<string, Record<string, () => Promise<{ default: { doc: ComponentDoc } }>>> = data
@@ -1,11 +1,11 @@
1
- import type { ComponentDoc, DocTag } from '@poveste/shared'
1
+ import type { AutoDocsSection, ComponentDoc, DocTag } from '@poveste/shared'
2
2
  import type { Ref } from 'vue'
3
3
  import { onBeforeUnmount, ref, watch } from 'vue'
4
4
 
5
5
  export interface ComponentDocsEntry {
6
6
  /** Relative to the book's root. */
7
7
  file: string
8
- /** The file name without its extension. */
8
+ /** The component's own name, else the file name without its extension. */
9
9
  name: string
10
10
  doc?: ComponentDoc
11
11
  error?: string
@@ -13,18 +13,78 @@ export interface ComponentDocsEntry {
13
13
 
14
14
  interface StoryDocsResult {
15
15
  storyId: string
16
- components: Record<string, { doc?: ComponentDoc, error?: string }>
16
+ components: Record<string, { doc?: ComponentDoc, error?: string, name?: string }>
17
+ /** Why auto-docs is off for this story, as the terminal says it. */
18
+ off?: string
17
19
  }
18
20
 
19
21
  export function toEntries(result: StoryDocsResult): ComponentDocsEntry[] {
20
- return Object.entries(result.components).map(([file, { doc, error }]) => ({
22
+ return Object.entries(result.components).map(([file, { doc, error, name }]) => ({
21
23
  file,
22
- name: file.split('/').pop()!.replace(/\.[^.]+$/, ''),
24
+ // A JSX story's result is keyed by the story file, so the extractor names the component,
25
+ // and an unnamed one, such as an inline function, goes by the story's file name.
26
+ name: doc?.name ?? name ?? file.split('/').pop()!.replace(/(?:\.story)?\.[^.]+$/, ''),
23
27
  ...doc ? { doc } : {},
24
28
  ...error ? { error } : {},
25
29
  }))
26
30
  }
27
31
 
32
+ /**
33
+ * Only the sections a story's `autoDocs` names, in the table's own order. A component
34
+ * left with nothing to show is dropped; one that failed keeps its error (#1398).
35
+ */
36
+ export function onlySections(entries: ComponentDocsEntry[], autoDocs: boolean | AutoDocsSection[] | undefined): ComponentDocsEntry[] {
37
+ if (!Array.isArray(autoDocs)) {
38
+ return entries
39
+ }
40
+ return entries.flatMap((entry) => {
41
+ if (!entry.doc) {
42
+ return [entry]
43
+ }
44
+ const doc = {
45
+ ...entry.doc,
46
+ props: autoDocs.includes('props') ? entry.doc.props : [],
47
+ slots: autoDocs.includes('slots') ? entry.doc.slots : [],
48
+ events: autoDocs.includes('events') ? entry.doc.events : [],
49
+ }
50
+ return doc.props.length || doc.slots.length || doc.events.length ? [{ ...entry, doc }] : []
51
+ })
52
+ }
53
+
54
+ /** Above this many, a story's components after the first start collapsed: a compound story can name 27 parts (#1432). */
55
+ export const OPEN_PARTS = 3
56
+
57
+ /**
58
+ * A compound component's root first, `SelectRoot` before `SelectTrigger`, else the order
59
+ * they came in. A story rendering two families, such as a Select inside a Calendar, opens
60
+ * the root its own folder or file is named for: `Calendar/story/CalendarSelect.story.vue`
61
+ * opens `CalendarRoot`, not the `SelectRoot` it imported first.
62
+ */
63
+ export function rootFirst(entries: ComponentDocsEntry[], storyFile?: string): ComponentDocsEntry[] {
64
+ const roots = entries.filter(entry => entry.name.endsWith('Root'))
65
+ const family = (entry: ComponentDocsEntry) => entry.name.slice(0, -'Root'.length)
66
+ const segments = storyFile?.split(/[\\/]/) ?? []
67
+ const fileName = segments.at(-1) ?? ''
68
+ const root = roots.find(entry => segments.slice(0, -1).includes(family(entry)))
69
+ ?? roots.find(entry => fileName.startsWith(family(entry)))
70
+ ?? roots[0]
71
+ return root ? [root, ...entries.filter(entry => entry !== root)] : entries
72
+ }
73
+
74
+ /**
75
+ * The `v-model` a prop and its `update:` event make together, as a template writes it:
76
+ * `v-model` for `modelValue`, `v-model:open` for `open` (#1436). Asked with either name,
77
+ * so the two rows of a pair, one under Props and one under Events, carry the same mark.
78
+ */
79
+ export function modelLabel(doc: ComponentDoc, name: string): string | undefined {
80
+ const prop = name.startsWith('update:') ? name.slice('update:'.length) : name
81
+ const paired = doc.props.some(candidate => candidate.name === prop) && doc.events.some(event => event.name === `update:${prop}`)
82
+ if (!paired) {
83
+ return undefined
84
+ }
85
+ return prop === 'modelValue' ? 'v-model' : `v-model:${prop}`
86
+ }
87
+
28
88
  export function tagText(tags: DocTag[], name: string): string | undefined {
29
89
  const tag = tags.find(tag => tag.name === name)
30
90
  return tag ? (tag.text ?? '') : undefined
@@ -42,24 +102,53 @@ export function tagHref(text: string): string | undefined {
42
102
  */
43
103
  export function useComponentDocs(storyId: Ref<string | undefined>) {
44
104
  const components = ref<ComponentDocsEntry[]>()
105
+ // Asked and not yet answered: the first answer can take many seconds (#1400).
106
+ const pending = ref(false)
107
+ const off = ref<string>()
45
108
  const hot = import.meta.hot
46
109
  if (!hot) {
47
- watch(storyId, async (id) => {
48
- const { componentDocs } = await import('./component-docs-data')
49
- const docs = id ? componentDocs[id] : undefined
50
- components.value = id && docs ? toEntries({ storyId: id, components: docs }) : undefined
110
+ // The chunk loads on first use, and until then the tab said there were no docs (#1400).
111
+ watch(storyId, async (id, _, onCleanup) => {
112
+ // Another story opened while these loaded.
113
+ let stale = false
114
+ onCleanup(() => stale = true)
115
+ pending.value = true
116
+ try {
117
+ const { componentDocs } = await import('./component-docs-data')
118
+ const loaders = id ? componentDocs[id] : undefined
119
+ const docs = loaders && Object.fromEntries(await Promise.all(Object.entries(loaders).map(async ([file, load]) => [file, (await load()).default])))
120
+ if (!stale) {
121
+ components.value = id && docs ? toEntries({ storyId: id, components: docs }) : undefined
122
+ }
123
+ }
124
+ catch (error) {
125
+ // A chunk the server no longer has, as in a tab left open across a deploy:
126
+ // no table, rather than a tab that waits forever.
127
+ if (!stale) {
128
+ components.value = undefined
129
+ console.error('[poveste] could not load this story\'s component docs:', error)
130
+ }
131
+ }
132
+ finally {
133
+ if (!stale) {
134
+ pending.value = false
135
+ }
136
+ }
51
137
  }, { immediate: true })
52
- return { components }
138
+ return { components, pending, off }
53
139
  }
54
140
 
55
141
  function ask() {
56
142
  if (storyId.value) {
143
+ pending.value = true
57
144
  hot!.send('poveste:docgen-request', { storyId: storyId.value })
58
145
  }
59
146
  }
60
147
  function onResult(result: StoryDocsResult) {
61
148
  if (result.storyId === storyId.value) {
62
149
  components.value = toEntries(result)
150
+ off.value = result.off
151
+ pending.value = false
63
152
  }
64
153
  }
65
154
 
@@ -71,8 +160,10 @@ export function useComponentDocs(storyId: Ref<string | undefined>) {
71
160
  })
72
161
  watch(storyId, () => {
73
162
  components.value = undefined
163
+ off.value = undefined
164
+ pending.value = false
74
165
  ask()
75
166
  }, { immediate: true })
76
167
 
77
- return { components }
168
+ return { components, pending, off }
78
169
  }