@fy-/fws-vue 2.3.7 → 2.3.8
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 +243 -262
- package/package.json +1 -1
- package/components/ui/DefaultModalOld.vue +0 -304
|
@@ -1,323 +1,304 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
|
3
|
+
import { h, nextTick, onMounted, onUnmounted, 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
|
-
|
|
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
|
+
}
|
|
27
52
|
}
|
|
28
53
|
|
|
29
|
-
//
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
)
|
|
37
73
|
|
|
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
|
|
48
74
|
const eventBus = useEventBus()
|
|
49
75
|
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
}
|
|
75
|
-
|
|
76
|
-
// Fallback for SSR
|
|
77
|
-
return {
|
|
78
|
-
modals: new Map(),
|
|
79
|
-
baseZIndex: 1000,
|
|
80
|
-
register: () => 1000,
|
|
81
|
-
unregister: () => {},
|
|
82
|
-
isTopModal: () => true,
|
|
83
|
-
}
|
|
84
|
-
})()
|
|
76
|
+
const isOpen = ref<boolean>(false)
|
|
77
|
+
const modalRef = ref<HTMLElement | null>(null)
|
|
78
|
+
let previouslyFocusedElement: HTMLElement | null = null
|
|
79
|
+
let focusableElements: HTMLElement[] = []
|
|
85
80
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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)
|
|
89
85
|
|
|
86
|
+
// Trap focus within modal for accessibility
|
|
87
|
+
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
90
88
|
return Array.from(
|
|
91
|
-
|
|
92
|
-
'a[href], button
|
|
89
|
+
element.querySelectorAll(
|
|
90
|
+
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
|
|
93
91
|
),
|
|
92
|
+
).filter(
|
|
93
|
+
el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
94
94
|
) as HTMLElement[]
|
|
95
|
-
}
|
|
95
|
+
}
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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'
|
|
106
|
-
}
|
|
107
|
-
})
|
|
97
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
98
|
+
// Only handle events for the top-most modal
|
|
99
|
+
if (!isOpen.value) return
|
|
108
100
|
|
|
109
|
-
//
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
default: return 'items-center'
|
|
101
|
+
// Check if this modal is the top-most one
|
|
102
|
+
const isTopMost = isTopMostModal(props.id)
|
|
103
|
+
if (!isTopMost) {
|
|
104
|
+
return
|
|
114
105
|
}
|
|
115
|
-
})
|
|
116
106
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// Manage modal state
|
|
123
|
-
function setModalState(state: boolean) {
|
|
124
|
-
if (state === isVisible.value) return
|
|
125
|
-
|
|
126
|
-
if (state) {
|
|
127
|
-
// Save the currently focused element
|
|
128
|
-
lastActiveElement.value = document.activeElement as HTMLElement
|
|
129
|
-
|
|
130
|
-
// Register this modal and get its z-index
|
|
131
|
-
zIndex.value = modalStack.register(props.id)
|
|
132
|
-
|
|
133
|
-
// Call onOpen callback
|
|
134
|
-
if (props.onOpen) props.onOpen()
|
|
135
|
-
|
|
136
|
-
// Set the body to prevent scrolling
|
|
137
|
-
if (typeof document !== 'undefined') {
|
|
138
|
-
document.body.classList.add('overflow-hidden')
|
|
139
|
-
}
|
|
107
|
+
// Close on escape
|
|
108
|
+
if (event.key === 'Escape') {
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
setModal(false)
|
|
111
|
+
return
|
|
140
112
|
}
|
|
141
|
-
else {
|
|
142
|
-
// Unregister the modal
|
|
143
|
-
modalStack.unregister(props.id)
|
|
144
|
-
|
|
145
|
-
// Call onClose callback
|
|
146
|
-
if (props.onClose) props.onClose()
|
|
147
113
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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()
|
|
151
120
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
lastActiveElement.value?.focus()
|
|
157
|
-
})
|
|
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()
|
|
158
125
|
}
|
|
159
126
|
}
|
|
160
|
-
|
|
161
|
-
isVisible.value = state
|
|
162
127
|
}
|
|
163
128
|
|
|
164
|
-
//
|
|
165
|
-
function
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
170
142
|
}
|
|
171
143
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
144
|
+
function setModal(value: boolean) {
|
|
145
|
+
if (value === true) {
|
|
146
|
+
if (props.onOpen) props.onOpen()
|
|
147
|
+
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
176
148
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
e.preventDefault()
|
|
180
|
-
setModalState(false)
|
|
181
|
-
return
|
|
182
|
-
}
|
|
149
|
+
// Get the next z-index from the global registry
|
|
150
|
+
const newZIndex = modalRegistry.getNextZIndex()
|
|
183
151
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const lastElement = focusableElements.value[focusableElements.value.length - 1]
|
|
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)
|
|
188
155
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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)
|
|
199
168
|
}
|
|
200
|
-
|
|
169
|
+
if (value === false) {
|
|
170
|
+
if (props.onClose) props.onClose()
|
|
201
171
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
}
|
|
208
179
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
modalRef.value.focus()
|
|
180
|
+
|
|
181
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
182
|
+
if (previouslyFocusedElement) {
|
|
183
|
+
previouslyFocusedElement.focus()
|
|
214
184
|
}
|
|
215
|
-
}
|
|
185
|
+
}
|
|
186
|
+
isOpen.value = value
|
|
216
187
|
}
|
|
217
188
|
|
|
218
|
-
//
|
|
219
|
-
watch(
|
|
189
|
+
// After modal is opened, set focus and collect focusable elements
|
|
190
|
+
watch(isOpen, async (newVal) => {
|
|
220
191
|
if (newVal) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|
|
226
209
|
}
|
|
227
210
|
})
|
|
228
211
|
|
|
229
|
-
// Setup event listeners
|
|
230
212
|
onMounted(() => {
|
|
231
|
-
|
|
232
|
-
eventBus.on(`${props.id}Modal`, setModalState)
|
|
213
|
+
eventBus.on(`${props.id}Modal`, setModal)
|
|
233
214
|
})
|
|
234
215
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
eventBus.off(`${props.id}Modal`, setModalState)
|
|
216
|
+
onUnmounted(() => {
|
|
217
|
+
eventBus.off(`${props.id}Modal`, setModal)
|
|
238
218
|
document.removeEventListener('keydown', handleKeyDown)
|
|
239
219
|
|
|
240
|
-
// Clean up modal
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
228
|
}
|
|
248
229
|
}
|
|
249
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
|
+
}
|
|
250
239
|
</script>
|
|
251
240
|
|
|
252
241
|
<template>
|
|
253
|
-
<
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
class="
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
@click="handleOutsideClick"
|
|
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"
|
|
262
250
|
>
|
|
263
251
|
<div
|
|
264
|
-
|
|
265
|
-
|
|
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"
|
|
266
260
|
>
|
|
267
|
-
<!-- Backdrop -->
|
|
268
|
-
<div
|
|
269
|
-
class="fixed inset-0 bg-black/30 backdrop-blur-sm"
|
|
270
|
-
aria-hidden="true"
|
|
271
|
-
/>
|
|
272
|
-
|
|
273
|
-
<!-- Modal container -->
|
|
261
|
+
<!-- Backdrop with click to close functionality -->
|
|
274
262
|
<div
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
@click.stop
|
|
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"
|
|
279
266
|
>
|
|
280
|
-
<!--
|
|
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) -->
|
|
267
|
+
<!-- Modal panel -->
|
|
293
268
|
<div
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
297
274
|
>
|
|
298
|
-
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
class="
|
|
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"
|
|
302
279
|
>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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>
|
|
318
299
|
</div>
|
|
319
300
|
</div>
|
|
320
301
|
</div>
|
|
321
|
-
</
|
|
322
|
-
</
|
|
302
|
+
</transition>
|
|
303
|
+
</ClientOnly>
|
|
323
304
|
</template>
|
package/package.json
CHANGED
|
@@ -1,304 +0,0 @@
|
|
|
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>
|