@wishbone-media/spark 0.28.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 +1967 -1605
- package/package.json +1 -1
- package/src/components/SparkNotificationOutlet.vue +44 -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/sparkNotificationService.js +19 -0
- 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
package/package.json
CHANGED
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
</template>
|
|
31
31
|
|
|
32
32
|
<script setup>
|
|
33
|
-
import { computed, ref, watch } from 'vue'
|
|
33
|
+
import { computed, ref, watch, onMounted } from 'vue'
|
|
34
|
+
import { useRoute } from 'vue-router'
|
|
34
35
|
import { sparkNotificationService } from '@/composables/sparkNotificationService'
|
|
35
36
|
import SparkAlert from '@/components/SparkAlert.vue'
|
|
36
37
|
|
|
@@ -39,8 +40,13 @@ const props = defineProps({
|
|
|
39
40
|
type: String,
|
|
40
41
|
default: 'default',
|
|
41
42
|
},
|
|
43
|
+
clearOnRouteChange: {
|
|
44
|
+
type: Boolean,
|
|
45
|
+
default: true,
|
|
46
|
+
},
|
|
42
47
|
})
|
|
43
48
|
|
|
49
|
+
const route = useRoute()
|
|
44
50
|
const outlet = computed(() => sparkNotificationService.getOutlet(props.name))
|
|
45
51
|
|
|
46
52
|
// Generate unique key for each notification to trigger transition on content change
|
|
@@ -54,6 +60,43 @@ watch(
|
|
|
54
60
|
},
|
|
55
61
|
)
|
|
56
62
|
|
|
63
|
+
// Track which path the notification was shown on
|
|
64
|
+
watch(
|
|
65
|
+
() => outlet.value.state.isVisible,
|
|
66
|
+
(isVisible) => {
|
|
67
|
+
if (isVisible && !outlet.value.state.shownOnPath) {
|
|
68
|
+
outlet.value.state.shownOnPath = route.path
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{ immediate: true },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// On mount, check if notification was shown on a different route
|
|
75
|
+
onMounted(() => {
|
|
76
|
+
if (!props.clearOnRouteChange) return
|
|
77
|
+
if (route.meta.preserveNotifications) return
|
|
78
|
+
if (!outlet.value.state.isVisible) return
|
|
79
|
+
if (outlet.value.state.persistent) return
|
|
80
|
+
|
|
81
|
+
// If notification was shown on a different path, hide it
|
|
82
|
+
if (outlet.value.state.shownOnPath && outlet.value.state.shownOnPath !== route.path) {
|
|
83
|
+
outlet.value.hide()
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Auto-clear notification on route change (while mounted)
|
|
88
|
+
watch(
|
|
89
|
+
() => route.path,
|
|
90
|
+
() => {
|
|
91
|
+
if (!props.clearOnRouteChange) return
|
|
92
|
+
if (route.meta.preserveNotifications) return
|
|
93
|
+
if (!outlet.value.state.isVisible) return
|
|
94
|
+
if (outlet.value.state.persistent) return
|
|
95
|
+
|
|
96
|
+
outlet.value.hide()
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
57
100
|
const handleClose = () => {
|
|
58
101
|
sparkNotificationService.hide(props.name)
|
|
59
102
|
}
|
|
@@ -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>
|
package/src/components/index.js
CHANGED
|
@@ -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'
|
package/src/composables/index.js
CHANGED
|
@@ -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'
|
|
@@ -24,6 +24,8 @@ function createNotificationOutlet() {
|
|
|
24
24
|
props: {},
|
|
25
25
|
closeable: true,
|
|
26
26
|
duration: null,
|
|
27
|
+
persistent: false,
|
|
28
|
+
shownOnPath: null,
|
|
27
29
|
})
|
|
28
30
|
|
|
29
31
|
// Timer state (not reactive, internal only)
|
|
@@ -90,6 +92,7 @@ function createNotificationOutlet() {
|
|
|
90
92
|
props = {},
|
|
91
93
|
closeable = true,
|
|
92
94
|
duration = null,
|
|
95
|
+
persistent = false,
|
|
93
96
|
} = options
|
|
94
97
|
|
|
95
98
|
state.type = type
|
|
@@ -97,6 +100,7 @@ function createNotificationOutlet() {
|
|
|
97
100
|
state.component = component ? markRaw(component) : null
|
|
98
101
|
state.props = props
|
|
99
102
|
state.closeable = closeable
|
|
103
|
+
state.persistent = persistent
|
|
100
104
|
state.isVisible = true
|
|
101
105
|
|
|
102
106
|
// Determine duration: explicit > default for type
|
|
@@ -116,6 +120,8 @@ function createNotificationOutlet() {
|
|
|
116
120
|
state.props = {}
|
|
117
121
|
state.closeable = true
|
|
118
122
|
state.duration = null
|
|
123
|
+
state.persistent = false
|
|
124
|
+
state.shownOnPath = null
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
return {
|
|
@@ -187,6 +193,7 @@ class SparkNotificationService {
|
|
|
187
193
|
* @param {Object} options.props - Props to pass to the component
|
|
188
194
|
* @param {boolean} options.closeable - Whether to show close button (default: true)
|
|
189
195
|
* @param {number} options.duration - Auto-dismiss duration in ms (0 = sticky, null = use default)
|
|
196
|
+
* @param {boolean} options.persistent - Whether notification survives route changes (default: false)
|
|
190
197
|
* @param {string} outletName - Target outlet name (default: 'default')
|
|
191
198
|
*/
|
|
192
199
|
show(options = {}, outletName = 'default') {
|
|
@@ -212,6 +219,18 @@ class SparkNotificationService {
|
|
|
212
219
|
}
|
|
213
220
|
}
|
|
214
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Hide all non-persistent banner notifications
|
|
224
|
+
* Called by setupNotificationGuard on route change
|
|
225
|
+
*/
|
|
226
|
+
hideAllNonPersistent() {
|
|
227
|
+
for (const outlet of this.outlets.values()) {
|
|
228
|
+
if (!outlet.state.persistent) {
|
|
229
|
+
outlet.hide()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
215
234
|
/**
|
|
216
235
|
* Pause auto-dismiss timer for the specified banner outlet
|
|
217
236
|
* @param {string} outletName - Target outlet name (default: 'default')
|
|
@@ -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
|
+
}
|