@dfosco/storyboard 0.9.4 → 0.9.6

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.
@@ -518,11 +518,14 @@ export function buildSecondaryIframeUrl(widget) {
518
518
  params.set('_sb_component_set', '')
519
519
  const layout = widget.props?.layout
520
520
  if (layout) params.set('layout', layout)
521
+ // `selected` lives in the URL hash, not the query string — see
522
+ // StorySetWidget.resolveStorySetUrl. Keeps variant-change navigation
523
+ // as a same-document fragment update (no iframe reload).
521
524
  const selected = widget.props?.selected
522
- if (selected) params.set('selected', selected)
525
+ const hash = selected ? `#selected=${encodeURIComponent(selected)}` : ''
523
526
  const route = storyData._route || `/components/${storyId}`
524
- if (import.meta.env.DEV) return `${baseClean}/stories.html${route}?${params}`
525
- return `${baseClean}${route}?${params}`
527
+ if (import.meta.env.DEV) return `${baseClean}/stories.html${route}?${params}${hash}`
528
+ return `${baseClean}${route}?${params}${hash}`
526
529
  }
527
530
 
528
531
  return null
@@ -216,9 +216,17 @@ export const widgetTypes = new Proxy({}, {
216
216
  function readInteraction(def) {
217
217
  if (!def) return null
218
218
  const i = def.interaction || {}
219
+ // Preserve shape of `movable` so consumers can opt into prod via the
220
+ // structured form `{ enabled, prod }` (mirroring `resize`). Boolean stays
221
+ // boolean for back-compat — readers consult `isMovable()` for the
222
+ // env-aware verdict.
223
+ let movable
224
+ if (i.movable === false) movable = false
225
+ else if (typeof i.movable === 'object' && i.movable !== null) movable = i.movable
226
+ else movable = true
219
227
  return {
220
228
  selectable: i.selectable !== false, // default true
221
- movable: i.movable !== false, // default true
229
+ movable, // default true
222
230
  resize: i.resize ?? def.resize ?? null,
223
231
  expandable: i.expandable ?? def.expandable ?? false,
224
232
  splitScreen: i.splitScreen ?? def.splitScreen ?? false,
@@ -323,6 +331,30 @@ export function isResizable(type) {
323
331
  return true
324
332
  }
325
333
 
334
+ /**
335
+ * Check if a widget type supports drag-to-move in the current environment.
336
+ *
337
+ * `interaction.movable` accepts either:
338
+ * - boolean (default true) — movable in dev only; locked in production
339
+ * - `{ enabled: boolean, prod?: boolean }` — opt into production drag via
340
+ * `prod: true` (mirrors the `resize` shape)
341
+ *
342
+ * @param {string} type — widget type string
343
+ * @returns {boolean}
344
+ */
345
+ export function isMovable(type) {
346
+ const movable = getInteractionOptions(type).movable
347
+ if (movable === false) return false
348
+ if (typeof movable === 'object' && movable !== null) {
349
+ if (movable.enabled === false) return false
350
+ if (import.meta.env?.PROD && !movable.prod) return false
351
+ return true
352
+ }
353
+ // Boolean true (or undefined): historical "movable in dev only" semantic.
354
+ if (import.meta.env?.PROD) return false
355
+ return true
356
+ }
357
+
326
358
  /**
327
359
  * Get the display metadata (label, icon) for a widget type.
328
360
  * @param {string} type — widget type string
@@ -7,6 +7,7 @@ import {
7
7
  getWidgetMeta,
8
8
  isResizable,
9
9
  isExpandable,
10
+ isMovable,
10
11
  isSplitScreenCapable,
11
12
  getInteractGate,
12
13
  getMenuWidgetTypes,
@@ -53,6 +54,32 @@ describe('widgetConfig — merged consumer + built-in registry', () => {
53
54
  expect(getInteractionOptions('decorative').movable).toBe(true)
54
55
  })
55
56
 
57
+ it('isMovable() returns dev-only for the boolean shape (back-compat)', () => {
58
+ // Default sticky-note has no interaction.movable → boolean true semantic.
59
+ // Vitest runs in dev mode by default (import.meta.env.PROD = false),
60
+ // so isMovable returns true.
61
+ expect(isMovable('sticky-note')).toBe(true)
62
+ registerWidget('locked', { label: 'Locked', interaction: { movable: false } })
63
+ expect(isMovable('locked')).toBe(false)
64
+ })
65
+
66
+ it('isMovable() honors the { enabled, prod } object shape', () => {
67
+ registerWidget('alwaysDraggable', {
68
+ label: 'Always',
69
+ interaction: { movable: { enabled: true, prod: true } },
70
+ })
71
+ // enabled: true → movable in dev (PROD branch is irrelevant under vitest)
72
+ expect(isMovable('alwaysDraggable')).toBe(true)
73
+ // Object form is preserved on getInteractionOptions for downstream readers
74
+ expect(getInteractionOptions('alwaysDraggable').movable).toEqual({ enabled: true, prod: true })
75
+
76
+ registerWidget('explicitlyOff', {
77
+ label: 'Off',
78
+ interaction: { movable: { enabled: false } },
79
+ })
80
+ expect(isMovable('explicitlyOff')).toBe(false)
81
+ })
82
+
56
83
  it('interaction.resize/expandable/splitScreen flow through to legacy getters', () => {
57
84
  registerWidget('big', {
58
85
  label: 'Big',
@@ -21,6 +21,18 @@ import styles from './ComponentSetPage.module.css'
21
21
 
22
22
  const StoryWrapper = getStoryWrapper()
23
23
 
24
+ function parseHashSelected(hash) {
25
+ if (!hash) return ''
26
+ const trimmed = hash.startsWith('#') ? hash.slice(1) : hash
27
+ if (!trimmed) return ''
28
+ try {
29
+ const params = new URLSearchParams(trimmed)
30
+ return params.get('selected') || ''
31
+ } catch {
32
+ return ''
33
+ }
34
+ }
35
+
24
36
  export default function ComponentSetPage({ name }) {
25
37
  const location = useLocation()
26
38
  const navigate = useNavigate()
@@ -28,7 +40,7 @@ export default function ComponentSetPage({ name }) {
28
40
 
29
41
  const layout = searchParams.get('layout') || 'auto'
30
42
  const density = searchParams.get('density') || 'comfy'
31
- const selected = searchParams.get('selected') || ''
43
+ const selected = parseHashSelected(location.hash)
32
44
  const isEmbed = searchParams.has('_sb_embed')
33
45
 
34
46
  // Same reload-guard policy as StoryPage / prototype embeds.
@@ -157,20 +169,20 @@ export default function ComponentSetPage({ name }) {
157
169
  }, [exports, layout, density, isEmbed])
158
170
 
159
171
  const handleSelect = useCallback((exportName) => {
160
- const params = new URLSearchParams(location.search)
161
- if (exportName === params.get('selected')) {
162
- params.delete('selected')
163
- } else {
164
- params.set('selected', exportName)
165
- }
166
- navigate(`${location.pathname}?${params}`, { replace: true })
172
+ const isToggleOff = exportName === selected
173
+ const newSelected = isToggleOff ? '' : exportName
174
+ const hash = newSelected ? `#selected=${encodeURIComponent(newSelected)}` : ''
175
+ // Preserve current search params; only flip the fragment. The parent
176
+ // widget keeps `selected` in the URL hash specifically so this update
177
+ // round-trips as a same-document fragment change — no iframe reload.
178
+ navigate(`${location.pathname}${location.search}${hash}`, { replace: true })
167
179
 
168
180
  // Notify parent widget
169
181
  if (window.parent !== window) {
170
182
  window.parent.postMessage({
171
183
  type: 'storyboard:component-set:select',
172
184
  storyId: name,
173
- exportName: exportName === selected ? null : exportName,
185
+ exportName: newSelected || null,
174
186
  }, '*')
175
187
  }
176
188
  }, [location, navigate, name, selected])