@wishbone-media/spark 0.20.0 → 0.22.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.
@@ -0,0 +1,459 @@
1
+ import { markRaw, reactive } from 'vue'
2
+
3
+ // Default durations by type (in ms)
4
+ const DEFAULT_DURATIONS = {
5
+ success: 5000,
6
+ info: 5000,
7
+ warning: 5000,
8
+ danger: 0, // sticky by default
9
+ }
10
+
11
+ // Valid toast positions
12
+ const TOAST_POSITIONS = ['top-left', 'top-right', 'center', 'bottom-left', 'bottom-right']
13
+
14
+ /**
15
+ * Creates a notification outlet state manager (for banner notifications)
16
+ * @private
17
+ */
18
+ function createNotificationOutlet() {
19
+ const state = reactive({
20
+ isVisible: false,
21
+ type: 'info',
22
+ message: null,
23
+ component: null,
24
+ props: {},
25
+ closeable: true,
26
+ duration: null,
27
+ })
28
+
29
+ // Timer state (not reactive, internal only)
30
+ let dismissTimeout = null
31
+ let remainingTime = 0
32
+ let isPaused = false
33
+ let timerStartedAt = null
34
+
35
+ const clearDismissTimeout = () => {
36
+ if (dismissTimeout) {
37
+ clearTimeout(dismissTimeout)
38
+ dismissTimeout = null
39
+ }
40
+ remainingTime = 0
41
+ isPaused = false
42
+ timerStartedAt = null
43
+ }
44
+
45
+ const hide = () => {
46
+ clearDismissTimeout()
47
+ state.isVisible = false
48
+ }
49
+
50
+ const startDismissTimer = (duration) => {
51
+ if (duration <= 0) return
52
+
53
+ remainingTime = duration
54
+ timerStartedAt = Date.now()
55
+
56
+ dismissTimeout = setTimeout(() => {
57
+ hide()
58
+ }, duration)
59
+ }
60
+
61
+ const pause = () => {
62
+ if (!dismissTimeout || isPaused) return
63
+
64
+ clearTimeout(dismissTimeout)
65
+ dismissTimeout = null
66
+
67
+ const elapsed = Date.now() - timerStartedAt
68
+ remainingTime = Math.max(0, remainingTime - elapsed)
69
+ isPaused = true
70
+ }
71
+
72
+ const resume = () => {
73
+ if (!isPaused || remainingTime <= 0) return
74
+
75
+ isPaused = false
76
+ timerStartedAt = Date.now()
77
+
78
+ dismissTimeout = setTimeout(() => {
79
+ hide()
80
+ }, remainingTime)
81
+ }
82
+
83
+ const show = (options = {}) => {
84
+ clearDismissTimeout()
85
+
86
+ const {
87
+ type = 'info',
88
+ message = null,
89
+ component = null,
90
+ props = {},
91
+ closeable = true,
92
+ duration = null,
93
+ } = options
94
+
95
+ state.type = type
96
+ state.message = message
97
+ state.component = component ? markRaw(component) : null
98
+ state.props = props
99
+ state.closeable = closeable
100
+ state.isVisible = true
101
+
102
+ // Determine duration: explicit > default for type
103
+ const effectiveDuration = duration !== null ? duration : DEFAULT_DURATIONS[type]
104
+ state.duration = effectiveDuration
105
+
106
+ if (effectiveDuration > 0) {
107
+ startDismissTimer(effectiveDuration)
108
+ }
109
+ }
110
+
111
+ const reset = () => {
112
+ hide()
113
+ state.type = 'info'
114
+ state.message = null
115
+ state.component = null
116
+ state.props = {}
117
+ state.closeable = true
118
+ state.duration = null
119
+ }
120
+
121
+ return {
122
+ state,
123
+ show,
124
+ hide,
125
+ reset,
126
+ pause,
127
+ resume,
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Service for managing notifications (both banner and toast)
133
+ *
134
+ * Banner Usage:
135
+ * sparkNotificationService.show({ type: 'success', message: 'Saved!' })
136
+ * sparkNotificationService.show({ type: 'error', message: 'Failed' }, 'form-errors')
137
+ * sparkNotificationService.hide()
138
+ *
139
+ * Toast Usage:
140
+ * sparkNotificationService.toast({ type: 'success', message: 'Saved!' })
141
+ * sparkNotificationService.toast({ type: 'error', message: 'Failed', position: 'top-right' })
142
+ * sparkNotificationService.hideToast(toastId)
143
+ * sparkNotificationService.hideAllToasts()
144
+ */
145
+ class SparkNotificationService {
146
+ constructor() {
147
+ // Banner outlets
148
+ this.outlets = new Map()
149
+
150
+ // Toast state
151
+ this.toastState = reactive({
152
+ toasts: [],
153
+ maxToasts: 3,
154
+ defaultPosition: 'bottom-right',
155
+ newestOnTop: true,
156
+ })
157
+
158
+ // Toast timers (keyed by toast id)
159
+ this._toastTimers = new Map()
160
+
161
+ // Counter for unique toast IDs
162
+ this._toastIdCounter = 0
163
+ }
164
+
165
+ // ============================================
166
+ // Banner Notification Methods
167
+ // ============================================
168
+
169
+ /**
170
+ * Get or create a notification outlet by name
171
+ * @param {string} name - Outlet name (default: 'default')
172
+ * @returns {Object} Notification outlet instance
173
+ */
174
+ getOutlet(name = 'default') {
175
+ if (!this.outlets.has(name)) {
176
+ this.outlets.set(name, createNotificationOutlet())
177
+ }
178
+ return this.outlets.get(name)
179
+ }
180
+
181
+ /**
182
+ * Show a banner notification in the specified outlet
183
+ * @param {Object} options - Notification options
184
+ * @param {string} options.type - Alert type: 'success' | 'warning' | 'danger' | 'info'
185
+ * @param {string} options.message - Simple text message (mutually exclusive with component)
186
+ * @param {Component} options.component - Vue component to render (mutually exclusive with message)
187
+ * @param {Object} options.props - Props to pass to the component
188
+ * @param {boolean} options.closeable - Whether to show close button (default: true)
189
+ * @param {number} options.duration - Auto-dismiss duration in ms (0 = sticky, null = use default)
190
+ * @param {string} outletName - Target outlet name (default: 'default')
191
+ */
192
+ show(options = {}, outletName = 'default') {
193
+ const outlet = this.getOutlet(outletName)
194
+ outlet.show(options)
195
+ }
196
+
197
+ /**
198
+ * Hide banner notification in the specified outlet
199
+ * @param {string} outletName - Target outlet name (default: 'default')
200
+ */
201
+ hide(outletName = 'default') {
202
+ const outlet = this.getOutlet(outletName)
203
+ outlet.hide()
204
+ }
205
+
206
+ /**
207
+ * Hide all banner notifications in all outlets
208
+ */
209
+ hideAll() {
210
+ for (const outlet of this.outlets.values()) {
211
+ outlet.hide()
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Pause auto-dismiss timer for the specified banner outlet
217
+ * @param {string} outletName - Target outlet name (default: 'default')
218
+ */
219
+ pause(outletName = 'default') {
220
+ const outlet = this.getOutlet(outletName)
221
+ outlet.pause()
222
+ }
223
+
224
+ /**
225
+ * Resume auto-dismiss timer for the specified banner outlet
226
+ * @param {string} outletName - Target outlet name (default: 'default')
227
+ */
228
+ resume(outletName = 'default') {
229
+ const outlet = this.getOutlet(outletName)
230
+ outlet.resume()
231
+ }
232
+
233
+ /**
234
+ * Reset and remove a banner outlet
235
+ * @param {string} outletName - Target outlet name (default: 'default')
236
+ */
237
+ reset(outletName = 'default') {
238
+ const outlet = this.outlets.get(outletName)
239
+ if (outlet) {
240
+ outlet.reset()
241
+ this.outlets.delete(outletName)
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Reset all banner outlets
247
+ */
248
+ resetAll() {
249
+ for (const outlet of this.outlets.values()) {
250
+ outlet.reset()
251
+ }
252
+ this.outlets.clear()
253
+ }
254
+
255
+ // ============================================
256
+ // Toast Notification Methods
257
+ // ============================================
258
+
259
+ /**
260
+ * Configure toast defaults
261
+ * @param {Object} options - Configuration options
262
+ * @param {number} options.maxToasts - Maximum visible toasts (default: 3)
263
+ * @param {string} options.defaultPosition - Default position (default: 'bottom-right')
264
+ * @param {boolean} options.newestOnTop - Whether newest toasts appear on top (default: true)
265
+ */
266
+ configureToasts(options = {}) {
267
+ if (options.maxToasts !== undefined) {
268
+ this.toastState.maxToasts = options.maxToasts
269
+ }
270
+ if (options.defaultPosition !== undefined && TOAST_POSITIONS.includes(options.defaultPosition)) {
271
+ this.toastState.defaultPosition = options.defaultPosition
272
+ }
273
+ if (options.newestOnTop !== undefined) {
274
+ this.toastState.newestOnTop = options.newestOnTop
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Show a toast notification
280
+ * @param {Object} options - Toast options
281
+ * @param {string} options.type - Alert type: 'success' | 'warning' | 'danger' | 'info'
282
+ * @param {string} options.message - Simple text message (mutually exclusive with component)
283
+ * @param {Component} options.component - Vue component to render (mutually exclusive with message)
284
+ * @param {Object} options.props - Props to pass to the component
285
+ * @param {boolean} options.closeable - Whether to show close button (default: true)
286
+ * @param {number} options.duration - Auto-dismiss duration in ms (0 = sticky, null = use default)
287
+ * @param {string} options.position - Position: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center'
288
+ * @returns {number} Toast ID for programmatic dismissal
289
+ */
290
+ toast(options = {}) {
291
+ const {
292
+ type = 'info',
293
+ message = null,
294
+ component = null,
295
+ props = {},
296
+ closeable = true,
297
+ duration = null,
298
+ position = null,
299
+ } = options
300
+
301
+ // Generate unique ID
302
+ const id = ++this._toastIdCounter
303
+
304
+ // Determine effective values
305
+ const effectivePosition = (position && TOAST_POSITIONS.includes(position))
306
+ ? position
307
+ : this.toastState.defaultPosition
308
+ const effectiveDuration = duration !== null ? duration : DEFAULT_DURATIONS[type]
309
+
310
+ // Create toast object
311
+ const toast = {
312
+ id,
313
+ type,
314
+ message,
315
+ component: component ? markRaw(component) : null,
316
+ props,
317
+ closeable,
318
+ duration: effectiveDuration,
319
+ position: effectivePosition,
320
+ createdAt: Date.now(),
321
+ }
322
+
323
+ // Remove oldest toasts if at capacity (per position)
324
+ const positionToasts = this.toastState.toasts.filter(t => t.position === effectivePosition)
325
+ if (positionToasts.length >= this.toastState.maxToasts) {
326
+ // Remove oldest: when newestOnTop, oldest is at end; otherwise at beginning
327
+ const oldest = this.toastState.newestOnTop
328
+ ? positionToasts[positionToasts.length - 1]
329
+ : positionToasts[0]
330
+ this.hideToast(oldest.id)
331
+ }
332
+
333
+ // Add toast to array
334
+ if (this.toastState.newestOnTop) {
335
+ // Find insertion point: after all toasts of same position
336
+ const insertIndex = this.toastState.toasts.findIndex(t => t.position === effectivePosition)
337
+ if (insertIndex === -1) {
338
+ this.toastState.toasts.push(toast)
339
+ } else {
340
+ this.toastState.toasts.splice(insertIndex, 0, toast)
341
+ }
342
+ } else {
343
+ this.toastState.toasts.push(toast)
344
+ }
345
+
346
+ // Start auto-dismiss timer if duration > 0
347
+ if (effectiveDuration > 0) {
348
+ this._startToastTimer(id, effectiveDuration)
349
+ }
350
+
351
+ return id
352
+ }
353
+
354
+ /**
355
+ * Hide a specific toast by ID
356
+ * @param {number} toastId - Toast ID to hide
357
+ */
358
+ hideToast(toastId) {
359
+ // Clear timer
360
+ this._clearToastTimer(toastId)
361
+
362
+ // Remove from array
363
+ const index = this.toastState.toasts.findIndex(t => t.id === toastId)
364
+ if (index !== -1) {
365
+ this.toastState.toasts.splice(index, 1)
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Hide all toasts
371
+ */
372
+ hideAllToasts() {
373
+ // Clear all timers
374
+ for (const id of this._toastTimers.keys()) {
375
+ this._clearToastTimer(id)
376
+ }
377
+
378
+ // Clear array
379
+ this.toastState.toasts.splice(0, this.toastState.toasts.length)
380
+ }
381
+
382
+ /**
383
+ * Pause auto-dismiss timer for a specific toast
384
+ * @param {number} toastId - Toast ID
385
+ */
386
+ pauseToast(toastId) {
387
+ const timer = this._toastTimers.get(toastId)
388
+ if (!timer || timer.isPaused) return
389
+
390
+ clearTimeout(timer.timeout)
391
+ timer.timeout = null
392
+
393
+ const elapsed = Date.now() - timer.startedAt
394
+ timer.remainingTime = Math.max(0, timer.remainingTime - elapsed)
395
+ timer.isPaused = true
396
+ }
397
+
398
+ /**
399
+ * Resume auto-dismiss timer for a specific toast
400
+ * @param {number} toastId - Toast ID
401
+ */
402
+ resumeToast(toastId) {
403
+ const timer = this._toastTimers.get(toastId)
404
+ if (!timer || !timer.isPaused || timer.remainingTime <= 0) return
405
+
406
+ timer.isPaused = false
407
+ timer.startedAt = Date.now()
408
+
409
+ timer.timeout = setTimeout(() => {
410
+ this.hideToast(toastId)
411
+ }, timer.remainingTime)
412
+ }
413
+
414
+ /**
415
+ * Get toasts for a specific position
416
+ * @param {string} position - Position to filter by
417
+ * @returns {Array} Toasts at that position
418
+ */
419
+ getToastsByPosition(position) {
420
+ return this.toastState.toasts.filter(t => t.position === position)
421
+ }
422
+
423
+ // ============================================
424
+ // Private Toast Timer Methods
425
+ // ============================================
426
+
427
+ /**
428
+ * @private
429
+ */
430
+ _startToastTimer(toastId, duration) {
431
+ const timer = {
432
+ timeout: null,
433
+ remainingTime: duration,
434
+ startedAt: Date.now(),
435
+ isPaused: false,
436
+ }
437
+
438
+ timer.timeout = setTimeout(() => {
439
+ this.hideToast(toastId)
440
+ }, duration)
441
+
442
+ this._toastTimers.set(toastId, timer)
443
+ }
444
+
445
+ /**
446
+ * @private
447
+ */
448
+ _clearToastTimer(toastId) {
449
+ const timer = this._toastTimers.get(toastId)
450
+ if (timer) {
451
+ if (timer.timeout) {
452
+ clearTimeout(timer.timeout)
453
+ }
454
+ this._toastTimers.delete(toastId)
455
+ }
456
+ }
457
+ }
458
+
459
+ export const sparkNotificationService = new SparkNotificationService()
@@ -6,7 +6,7 @@ class SparkOverlayService {
6
6
  this.right = useSparkOverlay()
7
7
  }
8
8
 
9
- showLeft = (component, props = {}, eventHandlers = {}) => {
9
+ showLeft = (component, props = {}, eventHandlers = {}, options = {}) => {
10
10
  const handlers = {
11
11
  close: () => {
12
12
  // Call user's close handler if provided
@@ -16,10 +16,10 @@ class SparkOverlayService {
16
16
  },
17
17
  ...eventHandlers,
18
18
  }
19
- this.left.show(component, props, handlers)
19
+ this.left.show(component, props, handlers, options)
20
20
  }
21
21
 
22
- showRight = (component, props = {}, eventHandlers = {}) => {
22
+ showRight = (component, props = {}, eventHandlers = {}, options = {}) => {
23
23
  const handlers = {
24
24
  close: () => {
25
25
  // Call user's close handler if provided
@@ -29,7 +29,7 @@ class SparkOverlayService {
29
29
  },
30
30
  ...eventHandlers,
31
31
  }
32
- this.right.show(component, props, handlers)
32
+ this.right.show(component, props, handlers, options)
33
33
  }
34
34
 
35
35
  closeLeft = () => {
@@ -6,6 +6,7 @@ export function useSparkOverlay() {
6
6
  content: null,
7
7
  props: {},
8
8
  eventHandlers: {},
9
+ size: 'md',
9
10
  })
10
11
 
11
12
  const toggle = () => {
@@ -17,20 +18,22 @@ export function useSparkOverlay() {
17
18
  state.content = null
18
19
  state.props = {}
19
20
  state.eventHandlers = {}
21
+ state.size = 'md'
20
22
  }
21
23
 
22
24
  const open = () => {
23
25
  state.isVisible = true
24
26
  }
25
27
 
26
- const setContent = (content, props = {}, eventHandlers = {}) => {
28
+ const setContent = (content, props = {}, eventHandlers = {}, options = {}) => {
27
29
  state.content = markRaw(content)
28
30
  state.props = props
29
31
  state.eventHandlers = eventHandlers
32
+ state.size = options.size || 'md'
30
33
  }
31
34
 
32
- const show = (content, props = {}, eventHandlers = {}) => {
33
- if (content) setContent(content, props, eventHandlers)
35
+ const show = (content, props = {}, eventHandlers = {}, options = {}) => {
36
+ if (content) setContent(content, props, eventHandlers, options)
34
37
  open()
35
38
  }
36
39