@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.
- package/dist/index.js +1815 -1482
- package/package.json +1 -1
- package/src/components/SparkSubNav.vue +266 -0
- package/src/components/index.js +1 -0
- package/src/composables/index.js +8 -1
- package/src/composables/useFormDirtyGuard.js +211 -0
- package/src/composables/useSubNavigation.js +225 -0
- package/src/plugins/fontawesome.js +2 -0
- package/src/plugins/index.js +1 -1
- package/src/plugins/router.js +63 -0
|
@@ -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,
|
package/src/plugins/index.js
CHANGED
|
@@ -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'
|
package/src/plugins/router.js
CHANGED
|
@@ -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
|
+
}
|