@wishbone-media/spark 0.19.0 → 0.21.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
 
@@ -203,6 +203,19 @@ export const useSparkTableRouteSync = (sparkTable, options = {}) => {
203
203
  return false
204
204
  }
205
205
 
206
+ /**
207
+ * Coerce URL query param values to their proper types
208
+ * URL params are always strings, but page/limit should be integers
209
+ */
210
+ const coerceParamValue = (key, value) => {
211
+ const numericParams = ['page', 'limit']
212
+ if (numericParams.includes(key) && value !== null && value !== undefined) {
213
+ const parsed = parseInt(value, 10)
214
+ return isNaN(parsed) ? value : parsed
215
+ }
216
+ return value
217
+ }
218
+
206
219
  /**
207
220
  * Restore params from route query to sparkTable
208
221
  */
@@ -211,7 +224,7 @@ export const useSparkTableRouteSync = (sparkTable, options = {}) => {
211
224
  // Flat mode: read any query param that looks like a table param
212
225
  Object.keys(route.query).forEach((key) => {
213
226
  if (isTableParam(key)) {
214
- sparkTable.params[key] = route.query[key]
227
+ sparkTable.params[key] = coerceParamValue(key, route.query[key])
215
228
  }
216
229
  })
217
230
  } else {
@@ -224,7 +237,7 @@ export const useSparkTableRouteSync = (sparkTable, options = {}) => {
224
237
  // e.g., "table[selectFilters[is_quote]]" => "selectFilters[is_quote]"
225
238
  // e.g., "table[page]" => "page"
226
239
  const paramKey = key.slice(prefix.length, -1)
227
- sparkTable.params[paramKey] = route.query[key]
240
+ sparkTable.params[paramKey] = coerceParamValue(paramKey, route.query[key])
228
241
  }
229
242
  })
230
243
  }
@@ -275,6 +288,10 @@ export const useSparkTableRouteSync = (sparkTable, options = {}) => {
275
288
  if (!hasUrlParams && persistToStorage) {
276
289
  const storedParams = loadFromStorage()
277
290
  if (storedParams && Object.keys(storedParams).length > 0) {
291
+ // Coerce numeric params in case localStorage has string values from previous buggy saves
292
+ Object.keys(storedParams).forEach((key) => {
293
+ storedParams[key] = coerceParamValue(key, storedParams[key])
294
+ })
278
295
  Object.assign(sparkTable.params, storedParams)
279
296
  }
280
297
  }
@@ -1,4 +1,5 @@
1
1
  import { icon } from '@fortawesome/fontawesome-svg-core'
2
+ import { sparkModalService } from '@/composables/sparkModalService'
2
3
 
3
4
  /**
4
5
  * Spark Actions Renderer
@@ -25,6 +26,16 @@ import { icon } from '@fortawesome/fontawesome-svg-core'
25
26
  * {
26
27
  * icon: 'download',
27
28
  * handler: (row) => downloadFile(row.file_url)
29
+ * },
30
+ * {
31
+ * icon: 'trash',
32
+ * label: 'Delete',
33
+ * event: 'deleteRow',
34
+ * confirm: true,
35
+ * confirmTitle: 'Delete Item',
36
+ * confirmType: 'danger',
37
+ * confirmText: 'Delete',
38
+ * cancelText: 'Keep'
28
39
  * }
29
40
  * ]
30
41
  * }
@@ -37,6 +48,11 @@ import { icon } from '@fortawesome/fontawesome-svg-core'
37
48
  * @property {string} [event] - Event name to emit (e.g., 'editRow')
38
49
  * @property {Function} [handler] - Custom handler function (row) => void
39
50
  * @property {boolean|string} [confirm] - Show confirmation dialog (true or custom message)
51
+ * @property {string} [confirmTitle] - Custom confirmation dialog title (default: 'Confirm')
52
+ * @property {string} [confirmType] - Confirmation dialog type: 'info', 'success', 'warning', 'danger' (default: 'warning')
53
+ * @property {string} [confirmText] - Confirm button text (default: 'Confirm')
54
+ * @property {string} [cancelText] - Cancel button text (default: 'Cancel')
55
+ * @property {string} [confirmVariant] - Confirm button variant (default: 'primary')
40
56
  * @property {Function} [condition] - Conditional visibility (row) => boolean
41
57
  */
42
58
 
@@ -89,7 +105,7 @@ export const actionsRenderer = (sparkTable) => {
89
105
  }
90
106
 
91
107
  // Add click handler
92
- button.addEventListener('click', (e) => {
108
+ button.addEventListener('click', async (e) => {
93
109
  e.preventDefault()
94
110
  e.stopPropagation()
95
111
 
@@ -100,7 +116,16 @@ export const actionsRenderer = (sparkTable) => {
100
116
  ? action.confirm
101
117
  : `Are you sure you want to ${action.label?.toLowerCase() || 'perform this action'}?`
102
118
 
103
- if (!window.confirm(confirmMessage)) {
119
+ const confirmed = await sparkModalService.confirm({
120
+ title: action.confirmTitle,
121
+ message: confirmMessage,
122
+ type: action.confirmType,
123
+ confirmText: action.confirmText,
124
+ cancelText: action.cancelText,
125
+ confirmVariant: action.confirmVariant,
126
+ })
127
+
128
+ if (!confirmed) {
104
129
  return
105
130
  }
106
131
  }