@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.
@@ -1,304 +1,323 @@
1
1
  <script setup lang="ts">
2
- import { XCircleIcon } from '@heroicons/vue/24/solid'
3
- import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
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
- // 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
- }
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
- // @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
- )
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
- const isOpen = ref<boolean>(false)
77
- const modalRef = ref<HTMLElement | null>(null)
78
- let previouslyFocusedElement: HTMLElement | null = null
79
- let focusableElements: HTMLElement[] = []
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
- // 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)
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
- element.querySelectorAll(
90
- 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
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
- // Check if this modal is the top-most one
102
- const isTopMost = isTopMostModal(props.id)
103
- if (!isTopMost) {
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
- // Close on escape
108
- if (event.key === 'Escape') {
109
- event.preventDefault()
110
- setModal(false)
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
- // 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
- }
117
+ // Check if this is the topmost modal
118
+ const isTopModal = computed(() => {
119
+ return modalStack.isTopModal(props.id)
120
+ })
128
121
 
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
122
+ // Manage modal state
123
+ function setModalState(state: boolean) {
124
+ if (state === isVisible.value) return
132
125
 
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
- )
126
+ if (state) {
127
+ // Save the currently focused element
128
+ lastActiveElement.value = document.activeElement as HTMLElement
139
129
 
140
- // @ts-expect-error: TS doesn't know that entries are tuples
141
- return highestEntry[0] === id
142
- }
130
+ // Register this modal and get its z-index
131
+ zIndex.value = modalStack.register(props.id)
143
132
 
144
- function setModal(value: boolean) {
145
- if (value === true) {
133
+ // Call onOpen callback
146
134
  if (props.onOpen) props.onOpen()
147
- previouslyFocusedElement = document.activeElement as HTMLElement
148
135
 
149
- // Get the next z-index from the global registry
150
- const newZIndex = modalRegistry.getNextZIndex()
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
- // 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)
145
+ // Call onClose callback
146
+ if (props.onClose) props.onClose()
155
147
 
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
- })
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
- // Set this modal's z-index
165
- zIndex.value = newZIndex
153
+ // Restore focus
154
+ if (lastActiveElement.value) {
155
+ nextTick(() => {
156
+ lastActiveElement.value?.focus()
157
+ })
158
+ }
159
+ }
166
160
 
167
- document.addEventListener('keydown', handleKeyDown)
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
- if (value === false) {
170
- if (props.onClose) props.onClose()
170
+ }
171
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
- }
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
- document.removeEventListener('keydown', handleKeyDown)
182
- if (previouslyFocusedElement) {
183
- previouslyFocusedElement.focus()
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
- // 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
- }
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
- eventBus.on(`${props.id}Modal`, setModal)
231
+ // Listen for openModal/closeModal events through the event bus
232
+ eventBus.on(`${props.id}Modal`, setModalState)
214
233
  })
215
234
 
216
- onUnmounted(() => {
217
- eventBus.off(`${props.id}Modal`, setModal)
235
+ // Clean up
236
+ onBeforeUnmount(() => {
237
+ eventBus.off(`${props.id}Modal`, setModalState)
218
238
  document.removeEventListener('keydown', handleKeyDown)
219
239
 
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
- }
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
- <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"
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
- 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"
264
+ class="min-h-screen px-4 flex justify-center text-center"
265
+ :class="positionClass"
260
266
  >
261
- <!-- Backdrop with click to close functionality -->
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
- 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"
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
- <!-- Modal panel -->
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
- 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
294
+ v-if="title"
295
+ ref="headerRef"
296
+ class="px-6 pt-6 pb-0"
274
297
  >
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"
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
- <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>
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
- </transition>
303
- </ClientOnly>
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>
@@ -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().Scheme}://${getURL().Host}${route.fullPath}`,
91
+ ogUrl: () => `${getURL().Canonical}`,
94
92
  ogLocale: () => {
95
93
  if (currentLocale) {
96
94
  return currentLocale.replace('-', '_')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.3.05",
3
+ "version": "2.3.07",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",