@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/dist/index.js +3201 -3107
- package/package.json +1 -1
- package/src/components/SparkModalContainer.vue +8 -1
- package/src/components/SparkOverlay.vue +15 -2
- package/src/composables/sparkModalService.js +32 -1
- package/src/composables/sparkOverlayService.js +12 -20
- package/src/composables/useSparkOverlay.js +142 -10
- package/src/containers/SparkDefaultContainer.vue +10 -4
- package/src/containers/SparkPublicContainer.vue +18 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
20
|
+
<script setup>
|
|
21
|
+
import { SparkModalContainer, SparkOverlay, sparkOverlayService } from '@/index.js'
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits(['overlayClose'])
|
|
24
|
+
</script>
|