@wishbone-media/spark 0.29.0 → 0.30.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,225 @@
1
+ import { ref, computed, watch, markRaw } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+ import { sparkModalService } from './sparkModalService.js'
4
+ import { sparkOverlayService } from './sparkOverlayService.js'
5
+
6
+ /**
7
+ * Composable for managing sub-navigation state with support for routes, overlays, modals, and custom actions.
8
+ *
9
+ * @param {Object} options - Configuration options
10
+ * @param {Array} options.items - Navigation items array
11
+ * @param {string} [options.defaultId] - Default active item ID (falls back to first visible item)
12
+ * @returns {Object} Sub-navigation utilities
13
+ *
14
+ * @example
15
+ * const subNav = useSubNavigation({
16
+ * items: [
17
+ * { id: 'general', label: 'General', route: { name: 'brands.general' } },
18
+ * { id: 'templates', label: 'Templates', route: { name: 'brands.templates' } },
19
+ * { id: 'preview', label: 'Preview', overlay: { component: PreviewPanel, position: 'right' } },
20
+ * { id: 'delete', label: 'Delete', action: () => confirmDelete(), icon: 'farTrash' },
21
+ * ]
22
+ * })
23
+ *
24
+ * // Item structure:
25
+ * // {
26
+ * // id: 'general', // Unique identifier (required)
27
+ * // label: 'General', // Display text (required)
28
+ * // icon: 'farGear', // Optional FontAwesome icon key
29
+ * // badge: 3, // Optional badge (number or string)
30
+ * // badgeVariant: 'primary', // Badge color variant (default: 'primary')
31
+ * //
32
+ * // // Navigation type (choose one):
33
+ * // route: { name: 'brands.general', params: { id } }, // Vue Router navigation
34
+ * // overlay: { component, props, position, size }, // sparkOverlayService
35
+ * // modal: { component, props }, // sparkModalService
36
+ * // action: () => {}, // Custom callback
37
+ * //
38
+ * // // State (can be boolean or function returning boolean):
39
+ * // disabled: false,
40
+ * // hidden: false,
41
+ * // }
42
+ */
43
+ export function useSubNavigation(options = {}) {
44
+ const { items: initialItems = [], defaultId = null } = options
45
+
46
+ const router = useRouter()
47
+ const route = useRoute()
48
+
49
+ // Reactive items list
50
+ const items = ref(initialItems.map(normalizeItem))
51
+
52
+ /**
53
+ * Normalize item to ensure consistent structure
54
+ */
55
+ function normalizeItem(item) {
56
+ return {
57
+ id: item.id,
58
+ label: item.label,
59
+ icon: item.icon || null,
60
+ badge: item.badge ?? null,
61
+ badgeVariant: item.badgeVariant || 'primary',
62
+ route: item.route || null,
63
+ overlay: item.overlay || null,
64
+ modal: item.modal || null,
65
+ action: item.action || null,
66
+ disabled: item.disabled ?? false,
67
+ hidden: item.hidden ?? false,
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Evaluate a value that might be a function
73
+ */
74
+ function evaluate(value) {
75
+ return typeof value === 'function' ? value() : value
76
+ }
77
+
78
+ /**
79
+ * Get visible items (not hidden)
80
+ */
81
+ const visibleItems = computed(() => {
82
+ return items.value.filter(item => !evaluate(item.hidden))
83
+ })
84
+
85
+ /**
86
+ * Check if an item is disabled
87
+ */
88
+ function isDisabled(item) {
89
+ const itemObj = typeof item === 'string' ? items.value.find(i => i.id === item) : item
90
+ return itemObj ? evaluate(itemObj.disabled) : false
91
+ }
92
+
93
+ /**
94
+ * Check if an item is the currently active one (for route-based items)
95
+ */
96
+ function isActive(item) {
97
+ const itemObj = typeof item === 'string' ? items.value.find(i => i.id === item) : item
98
+ if (!itemObj || !itemObj.route) return false
99
+
100
+ // Handle route as string (route name) or object
101
+ const itemRoute = typeof itemObj.route === 'string'
102
+ ? { name: itemObj.route }
103
+ : itemObj.route
104
+
105
+ // Match by route name
106
+ if (itemRoute.name && route.name === itemRoute.name) {
107
+ return true
108
+ }
109
+
110
+ // Match by path if provided
111
+ if (itemRoute.path) {
112
+ const resolved = router.resolve(itemRoute)
113
+ return route.path === resolved.path
114
+ }
115
+
116
+ return false
117
+ }
118
+
119
+ /**
120
+ * Get the currently active item based on route matching
121
+ */
122
+ const activeItem = computed(() => {
123
+ // Find item matching current route
124
+ const matchedItem = visibleItems.value.find(item => isActive(item))
125
+ if (matchedItem) return matchedItem
126
+
127
+ // Fall back to default or first visible item
128
+ if (defaultId) {
129
+ return visibleItems.value.find(item => item.id === defaultId) || visibleItems.value[0]
130
+ }
131
+
132
+ return visibleItems.value[0] || null
133
+ })
134
+
135
+ /**
136
+ * Get the active item's ID
137
+ */
138
+ const activeId = computed(() => activeItem.value?.id || null)
139
+
140
+ /**
141
+ * Navigate to an item (route, overlay, modal, or action)
142
+ * @param {string|Object} item - Item ID or item object
143
+ * @returns {Promise<boolean>} - Whether navigation/action was executed
144
+ */
145
+ async function navigateTo(item) {
146
+ const itemObj = typeof item === 'string' ? items.value.find(i => i.id === item) : item
147
+
148
+ if (!itemObj) {
149
+ console.warn(`[useSubNavigation] Item not found: ${item}`)
150
+ return false
151
+ }
152
+
153
+ if (isDisabled(itemObj)) {
154
+ return false
155
+ }
156
+
157
+ // Handle route navigation
158
+ if (itemObj.route) {
159
+ const routeConfig = typeof itemObj.route === 'string'
160
+ ? { name: itemObj.route }
161
+ : itemObj.route
162
+ await router.push(routeConfig)
163
+ return true
164
+ }
165
+
166
+ // Handle overlay
167
+ if (itemObj.overlay) {
168
+ const { component, props = {}, eventHandlers = {}, position = 'right', size } = itemObj.overlay
169
+ const showMethod = position === 'left' ? 'showLeft' : 'showRight'
170
+ sparkOverlayService[showMethod](markRaw(component), props, eventHandlers, { size })
171
+ return true
172
+ }
173
+
174
+ // Handle modal
175
+ if (itemObj.modal) {
176
+ const { component, props = {}, eventHandlers = {} } = itemObj.modal
177
+ sparkModalService.show(markRaw(component), props, eventHandlers)
178
+ return true
179
+ }
180
+
181
+ // Handle custom action
182
+ if (itemObj.action) {
183
+ const result = itemObj.action(itemObj)
184
+ // Support async actions
185
+ if (result instanceof Promise) {
186
+ await result
187
+ }
188
+ return true
189
+ }
190
+
191
+ return false
192
+ }
193
+
194
+ /**
195
+ * Update items list
196
+ * @param {Array} newItems - New items array
197
+ */
198
+ function setItems(newItems) {
199
+ items.value = newItems.map(normalizeItem)
200
+ }
201
+
202
+ /**
203
+ * Update a single item
204
+ * @param {string} id - Item ID
205
+ * @param {Object} updates - Properties to update
206
+ */
207
+ function updateItem(id, updates) {
208
+ const index = items.value.findIndex(item => item.id === id)
209
+ if (index !== -1) {
210
+ items.value[index] = normalizeItem({ ...items.value[index], ...updates })
211
+ }
212
+ }
213
+
214
+ return {
215
+ items,
216
+ visibleItems,
217
+ activeItem,
218
+ activeId,
219
+ navigateTo,
220
+ isActive,
221
+ isDisabled,
222
+ setItems,
223
+ updateItem,
224
+ }
225
+ }
@@ -43,6 +43,7 @@ import {
43
43
  faBolt,
44
44
  faCloudDownload,
45
45
  faInbox,
46
+ faCircleCheck,
46
47
  // Additional icons for FormKit genesis replacement
47
48
  faPlus,
48
49
  faArrowDown,
@@ -106,6 +107,7 @@ export const Icons = {
106
107
  farBolt: faBolt,
107
108
  farCloudDownload: faCloudDownload,
108
109
  farInbox: faInbox,
110
+ farCircleCheck: faCircleCheck,
109
111
 
110
112
  fadSort: faSortDuotone,
111
113
  fadSortDown: faSortDownDuotone,
@@ -1,4 +1,4 @@
1
1
  export { Icons, addIcons, setupFontAwesome, formKitIcons, formKitIconLoader, formKitGenesisOverride } from './fontawesome.js'
2
- export { createAuthRoutes, setupAuthGuards, create403Route, create404Route, setupBootstrapGuard } from './router.js'
2
+ export { createAuthRoutes, setupAuthGuards, create403Route, create404Route, setupBootstrapGuard, setupDirtyFormGuard } from './router.js'
3
3
  export { createAxiosInstance, setupAxios, getAxiosInstance } from './axios.js'
4
4
  export { createBootstrapService } from './app-bootstrap.js'
@@ -1,4 +1,6 @@
1
1
  import { useSparkAuthStore } from '../stores/auth.js'
2
+ import { hasAnyDirtyForm, getDirtyFormMessage, clearAllDirtyForms } from '../composables/useFormDirtyGuard.js'
3
+ import { sparkModalService } from '../composables/sparkModalService.js'
2
4
  import {
3
5
  SparkLoginView,
4
6
  SparkLogoutView,
@@ -212,3 +214,64 @@ export function setupBootstrapGuard(router, bootstrapFn) {
212
214
  }
213
215
  })
214
216
  }
217
+
218
+ /**
219
+ * Setup global dirty form guard to warn users before navigating away from unsaved changes.
220
+ *
221
+ * This guard checks a global registry of dirty forms and shows a confirmation dialog
222
+ * using sparkModalService when attempting to navigate away with unsaved changes.
223
+ *
224
+ * IMPORTANT: This should be called AFTER setupAuthGuards() and setupBootstrapGuard()
225
+ *
226
+ * @param {Router} router - Vue router instance
227
+ * @param {Object} [options] - Configuration options
228
+ * @param {string} [options.title='Unsaved Changes'] - Dialog title
229
+ * @param {string} [options.message='You have unsaved changes. Leave anyway?'] - Default message (can be overridden per-form)
230
+ * @param {string} [options.confirmText='Leave'] - Confirm button text
231
+ * @param {string} [options.cancelText='Stay'] - Cancel button text
232
+ *
233
+ * @example
234
+ * import { setupAuthGuards, setupBootstrapGuard, setupDirtyFormGuard } from '@wishbone-media/spark'
235
+ *
236
+ * // Setup guards in order
237
+ * setupAuthGuards(router)
238
+ * setupBootstrapGuard(router, bootstrapApp)
239
+ * setupDirtyFormGuard(router)
240
+ */
241
+ export function setupDirtyFormGuard(router, options = {}) {
242
+ const {
243
+ title = 'Unsaved Changes',
244
+ message = 'You have unsaved changes. Leave anyway?',
245
+ confirmText = 'Leave',
246
+ cancelText = 'Stay',
247
+ } = options
248
+
249
+ router.beforeEach(async (_to, _from, next) => {
250
+ // Check if any form has unsaved changes
251
+ if (!hasAnyDirtyForm()) {
252
+ next()
253
+ return
254
+ }
255
+
256
+ // Get the message from the first dirty form, or use default
257
+ const displayMessage = getDirtyFormMessage() || message
258
+
259
+ // Show confirmation dialog
260
+ const confirmed = await sparkModalService.confirm({
261
+ title,
262
+ message: displayMessage,
263
+ confirmText,
264
+ cancelText,
265
+ type: 'warning',
266
+ })
267
+
268
+ if (confirmed) {
269
+ // User chose to leave, clear all dirty states and proceed
270
+ clearAllDirtyForms()
271
+ next()
272
+ } else {
273
+ // User chose to stay, cancel navigation
274
+ next(false)
275
+ }
276
+ })
277
+ }