@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -0,0 +1,266 @@
1
+ <template>
2
+ <nav :class="containerClasses">
3
+ <!-- Custom layout slot -->
4
+ <slot
5
+ v-if="props.layout === 'custom'"
6
+ :items="computedItems"
7
+ :active-id="computedActiveId"
8
+ :is-active="isItemActive"
9
+ :is-disabled="isItemDisabled"
10
+ :navigate="handleNavigate"
11
+ />
12
+
13
+ <!-- Horizontal: SparkButtonGroup -->
14
+ <SparkButtonGroup v-else-if="props.layout === 'horizontal'" class="isolate">
15
+ <SparkButton
16
+ v-for="item in computedItems"
17
+ :key="item.id"
18
+ size="xl"
19
+ :variant="isItemActive(item) ? 'primary' : 'secondary'"
20
+ :disabled="isItemDisabled(item)"
21
+ @click="handleNavigate(item)"
22
+ >
23
+ <FontAwesomeIcon
24
+ v-if="item.icon"
25
+ :icon="Icons[item.icon]"
26
+ class="mr-1.5 size-4"
27
+ />
28
+ {{ item.label }}
29
+ <span
30
+ v-if="item.badge != null"
31
+ :class="getBadgeClasses(item)"
32
+ >
33
+ {{ item.badge }}
34
+ </span>
35
+ </SparkButton>
36
+ </SparkButtonGroup>
37
+
38
+ <!-- Tabs/Vertical: button-based -->
39
+ <template v-else>
40
+ <button
41
+ v-for="item in computedItems"
42
+ :key="item.id"
43
+ :class="getItemClasses(item)"
44
+ :disabled="isItemDisabled(item) || undefined"
45
+ @click="handleNavigate(item)"
46
+ >
47
+ <!-- Active checkmark icon for vertical layout -->
48
+ <FontAwesomeIcon
49
+ v-if="isItemActive(item) && props.layout === 'vertical'"
50
+ :icon="Icons.farCircleCheck"
51
+ class="mr-2 size-4"
52
+ />
53
+ <!-- Item icon (not for vertical active state) -->
54
+ <FontAwesomeIcon
55
+ v-if="item.icon && !(isItemActive(item) && props.layout === 'vertical')"
56
+ :icon="Icons[item.icon]"
57
+ class="mr-2"
58
+ :class="iconClasses"
59
+ />
60
+ <span>{{ item.label }}</span>
61
+ <span
62
+ v-if="item.badge != null"
63
+ :class="getBadgeClasses(item)"
64
+ >
65
+ {{ item.badge }}
66
+ </span>
67
+ </button>
68
+ </template>
69
+ </nav>
70
+ </template>
71
+
72
+ <script setup>
73
+ import { computed } from 'vue'
74
+ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
75
+ import { Icons } from '../plugins/fontawesome.js'
76
+ import SparkButton from './SparkButton.vue'
77
+ import SparkButtonGroup from './SparkButtonGroup.vue'
78
+
79
+ const props = defineProps({
80
+ /**
81
+ * Navigation instance from useSubNavigation composable.
82
+ * If provided, items and active state come from the instance.
83
+ */
84
+ navInstance: {
85
+ type: Object,
86
+ default: null,
87
+ },
88
+
89
+ /**
90
+ * Static items array (used when navInstance is not provided)
91
+ */
92
+ items: {
93
+ type: Array,
94
+ default: () => [],
95
+ },
96
+
97
+ /**
98
+ * Layout style
99
+ * - 'tabs': Underline-style tabs (for card body)
100
+ * - 'vertical': Vertical sidebar links
101
+ * - 'horizontal': SparkButtonGroup with buttons
102
+ * - 'custom': Use scoped slot for full control
103
+ */
104
+ layout: {
105
+ type: String,
106
+ default: 'tabs',
107
+ validator: (v) => ['tabs', 'vertical', 'horizontal', 'custom'].includes(v),
108
+ },
109
+
110
+ /**
111
+ * Currently active item ID (for controlled mode without navInstance)
112
+ */
113
+ activeId: {
114
+ type: String,
115
+ default: null,
116
+ },
117
+
118
+ /**
119
+ * Compact mode (smaller padding/text)
120
+ */
121
+ compact: {
122
+ type: Boolean,
123
+ default: false,
124
+ },
125
+ })
126
+
127
+ const emit = defineEmits(['navigate', 'update:activeId'])
128
+
129
+ // Compute items from navInstance or props
130
+ const computedItems = computed(() => {
131
+ if (props.navInstance) {
132
+ return props.navInstance.visibleItems.value
133
+ }
134
+ return props.items.filter((item) => {
135
+ const hidden = typeof item.hidden === 'function' ? item.hidden() : item.hidden
136
+ return !hidden
137
+ })
138
+ })
139
+
140
+ // Compute active ID from navInstance or props
141
+ const computedActiveId = computed(() => {
142
+ if (props.navInstance) {
143
+ return props.navInstance.activeId.value
144
+ }
145
+ return props.activeId
146
+ })
147
+
148
+ // Check if item is active
149
+ function isItemActive(item) {
150
+ if (props.navInstance) {
151
+ return props.navInstance.isActive(item)
152
+ }
153
+ return item.id === props.activeId
154
+ }
155
+
156
+ // Check if item is disabled
157
+ function isItemDisabled(item) {
158
+ if (props.navInstance) {
159
+ return props.navInstance.isDisabled(item)
160
+ }
161
+ const disabled = typeof item.disabled === 'function' ? item.disabled() : item.disabled
162
+ return disabled
163
+ }
164
+
165
+ // Handle navigation
166
+ async function handleNavigate(item) {
167
+ if (isItemDisabled(item)) return
168
+
169
+ if (props.navInstance) {
170
+ await props.navInstance.navigateTo(item)
171
+ }
172
+
173
+ emit('navigate', item)
174
+ emit('update:activeId', item.id)
175
+ }
176
+
177
+ // Container classes based on layout
178
+ const containerClasses = computed(() => {
179
+ const base = 'spark-sub-nav'
180
+ const layoutClasses = {
181
+ tabs: 'flex items-center gap-1 -mb-px',
182
+ vertical: 'flex flex-col gap-1',
183
+ horizontal: '', // SparkButtonGroup handles its own styling
184
+ custom: '',
185
+ }
186
+ return [base, layoutClasses[props.layout]]
187
+ })
188
+
189
+ // Icon classes
190
+ const iconClasses = computed(() => {
191
+ return props.compact ? 'text-sm' : ''
192
+ })
193
+
194
+ // Get classes for nav item (tabs and vertical only)
195
+ function getItemClasses(item) {
196
+ const isActive = isItemActive(item)
197
+ const isDisabled = isItemDisabled(item)
198
+
199
+ const base = [
200
+ 'inline-flex items-center transition-colors cursor-pointer',
201
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1',
202
+ ]
203
+
204
+ // Disabled state
205
+ if (isDisabled) {
206
+ base.push('opacity-50 cursor-not-allowed')
207
+ }
208
+
209
+ // Layout-specific styles
210
+ if (props.layout === 'tabs') {
211
+ base.push(...getTabsClasses(isActive, isDisabled))
212
+ } else if (props.layout === 'vertical') {
213
+ base.push(...getVerticalClasses(isActive, isDisabled))
214
+ }
215
+
216
+ return base
217
+ }
218
+
219
+ // Tabs layout (underline style)
220
+ function getTabsClasses(isActive, isDisabled) {
221
+ const classes = ['border-b-2 -mb-px rounded-t font-medium']
222
+
223
+ // Size classes
224
+ const sizeClasses = props.compact ? 'text-sm px-3 py-1.5' : 'text-sm px-8 py-4'
225
+ classes.push(sizeClasses)
226
+
227
+ if (isActive) {
228
+ classes.push('border-primary-600 text-primary-600')
229
+ } else if (!isDisabled) {
230
+ classes.push('border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300')
231
+ } else {
232
+ classes.push('border-transparent text-gray-400')
233
+ }
234
+
235
+ return classes
236
+ }
237
+
238
+ // Vertical layout (simplified with checkmark)
239
+ function getVerticalClasses(isActive, isDisabled) {
240
+ const classes = ['font-medium text-sm py-1']
241
+
242
+ if (isActive) {
243
+ classes.push('text-primary-600')
244
+ } else if (!isDisabled) {
245
+ classes.push('text-gray-600 hover:text-gray-900')
246
+ } else {
247
+ classes.push('text-gray-400')
248
+ }
249
+
250
+ return classes
251
+ }
252
+
253
+ // Badge classes
254
+ function getBadgeClasses(item) {
255
+ const variant = item.badgeVariant || 'primary'
256
+ const variants = {
257
+ primary: 'bg-primary-100 text-primary-700',
258
+ secondary: 'bg-gray-100 text-gray-700',
259
+ success: 'bg-green-100 text-green-700',
260
+ warning: 'bg-yellow-100 text-yellow-700',
261
+ danger: 'bg-red-100 text-red-700',
262
+ }
263
+
264
+ return ['ml-2 px-2 py-0.5 text-xs font-medium rounded-full', variants[variant] || variants.primary]
265
+ }
266
+ </script>
@@ -10,6 +10,7 @@ export { default as SparkImageUpload } from './SparkImageUpload.vue'
10
10
  export { default as SparkModalContainer } from './SparkModalContainer.vue'
11
11
  export { default as SparkModalDialog } from './SparkModalDialog.vue'
12
12
  export { default as SparkOverlay } from './SparkOverlay.vue'
13
+ export { default as SparkSubNav } from './SparkSubNav.vue'
13
14
  export { default as SparkTable } from './SparkTable.vue'
14
15
  export { default as SparkTablePaginationPaging } from './SparkTablePaginationPaging.vue'
15
16
  export { default as SparkTablePaginationPerPage } from './SparkTablePaginationPerPage.vue'
@@ -3,4 +3,11 @@ export { sparkNotificationService } from './sparkNotificationService.js'
3
3
  export { sparkOverlayService } from './sparkOverlayService.js'
4
4
  export { useSparkOverlay } from './useSparkOverlay.js'
5
5
  export { useSparkTableRouteSync } from './useSparkTableRouteSync.js'
6
- export { useFormSubmission } from './useFormSubmission.js'
6
+ export { useFormSubmission } from './useFormSubmission.js'
7
+ export { useSubNavigation } from './useSubNavigation.js'
8
+ export {
9
+ useFormDirtyGuard,
10
+ hasAnyDirtyForm,
11
+ getDirtyFormMessage,
12
+ clearAllDirtyForms,
13
+ } from './useFormDirtyGuard.js'
@@ -0,0 +1,211 @@
1
+ import { ref, onUnmounted } from 'vue'
2
+ import { onBeforeRouteLeave } from 'vue-router'
3
+ import { sparkModalService } from './sparkModalService.js'
4
+
5
+ /**
6
+ * Global registry of dirty form instances.
7
+ * Each entry maps a unique ID to its dirty state and message.
8
+ */
9
+ const dirtyFormRegistry = new Map()
10
+
11
+ /**
12
+ * Counter for generating unique form IDs
13
+ */
14
+ let formIdCounter = 0
15
+
16
+ /**
17
+ * Check if any form in the registry is dirty
18
+ * @returns {boolean}
19
+ */
20
+ export function hasAnyDirtyForm() {
21
+ for (const entry of dirtyFormRegistry.values()) {
22
+ if (entry.isDirty) {
23
+ return true
24
+ }
25
+ }
26
+ return false
27
+ }
28
+
29
+ /**
30
+ * Get the first dirty form's message (for display in confirmation dialog)
31
+ * @returns {string|null}
32
+ */
33
+ export function getDirtyFormMessage() {
34
+ for (const entry of dirtyFormRegistry.values()) {
35
+ if (entry.isDirty && entry.message) {
36
+ return entry.message
37
+ }
38
+ }
39
+ return null
40
+ }
41
+
42
+ /**
43
+ * Clear all dirty states (useful for testing or forced navigation)
44
+ */
45
+ export function clearAllDirtyForms() {
46
+ for (const entry of dirtyFormRegistry.values()) {
47
+ entry.isDirty = false
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Composable for tracking dirty form state with route guard and browser warning support.
53
+ *
54
+ * This composable is completely independent of sub-navigation - use it on any page with forms.
55
+ *
56
+ * @param {Object} options - Configuration options
57
+ * @param {string} [options.message='You have unsaved changes. Leave anyway?'] - Confirmation message
58
+ * @param {string} [options.title='Unsaved Changes'] - Confirmation dialog title
59
+ * @param {string} [options.confirmText='Leave'] - Confirm button text
60
+ * @param {string} [options.cancelText='Stay'] - Cancel button text
61
+ * @param {boolean} [options.useRouteGuard=true] - Whether to use onBeforeRouteLeave guard
62
+ * @returns {Object} Form dirty guard utilities
63
+ *
64
+ * @example
65
+ * const {
66
+ * isDirty,
67
+ * setDirty,
68
+ * setClean,
69
+ * checkDirty,
70
+ * enableBrowserWarning,
71
+ * disableBrowserWarning
72
+ * } = useFormDirtyGuard({
73
+ * message: 'You have unsaved changes. Leave anyway?'
74
+ * })
75
+ *
76
+ * // On form input change
77
+ * const handleChange = () => setDirty()
78
+ *
79
+ * // After successful save
80
+ * const handleSave = async () => {
81
+ * await saveData()
82
+ * setClean()
83
+ * }
84
+ *
85
+ * // Enable browser close/reload warning
86
+ * onMounted(() => enableBrowserWarning())
87
+ * onUnmounted(() => disableBrowserWarning())
88
+ */
89
+ export function useFormDirtyGuard(options = {}) {
90
+ const {
91
+ message = 'You have unsaved changes. Leave anyway?',
92
+ title = 'Unsaved Changes',
93
+ confirmText = 'Leave',
94
+ cancelText = 'Stay',
95
+ useRouteGuard = true,
96
+ } = options
97
+
98
+ // Generate unique ID for this form instance
99
+ const formId = `form-${++formIdCounter}`
100
+
101
+ // Local reactive state
102
+ const isDirty = ref(false)
103
+
104
+ // Register in global registry
105
+ dirtyFormRegistry.set(formId, {
106
+ isDirty: false,
107
+ message,
108
+ })
109
+
110
+ /**
111
+ * Mark the form as dirty
112
+ * @param {boolean} [dirty=true] - Whether the form is dirty
113
+ */
114
+ const setDirty = (dirty = true) => {
115
+ isDirty.value = dirty
116
+ const entry = dirtyFormRegistry.get(formId)
117
+ if (entry) {
118
+ entry.isDirty = dirty
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Mark the form as clean (no unsaved changes)
124
+ */
125
+ const setClean = () => {
126
+ setDirty(false)
127
+ }
128
+
129
+ /**
130
+ * Check if form is dirty and optionally show confirmation dialog
131
+ * @param {Object} [confirmOptions] - Override default confirmation options
132
+ * @returns {Promise<boolean>} - True if safe to proceed (clean or user confirmed), false if should stay
133
+ */
134
+ const checkDirty = async (confirmOptions = {}) => {
135
+ if (!isDirty.value) {
136
+ return true
137
+ }
138
+
139
+ const confirmed = await sparkModalService.confirm({
140
+ title: confirmOptions.title || title,
141
+ message: confirmOptions.message || message,
142
+ confirmText: confirmOptions.confirmText || confirmText,
143
+ cancelText: confirmOptions.cancelText || cancelText,
144
+ type: 'warning',
145
+ })
146
+
147
+ if (confirmed) {
148
+ // User chose to leave, clear dirty state
149
+ setClean()
150
+ }
151
+
152
+ return confirmed
153
+ }
154
+
155
+ // Browser beforeunload handler
156
+ let browserWarningEnabled = false
157
+ const handleBeforeUnload = (event) => {
158
+ if (isDirty.value) {
159
+ // Standard way to trigger browser's native "unsaved changes" dialog
160
+ event.preventDefault()
161
+ // Some browsers require returnValue to be set
162
+ event.returnValue = ''
163
+ return ''
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Enable browser close/reload warning when form is dirty
169
+ */
170
+ const enableBrowserWarning = () => {
171
+ if (!browserWarningEnabled) {
172
+ window.addEventListener('beforeunload', handleBeforeUnload)
173
+ browserWarningEnabled = true
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Disable browser close/reload warning
179
+ */
180
+ const disableBrowserWarning = () => {
181
+ if (browserWarningEnabled) {
182
+ window.removeEventListener('beforeunload', handleBeforeUnload)
183
+ browserWarningEnabled = false
184
+ }
185
+ }
186
+
187
+ // Setup route guard if enabled
188
+ if (useRouteGuard) {
189
+ onBeforeRouteLeave(async () => {
190
+ const canLeave = await checkDirty()
191
+ return canLeave
192
+ })
193
+ }
194
+
195
+ // Cleanup on unmount
196
+ onUnmounted(() => {
197
+ // Remove from registry
198
+ dirtyFormRegistry.delete(formId)
199
+ // Remove browser warning listener
200
+ disableBrowserWarning()
201
+ })
202
+
203
+ return {
204
+ isDirty,
205
+ setDirty,
206
+ setClean,
207
+ checkDirty,
208
+ enableBrowserWarning,
209
+ disableBrowserWarning,
210
+ }
211
+ }