@poveste/app 0.5.3 → 0.6.1

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 (90) hide show
  1. package/dist/app/style/font.css +78 -0
  2. package/dist/app/style/main.pcss +4 -1
  3. package/dist/app/style/sandbox.css +3 -0
  4. package/dist/bundle-main-dev.js +1 -0
  5. package/dist/bundle-main.js +3 -0
  6. package/dist/bundled/app.css +28 -20
  7. package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +2 -2
  8. package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/bundled/components/layout/LayoutModal.vue_vue_type_script_setup_true_lang.js +2 -2
  10. package/dist/bundled/components/layout/LayoutPane.vue_vue_type_script_setup_true_lang.js +4 -4
  11. package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +5 -5
  12. package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
  13. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +1 -1
  14. package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +1 -1
  15. package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +1 -1
  16. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  17. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +2 -2
  18. package/dist/bundled/components/search/SearchItem.js +1 -1
  19. package/dist/bundled/components/search/SearchItem.vue_vue_type_script_setup_true_lang.js +2 -2
  20. package/dist/bundled/components/search/SearchModal.vue_vue_type_script_setup_true_lang.js +1 -1
  21. package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -0
  22. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  23. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +69 -64
  24. package/dist/bundled/components/story/StoryVariantGrid.js +3 -1
  25. package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +82 -35
  26. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  27. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +20 -12
  28. package/dist/bundled/components/story/StoryVariantListItem.js +1 -1
  29. package/dist/bundled/components/story/StoryVariantListItem.vue_vue_type_script_setup_true_lang.js +2 -2
  30. package/dist/bundled/components/story/StoryVariantSinglePreviewNative.vue_vue_type_script_setup_true_lang.js +1 -1
  31. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +97 -32
  32. package/dist/bundled/components/story/StoryVariantSingleView.vue_vue_type_script_setup_true_lang.js +1 -1
  33. package/dist/bundled/components/toolbar/ToolbarBackground.js +1 -1
  34. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +7 -7
  35. package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +1 -1
  36. package/dist/bundled/components/tree/StoryListFolder.js +1 -1
  37. package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +2 -2
  38. package/dist/bundled/components/tree/StoryListItem.js +1 -1
  39. package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
  40. package/dist/bundled/index.js +3 -0
  41. package/dist/bundled/sandbox.js +53 -24
  42. package/dist/bundled/util/const.js +2 -1
  43. package/dist/bundled/util/grid-cell-height.js +18 -0
  44. package/dist/bundled/util/grid-slots.js +64 -0
  45. package/dist/bundled/util/icons.generated.js +104 -0
  46. package/dist/bundled/util/state-bridge.js +75 -0
  47. package/dist/bundled/util/state.js +19 -1
  48. package/dist/style.css +7 -2
  49. package/package.json +12 -5
  50. package/src/__tests__/font.spec.ts +30 -0
  51. package/src/__tests__/grid-slots.spec.ts +84 -0
  52. package/src/__tests__/icons.spec.ts +26 -0
  53. package/src/__tests__/state-bridge.spec.ts +155 -0
  54. package/src/__tests__/state-clone.spec.ts +66 -0
  55. package/src/app/components/app/AppActions.vue +2 -2
  56. package/src/app/components/layout/LayoutModal.vue +2 -2
  57. package/src/app/components/layout/LayoutPane.vue +4 -4
  58. package/src/app/components/panel/StatePresets.vue +5 -5
  59. package/src/app/components/panel/StoryControls.vue +1 -1
  60. package/src/app/components/panel/StoryDocs.vue +1 -1
  61. package/src/app/components/panel/StoryEvent.vue +1 -1
  62. package/src/app/components/panel/StorySidePanel.vue +1 -1
  63. package/src/app/components/panel/StorySourceCode.vue +2 -2
  64. package/src/app/components/search/SearchItem.vue +1 -1
  65. package/src/app/components/search/SearchModal.vue +1 -1
  66. package/src/app/components/search/SearchPane.vue +8 -0
  67. package/src/app/components/story/StoryResponsivePreview.vue +85 -50
  68. package/src/app/components/story/StoryVariantGrid.vue +126 -49
  69. package/src/app/components/story/StoryVariantGridItem.vue +35 -10
  70. package/src/app/components/story/StoryVariantListItem.vue +1 -1
  71. package/src/app/components/story/StoryVariantSinglePreviewNative.vue +1 -1
  72. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +165 -35
  73. package/src/app/components/story/StoryVariantSingleView.vue +1 -1
  74. package/src/app/components/toolbar/ToolbarBackground.vue +4 -4
  75. package/src/app/components/tree/StoryGroup.vue +1 -1
  76. package/src/app/components/tree/StoryListFolder.vue +1 -1
  77. package/src/app/components/tree/StoryListItem.vue +1 -1
  78. package/src/app/index.ts +10 -0
  79. package/src/app/sandbox.ts +95 -31
  80. package/src/app/style/font.css +78 -0
  81. package/src/app/style/main.pcss +4 -1
  82. package/src/app/style/sandbox.css +3 -0
  83. package/src/app/util/const.ts +1 -0
  84. package/src/app/util/grid-cell-height.ts +16 -0
  85. package/src/app/util/grid-slots.ts +72 -0
  86. package/src/app/util/icons.generated.ts +9 -0
  87. package/src/app/util/state-bridge.ts +95 -0
  88. package/src/app/util/state.ts +19 -0
  89. package/src/bundle-main-dev.js +1 -0
  90. package/src/bundle-main.js +3 -0
@@ -9,9 +9,16 @@ import { usePreviewSettingsStore } from '../../stores/preview-settings'
9
9
  import CheckerboardPattern from '../misc/CheckerboardPattern.vue'
10
10
  import HatchedPattern from '../misc/HatchedPattern.vue'
11
11
 
12
- const props = defineProps<{
12
+ const props = withDefaults(defineProps<{
13
13
  variant: Variant
14
- }>()
14
+ /**
15
+ * Whether this preview is one a reader can size. False in a grid, where the
16
+ * cell is sized by the column and the story it renders (#257).
17
+ */
18
+ responsive?: boolean
19
+ }>(), {
20
+ responsive: true,
21
+ })
15
22
 
16
23
  const settings = usePreviewSettingsStore().currentSettings
17
24
 
@@ -116,7 +123,7 @@ const finalHeight = computed(() => settings.rotate ? settings.responsiveWidth :
116
123
 
117
124
  // Disabled responsive
118
125
 
119
- const isResponsiveEnabled = computed(() => !props.variant.responsiveDisabled)
126
+ const isResponsiveEnabled = computed(() => props.responsive && !props.variant.responsiveDisabled)
120
127
 
121
128
  const sizeTooltip = computed(() => `${responsiveWidth.value ?? 'Auto'} × ${responsiveHeight.value ?? 'Auto'}`)
122
129
  </script>
@@ -137,22 +144,47 @@ const sizeTooltip = computed(() => `${responsiveWidth.value ?? 'Auto'} × ${resp
137
144
  class="h-full overflow-auto relative"
138
145
  >
139
146
  <div
140
- class="overflow-hidden bg-white dark:bg-gray-700 rounded-lg relative"
141
- :class="isResponsiveEnabled ? {
142
- 'w-fit': !!finalWidth,
143
- 'h-fit': !!finalHeight,
144
- 'h-full': !finalHeight,
145
- } : 'h-full'"
147
+ class="rounded-lg relative"
148
+ :class="[
149
+ // The surface, the reader's background, the checkerboard and the
150
+ // padding below are all skipped in a grid cell, where the item around
151
+ // this preview already paints and pads them — rendering them again
152
+ // stacked a second identical box inside the first and doubled the
153
+ // padding (#264). Gated on `responsive`, not `isResponsiveEnabled`:
154
+ // a single-view story with `responsiveDisabled` still needs its own
155
+ // chrome, it just loses the draggers.
156
+ responsive ? 'bg-white dark:bg-gray-700' : '',
157
+ isResponsiveEnabled ? {
158
+ 'w-fit': !!finalWidth,
159
+ 'h-fit': !!finalHeight,
160
+ 'h-full': !finalHeight,
161
+ } : 'h-full',
162
+ // Sized to content, so nothing can overflow it and clipping keeps the
163
+ // corners. Sized by its container, a taller story overflows — and
164
+ // clipping there put the content past the fold out of reach entirely,
165
+ // since the scroll parent then sees nothing to scroll (#258).
166
+ isResponsiveEnabled && finalHeight ? 'overflow-hidden' : 'overflow-auto',
167
+ ]"
146
168
  >
169
+ <!-- `min-h-full`, not `h-full`: when this paints the reader's preview
170
+ background the box above it scrolls, so a story taller than the
171
+ preview would otherwise be shown against the bare box below the
172
+ first screenful. The checkerboard is measured against this for the
173
+ same reason — but the draggers are not, since they belong to the
174
+ visible box rather than to the story's full height. -->
147
175
  <div
148
- class="bind-preview-bg rounded-lg h-full"
149
- data-test-id="responsive-preview-bg"
176
+ class="rounded-lg min-h-full relative"
177
+ :class="responsive ? 'bind-preview-bg' : ''"
178
+ :data-testid="responsive ? 'responsive-preview-bg' : undefined"
150
179
  >
151
180
  <CheckerboardPattern
152
- v-if="settings.checkerboard"
181
+ v-if="settings.checkerboard && responsive"
153
182
  class="absolute inset-0 w-full h-full text-gray-500/20"
154
183
  />
155
- <div class="p-8 h-full relative">
184
+ <div
185
+ class="h-full relative"
186
+ :class="responsive ? 'p-8' : ''"
187
+ >
156
188
  <div class="w-full h-full relative">
157
189
  <div class="absolute inset-0" />
158
190
 
@@ -164,46 +196,49 @@ const sizeTooltip = computed(() => `${responsiveWidth.value ?? 'Auto'} × ${resp
164
196
  />
165
197
  </div>
166
198
 
167
- <!-- Markers -->
168
- <div class="absolute top-5 left-8 h-2 w-px bg-gray-400/25" />
169
- <div class="absolute top-5 right-8 h-2 w-px bg-gray-400/25" />
170
- <div class="absolute bottom-5 left-8 h-2 w-px bg-gray-400/25" />
171
- <div class="absolute bottom-5 right-8 h-2 w-px bg-gray-400/25" />
172
- <div class="absolute left-5 top-8 w-2 h-px bg-gray-400/25" />
173
- <div class="absolute left-5 bottom-8 w-2 h-px bg-gray-400/25" />
174
- <div class="absolute right-5 top-8 w-2 h-px bg-gray-400/25" />
175
- <div class="absolute right-5 bottom-8 w-2 h-px bg-gray-400/25" />
199
+ <!-- Markers: the ruler ticks for the size the draggers set, so they
200
+ go wherever the draggers do (#257). -->
201
+ <template v-if="isResponsiveEnabled">
202
+ <div class="absolute top-5 left-8 h-2 w-px bg-gray-400/25" />
203
+ <div class="absolute top-5 right-8 h-2 w-px bg-gray-400/25" />
204
+ <div class="absolute bottom-5 left-8 h-2 w-px bg-gray-400/25" />
205
+ <div class="absolute bottom-5 right-8 h-2 w-px bg-gray-400/25" />
206
+ <div class="absolute left-5 top-8 w-2 h-px bg-gray-400/25" />
207
+ <div class="absolute left-5 bottom-8 w-2 h-px bg-gray-400/25" />
208
+ <div class="absolute right-5 top-8 w-2 h-px bg-gray-400/25" />
209
+ <div class="absolute right-5 bottom-8 w-2 h-px bg-gray-400/25" />
210
+ </template>
176
211
  </div>
212
+ </div>
177
213
 
178
- <!-- Resize Dragger -->
179
- <template v-if="isResponsiveEnabled">
180
- <div
181
- ref="horizontalDragger"
182
- v-tooltip.right="sizeTooltip"
183
- class="absolute w-4 top-0 bottom-4 right-0 hover:bg-primary-500/30 flex items-center justify-center cursor-ew-resize group hover:text-primary-500"
184
- >
185
- <Icon
186
- icon="mdi:drag-vertical-variant"
187
- class="w-4 h-4 opacity-20 group-hover:opacity-90"
188
- />
189
- </div>
190
- <div
191
- ref="verticalDragger"
192
- v-tooltip.bottom="sizeTooltip"
193
- class="absolute h-4 left-0 right-4 bottom-0 hover:bg-primary-500/30 flex items-center justify-center cursor-ns-resize group hover:text-primary-500"
194
- >
195
- <Icon
196
- icon="mdi:drag-horizontal-variant"
197
- class="w-4 h-4 opacity-20 group-hover:opacity-90"
198
- />
199
- </div>
200
- <div
201
- ref="cornerDragger"
202
- v-tooltip.bottom="sizeTooltip"
203
- class="absolute w-4 h-4 right-0 bottom-0 hover:bg-primary-500/30 flex items-center justify-center cursor-nwse-resize group hover:text-primary-500"
214
+ <!-- Resize Dragger -->
215
+ <template v-if="isResponsiveEnabled">
216
+ <div
217
+ ref="horizontalDragger"
218
+ v-tooltip.right="sizeTooltip"
219
+ class="absolute w-4 top-0 bottom-4 right-0 hover:bg-primary-500/30 flex items-center justify-center cursor-ew-resize group hover:text-primary-500"
220
+ >
221
+ <Icon
222
+ icon="mdi:drag-vertical-variant"
223
+ class="w-4 h-4 opacity-20 group-hover:opacity-90"
204
224
  />
205
- </template>
206
- </div>
225
+ </div>
226
+ <div
227
+ ref="verticalDragger"
228
+ v-tooltip.bottom="sizeTooltip"
229
+ class="absolute h-4 left-0 right-4 bottom-0 hover:bg-primary-500/30 flex items-center justify-center cursor-ns-resize group hover:text-primary-500"
230
+ >
231
+ <Icon
232
+ icon="mdi:drag-horizontal-variant"
233
+ class="w-4 h-4 opacity-20 group-hover:opacity-90"
234
+ />
235
+ </div>
236
+ <div
237
+ ref="cornerDragger"
238
+ v-tooltip.bottom="sizeTooltip"
239
+ class="absolute w-4 h-4 right-0 bottom-0 hover:bg-primary-500/30 flex items-center justify-center cursor-nwse-resize group hover:text-primary-500"
240
+ />
241
+ </template>
207
242
  </div>
208
243
  </div>
209
244
  </div>
@@ -1,7 +1,9 @@
1
1
  <script lang="ts" setup>
2
+ import type { Slot } from '../../util/grid-slots'
2
3
  import { useResizeObserver } from '@vueuse/core'
3
- import { computed, onMounted, ref, watch } from 'vue'
4
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
4
5
  import { useStoryStore } from '../../stores/story'
6
+ import { assignSlots, trimSlots } from '../../util/grid-slots'
5
7
  import StoryVariantGridItem from './StoryVariantGridItem.vue'
6
8
 
7
9
  const storyStore = useStoryStore()
@@ -24,65 +26,44 @@ const gridTemplateWidth = computed(() => {
24
26
  return layoutWidth
25
27
  })
26
28
 
27
- const margin = 16
28
29
  const gap = 16
29
30
 
30
- const itemWidth = ref(16)
31
+ // Rows kept mounted on each side of the viewport, so a scroll does not expose a
32
+ // blank band before the next update lands.
33
+ const overscanRows = 2
34
+
35
+ // What to render before any item has reported a height. One row would be enough
36
+ // to measure with, but the row width is not known yet either.
37
+ const initialCount = 10
38
+
31
39
  const maxItemHeight = ref(0)
32
- const maxCount = ref(10)
33
- const countPerRow = ref(0)
34
- const visibleRows = ref(0)
35
40
 
36
41
  const el = ref<HTMLDivElement>(null)
42
+ const scrollTop = ref(0)
43
+ const viewportHeight = ref(0)
44
+
45
+ const variants = computed(() => storyStore.currentStory.variants)
37
46
 
38
47
  useResizeObserver(el, () => {
39
- updateMaxCount()
48
+ updateWindow()
40
49
  updateSize()
41
50
  })
42
51
 
43
- function updateMaxCount() {
44
- if (!maxItemHeight.value) return
45
-
46
- const width = el.value!.clientWidth - margin * 2
47
- const height = el.value!.clientHeight
48
- const scrollTop = el.value!.scrollTop
49
-
50
- // width = (countPerRow - 1) * gap + countPerRow * itemWidth.value
51
-
52
- // W = (C - 1) * G + C * I
53
- // W = C * G - G + C * I
54
- // W + G = C * G + C * I
55
- // W + G = C * (G + I)
56
- // (W + G) / (G + I) = C
57
-
58
- countPerRow.value = Math.floor((width + gap) / (itemWidth.value + gap))
59
- visibleRows.value = Math.ceil((height + scrollTop + gap) / (maxItemHeight.value + gap))
60
- const newMaxCount = countPerRow.value * visibleRows.value
61
- if (maxCount.value < newMaxCount) {
62
- maxCount.value = newMaxCount
63
- }
64
-
65
- if (storyStore.currentVariant) {
66
- const index = storyStore.currentStory.variants.indexOf(storyStore.currentVariant)
67
- if (index + 1 > maxCount.value) {
68
- maxCount.value = index + 1
69
- }
70
- }
52
+ function updateWindow() {
53
+ if (!el.value) return
54
+ scrollTop.value = el.value.scrollTop
55
+ viewportHeight.value = el.value.clientHeight
71
56
  }
72
57
 
73
- function onItemResize(w: number, h: number) {
74
- itemWidth.value = w
58
+ // The reported width is ignored: `columnCount` is what CSS lays out, so it is
59
+ // the authority on how many items sit in a row.
60
+ function onItemResize(_w: number, h: number) {
75
61
  if (maxItemHeight.value < h) {
76
62
  maxItemHeight.value = h
77
- updateMaxCount()
63
+ updateWindow()
78
64
  }
79
65
  }
80
66
 
81
- watch(() => storyStore.currentVariant, () => {
82
- maxItemHeight.value = 0 // Reset max height
83
- updateMaxCount()
84
- })
85
-
86
67
  // Grid size
87
68
 
88
69
  const gridEl = ref<HTMLDivElement>(null)
@@ -105,13 +86,98 @@ function updateSize() {
105
86
 
106
87
  onMounted(() => {
107
88
  updateSize()
89
+ updateWindow()
108
90
  })
109
91
 
110
92
  useResizeObserver(gridEl, () => {
111
93
  updateSize()
112
94
  })
113
95
 
114
- const columnCount = computed(() => Math.min(storyStore.currentStory.variants.length, Math.floor((viewWidth.value + gap) / (gridColumnWidth.value + gap))))
96
+ const columnCount = computed(() => Math.max(1, Math.min(variants.value.length, Math.floor((viewWidth.value + gap) / (gridColumnWidth.value + gap)))))
97
+
98
+ // The window is derived from what is on screen — a start *and* an end — rather
99
+ // than from a high-water mark of everything scrolled past. `columnCount` is what
100
+ // CSS actually lays out, so the row maths uses it rather than a second count
101
+ // derived from the measured item width.
102
+ const rowHeight = computed(() => maxItemHeight.value + gap)
103
+ const measured = computed(() => maxItemHeight.value > 0)
104
+ const rowCount = computed(() => Math.ceil(variants.value.length / columnCount.value))
105
+
106
+ const startIndex = computed(() => {
107
+ if (!measured.value) return 0
108
+ const firstRow = Math.floor(scrollTop.value / rowHeight.value) - overscanRows
109
+ return Math.max(0, firstRow) * columnCount.value
110
+ })
111
+
112
+ const endIndex = computed(() => {
113
+ if (!measured.value) return Math.min(variants.value.length, initialCount)
114
+ const lastRow = Math.ceil((scrollTop.value + viewportHeight.value) / rowHeight.value) + overscanRows
115
+ return Math.min(variants.value.length, Math.max(lastRow, 1) * columnCount.value)
116
+ })
117
+
118
+ const visibleVariants = computed(() => variants.value.slice(startIndex.value, endIndex.value))
119
+
120
+ /*
121
+ * Cells are slots, not variants (#240). Keyed by variant, a cell that scrolls
122
+ * out of the window unmounts — and with it the sandbox realm it had booted —
123
+ * and a cell scrolling in boots a new one. Keyed by slot, the same component and
124
+ * the same iframe stay mounted and are handed the next variant to show, which
125
+ * the preview turns into a retarget rather than a new document. DOM order is
126
+ * slot order, so each cell carries its visual position as `order` for the grid
127
+ * to lay it out where the variant belongs; see `grid-slots` for the rules.
128
+ */
129
+ const slots = ref<Slot[]>([])
130
+
131
+ // Idle slots are let go once the window has stopped moving. Not before it is
132
+ // measured: a story switch resets the measurement, which collapses the window
133
+ // to its initial ten until the first cell reports, and trimming on that would
134
+ // throw away the realms the re-grown window is about to ask for.
135
+ const TRIM_DELAY = 2_000
136
+ let trimTimer: ReturnType<typeof setTimeout> | undefined
137
+
138
+ function scheduleTrim() {
139
+ clearTimeout(trimTimer)
140
+ trimTimer = setTimeout(() => {
141
+ if (!measured.value) {
142
+ scheduleTrim()
143
+ return
144
+ }
145
+ slots.value = trimSlots(slots.value)
146
+ }, TRIM_DELAY)
147
+ }
148
+
149
+ watch(visibleVariants, (visible) => {
150
+ slots.value = assignSlots(slots.value, storyStore.currentStory.id, visible)
151
+ scheduleTrim()
152
+ }, { immediate: true })
153
+
154
+ onBeforeUnmount(() => {
155
+ clearTimeout(trimTimer)
156
+ })
157
+
158
+ // The spacer holds the full scroll extent; the grid is translated into place
159
+ // inside it. Before anything is measured there is nothing to reserve, and a
160
+ // height derived from a zero row height would be `Infinitypx` — dropped by the
161
+ // browser, which is what left the grid with no scroll extent at all.
162
+ const totalHeight = computed(() => measured.value ? rowCount.value * rowHeight.value - gap : undefined)
163
+ const offsetY = computed(() => measured.value ? (startIndex.value / columnCount.value) * rowHeight.value : 0)
164
+
165
+ // Selecting a variant that is outside the window has to move the scroller,
166
+ // since the item it would otherwise scroll to is not mounted.
167
+ watch(() => storyStore.currentVariant, (variant) => {
168
+ maxItemHeight.value = 0 // Reset max height
169
+ updateWindow()
170
+
171
+ if (!variant || !el.value || !measured.value) return
172
+ const index = variants.value.indexOf(variant)
173
+ if (index === -1) return
174
+
175
+ const top = Math.floor(index / columnCount.value) * rowHeight.value
176
+ if (top < scrollTop.value || top + rowHeight.value > scrollTop.value + viewportHeight.value) {
177
+ el.value.scrollTop = top
178
+ updateWindow()
179
+ }
180
+ })
115
181
  </script>
116
182
 
117
183
  <template>
@@ -119,27 +185,29 @@ const columnCount = computed(() => Math.min(storyStore.currentStory.variants.len
119
185
  <div
120
186
  ref="el"
121
187
  class="overflow-y-auto flex flex-1"
122
- @scroll="updateMaxCount()"
188
+ @scroll="updateWindow()"
123
189
  >
124
190
  <div class="flex w-0 flex-1 mx-4">
125
191
  <div
126
192
  class="m-auto"
127
193
  :style="{
128
- minHeight: `${(storyStore.currentStory.variants.length / countPerRow) * (maxItemHeight + gap) - gap}px`,
194
+ minHeight: totalHeight != null ? `${totalHeight}px` : undefined,
129
195
  }"
130
196
  >
131
197
  <div
132
198
  ref="gridEl"
133
- class="grid gap-4 my-4"
199
+ class="poveste-story-variant-grid-cells grid gap-4 my-4"
134
200
  :style="{
135
201
  gridTemplateColumns: `repeat(${columnCount}, ${gridColumnWidth}px)`,
202
+ transform: offsetY ? `translateY(${offsetY}px)` : undefined,
136
203
  }"
137
204
  >
138
205
  <StoryVariantGridItem
139
- v-for="(variant, index) of storyStore.currentStory.variants.slice(0, maxCount)"
206
+ v-for="(slot, index) of slots"
140
207
  :key="index"
141
- :variant="variant"
208
+ :variant="slot.variant"
142
209
  :story="storyStore.currentStory"
210
+ :style="{ order: slot.order, display: slot.visible ? undefined : 'none' }"
143
211
  @resize="onItemResize"
144
212
  />
145
213
  </div>
@@ -148,3 +216,12 @@ const columnCount = computed(() => Math.min(storyStore.currentStory.variants.len
148
216
  </div>
149
217
  </div>
150
218
  </template>
219
+
220
+ <style scoped>
221
+ /* Cells are placed with `order`, so without this Tab and a screen reader walk
222
+ them in slot order — the order they were recycled in, not the screen's.
223
+ Chromium only for now; elsewhere the rule is ignored. */
224
+ .poveste-story-variant-grid-cells {
225
+ reading-flow: grid-order;
226
+ }
227
+ </style>
@@ -4,7 +4,7 @@ import type { Story, Variant } from '../../types'
4
4
  import { Icon } from '@iconify/vue'
5
5
  import { HstCopyIcon } from '@poveste/controls'
6
6
  import { useResizeObserver } from '@vueuse/core'
7
- import { computed, ref, toRefs } from 'vue'
7
+ import { computed, nextTick, ref, toRefs, watch } from 'vue'
8
8
  import { useRouter } from 'vue-router'
9
9
  import { usePreviewSettingsStore } from '../../stores/preview-settings'
10
10
  import { useStoryStore } from '../../stores/story'
@@ -42,6 +42,16 @@ const storyStore = useStoryStore()
42
42
 
43
43
  storyStore.setPreviewReady(props.variant, false)
44
44
 
45
+ // This cell is a slot (#240): it is handed other variants without being
46
+ // recreated, so the setup-time reset above runs once. Neither the variant
47
+ // leaving nor the one arriving is shown anywhere until something renders it
48
+ // again, and a stale `previewReady` lets the size report below and the
49
+ // preview's state sync run against a render that has not happened.
50
+ watch(() => props.variant, (next, previous) => {
51
+ if (previous) storyStore.setPreviewReady(previous, false)
52
+ storyStore.setPreviewReady(next, false)
53
+ })
54
+
45
55
  function onReady() {
46
56
  storyStore.setPreviewReady(props.variant, true)
47
57
  }
@@ -56,12 +66,25 @@ const el = ref<HTMLDivElement>()
56
66
 
57
67
  const { autoScroll } = useScrollOnActive(isActive, el)
58
68
 
59
- useResizeObserver(el, () => {
60
- if (props.variant.previewReady) {
61
- emit('resize', el.value!.clientWidth, el.value!.clientHeight)
62
- if (isActive.value) {
63
- autoScroll()
64
- }
69
+ function reportSize() {
70
+ if (!el.value || !props.variant.previewReady) return
71
+ emit('resize', el.value.clientWidth, el.value.clientHeight)
72
+ if (isActive.value) {
73
+ autoScroll()
74
+ }
75
+ }
76
+
77
+ useResizeObserver(el, reportSize)
78
+
79
+ // The observer fires once on mount and then only on a real size change. The
80
+ // sandbox reports ready about a second later, and the item often never changes
81
+ // size again — so the one callback that did happen was skipped by the guard
82
+ // above and the grid never learned any item's height. It then divided by a
83
+ // zero row count, produced `Infinitypx` for its scroll spacer, and stayed stuck
84
+ // at its initial ten items with the rest unreachable (#103).
85
+ watch(() => props.variant.previewReady, (ready) => {
86
+ if (ready) {
87
+ nextTick(reportSize)
65
88
  }
66
89
  })
67
90
 
@@ -127,13 +150,13 @@ const useIframe = computed(() => {
127
150
  'border-gray-100 dark:border-gray-800': !isActive,
128
151
  'border-primary-200 dark:border-primary-900': isActive,
129
152
  }"
130
- data-test-id="sandbox-render"
153
+ data-testid="sandbox-render"
131
154
  @click.stop="selectVariant()"
132
155
  @keyup="selectVariant()"
133
156
  >
134
157
  <div
135
158
  class="absolute inset-0 rounded bind-preview-bg"
136
- data-test-id="responsive-preview-bg"
159
+ data-testid="responsive-preview-bg"
137
160
  />
138
161
 
139
162
  <CheckerboardPattern
@@ -150,12 +173,14 @@ const useIframe = computed(() => {
150
173
  'color': autoApplyContrastColor ? contrastColor : undefined,
151
174
  }"
152
175
  >
176
+ <!-- Not keyed by variant: the realm is reused and retargeted when
177
+ this cell is handed another variant (#240). -->
153
178
  <StoryVariantSinglePreviewRemote
154
179
  v-if="useIframe"
155
- :key="`iframe-${story.id}-${variant.id}`"
156
180
  :story="story"
157
181
  :variant="variant"
158
182
  auto-height
183
+ :responsive="false"
159
184
  />
160
185
  <GenericRenderStory
161
186
  v-else
@@ -24,7 +24,7 @@ useScrollOnActive(isActive, el)
24
24
  <div
25
25
  ref="el"
26
26
  class="poveste-story-variant-list-item"
27
- data-test-id="story-variant-list-item"
27
+ data-testid="story-variant-list-item"
28
28
  >
29
29
  <BaseListItemLink
30
30
  v-slot="{ active }"
@@ -50,7 +50,7 @@ const autoApplyContrastColor = computed(() => !!povesteConfig.autoApplyContrastC
50
50
  },
51
51
  ]"
52
52
  class="relative"
53
- data-test-id="sandbox-render"
53
+ data-testid="sandbox-render"
54
54
  >
55
55
  <GenericRenderStory
56
56
  :key="`${story.id}-${variant.id}`"