@wishbone-media/spark 1.2.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <TransitionRoot as="template" :show="sparkModalService.state.isVisible">
2
+ <TransitionRoot v-if="isActive" as="template" :show="sparkModalService.state.isVisible">
3
3
  <!-- Headless UI emits close with a payload (false); call hide() without it
4
4
  so a backdrop/ESC dismiss resolves the show() promise with null -->
5
5
  <Dialog class="relative z-1000" @close="() => sparkModalService.hide()">
@@ -46,6 +46,13 @@
46
46
  </template>
47
47
 
48
48
  <script setup>
49
+ import { computed, onUnmounted } from 'vue'
49
50
  import { Dialog, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
50
51
  import { sparkModalService } from '@/composables/sparkModalService'
52
+
53
+ // Dedupe duplicate mounts: only the first mounted container renders, so twin
54
+ // Dialogs can't dismiss each other's modal.
55
+ const containerToken = sparkModalService.registerContainer()
56
+ const isActive = computed(() => sparkModalService.isActiveContainer(containerToken))
57
+ onUnmounted(() => sparkModalService.unregisterContainer(containerToken))
51
58
  </script>
@@ -1,5 +1,11 @@
1
1
  <template>
2
- <TransitionRoot :show="overlayInstance.state.isVisible" as="template">
2
+ <TransitionRoot
3
+ v-if="isActive"
4
+ :show="overlayInstance.state.isVisible"
5
+ as="template"
6
+ @after-enter="overlayInstance.notifyAfterEnter?.(rendererToken)"
7
+ @after-leave="overlayInstance.notifyAfterLeave?.(rendererToken)"
8
+ >
3
9
  <Dialog :initial-focus="panelRef" class="relative z-1000" @close="handleClose">
4
10
  <TransitionChild
5
11
  as="template"
@@ -49,7 +55,7 @@
49
55
  </template>
50
56
 
51
57
  <script setup>
52
- import { computed, ref } from 'vue'
58
+ import { computed, onUnmounted, ref } from 'vue'
53
59
  import { Dialog, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
54
60
 
55
61
  const panelRef = ref(null)
@@ -77,6 +83,13 @@ const props = defineProps({
77
83
 
78
84
  const emit = defineEmits(['close'])
79
85
 
86
+ // Register with the overlay instance: enables its transition guard and
87
+ // dedupes duplicate mounts (only the first mounted renderer renders).
88
+ // Optional-chained so plain state objects without the renderer API still work.
89
+ const rendererToken = props.overlayInstance.registerRenderer?.()
90
+ const isActive = computed(() => props.overlayInstance.isActiveRenderer?.(rendererToken) ?? true)
91
+ onUnmounted(() => props.overlayInstance.unregisterRenderer?.(rendererToken))
92
+
80
93
  const widthClass = computed(() => {
81
94
  return sizeClasses[props.overlayInstance.state.size] || sizeClasses.md
82
95
  })
@@ -1,4 +1,4 @@
1
- import { reactive, markRaw } from 'vue'
1
+ import { reactive, markRaw, shallowReactive } from 'vue'
2
2
  import SparkModalDialog from '@/components/SparkModalDialog.vue'
3
3
 
4
4
  class SparkModalService {
@@ -10,8 +10,39 @@ class SparkModalService {
10
10
  eventHandlers: {},
11
11
  })
12
12
  this._resolveShow = null
13
+ // Mounted SparkModalContainer instances, in mount order — only index 0
14
+ // renders. Duplicate containers each render their own Dialog and each
15
+ // twin's outside-click sensor dismisses the other, which silently breaks
16
+ // mouse-confirm on every dialog.
17
+ this._containers = shallowReactive([])
13
18
  }
14
19
 
20
+ /**
21
+ * Register a mounted SparkModalContainer. Only the first registered
22
+ * container renders; extra mounts no-op with a console warning.
23
+ *
24
+ * @returns {symbol} Token for isActiveContainer/unregisterContainer
25
+ */
26
+ registerContainer = () => {
27
+ const token = Symbol('SparkModalContainer')
28
+ this._containers.push(token)
29
+ if (this._containers.length > 1) {
30
+ console.warn(
31
+ '[spark] Duplicate SparkModalContainer mounted — only the first mounted instance ' +
32
+ 'renders. SparkDefaultContainer and SparkPublicContainer already include one; ' +
33
+ 'remove the extra mount.',
34
+ )
35
+ }
36
+ return token
37
+ }
38
+
39
+ unregisterContainer = (token) => {
40
+ const index = this._containers.indexOf(token)
41
+ if (index !== -1) this._containers.splice(index, 1)
42
+ }
43
+
44
+ isActiveContainer = (token) => this._containers[0] === token
45
+
15
46
  /**
16
47
  * Show a modal with a custom component.
17
48
  *
@@ -1,5 +1,15 @@
1
1
  import { useSparkOverlay } from '@/composables/useSparkOverlay'
2
2
 
3
+ // Merge the caller's handlers with the auto-close behavior: the overlay
4
+ // always closes on a `close` event, after the caller's own handler runs.
5
+ const withAutoClose = (eventHandlers, close) => ({
6
+ ...eventHandlers,
7
+ close: () => {
8
+ eventHandlers.close?.()
9
+ close()
10
+ },
11
+ })
12
+
3
13
  class SparkOverlayService {
4
14
  constructor() {
5
15
  this.left = useSparkOverlay()
@@ -7,29 +17,11 @@ class SparkOverlayService {
7
17
  }
8
18
 
9
19
  showLeft = (component, props = {}, eventHandlers = {}, options = {}) => {
10
- const handlers = {
11
- close: () => {
12
- // Call user's close handler if provided
13
- eventHandlers.close?.()
14
- // Then close the overlay
15
- this.closeLeft()
16
- },
17
- ...eventHandlers,
18
- }
19
- this.left.show(component, props, handlers, options)
20
+ this.left.show(component, props, withAutoClose(eventHandlers, this.closeLeft), options)
20
21
  }
21
22
 
22
23
  showRight = (component, props = {}, eventHandlers = {}, options = {}) => {
23
- const handlers = {
24
- close: () => {
25
- // Call user's close handler if provided
26
- eventHandlers.close?.()
27
- // Then close the overlay
28
- this.closeRight()
29
- },
30
- ...eventHandlers,
31
- }
32
- this.right.show(component, props, handlers, options)
24
+ this.right.show(component, props, withAutoClose(eventHandlers, this.closeRight), options)
33
25
  }
34
26
 
35
27
  closeLeft = () => {
@@ -1,4 +1,9 @@
1
- import { markRaw, reactive } from 'vue'
1
+ import { markRaw, nextTick, reactive, shallowReactive } from 'vue'
2
+
3
+ // Liveness fallback: if a renderer's after-enter/after-leave never arrives
4
+ // (unmounted mid-transition, throttled background tab), force-settle so the
5
+ // overlay can never hang in a transitioning phase.
6
+ const SETTLE_FALLBACK_MS = 1000
2
7
 
3
8
  export function useSparkOverlay() {
4
9
  const state = reactive({
@@ -9,34 +14,156 @@ export function useSparkOverlay() {
9
14
  size: 'md',
10
15
  })
11
16
 
12
- const toggle = () => {
13
- state.isVisible = !state.isVisible
17
+ // Mounted SparkOverlay renderers for this instance, in mount order — only
18
+ // index 0 renders. Duplicate mounts would each render a Dialog whose
19
+ // outside-click sensor dismisses the other, so extras no-op.
20
+ const renderers = shallowReactive([])
21
+
22
+ // Transition phase machine. isVisible only flips from a settled phase
23
+ // ('open'/'closed'); show/close calls arriving mid-transition are queued
24
+ // (latest intent wins) and applied when the transition settles. Cancelling
25
+ // a Headless UI transition mid-flight can desync the Dialog from isVisible
26
+ // (overlay stuck closed with isVisible=true) and swallows its after-*
27
+ // events, so it is never done.
28
+ let phase = 'closed' // 'closed' | 'entering' | 'open' | 'leaving'
29
+ let pending = null
30
+ let settleFallbackTimer = null
31
+
32
+ const guarded = () => renderers.length > 0
33
+ const transitioning = () => phase === 'entering' || phase === 'leaving'
34
+
35
+ const setContent = (content, props = {}, eventHandlers = {}, options = {}) => {
36
+ state.content = markRaw(content)
37
+ state.props = props
38
+ state.eventHandlers = eventHandlers
39
+ state.size = options.size || 'md'
14
40
  }
15
41
 
16
- const close = () => {
17
- state.isVisible = false
42
+ const resetContent = () => {
18
43
  state.content = null
19
44
  state.props = {}
20
45
  state.eventHandlers = {}
21
46
  state.size = 'md'
22
47
  }
23
48
 
49
+ const beginTransition = (nextPhase) => {
50
+ phase = nextPhase
51
+ clearTimeout(settleFallbackTimer)
52
+ settleFallbackTimer = setTimeout(() => {
53
+ if (transitioning()) settle(state.isVisible ? 'open' : 'closed')
54
+ }, SETTLE_FALLBACK_MS)
55
+ }
56
+
57
+ const settle = (settledPhase) => {
58
+ phase = settledPhase
59
+ clearTimeout(settleFallbackTimer)
60
+ // Keep content when a queued show is about to flush — resetting first
61
+ // would make a queued open()/toggle() reopen an empty panel.
62
+ if (phase === 'closed' && pending?.action !== 'show') resetContent()
63
+ if (pending) {
64
+ const next = pending
65
+ pending = null
66
+ if (next.action === 'show') show(...next.args)
67
+ else close()
68
+ }
69
+ }
70
+
24
71
  const open = () => {
72
+ if (guarded() && transitioning()) {
73
+ pending = { action: 'show', args: [] }
74
+ return
75
+ }
76
+ if (state.isVisible) return
25
77
  state.isVisible = true
78
+ if (guarded()) {
79
+ beginTransition('entering')
80
+ nextTick(() => {
81
+ if (phase === 'entering') settle('open')
82
+ })
83
+ } else {
84
+ phase = 'open'
85
+ }
26
86
  }
27
87
 
28
- const setContent = (content, props = {}, eventHandlers = {}, options = {}) => {
29
- state.content = markRaw(content)
30
- state.props = props
31
- state.eventHandlers = eventHandlers
32
- state.size = options.size || 'md'
88
+ const close = () => {
89
+ if (guarded() && transitioning()) {
90
+ pending = { action: 'close' }
91
+ return
92
+ }
93
+ if (!state.isVisible) {
94
+ phase = 'closed'
95
+ if (!guarded()) resetContent()
96
+ return
97
+ }
98
+ state.isVisible = false
99
+ if (guarded()) {
100
+ // Content stays until after-leave so the panel doesn't empty out
101
+ // while it is still fading.
102
+ beginTransition('leaving')
103
+ } else {
104
+ phase = 'closed'
105
+ resetContent()
106
+ }
107
+ }
108
+
109
+ const toggle = () => {
110
+ if (state.isVisible) {
111
+ close()
112
+ } else {
113
+ open()
114
+ }
33
115
  }
34
116
 
35
117
  const show = (content, props = {}, eventHandlers = {}, options = {}) => {
118
+ if (guarded() && transitioning()) {
119
+ pending = { action: 'show', args: [content, props, eventHandlers, options] }
120
+ return
121
+ }
122
+ // Toggle semantics (opt-in): re-showing the component that is already
123
+ // open closes it instead — e.g. a header button that opens a panel.
124
+ if (options.toggle && state.isVisible && content && state.content === content) {
125
+ close()
126
+ return
127
+ }
36
128
  if (content) setContent(content, props, eventHandlers, options)
37
129
  open()
38
130
  }
39
131
 
132
+ const registerRenderer = () => {
133
+ const token = Symbol('SparkOverlayRenderer')
134
+ renderers.push(token)
135
+ if (renderers.length > 1) {
136
+ console.warn(
137
+ '[spark] Duplicate SparkOverlay mounted for the same overlay instance — ' +
138
+ 'only the first mounted instance renders. Remove the extra <SparkOverlay>.',
139
+ )
140
+ }
141
+ return token
142
+ }
143
+
144
+ const unregisterRenderer = (token) => {
145
+ const index = renderers.indexOf(token)
146
+ if (index === -1) return
147
+ renderers.splice(index, 1)
148
+ if (index === 0 && transitioning()) {
149
+ // The rendering instance unmounted mid-transition; its after-* events
150
+ // will never arrive. Settle to what the flags already claim.
151
+ settle(state.isVisible ? 'open' : 'closed')
152
+ }
153
+ }
154
+
155
+ const isActiveRenderer = (token) => renderers[0] === token
156
+
157
+ const notifyAfterEnter = (token) => {
158
+ if (token !== renderers[0]) return
159
+ if (phase === 'entering') settle('open')
160
+ }
161
+
162
+ const notifyAfterLeave = (token) => {
163
+ if (token !== renderers[0]) return
164
+ if (phase === 'leaving') settle('closed')
165
+ }
166
+
40
167
  return {
41
168
  state,
42
169
  toggle,
@@ -44,5 +171,10 @@ export function useSparkOverlay() {
44
171
  open,
45
172
  setContent,
46
173
  show,
174
+ registerRenderer,
175
+ unregisterRenderer,
176
+ isActiveRenderer,
177
+ notifyAfterEnter,
178
+ notifyAfterLeave,
47
179
  }
48
180
  }
@@ -270,11 +270,16 @@ const toggleAppSelector = () => {
270
270
  slotProps.footerSlot = props.appSelectorSlots.footerSlot
271
271
  }
272
272
 
273
- sparkOverlayService.showRight(SparkAppSelector, slotProps, {
274
- select: () => {
275
- sparkOverlayService.closeRight()
273
+ sparkOverlayService.showRight(
274
+ SparkAppSelector,
275
+ slotProps,
276
+ {
277
+ select: () => {
278
+ sparkOverlayService.closeRight()
279
+ },
276
280
  },
277
- })
281
+ { toggle: true },
282
+ )
278
283
  }
279
284
 
280
285
  const toggleBrandSelector = () => {
@@ -287,6 +292,7 @@ const toggleBrandSelector = () => {
287
292
  sparkOverlayService.closeLeft()
288
293
  },
289
294
  },
295
+ { toggle: true },
290
296
  )
291
297
  }
292
298
 
@@ -2,6 +2,23 @@
2
2
  <main class="h-full">
3
3
  <router-view />
4
4
  </main>
5
+
6
+ <SparkOverlay
7
+ position="left"
8
+ :overlay-instance="sparkOverlayService.left"
9
+ @close="emit('overlayClose', 'left')"
10
+ />
11
+ <SparkOverlay
12
+ position="right"
13
+ :overlay-instance="sparkOverlayService.right"
14
+ @close="emit('overlayClose', 'right')"
15
+ />
16
+
17
+ <SparkModalContainer />
5
18
  </template>
6
19
 
7
- <script setup></script>
20
+ <script setup>
21
+ import { SparkModalContainer, SparkOverlay, sparkOverlayService } from '@/index.js'
22
+
23
+ const emit = defineEmits(['overlayClose'])
24
+ </script>