@poveste/app 0.13.1 → 0.15.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 (132) hide show
  1. package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +1 -1
  2. package/dist/bundled/app.css +31 -31
  3. package/dist/bundled/components/HomeView.vue_vue_type_script_setup_true_lang.js +1 -1
  4. package/dist/bundled/components/app/AppLogo.vue_vue_type_script_setup_true_lang.js +2 -2
  5. package/dist/bundled/components/app/Breadcrumb.js +1 -1
  6. package/dist/bundled/components/app/Breadcrumb.vue_vue_type_script_setup_true_lang.js +2 -2
  7. package/dist/bundled/components/app/TopBar.vue_vue_type_script_setup_true_lang.js +4 -3
  8. package/dist/bundled/components/app/TopBarChipItem.js +1 -1
  9. package/dist/bundled/components/base/BaseCheckbox.vue_vue_type_script_setup_true_lang.js +1 -1
  10. package/dist/bundled/components/base/BaseEmpty.js +2 -2
  11. package/dist/bundled/components/base/BaseOverflowMenu.vue_vue_type_script_setup_true_lang.js +18 -15
  12. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +158 -26
  13. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  14. package/dist/bundled/components/base/BaseSplitPane.vue_vue_type_script_setup_true_lang.js +6 -4
  15. package/dist/bundled/components/command/CommandPrompts.vue_vue_type_script_setup_true_lang.js +1 -1
  16. package/dist/bundled/components/command/CommandPromptsModal.vue_vue_type_script_setup_true_lang.js +4 -3
  17. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +2 -2
  18. package/dist/bundled/components/command/PromptText.vue_vue_type_script_setup_true_lang.js +1 -1
  19. package/dist/bundled/components/panel/ControlsComponentPropItem.vue_vue_type_script_setup_true_lang.js +20 -14
  20. package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +6 -6
  21. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +94 -55
  22. package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
  23. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +3 -1
  24. package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +5 -1
  25. package/dist/bundled/components/panel/StoryEvents.vue_vue_type_script_setup_true_lang.js +2 -1
  26. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +8 -6
  27. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  28. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +8 -6
  29. package/dist/bundled/components/search/SearchItemContent.vue_vue_type_script_setup_true_lang.js +2 -1
  30. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +16 -9
  31. package/dist/bundled/components/story/GenericMountStory.vue_vue_type_script_setup_true_lang.js +2 -1
  32. package/dist/bundled/components/story/GenericRenderStory.vue_vue_type_script_setup_true_lang.js +2 -1
  33. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  34. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +23 -8
  35. package/dist/bundled/components/story/StoryVariantGrid.js +1 -1
  36. package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +15 -10
  37. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  38. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +3 -3
  39. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +9 -6
  40. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +7 -3
  41. package/dist/bundled/components/story/StoryViewer.js +1 -1
  42. package/dist/bundled/components/story/StoryViewer.vue_vue_type_script_setup_true_lang.js +3 -3
  43. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +1 -1
  44. package/dist/bundled/components/toolbar/ToolbarResponsiveSize.vue_vue_type_script_setup_true_lang.js +17 -16
  45. package/dist/bundled/components/toolbar/ToolbarTitle.js +1 -1
  46. package/dist/bundled/components/toolbar/ToolbarTitle.vue_vue_type_script_setup_true_lang.js +1 -1
  47. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +2 -2
  48. package/dist/bundled/components/tree/StoryList.vue_vue_type_script_setup_true_lang.js +3 -3
  49. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  50. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +3 -3
  51. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  52. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  53. package/dist/bundled/plugin.js +4 -3
  54. package/dist/bundled/sandbox.js +5 -4
  55. package/dist/bundled/stores/folder.js +1 -1
  56. package/dist/bundled/stores/story.js +2 -2
  57. package/dist/bundled/util/commands.js +21 -10
  58. package/dist/bundled/util/dark.js +1 -1
  59. package/dist/bundled/util/docs.js +2 -1
  60. package/dist/bundled/util/events.js +1 -1
  61. package/dist/bundled/util/grid-slots.js +2 -2
  62. package/dist/bundled/util/icons.generated.js +0 -1
  63. package/dist/bundled/util/keyboard.js +9 -14
  64. package/dist/bundled/util/mapping.js +3 -3
  65. package/dist/bundled/util/preview-settings.js +1 -1
  66. package/dist/bundled/util/render-tracker.js +33 -0
  67. package/dist/bundled/util/variant.js +12 -2
  68. package/dist/style.css +23 -1
  69. package/package.json +14 -18
  70. package/src/__tests__/render-tracker.spec.ts +38 -0
  71. package/src/app/App.vue +7 -4
  72. package/src/app/components/HomeView.vue +1 -1
  73. package/src/app/components/app/AppLogo.vue +6 -2
  74. package/src/app/components/app/Breadcrumb.vue +2 -1
  75. package/src/app/components/app/TopBar.vue +7 -2
  76. package/src/app/components/app/TopBarChipItem.vue +1 -1
  77. package/src/app/components/base/BaseCheckbox.vue +1 -1
  78. package/src/app/components/base/BaseEmpty.vue +1 -1
  79. package/src/app/components/base/BaseListItemLink.vue +4 -2
  80. package/src/app/components/base/BaseOverflowMenu.vue +25 -18
  81. package/src/app/components/base/BaseSelect.vue +171 -15
  82. package/src/app/components/base/BaseSplitPane.vue +11 -6
  83. package/src/app/components/command/CommandPrompts.vue +1 -1
  84. package/src/app/components/command/CommandPromptsModal.vue +1 -0
  85. package/src/app/components/command/PromptSelect.vue +3 -2
  86. package/src/app/components/command/PromptText.vue +7 -3
  87. package/src/app/components/panel/ControlsComponentPropItem.vue +26 -15
  88. package/src/app/components/panel/PaneTabs.vue +6 -6
  89. package/src/app/components/panel/StatePresets.vue +125 -68
  90. package/src/app/components/panel/StoryControls.vue +1 -1
  91. package/src/app/components/panel/StoryDocs.vue +3 -1
  92. package/src/app/components/panel/StoryEvent.vue +7 -2
  93. package/src/app/components/panel/StoryEvents.vue +4 -1
  94. package/src/app/components/panel/StorySidePanel.vue +3 -1
  95. package/src/app/components/panel/StorySourceCode.vue +12 -8
  96. package/src/app/components/search/SearchItemContent.vue +2 -1
  97. package/src/app/components/search/SearchPane.vue +16 -8
  98. package/src/app/components/story/GenericMountStory.vue +2 -1
  99. package/src/app/components/story/GenericRenderStory.vue +2 -1
  100. package/src/app/components/story/StoryResponsivePreview.vue +36 -7
  101. package/src/app/components/story/StoryVariantGrid.vue +33 -16
  102. package/src/app/components/story/StoryVariantGridItem.vue +1 -1
  103. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +14 -8
  104. package/src/app/components/story/StoryView.vue +10 -4
  105. package/src/app/components/story/StoryViewer.vue +3 -3
  106. package/src/app/components/toolbar/ToolbarBackground.vue +1 -1
  107. package/src/app/components/toolbar/ToolbarResponsiveSize.vue +23 -3
  108. package/src/app/components/toolbar/ToolbarTitle.vue +2 -2
  109. package/src/app/components/tree/StoryGroup.vue +2 -2
  110. package/src/app/components/tree/StoryList.vue +2 -2
  111. package/src/app/components/tree/StoryListFolder.vue +2 -2
  112. package/src/app/components/tree/StoryListItem.vue +1 -1
  113. package/src/app/plugin.ts +7 -3
  114. package/src/app/sandbox.ts +9 -3
  115. package/src/app/stores/folder.ts +1 -1
  116. package/src/app/stores/story.ts +2 -2
  117. package/src/app/types.ts +6 -6
  118. package/src/app/util/commands.ts +27 -17
  119. package/src/app/util/dark.ts +1 -1
  120. package/src/app/util/docs.ts +2 -1
  121. package/src/app/util/env.ts +0 -1
  122. package/src/app/util/events.ts +6 -5
  123. package/src/app/util/grid-slots.ts +2 -2
  124. package/src/app/util/icons.generated.ts +1 -1
  125. package/src/app/util/keyboard.ts +10 -11
  126. package/src/app/util/mapping.ts +3 -3
  127. package/src/app/util/preview-settings.ts +1 -1
  128. package/src/app/util/render-tracker.ts +31 -0
  129. package/src/app/util/scroll.ts +5 -1
  130. package/src/app/util/state.ts +4 -4
  131. package/src/app/util/variant.ts +16 -3
  132. package/src/app/components/base/BaseCopyIcon.vue +0 -34
@@ -2,7 +2,7 @@
2
2
  import type { Story, Variant } from '../../types'
3
3
  import { Icon } from '@iconify/vue'
4
4
  import { applyState, clone } from '@poveste/shared'
5
- import { onClickOutside, useStorage, useTimeoutFn } from '@vueuse/core'
5
+ import { onClickOutside, useLiveAnnouncer, useStorage, useTimeoutFn } from '@vueuse/core'
6
6
  import { computed, nextTick, onMounted, ref } from 'vue'
7
7
  import { toPresetState, toRawDeep } from '../../util/state'
8
8
  import BaseSelect from '../base/BaseSelect.vue'
@@ -41,7 +41,7 @@ const presetStates = useStorage<Map<string, { state: Record<string, unknown>, la
41
41
  )
42
42
 
43
43
  const presetsOptions = computed(() => {
44
- const options = { [DEFAULT_ID]: 'Initial state' }
44
+ const options: Record<string, string> = { [DEFAULT_ID]: 'Initial state' }
45
45
  presetStates.value.forEach((value, key) => {
46
46
  options[key] = value.label
47
47
  })
@@ -53,12 +53,17 @@ function resetState() {
53
53
  applyState(props.variant.state, clone(defaultState))
54
54
  }
55
55
 
56
- function applyPreset(id) {
56
+ function applyPreset(id: string) {
57
57
  if (id === DEFAULT_ID) {
58
58
  resetState()
59
59
  }
60
- else if (presetStates.value.has(id)) {
61
- applyState(props.variant.state, clone(toRawDeep(presetStates.value.get(id).state)))
60
+ else {
61
+ // `has` then `get` is two lookups and only the second one is the value;
62
+ // reading it once says the same thing and carries the type with it.
63
+ const preset = presetStates.value.get(id)
64
+ if (preset) {
65
+ applyState(props.variant.state, clone(toRawDeep(preset.state)))
66
+ }
62
67
  }
63
68
  }
64
69
 
@@ -70,6 +75,7 @@ onMounted(() => {
70
75
 
71
76
  const input = ref<HTMLInputElement>()
72
77
  const select = ref<HTMLInputElement>()
78
+ const picker = ref<InstanceType<typeof BaseSelect>>()
73
79
  const canEdit = computed(() => selectedOption.value !== DEFAULT_ID)
74
80
  const isEditing = ref(false)
75
81
 
@@ -80,10 +86,24 @@ async function createPreset() {
80
86
  selectedOption.value = id
81
87
  isEditing.value = true
82
88
  await nextTick()
83
- input.value.select()
89
+ // The input is rendered by the edit state set above, so it exists by now —
90
+ // unless editing was cancelled during the tick, which unmounts it.
91
+ input.value?.select()
84
92
  }
85
93
 
94
+ // The template bound `v-model` straight to `presetStates.get(selectedOption).label`,
95
+ // which is a Map lookup that can miss. Naming it once keeps the template honest
96
+ // and gives the miss somewhere to go.
97
+ const editingLabel = computed({
98
+ get: () => presetStates.value.get(selectedOption.value)?.label ?? '',
99
+ set: (value) => {
100
+ const preset = presetStates.value.get(selectedOption.value)
101
+ if (preset) preset.label = value
102
+ },
103
+ })
104
+
86
105
  const savedNotif = ref(false)
106
+ const { polite } = useLiveAnnouncer()
87
107
  const savedTimeout = useTimeoutFn(() => {
88
108
  savedNotif.value = false
89
109
  }, 1000)
@@ -92,12 +112,14 @@ async function savePreset() {
92
112
  if (!canEdit.value) return
93
113
 
94
114
  const preset = presetStates.value.get(selectedOption.value)
115
+ if (!preset) return
95
116
  preset.state = toPresetState(props.variant.state, omitKeys)
96
117
  savedNotif.value = true
97
118
  savedTimeout.start()
119
+ polite('Preset saved')
98
120
  }
99
121
 
100
- function deletePreset(id) {
122
+ function deletePreset(id: string) {
101
123
  // @TODO custom confirm modal UI
102
124
  // eslint-disable-next-line no-alert
103
125
  if (!confirm('Are you sure you want to delete this preset?')) {
@@ -117,14 +139,25 @@ async function startEditing() {
117
139
 
118
140
  isEditing.value = true
119
141
  await nextTick()
120
- input.value.select()
142
+ // The input is rendered by the edit state set above, so it exists by now —
143
+ // unless editing was cancelled during the tick, which unmounts it.
144
+ input.value?.select()
121
145
  }
122
146
 
123
- function stopEditing() {
147
+ async function stopEditing(refocus = false) {
148
+ if (!isEditing.value) {
149
+ return
150
+ }
124
151
  isEditing.value = false
152
+ // Finishing a rename from the keyboard unmounts the field that had focus, so
153
+ // hand it to the picker, which now shows the new name.
154
+ if (refocus) {
155
+ await nextTick()
156
+ picker.value?.focus()
157
+ }
125
158
  }
126
159
 
127
- onClickOutside(select, stopEditing)
160
+ onClickOutside(select, () => stopEditing())
128
161
  </script>
129
162
 
130
163
  <template>
@@ -133,79 +166,103 @@ onClickOutside(select, stopEditing)
133
166
  ref="select"
134
167
  class="flex-1 min-w-0"
135
168
  >
169
+ <!-- Swapped for the picker rather than put inside it: a text field inside a
170
+ combobox is a control nested in a control (#828). -->
171
+ <input
172
+ v-if="isEditing"
173
+ ref="input"
174
+ v-model="editingLabel"
175
+ type="text"
176
+ aria-label="Preset name"
177
+ class="text-inherit bg-transparent w-full px-2 h-[27px] -my-1 border border-solid border-primary-500 rounded-sm outline-none"
178
+ @keydown.enter="stopEditing(true)"
179
+ @keydown.escape="stopEditing(true)"
180
+ >
136
181
  <BaseSelect
182
+ v-else
183
+ ref="picker"
137
184
  v-model="selectedOption"
185
+ label="Preset"
138
186
  :options="presetsOptions"
139
187
  @dblclick="startEditing()"
140
- @keydown.enter="stopEditing()"
141
- @keydown.escape="stopEditing()"
142
188
  @select="id => applyPreset(id)"
143
- >
144
- <template #default="{ label }">
145
- <input
146
- v-if="isEditing"
147
- ref="input"
148
- v-model="presetStates.get(selectedOption).label"
149
- type="text"
150
- class="text-inherit bg-transparent w-full h-full outline-none"
151
- @click.stop.prevent
152
- >
153
-
154
- <div
155
- v-else
156
- class="flex items-center gap-2"
157
- >
158
- <span class="flex-1 truncate">
159
- {{ label }}
160
- </span>
161
- <Icon
162
- v-if="canEdit"
163
- v-tooltip="'Rename this preset'"
164
- icon="carbon:edit"
165
- class="flex-none cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
166
- @click.stop="startEditing()"
167
- />
168
- </div>
169
- </template>
170
-
171
- <template #option="{ label, value }">
172
- <div class="flex gap-2 items-center">
173
- <span
174
- class="flex-1 truncate"
175
- >{{ label }}</span>
176
- <Icon
177
- v-if="value !== DEFAULT_ID"
178
- v-tooltip="'Delete this preset'"
179
- icon="carbon:trash-can"
180
- class="flex-none cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
181
- @click.stop="deletePreset(value)"
182
- />
183
- </div>
184
- </template>
185
- </BaseSelect>
189
+ />
186
190
  </div>
187
- <Icon
191
+ <button
192
+ v-tooltip="canEdit ? 'Rename this preset' : null"
193
+ type="button"
194
+ data-testid="preset-rename"
195
+ aria-label="Rename preset"
196
+ :disabled="!canEdit"
197
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
198
+ :class="[
199
+ canEdit ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
200
+ ]"
201
+ @click="startEditing()"
202
+ >
203
+ <Icon
204
+ icon="carbon:edit"
205
+ class="w-4 h-4"
206
+ />
207
+ </button>
208
+ <button
209
+ v-tooltip="canEdit ? 'Delete this preset' : null"
210
+ type="button"
211
+ data-testid="preset-delete"
212
+ aria-label="Delete preset"
213
+ :disabled="!canEdit"
214
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
215
+ :class="[
216
+ canEdit ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
217
+ ]"
218
+ @click="deletePreset(selectedOption)"
219
+ >
220
+ <Icon
221
+ icon="carbon:trash-can"
222
+ class="w-4 h-4"
223
+ />
224
+ </button>
225
+ <button
188
226
  v-tooltip="savedNotif ? 'Saved!' : canEdit ? 'Save to preset' : null"
227
+ type="button"
189
228
  data-testid="preset-save"
190
- :icon="savedNotif ? 'carbon:checkmark' : 'carbon:save'"
191
- class="cursor-pointer w-4 h-4 hover:text-primary-500 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
229
+ aria-label="Save to preset"
230
+ :disabled="!canEdit"
231
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400"
192
232
  :class="[
193
- canEdit ? 'opacity-50 hover:opacity-100' : 'opacity-25 pointer-events-none',
233
+ canEdit ? 'opacity-50 hover:opacity-100 focus-visible:opacity-100' : 'opacity-25 pointer-events-none',
194
234
  ]"
195
235
  @click="savePreset()"
196
- />
197
- <Icon
236
+ >
237
+ <Icon
238
+ :icon="savedNotif ? 'carbon:checkmark' : 'carbon:save'"
239
+ class="w-4 h-4"
240
+ />
241
+ </button>
242
+ <button
198
243
  v-tooltip="'Create new preset'"
244
+ type="button"
199
245
  data-testid="preset-create"
200
- icon="carbon:add-alt"
201
- class="cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
246
+ aria-label="Create new preset"
247
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400 opacity-50 hover:opacity-100 focus-visible:opacity-100"
202
248
  @click="createPreset()"
203
- />
204
- <Icon
249
+ >
250
+ <Icon
251
+ icon="carbon:add-alt"
252
+ class="w-4 h-4"
253
+ />
254
+ </button>
255
+ <button
205
256
  v-tooltip="'Reset to initial state'"
206
- icon="carbon:reset"
207
- class="cursor-pointer w-4 h-4 hover:text-primary-500 opacity-50 hover:opacity-100 dark:hover:text-primary-400 text-gray-900 dark:text-gray-100"
257
+ type="button"
258
+ aria-label="Reset to initial state"
259
+ class="flex-none flex p-0 bg-transparent border-0 cursor-pointer text-gray-900 dark:text-gray-100 hover:text-primary-500 dark:hover:text-primary-400 opacity-50 hover:opacity-100 focus-visible:opacity-100"
208
260
  @click="resetState()"
209
- />
261
+ >
262
+ <Icon
263
+ icon="carbon:reset"
264
+ class="w-4 h-4"
265
+ />
266
+ </button>
210
267
  </div>
211
268
  </template>
@@ -28,7 +28,7 @@ watch(() => props.variant, () => {
28
28
  ready.value = false
29
29
  })
30
30
 
31
- const hasCustomControls = computed(() => props.variant.slots().controls || props.story.slots().controls)
31
+ const hasCustomControls = computed(() => props.variant.slots?.().controls || props.story.slots?.().controls)
32
32
 
33
33
  const hasInitState = computed(() => Object
34
34
  .entries(props.variant.state || {})
@@ -14,7 +14,9 @@ export function useStoryDoc(story: Ref<Story>) {
14
14
 
15
15
  watchEffect(async () => {
16
16
  // Markdown file
17
- const mdKey = story.value.file.filePath.replace(/\.(\w*)$/, '.md')
17
+ const filePath = story.value?.file?.filePath
18
+ if (!filePath) return
19
+ const mdKey = filePath.replace(/\.(\w*)$/, '.md')
18
20
  if (markdownFiles[mdKey]) {
19
21
  const md = await markdownFiles[mdKey]()
20
22
  renderedDoc.value = md.html
@@ -10,8 +10,13 @@ const formattedArgument = computed(() => {
10
10
  switch (typeof props.event.argument) {
11
11
  case 'string':
12
12
  return `"${props.event.argument}"`
13
- case 'object':
14
- return `{ ${Object.keys(props.event.argument).map(key => `${key}: ${props.event.argument[key]}`).join(', ')} }`
13
+ case 'object': {
14
+ // `argument` is `unknown`, and `typeof x === 'object'` narrows it only as
15
+ // far as `object | null` — enough for `Object.keys`, not for indexing.
16
+ const argument = props.event.argument as Record<string, unknown> | null
17
+ if (!argument) return 'null'
18
+ return `{ ${Object.keys(argument).map(key => `${key}: ${argument[key]}`).join(', ')} }`
19
+ }
15
20
  default:
16
21
  return props.event.argument
17
22
  }
@@ -19,7 +19,10 @@ async function resetUnseen() {
19
19
  eventsStore.unseen = 0
20
20
  }
21
21
  await nextTick()
22
- eventsElement.value.scrollTo({ top: eventsElement.value.scrollHeight })
22
+ // Read after the await, and it is a template ref: the panel can be closed
23
+ // during the tick, and then there is nothing to scroll.
24
+ const events = eventsElement.value
25
+ events?.scrollTo({ top: events.scrollHeight })
23
26
  }
24
27
  </script>
25
28
 
@@ -22,7 +22,7 @@ const innerOrientation = computed(() =>
22
22
  const route = useRoute()
23
23
 
24
24
  const panelContentComponent = computed(() => {
25
- switch (route.query.tab) {
25
+ switch (route.query['tab']) {
26
26
  case 'docs':
27
27
  return StoryDocs
28
28
  case 'events':
@@ -60,6 +60,7 @@ const panelContentComponent = computed(() => {
60
60
  <template #first>
61
61
  <div class="flex flex-col h-full">
62
62
  <PaneTabs
63
+ v-if="storyStore.currentStory"
63
64
  :story="storyStore.currentStory"
64
65
  :variant="storyStore.currentVariant"
65
66
  />
@@ -75,6 +76,7 @@ const panelContentComponent = computed(() => {
75
76
 
76
77
  <template #last>
77
78
  <StorySourceCode
79
+ v-if="storyStore.currentStory"
78
80
  :story="storyStore.currentStory"
79
81
  :variant="storyStore.currentVariant"
80
82
  class="h-full"
@@ -15,10 +15,13 @@ const props = defineProps<{
15
15
  variant: Variant
16
16
  }>()
17
17
 
18
- const generateSourceCodeFn = ref(null)
18
+ // `ref(null)` infers `Ref<null>`, so the assignment below and the call further
19
+ // down were both errors about a type nobody meant to declare.
20
+ const generateSourceCodeFn = shallowRef<((variant: Variant) => Promise<string>) | null>(null)
19
21
 
20
22
  watchEffect(async () => {
21
- const clientPlugin = clientSupportPlugins[props.story.file?.supportPluginId]
23
+ const supportPluginId = props.story.file?.supportPluginId
24
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
22
25
  if (clientPlugin) {
23
26
  const pluginModule = await clientPlugin()
24
27
  generateSourceCodeFn.value = markRaw(pluginModule.generateSourceCode)
@@ -28,7 +31,7 @@ watchEffect(async () => {
28
31
  const highlighter = shallowRef<HighlighterCore>()
29
32
 
30
33
  const dynamicSourceCode = ref('')
31
- const error = ref<string>(null)
34
+ const error = ref<string | null>(null)
32
35
 
33
36
  // Above the watch that assigns it: the callback is async but runs immediately,
34
37
  // and a story whose `slots()` throws reaches the auto-switch below before its
@@ -55,7 +58,7 @@ watch(() => [props.variant, generateSourceCodeFn.value], async () => {
55
58
  }
56
59
  catch (e) {
57
60
  console.error(e)
58
- error.value = e.message
61
+ error.value = e instanceof Error ? e.message : String(e)
59
62
  }
60
63
 
61
64
  // Auto-switch
@@ -118,8 +121,8 @@ watch(() => props.variant, () => {
118
121
 
119
122
  const scroller = ref<HTMLElement>()
120
123
 
121
- function onScroll(event) {
122
- if (sourceHtml.value) {
124
+ function onScroll(event: Event) {
125
+ if (sourceHtml.value && event.target instanceof HTMLElement) {
123
126
  lastScroll = event.target.scrollTop
124
127
  }
125
128
  }
@@ -153,7 +156,7 @@ watch(sourceHtml, async () => {
153
156
  class="flex items-center gap-1 h-full px-1 bg-gray-500/10 rounded-l transition-all ease-[cubic-bezier(0,1,.6,1)] duration-300 overflow-hidden"
154
157
  :class="[
155
158
  displayedSource !== 'dynamic' ? 'max-w-6 opacity-70' : 'max-w-[82px] text-primary-800 dark:text-primary-400',
156
- dynamicSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : 'opacity-50',
159
+ dynamicSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : displayedSource !== 'dynamic' && 'opacity-50',
157
160
  ]"
158
161
  @click="dynamicSourceCode && (displayedSource = 'dynamic')"
159
162
  >
@@ -175,7 +178,7 @@ watch(sourceHtml, async () => {
175
178
  class="flex items-center gap-1 h-full px-1 bg-gray-500/10 rounded-r transition-all ease-[cubic-bezier(0,1,.6,1)] duration-300 overflow-hidden"
176
179
  :class="[
177
180
  displayedSource !== 'static' ? 'max-w-6 opacity-70' : 'max-w-[63px] text-primary-800 dark:text-primary-400',
178
- staticSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : 'opacity-50',
181
+ staticSourceCode ? 'cursor-pointer hover:bg-gray-500/30 active:bg-gray-600/50' : displayedSource !== 'static' && 'opacity-50',
179
182
  ]"
180
183
  @click="staticSourceCode && (displayedSource = 'static')"
181
184
  >
@@ -220,6 +223,7 @@ watch(sourceHtml, async () => {
220
223
  ref="scroller"
221
224
  class="__poveste-code-placeholder w-full h-full p-4 outline-none bg-transparent resize-none m-0"
222
225
  :value="displayedSourceCode"
226
+ aria-label="Source code"
223
227
  readonly
224
228
  data-testid="story-source-code"
225
229
  @scroll="onScroll"
@@ -8,9 +8,10 @@ defineProps<{
8
8
  selected: boolean
9
9
  }>()
10
10
 
11
- const defaultIcons = {
11
+ const defaultIcons: Record<SearchResult['kind'], string> = {
12
12
  story: 'carbon:cube',
13
13
  variant: 'carbon:cube',
14
+ command: 'carbon:chevron-right',
14
15
  }
15
16
 
16
17
  const kindLabels = {
@@ -45,7 +45,9 @@ watch(() => props.shown, (value) => {
45
45
  if (value) {
46
46
  requestAnimationFrame(() => {
47
47
  focused.value = true
48
- input.value.select()
48
+ // A whole frame later, so the pane may have been closed again — a
49
+ // template ref is undefined once its element unmounts.
50
+ input.value?.select()
49
51
  })
50
52
  }
51
53
  })
@@ -127,14 +129,18 @@ watch(rateLimitedSearch, async (value) => {
127
129
  if (!idMapData) continue
128
130
  switch (idMapData.kind) {
129
131
  case 'story': {
130
- list.push(storyResultFactory(storyStore.getStoryById(idMapData.id), rank))
132
+ // The search index is built from the stories and outlives an
133
+ // individual one, so a hit can name a story that is no longer loaded.
134
+ const story = storyStore.getStoryById(idMapData.id)
135
+ if (!story) continue
136
+ list.push(storyResultFactory(story, rank))
131
137
  rank++
132
138
  break
133
139
  }
134
140
  case 'variant': {
135
- const [storyId] = idMapData.id.split(':')
136
- const story = storyStore.getStoryById(storyId)
141
+ const story = storyStore.getStoryById(idMapData.id.replace(/:.*$/s, ''))
137
142
  const variant = storyStore.getVariantById(idMapData.id)
143
+ if (!story || !variant) continue
138
144
  list.push(variantResultFactory(story, variant, rank))
139
145
  rank++
140
146
  break
@@ -158,7 +164,9 @@ async function searchOnDocField(query: string) {
158
164
  if (!idMapData) continue
159
165
  switch (idMapData.kind) {
160
166
  case 'story': {
161
- list.push(storyResultFactory(storyStore.getStoryById(idMapData.id), rank, 'docs'))
167
+ const story = storyStore.getStoryById(idMapData.id)
168
+ if (!story) continue
169
+ list.push(storyResultFactory(story, rank, 'docs'))
162
170
  rank++
163
171
  break
164
172
  }
@@ -188,7 +196,7 @@ function storyResultFactory(story: Story, rank: number, type: SearchResultType =
188
196
  : {},
189
197
  },
190
198
  },
191
- path: story.file.path.slice(0, -1),
199
+ path: story.file?.path.slice(0, -1) ?? [],
192
200
  icon: story.icon,
193
201
  iconColor: story.iconColor,
194
202
  }
@@ -212,7 +220,7 @@ function variantResultFactory(story: Story, variant: Variant, rank: number, type
212
220
  : {},
213
221
  },
214
222
  },
215
- path: [...story.file.path ?? [], story.title],
223
+ path: [...story.file?.path ?? [], story.title],
216
224
  icon: variant.icon,
217
225
  iconColor: variant.iconColor,
218
226
  }
@@ -259,7 +267,7 @@ const results = computed<SearchResult[]>(() => {
259
267
  ...commandResults.value,
260
268
  ...titleResults.value,
261
269
  ]
262
- const seen = {}
270
+ const seen: Record<string, boolean> = {}
263
271
  for (const r of titleResults.value) {
264
272
  seen[r.id] = true
265
273
  }
@@ -16,7 +16,8 @@ const props = defineProps<{
16
16
  const mountComponent = ref(null)
17
17
 
18
18
  watchEffect(async () => {
19
- const clientPlugin = clientSupportPlugins[props.story.file?.supportPluginId]
19
+ const supportPluginId = props.story.file?.supportPluginId
20
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
20
21
  if (clientPlugin) {
21
22
  const pluginModule = await clientPlugin()
22
23
  mountComponent.value = markRaw(pluginModule.MountStory)
@@ -16,7 +16,8 @@ const props = defineProps<{
16
16
  const mountComponent = ref(null)
17
17
 
18
18
  watchEffect(async () => {
19
- const clientPlugin = clientSupportPlugins[props.story.file?.supportPluginId]
19
+ const supportPluginId = props.story.file?.supportPluginId
20
+ const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
20
21
  if (clientPlugin) {
21
22
  try {
22
23
  const pluginModule = await clientPlugin()
@@ -43,12 +43,34 @@ function addWindowListener(event: string, listener: (event: any) => unknown) {
43
43
 
44
44
  const previewWrapper = ref<HTMLDivElement>()
45
45
 
46
- function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, max: number, axis: 'x' | 'y') {
46
+ function useDragger(el: Ref<HTMLDivElement | undefined>, value: Ref<number | null>, min: number, max: number, axis: 'x' | 'y') {
47
+ // The wrapper is a template ref, so it is undefined before mount and after
48
+ // unmount. Every read below happens during a drag, when it is on screen — but
49
+ // the move and up listeners are on `window`, so "during a drag" is not the
50
+ // same as "while this component is alive".
51
+ function wrapperSize() {
52
+ const wrapper = previewWrapper.value
53
+ if (!wrapper) return null
54
+ return axis === 'x' ? wrapper.clientWidth : wrapper.clientHeight
55
+ }
56
+
57
+ // `null` is the value that means "size to the available space", so a drag
58
+ // beginning from auto has to begin from the size actually rendered. The touch
59
+ // path had no fallback and `null + delta` coerces to `delta`, which collapsed
60
+ // a full-height preview to the minimum on the first move (#440).
61
+ function startFrom() {
62
+ if (value.value !== null) return value.value
63
+ const size = wrapperSize()
64
+ // Nothing to measure against, so start from the floor rather than from NaN.
65
+ if (size === null) return min
66
+ return size - (axis === 'x' ? 67 : 70)
67
+ }
68
+
47
69
  function onMouseDown(event: MouseEvent) {
48
70
  event.preventDefault()
49
71
  event.stopPropagation()
50
72
  const start = axis === 'x' ? event.clientX : event.clientY
51
- const startValue = value.value ?? (axis === 'x' ? previewWrapper.value.clientWidth - 67 : previewWrapper.value.clientHeight - 70)
73
+ const startValue = startFrom()
52
74
  resizing.value = true
53
75
 
54
76
  const removeListeners = [
@@ -57,11 +79,14 @@ function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, ma
57
79
  ]
58
80
 
59
81
  function onMouseMove(event: MouseEvent) {
60
- const snapTarget = (axis === 'x' ? previewWrapper.value.clientWidth : previewWrapper.value.clientHeight)
82
+ const snapTarget = wrapperSize()
61
83
  const delta = (axis === 'x' ? event.clientX : event.clientY) - start
62
84
  value.value = Math.max(min, Math.min(max, startValue + delta))
63
85
 
64
- if (Math.abs(value.value - (snapTarget - 67)) < 16) {
86
+ // 67 on both axes, unlike `startFrom`. Left as it was: the snap threshold
87
+ // is a feel, not a measurement, and changing it here would be a UX edit
88
+ // wearing a type fix's clothes.
89
+ if (snapTarget !== null && Math.abs(value.value - (snapTarget - 67)) < 16) {
65
90
  value.value = null
66
91
  }
67
92
  }
@@ -76,8 +101,10 @@ function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, ma
76
101
  function onTouchStart(event: TouchEvent) {
77
102
  event.preventDefault()
78
103
  event.stopPropagation()
79
- const start = axis === 'x' ? event.touches[0].clientX : event.touches[0].clientY
80
- const startValue = value.value
104
+ const [touch] = event.touches
105
+ if (!touch) return
106
+ const start = axis === 'x' ? touch.clientX : touch.clientY
107
+ const startValue = startFrom()
81
108
  resizing.value = true
82
109
 
83
110
  const removeListeners = [
@@ -87,7 +114,9 @@ function useDragger(el: Ref<HTMLDivElement>, value: Ref<number>, min: number, ma
87
114
  ]
88
115
 
89
116
  function onTouchMove(event: TouchEvent) {
90
- const delta = (axis === 'x' ? event.touches[0].clientX : event.touches[0].clientY) - start
117
+ const [touch] = event.touches
118
+ if (!touch) return
119
+ const delta = (axis === 'x' ? touch.clientX : touch.clientY) - start
91
120
  value.value = Math.max(min, Math.min(max, startValue + delta))
92
121
  }
93
122