@fy-/fws-vue 2.3.7 → 2.3.9

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.
@@ -2,7 +2,8 @@
2
2
  import type { Component } from 'vue'
3
3
  import type { LazyHead } from '../../composables/seo'
4
4
  import type { BreadcrumbLink } from '../../types'
5
- import { defineWebPage, useSchemaOrg } from '@unhead/schema-org'
5
+ import { defineWebPage } from '@unhead/schema-org'
6
+ import { useSchemaOrg } from '@unhead/schema-org/vue'
6
7
  import { ref, watchEffect } from 'vue'
7
8
  import { useRoute } from 'vue-router'
8
9
  import { useRest } from '../../composables/rest'
@@ -2,7 +2,8 @@
2
2
  import type { BreadcrumbLink } from '../../types'
3
3
  import { getURL, stringHash } from '@fy-/fws-js'
4
4
  import { ChevronRightIcon, HomeIcon } from '@heroicons/vue/24/solid'
5
- import { defineBreadcrumb, useSchemaOrg } from '@unhead/schema-org'
5
+ import { defineBreadcrumb } from '@unhead/schema-org'
6
+ import { useSchemaOrg } from '@unhead/schema-org/vue'
6
7
 
7
8
  const props = withDefaults(
8
9
  defineProps<{
@@ -4,7 +4,7 @@ import { computed, ref, toRef } from 'vue'
4
4
  import { useTranslation } from '../../composables/translations'
5
5
  import DefaultTagInput from './DefaultTagInput.vue'
6
6
 
7
- type modelValueType = string | number | string[] | number[] | undefined
7
+ type modelValueType = string | number | string[] | number[] | Record<string, any> | undefined
8
8
  type checkboxValueType = any[] | Set<any> | undefined | boolean
9
9
 
10
10
  const props = withDefaults(
@@ -1,323 +1,304 @@
1
1
  <script setup lang="ts">
2
- import { XMarkIcon } from '@heroicons/vue/24/solid'
3
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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
- 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
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
- // 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
- })
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
- // 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
- }
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
- // Focusable elements within the modal for keyboard navigation
87
- const focusableElements = computed(() => {
88
- if (!modalRef.value) return []
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
- modalRef.value.querySelectorAll(
92
- 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
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
- // 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'
106
- }
107
- })
97
+ function handleKeyDown(event: KeyboardEvent) {
98
+ // Only handle events for the top-most modal
99
+ if (!isOpen.value) return
108
100
 
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'
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
- // Check if this is the topmost modal
118
- const isTopModal = computed(() => {
119
- return modalStack.isTopModal(props.id)
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
- // Restore body scrolling if no modals are open
149
- if (typeof document !== 'undefined' && !modalStack.modals.size) {
150
- document.body.classList.remove('overflow-hidden')
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
- // Restore focus
154
- if (lastActiveElement.value) {
155
- nextTick(() => {
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
- // 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)
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
- // Handle keyboard events
173
- function handleKeyDown(e: KeyboardEvent) {
174
- // Only handle events for the top modal
175
- if (!isTopModal.value) return
144
+ function setModal(value: boolean) {
145
+ if (value === true) {
146
+ if (props.onOpen) props.onOpen()
147
+ previouslyFocusedElement = document.activeElement as HTMLElement
176
148
 
177
- // ESC key handling (close modal)
178
- if (e.key === 'Escape' && !props.noEscDismiss) {
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
- // 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]
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
- // 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()
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
- // 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()
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
- else if (focusableElements.value.length > 0) {
210
- focusableElements.value[0].focus()
211
- }
212
- else if (modalRef.value) {
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
- // Event listeners
219
- watch(isVisible, (newVal) => {
189
+ // After modal is opened, set focus and collect focusable elements
190
+ watch(isOpen, async (newVal) => {
220
191
  if (newVal) {
221
- setInitialFocus()
222
- document.addEventListener('keydown', handleKeyDown)
223
- }
224
- else {
225
- document.removeEventListener('keydown', handleKeyDown)
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
- // Listen for openModal/closeModal events through the event bus
232
- eventBus.on(`${props.id}Modal`, setModalState)
213
+ eventBus.on(`${props.id}Modal`, setModal)
233
214
  })
234
215
 
235
- // Clean up
236
- onBeforeUnmount(() => {
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 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')
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
- <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"
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
- class="min-h-screen px-4 flex justify-center text-center"
265
- :class="positionClass"
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
- 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
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
- <!-- 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) -->
267
+ <!-- Modal panel -->
293
268
  <div
294
- v-if="title"
295
- ref="headerRef"
296
- class="px-6 pt-6 pb-0"
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
- <slot name="before" />
299
- <h3
300
- :id="`${id}-title`"
301
- class="text-lg font-medium leading-6 text-fv-neutral-900 dark:text-white"
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
- {{ 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" />
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
- </div>
322
- </FadeTransition>
302
+ </transition>
303
+ </ClientOnly>
323
304
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.3.07",
3
+ "version": "2.3.09",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",
@@ -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>