@fy-/fws-vue 2.3.5 → 2.3.7
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/components/ui/DefaultModal.vue +262 -243
- package/components/ui/DefaultModalOld.vue +304 -0
- package/composables/seo.ts +1 -3
- package/package.json +1 -1
|
@@ -1,304 +1,323 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { XMarkIcon } from '@heroicons/vue/24/solid'
|
|
3
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
4
4
|
import { useEventBus } from '../../composables/event-bus'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const nextZIndex = highestZIndex + 1
|
|
28
|
-
|
|
29
|
-
// If we're approaching the upper limit, reset all
|
|
30
|
-
if (nextZIndex >= maxZIndex) {
|
|
31
|
-
this.resetAllZIndexes()
|
|
32
|
-
return baseZIndex
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return nextZIndex
|
|
36
|
-
},
|
|
37
|
-
resetAllZIndexes() {
|
|
38
|
-
// Sort by current z-index
|
|
39
|
-
const entries = Array.from(this.modals.entries())
|
|
40
|
-
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
41
|
-
entries.sort((a, b) => a[1] - b[1])
|
|
42
|
-
|
|
43
|
-
// Reassign starting from base
|
|
44
|
-
let newIndex = 40
|
|
45
|
-
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
46
|
-
entries.forEach(([id, _]) => {
|
|
47
|
-
this.modals.set(id, newIndex)
|
|
48
|
-
newIndex++
|
|
49
|
-
})
|
|
50
|
-
},
|
|
51
|
-
}
|
|
5
|
+
import FadeTransition from './transitions/FadeTransition.vue'
|
|
6
|
+
|
|
7
|
+
// Props interface for the modal component
|
|
8
|
+
interface ModalProps {
|
|
9
|
+
/** Unique identifier for the modal (required) */
|
|
10
|
+
id: string
|
|
11
|
+
/** Optional title to be displayed in the modal header */
|
|
12
|
+
title?: string
|
|
13
|
+
/** Optional callback function to be called when the modal is opened */
|
|
14
|
+
onOpen?: Function
|
|
15
|
+
/** Optional callback function to be called when the modal is closed */
|
|
16
|
+
onClose?: Function
|
|
17
|
+
/** Optional size class for the modal (defaults to 'md') */
|
|
18
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
19
|
+
/** Optional position class for the modal (defaults to 'center') */
|
|
20
|
+
position?: 'center' | 'top'
|
|
21
|
+
/** Whether to disable closing the modal when clicking outside (defaults to false) */
|
|
22
|
+
persistent?: boolean
|
|
23
|
+
/** Whether to disable the close button (defaults to false) */
|
|
24
|
+
hideCloseButton?: boolean
|
|
25
|
+
/** Whether to disable the close on escape key (defaults to false) */
|
|
26
|
+
noEscDismiss?: boolean
|
|
52
27
|
}
|
|
53
28
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
onClose?: Function
|
|
63
|
-
closeIcon?: object
|
|
64
|
-
mSize?: string
|
|
65
|
-
ofy?: string
|
|
66
|
-
}>(),
|
|
67
|
-
{
|
|
68
|
-
closeIcon: () => h(XCircleIcon),
|
|
69
|
-
mSize: 'w-full',
|
|
70
|
-
ofy: 'overflow-y-auto cool-scroll-modal',
|
|
71
|
-
},
|
|
72
|
-
)
|
|
29
|
+
// Default prop values
|
|
30
|
+
const props = withDefaults(defineProps<ModalProps>(), {
|
|
31
|
+
size: 'md',
|
|
32
|
+
position: 'center',
|
|
33
|
+
persistent: false,
|
|
34
|
+
hideCloseButton: false,
|
|
35
|
+
noEscDismiss: false,
|
|
36
|
+
})
|
|
73
37
|
|
|
38
|
+
// Modal state
|
|
39
|
+
const isVisible = ref(false)
|
|
40
|
+
const modalRef = ref<HTMLElement | null>(null)
|
|
41
|
+
const headerRef = ref<HTMLElement | null>(null)
|
|
42
|
+
const contentRef = ref<HTMLElement | null>(null)
|
|
43
|
+
const closeButtonRef = ref<HTMLElement | null>(null)
|
|
44
|
+
const lastActiveElement = ref<HTMLElement | null>(null)
|
|
45
|
+
const zIndex = ref(1000)
|
|
46
|
+
|
|
47
|
+
// Event bus instance
|
|
74
48
|
const eventBus = useEventBus()
|
|
75
49
|
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
50
|
+
// Track all modals to manage z-index properly
|
|
51
|
+
const modalStack = (() => {
|
|
52
|
+
if (typeof window !== 'undefined') {
|
|
53
|
+
// @ts-expect-error: TS doesn't know about the global modal stack
|
|
54
|
+
window.__FWS_MODAL_STACK = window.__FWS_MODAL_STACK || {
|
|
55
|
+
modals: new Map(),
|
|
56
|
+
baseZIndex: 1000,
|
|
57
|
+
register(id: string) {
|
|
58
|
+
const current = this.modals.size
|
|
59
|
+
const z = this.baseZIndex + (current * 10)
|
|
60
|
+
this.modals.set(id, z)
|
|
61
|
+
return z
|
|
62
|
+
},
|
|
63
|
+
unregister(id: string) {
|
|
64
|
+
this.modals.delete(id)
|
|
65
|
+
},
|
|
66
|
+
isTopModal(id: string) {
|
|
67
|
+
if (this.modals.size === 0) return false
|
|
68
|
+
// Get the last added modal (should be the top one)
|
|
69
|
+
return Array.from(this.modals.keys()).pop() === id
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
// @ts-expect-error: TS doesn't know about the global modal stack
|
|
73
|
+
return window.__FWS_MODAL_STACK
|
|
74
|
+
}
|
|
80
75
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
// Fallback for SSR
|
|
77
|
+
return {
|
|
78
|
+
modals: new Map(),
|
|
79
|
+
baseZIndex: 1000,
|
|
80
|
+
register: () => 1000,
|
|
81
|
+
unregister: () => {},
|
|
82
|
+
isTopModal: () => true,
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
|
|
86
|
+
// Focusable elements within the modal for keyboard navigation
|
|
87
|
+
const focusableElements = computed(() => {
|
|
88
|
+
if (!modalRef.value) return []
|
|
85
89
|
|
|
86
|
-
// Trap focus within modal for accessibility
|
|
87
|
-
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
88
90
|
return Array.from(
|
|
89
|
-
|
|
90
|
-
'a[href], button, input, textarea, select,
|
|
91
|
+
modalRef.value.querySelectorAll(
|
|
92
|
+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
91
93
|
),
|
|
92
|
-
).filter(
|
|
93
|
-
el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
94
94
|
) as HTMLElement[]
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function handleKeyDown(event: KeyboardEvent) {
|
|
98
|
-
// Only handle events for the top-most modal
|
|
99
|
-
if (!isOpen.value) return
|
|
95
|
+
})
|
|
100
96
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return
|
|
97
|
+
// Size class mapping
|
|
98
|
+
const sizeClass = computed(() => {
|
|
99
|
+
switch (props.size) {
|
|
100
|
+
case 'sm': return 'max-w-md'
|
|
101
|
+
case 'md': return 'max-w-lg'
|
|
102
|
+
case 'lg': return 'max-w-2xl'
|
|
103
|
+
case 'xl': return 'max-w-4xl'
|
|
104
|
+
case 'full': return 'max-w-full m-4'
|
|
105
|
+
default: return 'max-w-lg'
|
|
105
106
|
}
|
|
107
|
+
})
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return
|
|
109
|
+
// Position class mapping
|
|
110
|
+
const positionClass = computed(() => {
|
|
111
|
+
switch (props.position) {
|
|
112
|
+
case 'top': return 'items-start pt-16'
|
|
113
|
+
default: return 'items-center'
|
|
112
114
|
}
|
|
115
|
+
})
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
event.preventDefault()
|
|
119
|
-
focusableElements[focusableElements.length - 1].focus()
|
|
120
|
-
}
|
|
121
|
-
// If tab on last element, focus first element
|
|
122
|
-
else if (!event.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) {
|
|
123
|
-
event.preventDefault()
|
|
124
|
-
focusableElements[0].focus()
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
117
|
+
// Check if this is the topmost modal
|
|
118
|
+
const isTopModal = computed(() => {
|
|
119
|
+
return modalStack.isTopModal(props.id)
|
|
120
|
+
})
|
|
128
121
|
|
|
129
|
-
//
|
|
130
|
-
function
|
|
131
|
-
if (
|
|
122
|
+
// Manage modal state
|
|
123
|
+
function setModalState(state: boolean) {
|
|
124
|
+
if (state === isVisible.value) return
|
|
132
125
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
137
|
-
current[1] > prev[1] ? current : prev,
|
|
138
|
-
)
|
|
126
|
+
if (state) {
|
|
127
|
+
// Save the currently focused element
|
|
128
|
+
lastActiveElement.value = document.activeElement as HTMLElement
|
|
139
129
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
130
|
+
// Register this modal and get its z-index
|
|
131
|
+
zIndex.value = modalStack.register(props.id)
|
|
143
132
|
|
|
144
|
-
|
|
145
|
-
if (value === true) {
|
|
133
|
+
// Call onOpen callback
|
|
146
134
|
if (props.onOpen) props.onOpen()
|
|
147
|
-
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
148
135
|
|
|
149
|
-
//
|
|
150
|
-
|
|
136
|
+
// Set the body to prevent scrolling
|
|
137
|
+
if (typeof document !== 'undefined') {
|
|
138
|
+
document.body.classList.add('overflow-hidden')
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// Unregister the modal
|
|
143
|
+
modalStack.unregister(props.id)
|
|
151
144
|
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
modalRegistry.modals.set(uniqueId, newZIndex)
|
|
145
|
+
// Call onClose callback
|
|
146
|
+
if (props.onClose) props.onClose()
|
|
155
147
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
modalElement.setAttribute('data-modal-unique-id', uniqueId)
|
|
161
|
-
}
|
|
162
|
-
})
|
|
148
|
+
// Restore body scrolling if no modals are open
|
|
149
|
+
if (typeof document !== 'undefined' && !modalStack.modals.size) {
|
|
150
|
+
document.body.classList.remove('overflow-hidden')
|
|
151
|
+
}
|
|
163
152
|
|
|
164
|
-
//
|
|
165
|
-
|
|
153
|
+
// Restore focus
|
|
154
|
+
if (lastActiveElement.value) {
|
|
155
|
+
nextTick(() => {
|
|
156
|
+
lastActiveElement.value?.focus()
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
166
160
|
|
|
167
|
-
|
|
161
|
+
isVisible.value = state
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Handle clicking outside the modal
|
|
165
|
+
function handleOutsideClick(e: MouseEvent) {
|
|
166
|
+
if (props.persistent) return
|
|
167
|
+
if (modalRef.value && !modalRef.value.contains(e.target as Node)) {
|
|
168
|
+
setModalState(false)
|
|
168
169
|
}
|
|
169
|
-
|
|
170
|
-
if (props.onClose) props.onClose()
|
|
170
|
+
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (uniqueId) {
|
|
177
|
-
modalRegistry.modals.delete(uniqueId)
|
|
178
|
-
}
|
|
179
|
-
}
|
|
172
|
+
// Handle keyboard events
|
|
173
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
174
|
+
// Only handle events for the top modal
|
|
175
|
+
if (!isTopModal.value) return
|
|
180
176
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
177
|
+
// ESC key handling (close modal)
|
|
178
|
+
if (e.key === 'Escape' && !props.noEscDismiss) {
|
|
179
|
+
e.preventDefault()
|
|
180
|
+
setModalState(false)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tab key handling (focus trap)
|
|
185
|
+
if (e.key === 'Tab' && focusableElements.value.length > 0) {
|
|
186
|
+
const firstElement = focusableElements.value[0]
|
|
187
|
+
const lastElement = focusableElements.value[focusableElements.value.length - 1]
|
|
188
|
+
|
|
189
|
+
// If shift+tab on first element, move to last
|
|
190
|
+
if (e.shiftKey && document.activeElement === firstElement) {
|
|
191
|
+
e.preventDefault()
|
|
192
|
+
lastElement.focus()
|
|
193
|
+
}
|
|
194
|
+
// If tab on last element, move to first
|
|
195
|
+
else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
196
|
+
e.preventDefault()
|
|
197
|
+
firstElement.focus()
|
|
184
198
|
}
|
|
185
199
|
}
|
|
186
|
-
isOpen.value = value
|
|
187
200
|
}
|
|
188
201
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
else if (focusableElements.length > 0) {
|
|
202
|
-
focusableElements[0].focus()
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
// If no focusable elements, focus the modal itself
|
|
206
|
-
modalRef.value.focus()
|
|
207
|
-
}
|
|
202
|
+
// Set initial focus when modal opens
|
|
203
|
+
function setInitialFocus() {
|
|
204
|
+
nextTick(() => {
|
|
205
|
+
// Focus hierarchy: close button > first focusable element > modal container
|
|
206
|
+
if (closeButtonRef.value && !props.hideCloseButton) {
|
|
207
|
+
closeButtonRef.value.focus()
|
|
208
|
+
}
|
|
209
|
+
else if (focusableElements.value.length > 0) {
|
|
210
|
+
focusableElements.value[0].focus()
|
|
211
|
+
}
|
|
212
|
+
else if (modalRef.value) {
|
|
213
|
+
modalRef.value.focus()
|
|
208
214
|
}
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Event listeners
|
|
219
|
+
watch(isVisible, (newVal) => {
|
|
220
|
+
if (newVal) {
|
|
221
|
+
setInitialFocus()
|
|
222
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
209
226
|
}
|
|
210
227
|
})
|
|
211
228
|
|
|
229
|
+
// Setup event listeners
|
|
212
230
|
onMounted(() => {
|
|
213
|
-
|
|
231
|
+
// Listen for openModal/closeModal events through the event bus
|
|
232
|
+
eventBus.on(`${props.id}Modal`, setModalState)
|
|
214
233
|
})
|
|
215
234
|
|
|
216
|
-
|
|
217
|
-
|
|
235
|
+
// Clean up
|
|
236
|
+
onBeforeUnmount(() => {
|
|
237
|
+
eventBus.off(`${props.id}Modal`, setModalState)
|
|
218
238
|
document.removeEventListener('keydown', handleKeyDown)
|
|
219
239
|
|
|
220
|
-
// Clean up
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
240
|
+
// Clean up modal stack if the modal is visible when unmounted
|
|
241
|
+
if (isVisible.value) {
|
|
242
|
+
modalStack.unregister(props.id)
|
|
243
|
+
|
|
244
|
+
// Restore body scrolling if no other modals are open
|
|
245
|
+
if (typeof document !== 'undefined' && !modalStack.modals.size) {
|
|
246
|
+
document.body.classList.remove('overflow-hidden')
|
|
228
247
|
}
|
|
229
248
|
}
|
|
230
249
|
})
|
|
231
|
-
|
|
232
|
-
// Click outside to close
|
|
233
|
-
function handleBackdropClick(event: MouseEvent) {
|
|
234
|
-
// Close only if clicking the backdrop, not the modal content
|
|
235
|
-
if (event.target === event.currentTarget) {
|
|
236
|
-
setModal(false)
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
250
|
</script>
|
|
240
251
|
|
|
241
252
|
<template>
|
|
242
|
-
<
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
253
|
+
<FadeTransition>
|
|
254
|
+
<div
|
|
255
|
+
v-if="isVisible"
|
|
256
|
+
class="fixed inset-0 z-[1000] overflow-y-auto"
|
|
257
|
+
:style="{ zIndex }"
|
|
258
|
+
aria-modal="true"
|
|
259
|
+
role="dialog"
|
|
260
|
+
:aria-labelledby="title ? `${id}-title` : undefined"
|
|
261
|
+
@click="handleOutsideClick"
|
|
250
262
|
>
|
|
251
263
|
<div
|
|
252
|
-
|
|
253
|
-
class="
|
|
254
|
-
:style="{ zIndex }"
|
|
255
|
-
role="dialog"
|
|
256
|
-
:aria-labelledby="title ? `${props.id}-title` : undefined"
|
|
257
|
-
aria-modal="true"
|
|
258
|
-
data-modal-active="true"
|
|
259
|
-
:data-modal-id="props.id"
|
|
264
|
+
class="min-h-screen px-4 flex justify-center text-center"
|
|
265
|
+
:class="positionClass"
|
|
260
266
|
>
|
|
261
|
-
<!-- Backdrop
|
|
267
|
+
<!-- Backdrop -->
|
|
268
|
+
<div
|
|
269
|
+
class="fixed inset-0 bg-black/30 backdrop-blur-sm"
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
/>
|
|
272
|
+
|
|
273
|
+
<!-- Modal container -->
|
|
262
274
|
<div
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
275
|
+
ref="modalRef"
|
|
276
|
+
class="relative w-full bg-white dark:bg-fv-neutral-900 rounded-lg text-left shadow-xl transform transition-all sm:my-8 max-h-[90vh] flex flex-col" :class="[sizeClass]"
|
|
277
|
+
tabindex="-1"
|
|
278
|
+
@click.stop
|
|
266
279
|
>
|
|
267
|
-
<!--
|
|
280
|
+
<!-- Close button (top-right) -->
|
|
281
|
+
<button
|
|
282
|
+
v-if="!hideCloseButton"
|
|
283
|
+
ref="closeButtonRef"
|
|
284
|
+
type="button"
|
|
285
|
+
class="absolute top-2 right-2 text-fv-neutral-400 hover:text-fv-neutral-500 dark:text-fv-neutral-500 dark:hover:text-fv-neutral-400 z-10 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
|
286
|
+
aria-label="Close modal"
|
|
287
|
+
@click="setModalState(false)"
|
|
288
|
+
>
|
|
289
|
+
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
|
|
290
|
+
</button>
|
|
291
|
+
|
|
292
|
+
<!-- Header (if title provided) -->
|
|
268
293
|
<div
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
tabindex="-1"
|
|
273
|
-
@click.stop
|
|
294
|
+
v-if="title"
|
|
295
|
+
ref="headerRef"
|
|
296
|
+
class="px-6 pt-6 pb-0"
|
|
274
297
|
>
|
|
275
|
-
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
class="
|
|
298
|
+
<slot name="before" />
|
|
299
|
+
<h3
|
|
300
|
+
:id="`${id}-title`"
|
|
301
|
+
class="text-lg font-medium leading-6 text-fv-neutral-900 dark:text-white"
|
|
279
302
|
>
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<!-- Content area -->
|
|
296
|
-
<div :class="`p-3 space-y-3 flex-grow ${ofy}`">
|
|
297
|
-
<slot />
|
|
298
|
-
</div>
|
|
303
|
+
{{ title }}
|
|
304
|
+
</h3>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<!-- Content area -->
|
|
308
|
+
<div
|
|
309
|
+
ref="contentRef"
|
|
310
|
+
class="px-6 py-4 overflow-y-auto flex-1 rounded-b-lg"
|
|
311
|
+
>
|
|
312
|
+
<slot />
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<!-- Footer -->
|
|
316
|
+
<div v-if="$slots.footer" class="px-6 py-4 bg-fv-neutral-50 dark:bg-fv-neutral-800 rounded-b-lg border-t border-fv-neutral-200 dark:border-fv-neutral-700">
|
|
317
|
+
<slot name="footer" />
|
|
299
318
|
</div>
|
|
300
319
|
</div>
|
|
301
320
|
</div>
|
|
302
|
-
</
|
|
303
|
-
</
|
|
321
|
+
</div>
|
|
322
|
+
</FadeTransition>
|
|
304
323
|
</template>
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
|
3
|
+
import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
4
|
+
import { useEventBus } from '../../composables/event-bus'
|
|
5
|
+
|
|
6
|
+
// Use a shared global registry in the window to track all modals across instances
|
|
7
|
+
// This ensures proper z-index stacking even when modals are in different components
|
|
8
|
+
if (typeof window !== 'undefined') {
|
|
9
|
+
// @ts-expect-error: TS doesn't know about the global registry
|
|
10
|
+
window.__FWS_MODAL_REGISTRY = window.__FWS_MODAL_REGISTRY || {
|
|
11
|
+
modals: new Map<string, number>(),
|
|
12
|
+
getNextZIndex() {
|
|
13
|
+
const baseZIndex = 40
|
|
14
|
+
const maxZIndex = 59
|
|
15
|
+
|
|
16
|
+
// If no modals, start at base
|
|
17
|
+
if (this.modals.size === 0) {
|
|
18
|
+
return baseZIndex
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Find highest z-index
|
|
22
|
+
const values = Array.from(this.modals.values())
|
|
23
|
+
// @ts-expect-error: TS doesn't know that values are numbers
|
|
24
|
+
const highestZIndex = Math.max(...values)
|
|
25
|
+
|
|
26
|
+
// Calculate next z-index
|
|
27
|
+
const nextZIndex = highestZIndex + 1
|
|
28
|
+
|
|
29
|
+
// If we're approaching the upper limit, reset all
|
|
30
|
+
if (nextZIndex >= maxZIndex) {
|
|
31
|
+
this.resetAllZIndexes()
|
|
32
|
+
return baseZIndex
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return nextZIndex
|
|
36
|
+
},
|
|
37
|
+
resetAllZIndexes() {
|
|
38
|
+
// Sort by current z-index
|
|
39
|
+
const entries = Array.from(this.modals.entries())
|
|
40
|
+
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
41
|
+
entries.sort((a, b) => a[1] - b[1])
|
|
42
|
+
|
|
43
|
+
// Reassign starting from base
|
|
44
|
+
let newIndex = 40
|
|
45
|
+
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
46
|
+
entries.forEach(([id, _]) => {
|
|
47
|
+
this.modals.set(id, newIndex)
|
|
48
|
+
newIndex++
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// @ts-expect-error: TS doesn't know about the global registry
|
|
55
|
+
const modalRegistry = typeof window !== 'undefined' ? window.__FWS_MODAL_REGISTRY : { modals: new Map() }
|
|
56
|
+
|
|
57
|
+
const props = withDefaults(
|
|
58
|
+
defineProps<{
|
|
59
|
+
id: string
|
|
60
|
+
title?: string
|
|
61
|
+
onOpen?: Function
|
|
62
|
+
onClose?: Function
|
|
63
|
+
closeIcon?: object
|
|
64
|
+
mSize?: string
|
|
65
|
+
ofy?: string
|
|
66
|
+
}>(),
|
|
67
|
+
{
|
|
68
|
+
closeIcon: () => h(XCircleIcon),
|
|
69
|
+
mSize: 'w-full',
|
|
70
|
+
ofy: 'overflow-y-auto cool-scroll-modal',
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const eventBus = useEventBus()
|
|
75
|
+
|
|
76
|
+
const isOpen = ref<boolean>(false)
|
|
77
|
+
const modalRef = ref<HTMLElement | null>(null)
|
|
78
|
+
let previouslyFocusedElement: HTMLElement | null = null
|
|
79
|
+
let focusableElements: HTMLElement[] = []
|
|
80
|
+
|
|
81
|
+
// Dynamic z-index to ensure the most recently opened modal is on top
|
|
82
|
+
// Base z-index between 40 and 60 as required
|
|
83
|
+
const baseZIndex = 40 // Starting z-index value
|
|
84
|
+
const zIndex = ref<number>(baseZIndex)
|
|
85
|
+
|
|
86
|
+
// Trap focus within modal for accessibility
|
|
87
|
+
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
88
|
+
return Array.from(
|
|
89
|
+
element.querySelectorAll(
|
|
90
|
+
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
|
|
91
|
+
),
|
|
92
|
+
).filter(
|
|
93
|
+
el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
94
|
+
) as HTMLElement[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
98
|
+
// Only handle events for the top-most modal
|
|
99
|
+
if (!isOpen.value) return
|
|
100
|
+
|
|
101
|
+
// Check if this modal is the top-most one
|
|
102
|
+
const isTopMost = isTopMostModal(props.id)
|
|
103
|
+
if (!isTopMost) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Close on escape
|
|
108
|
+
if (event.key === 'Escape') {
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
setModal(false)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle tab trapping
|
|
115
|
+
if (event.key === 'Tab' && focusableElements.length > 0) {
|
|
116
|
+
// If shift + tab on first element, focus last element
|
|
117
|
+
if (event.shiftKey && document.activeElement === focusableElements[0]) {
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
focusableElements[focusableElements.length - 1].focus()
|
|
120
|
+
}
|
|
121
|
+
// If tab on last element, focus first element
|
|
122
|
+
else if (!event.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) {
|
|
123
|
+
event.preventDefault()
|
|
124
|
+
focusableElements[0].focus()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check if this modal is the top-most (highest z-index)
|
|
130
|
+
function isTopMostModal(id: string): boolean {
|
|
131
|
+
if (modalRegistry.modals.size === 0) return false
|
|
132
|
+
|
|
133
|
+
// Find the modal with the highest z-index
|
|
134
|
+
const entries = Array.from(modalRegistry.modals.entries())
|
|
135
|
+
const highestEntry = entries.reduce((prev, current) =>
|
|
136
|
+
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
137
|
+
current[1] > prev[1] ? current : prev,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// @ts-expect-error: TS doesn't know that entries are tuples
|
|
141
|
+
return highestEntry[0] === id
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setModal(value: boolean) {
|
|
145
|
+
if (value === true) {
|
|
146
|
+
if (props.onOpen) props.onOpen()
|
|
147
|
+
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
148
|
+
|
|
149
|
+
// Get the next z-index from the global registry
|
|
150
|
+
const newZIndex = modalRegistry.getNextZIndex()
|
|
151
|
+
|
|
152
|
+
// Register this modal in the global registry with a unique ID (combines component id with instance id)
|
|
153
|
+
const uniqueId = `${props.id}-${Date.now()}`
|
|
154
|
+
modalRegistry.modals.set(uniqueId, newZIndex)
|
|
155
|
+
|
|
156
|
+
// Store the unique ID as a data attribute for future reference
|
|
157
|
+
nextTick(() => {
|
|
158
|
+
const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
|
|
159
|
+
if (modalElement) {
|
|
160
|
+
modalElement.setAttribute('data-modal-unique-id', uniqueId)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Set this modal's z-index
|
|
165
|
+
zIndex.value = newZIndex
|
|
166
|
+
|
|
167
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
168
|
+
}
|
|
169
|
+
if (value === false) {
|
|
170
|
+
if (props.onClose) props.onClose()
|
|
171
|
+
|
|
172
|
+
// Find and remove this modal from the registry
|
|
173
|
+
const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
|
|
174
|
+
if (modalElement) {
|
|
175
|
+
const uniqueId = modalElement.getAttribute('data-modal-unique-id')
|
|
176
|
+
if (uniqueId) {
|
|
177
|
+
modalRegistry.modals.delete(uniqueId)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
182
|
+
if (previouslyFocusedElement) {
|
|
183
|
+
previouslyFocusedElement.focus()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
isOpen.value = value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// After modal is opened, set focus and collect focusable elements
|
|
190
|
+
watch(isOpen, async (newVal) => {
|
|
191
|
+
if (newVal) {
|
|
192
|
+
await nextTick()
|
|
193
|
+
if (modalRef.value) {
|
|
194
|
+
focusableElements = getFocusableElements(modalRef.value)
|
|
195
|
+
|
|
196
|
+
// Focus the first focusable element or the close button if available
|
|
197
|
+
const closeButton = modalRef.value.querySelector('button[aria-label="Close modal"]') as HTMLElement
|
|
198
|
+
if (closeButton) {
|
|
199
|
+
closeButton.focus()
|
|
200
|
+
}
|
|
201
|
+
else if (focusableElements.length > 0) {
|
|
202
|
+
focusableElements[0].focus()
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// If no focusable elements, focus the modal itself
|
|
206
|
+
modalRef.value.focus()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
onMounted(() => {
|
|
213
|
+
eventBus.on(`${props.id}Modal`, setModal)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
onUnmounted(() => {
|
|
217
|
+
eventBus.off(`${props.id}Modal`, setModal)
|
|
218
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
219
|
+
|
|
220
|
+
// Clean up the modal registry if this modal was open when unmounted
|
|
221
|
+
if (isOpen.value) {
|
|
222
|
+
const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
|
|
223
|
+
if (modalElement) {
|
|
224
|
+
const uniqueId = modalElement.getAttribute('data-modal-unique-id')
|
|
225
|
+
if (uniqueId) {
|
|
226
|
+
modalRegistry.modals.delete(uniqueId)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Click outside to close
|
|
233
|
+
function handleBackdropClick(event: MouseEvent) {
|
|
234
|
+
// Close only if clicking the backdrop, not the modal content
|
|
235
|
+
if (event.target === event.currentTarget) {
|
|
236
|
+
setModal(false)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
</script>
|
|
240
|
+
|
|
241
|
+
<template>
|
|
242
|
+
<ClientOnly>
|
|
243
|
+
<transition
|
|
244
|
+
enter-active-class="duration-300 ease-out"
|
|
245
|
+
enter-from-class="opacity-0"
|
|
246
|
+
enter-to-class="opacity-100"
|
|
247
|
+
leave-active-class="duration-200 ease-in"
|
|
248
|
+
leave-from-class="opacity-100"
|
|
249
|
+
leave-to-class="opacity-0"
|
|
250
|
+
>
|
|
251
|
+
<div
|
|
252
|
+
v-if="isOpen"
|
|
253
|
+
class="fixed inset-0"
|
|
254
|
+
:style="{ zIndex }"
|
|
255
|
+
role="dialog"
|
|
256
|
+
:aria-labelledby="title ? `${props.id}-title` : undefined"
|
|
257
|
+
aria-modal="true"
|
|
258
|
+
data-modal-active="true"
|
|
259
|
+
:data-modal-id="props.id"
|
|
260
|
+
>
|
|
261
|
+
<!-- Backdrop with click to close functionality -->
|
|
262
|
+
<div
|
|
263
|
+
class="flex absolute backdrop-blur-[8px] inset-0 flex-col items-center justify-center min-h-screen text-fv-neutral-800 dark:text-fv-neutral-300 bg-fv-neutral-900/[.20] dark:bg-fv-neutral-50/[.20]"
|
|
264
|
+
:style="{ zIndex }"
|
|
265
|
+
@click="handleBackdropClick"
|
|
266
|
+
>
|
|
267
|
+
<!-- Modal panel -->
|
|
268
|
+
<div
|
|
269
|
+
ref="modalRef"
|
|
270
|
+
:class="`relative ${mSize} max-w-6xl max-h-[85vh] px-4 sm:px-0 box-border bg-white rounded-lg shadow dark:bg-fv-neutral-900 flex flex-col`"
|
|
271
|
+
:style="{ zIndex }"
|
|
272
|
+
tabindex="-1"
|
|
273
|
+
@click.stop
|
|
274
|
+
>
|
|
275
|
+
<!-- Header with title if provided -->
|
|
276
|
+
<div
|
|
277
|
+
v-if="title"
|
|
278
|
+
class="flex items-center justify-between p-2 w-full border-b rounded-t dark:border-fv-neutral-700"
|
|
279
|
+
>
|
|
280
|
+
<slot name="before" />
|
|
281
|
+
<h2
|
|
282
|
+
v-if="title"
|
|
283
|
+
:id="`${props.id}-title`"
|
|
284
|
+
class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
|
|
285
|
+
v-html="title"
|
|
286
|
+
/>
|
|
287
|
+
<button
|
|
288
|
+
class="text-fv-neutral-400 bg-transparent hover:bg-fv-neutral-200 hover:text-fv-neutral-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-fv-neutral-600 dark:hover:text-white"
|
|
289
|
+
aria-label="Close modal"
|
|
290
|
+
@click="setModal(false)"
|
|
291
|
+
>
|
|
292
|
+
<component :is="closeIcon" class="w-7 h-7" />
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
<!-- Content area -->
|
|
296
|
+
<div :class="`p-3 space-y-3 flex-grow ${ofy}`">
|
|
297
|
+
<slot />
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</transition>
|
|
303
|
+
</ClientOnly>
|
|
304
|
+
</template>
|
package/composables/seo.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import { getLocale, getPrefix, getURL } from '@fy-/fws-js'
|
|
3
3
|
import { useHead, useSeoMeta } from '@unhead/vue'
|
|
4
|
-
import { useRoute } from 'vue-router'
|
|
5
4
|
|
|
6
5
|
export interface LazyHead {
|
|
7
6
|
name?: string
|
|
@@ -29,7 +28,6 @@ export interface LazyHead {
|
|
|
29
28
|
|
|
30
29
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
|
31
30
|
export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
32
|
-
const route = useRoute()
|
|
33
31
|
const currentLocale = getLocale()
|
|
34
32
|
// const url = getURL()
|
|
35
33
|
|
|
@@ -90,7 +88,7 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
90
88
|
})
|
|
91
89
|
|
|
92
90
|
useSeoMeta({
|
|
93
|
-
ogUrl: () => `${getURL().
|
|
91
|
+
ogUrl: () => `${getURL().Canonical}`,
|
|
94
92
|
ogLocale: () => {
|
|
95
93
|
if (currentLocale) {
|
|
96
94
|
return currentLocale.replace('-', '_')
|