@fy-/fws-vue 2.2.45 → 2.2.47
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/DefaultDropdown.vue +15 -3
- package/components/ui/DefaultGallery.vue +695 -167
- package/components/ui/DefaultModal.vue +137 -35
- package/package.json +1 -1
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
Dialog,
|
|
4
|
-
DialogPanel,
|
|
5
|
-
TransitionRoot,
|
|
6
|
-
} from '@headlessui/vue'
|
|
7
2
|
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
|
8
|
-
import { h, onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
9
4
|
import { useEventBus } from '../../composables/event-bus'
|
|
10
5
|
|
|
6
|
+
// Static counter for managing z-index across all modals
|
|
7
|
+
let modalCounter = 0
|
|
8
|
+
|
|
11
9
|
const props = withDefaults(
|
|
12
10
|
defineProps<{
|
|
13
11
|
id: string
|
|
@@ -30,14 +28,83 @@ const eventBus = useEventBus()
|
|
|
30
28
|
const isOpen = ref<boolean>(false)
|
|
31
29
|
const modalRef = ref<HTMLElement | null>(null)
|
|
32
30
|
let previouslyFocusedElement: HTMLElement | null = null
|
|
31
|
+
let focusableElements: HTMLElement[] = []
|
|
32
|
+
|
|
33
|
+
// Dynamic z-index to ensure the most recently opened modal is on top
|
|
34
|
+
const baseZIndex = 100 // Use a higher base z-index to avoid conflicts
|
|
35
|
+
const zIndex = ref<number>(baseZIndex)
|
|
36
|
+
|
|
37
|
+
// Trap focus within modal for accessibility
|
|
38
|
+
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
39
|
+
return Array.from(
|
|
40
|
+
element.querySelectorAll(
|
|
41
|
+
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
|
|
42
|
+
),
|
|
43
|
+
).filter(
|
|
44
|
+
el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
45
|
+
) as HTMLElement[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
49
|
+
// Only handle events for the top-most modal
|
|
50
|
+
if (!isOpen.value) return
|
|
51
|
+
|
|
52
|
+
// Get all active modals
|
|
53
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
54
|
+
// If this is not the top-most modal, don't handle keyboard events
|
|
55
|
+
if (activeModals.length > 0 && modalRef.value !== activeModals[activeModals.length - 1]) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Close on escape
|
|
60
|
+
if (event.key === 'Escape') {
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
setModal(false)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle tab trapping
|
|
67
|
+
if (event.key === 'Tab' && focusableElements.length > 0) {
|
|
68
|
+
// If shift + tab on first element, focus last element
|
|
69
|
+
if (event.shiftKey && document.activeElement === focusableElements[0]) {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
focusableElements[focusableElements.length - 1].focus()
|
|
72
|
+
}
|
|
73
|
+
// If tab on last element, focus first element
|
|
74
|
+
else if (!event.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) {
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
focusableElements[0].focus()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
33
80
|
|
|
34
81
|
function setModal(value: boolean) {
|
|
35
82
|
if (value === true) {
|
|
36
83
|
if (props.onOpen) props.onOpen()
|
|
37
84
|
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
85
|
+
|
|
86
|
+
// Set this modal's z-index higher than any existing modal
|
|
87
|
+
modalCounter += 3 // Increase by 3 to handle backdrop, container and content
|
|
88
|
+
zIndex.value = baseZIndex + modalCounter
|
|
89
|
+
|
|
90
|
+
// Only manage body overflow for the first opened modal
|
|
91
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
92
|
+
if (activeModals.length === 0) {
|
|
93
|
+
document.body.style.overflow = 'hidden' // Prevent scrolling when modal is open
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
38
97
|
}
|
|
39
98
|
if (value === false) {
|
|
40
99
|
if (props.onClose) props.onClose()
|
|
100
|
+
|
|
101
|
+
// Only restore body overflow if this is the last open modal
|
|
102
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
103
|
+
if (activeModals.length <= 1) {
|
|
104
|
+
document.body.style.overflow = '' // Restore scrolling
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
41
108
|
if (previouslyFocusedElement) {
|
|
42
109
|
previouslyFocusedElement.focus()
|
|
43
110
|
}
|
|
@@ -45,54 +112,88 @@ function setModal(value: boolean) {
|
|
|
45
112
|
isOpen.value = value
|
|
46
113
|
}
|
|
47
114
|
|
|
115
|
+
// After modal is opened, set focus and collect focusable elements
|
|
116
|
+
watch(isOpen, async (newVal) => {
|
|
117
|
+
if (newVal) {
|
|
118
|
+
await nextTick()
|
|
119
|
+
if (modalRef.value) {
|
|
120
|
+
focusableElements = getFocusableElements(modalRef.value)
|
|
121
|
+
|
|
122
|
+
// Focus the first focusable element or the close button if available
|
|
123
|
+
const closeButton = modalRef.value.querySelector('button[aria-label="Close modal"]') as HTMLElement
|
|
124
|
+
if (closeButton) {
|
|
125
|
+
closeButton.focus()
|
|
126
|
+
}
|
|
127
|
+
else if (focusableElements.length > 0) {
|
|
128
|
+
focusableElements[0].focus()
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// If no focusable elements, focus the modal itself
|
|
132
|
+
modalRef.value.focus()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
48
138
|
onMounted(() => {
|
|
49
139
|
eventBus.on(`${props.id}Modal`, setModal)
|
|
50
140
|
})
|
|
51
141
|
|
|
52
142
|
onUnmounted(() => {
|
|
53
143
|
eventBus.off(`${props.id}Modal`, setModal)
|
|
54
|
-
|
|
144
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
55
145
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
146
|
+
// Only restore body overflow if this modal was open when unmounted
|
|
147
|
+
if (isOpen.value) {
|
|
148
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
149
|
+
if (activeModals.length <= 1) {
|
|
150
|
+
document.body.style.overflow = '' // Restore scrolling
|
|
151
|
+
}
|
|
61
152
|
}
|
|
62
153
|
})
|
|
63
|
-
|
|
154
|
+
|
|
155
|
+
// Click outside to close
|
|
156
|
+
function handleBackdropClick(event: MouseEvent) {
|
|
157
|
+
// Close only if clicking the backdrop, not the modal content
|
|
158
|
+
if (event.target === event.currentTarget) {
|
|
159
|
+
setModal(false)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
64
162
|
</script>
|
|
65
163
|
|
|
66
164
|
<template>
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
enter="
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
leave="
|
|
74
|
-
leave-from="opacity-100"
|
|
75
|
-
leave-to="opacity-0"
|
|
165
|
+
<transition
|
|
166
|
+
enter-active-class="duration-300 ease-out"
|
|
167
|
+
enter-from-class="opacity-0"
|
|
168
|
+
enter-to-class="opacity-100"
|
|
169
|
+
leave-active-class="duration-200 ease-in"
|
|
170
|
+
leave-from-class="opacity-100"
|
|
171
|
+
leave-to-class="opacity-0"
|
|
76
172
|
>
|
|
77
|
-
<
|
|
78
|
-
|
|
173
|
+
<div
|
|
174
|
+
v-if="isOpen"
|
|
79
175
|
class="fixed inset-0 overflow-y-auto"
|
|
80
|
-
style="
|
|
81
|
-
aria-modal="true"
|
|
176
|
+
:style="{ zIndex }"
|
|
82
177
|
role="dialog"
|
|
83
178
|
:aria-labelledby="title ? `${props.id}-title` : undefined"
|
|
84
|
-
|
|
179
|
+
aria-modal="true"
|
|
180
|
+
data-modal-active="true"
|
|
85
181
|
>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
tabindex="-1"
|
|
182
|
+
<!-- Backdrop with click to close functionality -->
|
|
183
|
+
<div
|
|
89
184
|
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]"
|
|
90
|
-
style="
|
|
185
|
+
:style="{ zIndex: zIndex + 1 }"
|
|
186
|
+
@click="handleBackdropClick"
|
|
91
187
|
>
|
|
188
|
+
<!-- Modal panel -->
|
|
92
189
|
<div
|
|
190
|
+
ref="modalRef"
|
|
93
191
|
:class="`relative ${mSize} max-w-6xl max-h-full ${ofy} bg-white rounded-lg shadow dark:bg-fv-neutral-900`"
|
|
94
|
-
style="
|
|
192
|
+
:style="{ zIndex: zIndex + 2 }"
|
|
193
|
+
tabindex="-1"
|
|
194
|
+
@click.stop
|
|
95
195
|
>
|
|
196
|
+
<!-- Header with title if provided -->
|
|
96
197
|
<div
|
|
97
198
|
v-if="title"
|
|
98
199
|
class="flex items-center justify-between p-2 w-full border-b rounded-t dark:border-fv-neutral-700"
|
|
@@ -112,11 +213,12 @@ watch(isOpen, async (newVal) => {
|
|
|
112
213
|
<component :is="closeIcon" class="w-7 h-7" />
|
|
113
214
|
</button>
|
|
114
215
|
</div>
|
|
216
|
+
<!-- Content area -->
|
|
115
217
|
<div class="p-3 space-y-3">
|
|
116
218
|
<slot />
|
|
117
219
|
</div>
|
|
118
220
|
</div>
|
|
119
|
-
</
|
|
120
|
-
</
|
|
121
|
-
</
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</transition>
|
|
122
224
|
</template>
|