@fy-/fws-vue 2.3.63 → 2.3.64
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.
|
@@ -27,23 +27,44 @@ const baseUrl = computed(() => {
|
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
// Memoize breadcrumb schema format to avoid recalculation
|
|
30
|
-
const breadcrumbsSchemaFormat = computed(() =>
|
|
31
|
-
|
|
30
|
+
const breadcrumbsSchemaFormat = computed(() => {
|
|
31
|
+
const navLength = props.nav.length
|
|
32
|
+
|
|
33
|
+
return props.nav.map((item, index) => {
|
|
34
|
+
const isLastItem = index === navLength - 1
|
|
35
|
+
|
|
36
|
+
// According to Google's guidelines, the last item should not have a URL
|
|
37
|
+
if (!item.to || isLastItem) {
|
|
38
|
+
return {
|
|
39
|
+
'@type': 'ListItem',
|
|
40
|
+
'position': index + 1,
|
|
41
|
+
'name': item.name,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Ensure proper URL construction
|
|
46
|
+
let itemUrl = item.to
|
|
47
|
+
|
|
48
|
+
// Handle relative URLs
|
|
49
|
+
if (itemUrl.startsWith('/')) {
|
|
50
|
+
itemUrl = `${baseUrl.value.scheme}://${baseUrl.value.host}${itemUrl}`
|
|
51
|
+
}
|
|
52
|
+
else if (!itemUrl.startsWith('http')) {
|
|
53
|
+
// Handle paths without leading slash
|
|
54
|
+
itemUrl = `${baseUrl.value.scheme}://${baseUrl.value.host}/${itemUrl}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clean up any double slashes (except after protocol)
|
|
58
|
+
itemUrl = itemUrl.replace(/([^:]\/)\/+/g, '$1')
|
|
59
|
+
|
|
32
60
|
return {
|
|
61
|
+
'@type': 'ListItem',
|
|
33
62
|
'position': index + 1,
|
|
34
63
|
'name': item.name,
|
|
35
|
-
'
|
|
64
|
+
'item': itemUrl,
|
|
36
65
|
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const fullUrl = `${baseUrl.value.host}${item.to}`.replace(/\/\//g, '/')
|
|
40
|
-
return {
|
|
41
|
-
'position': index + 1,
|
|
42
|
-
'name': item.name,
|
|
43
|
-
'item': `${baseUrl.value.scheme}://${fullUrl}`,
|
|
44
|
-
'@type': 'ListItem',
|
|
45
|
-
}
|
|
46
|
-
}))
|
|
66
|
+
})
|
|
67
|
+
})
|
|
47
68
|
|
|
48
69
|
// Cache breadcrumb ID to avoid string operations on every render
|
|
49
70
|
const breadcrumbId = computed(() => {
|
|
@@ -52,11 +73,19 @@ const breadcrumbId = computed(() => {
|
|
|
52
73
|
return stringHash(chain)
|
|
53
74
|
})
|
|
54
75
|
|
|
76
|
+
// Class strings extracted for reusability and mobile optimization
|
|
77
|
+
const linkClasses = 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white transition-colors duration-200'
|
|
78
|
+
const textClasses = 'text-xs font-medium text-fv-neutral-500 dark:text-fv-neutral-200'
|
|
79
|
+
const chevronClasses = 'w-4 h-4 md:w-5 md:h-5 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
|
|
80
|
+
const homeIconClasses = 'w-3.5 h-3.5 md:w-4 md:h-4 mr-1.5 md:mr-2 inline-block'
|
|
81
|
+
|
|
55
82
|
// Only run schema.org setup if we have breadcrumbs
|
|
56
|
-
if (props.nav && props.nav.length) {
|
|
83
|
+
if (props.nav && props.nav.length > 0) {
|
|
57
84
|
useSchemaOrg([
|
|
58
85
|
defineBreadcrumb({
|
|
59
86
|
'@id': `#${breadcrumbId.value}`,
|
|
87
|
+
'@context': 'https://schema.org',
|
|
88
|
+
'@type': 'BreadcrumbList',
|
|
60
89
|
'itemListElement': breadcrumbsSchemaFormat,
|
|
61
90
|
}),
|
|
62
91
|
])
|
|
@@ -64,44 +93,36 @@ if (props.nav && props.nav.length) {
|
|
|
64
93
|
</script>
|
|
65
94
|
|
|
66
95
|
<template>
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
? 'w-4 h-4 mr-2 inline-block'
|
|
75
|
-
: 'w-5 h-5 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
|
|
76
|
-
"
|
|
77
|
-
/>
|
|
78
|
-
|
|
79
|
-
<router-link
|
|
80
|
-
v-if="item.to"
|
|
81
|
-
:to="item.to"
|
|
82
|
-
:class="
|
|
83
|
-
index === 0
|
|
84
|
-
? 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white'
|
|
85
|
-
: 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white'
|
|
86
|
-
"
|
|
87
|
-
>
|
|
88
|
-
<HomeIcon
|
|
89
|
-
v-if="showHome && index === 0"
|
|
90
|
-
:class="
|
|
91
|
-
index === 0
|
|
92
|
-
? 'w-4 h-4 mr-2 inline-block'
|
|
93
|
-
: 'w-4 h-4 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
|
|
94
|
-
"
|
|
96
|
+
<nav aria-label="Breadcrumb">
|
|
97
|
+
<ol class="inline-flex items-center flex-wrap gap-y-1">
|
|
98
|
+
<template v-for="(item, index) in nav" :key="`bc_${index.toString()}`">
|
|
99
|
+
<li class="inline-flex items-center">
|
|
100
|
+
<ChevronRightIcon
|
|
101
|
+
v-if="index !== 0"
|
|
102
|
+
:class="chevronClasses"
|
|
95
103
|
/>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
|
|
105
|
+
<router-link
|
|
106
|
+
v-if="item.to && index !== nav.length - 1"
|
|
107
|
+
:to="item.to"
|
|
108
|
+
:class="linkClasses"
|
|
109
|
+
:aria-current="index === nav.length - 1 ? 'page' : undefined"
|
|
110
|
+
>
|
|
111
|
+
<HomeIcon
|
|
112
|
+
v-if="showHome && index === 0"
|
|
113
|
+
:class="homeIconClasses"
|
|
114
|
+
/>
|
|
115
|
+
<span>{{ item.name }}</span>
|
|
116
|
+
</router-link>
|
|
117
|
+
<span
|
|
118
|
+
v-else
|
|
119
|
+
:class="textClasses"
|
|
120
|
+
:aria-current="index === nav.length - 1 ? 'page' : undefined"
|
|
121
|
+
>
|
|
122
|
+
{{ item.name }}
|
|
123
|
+
</span>
|
|
124
|
+
</li>
|
|
125
|
+
</template>
|
|
126
|
+
</ol>
|
|
127
|
+
</nav>
|
|
107
128
|
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
|
3
3
|
import { useDebounceFn, useEventListener } from '@vueuse/core'
|
|
4
|
-
import { h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
4
|
+
import { computed, h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
5
5
|
import { useEventBus } from '../../composables/event-bus'
|
|
6
6
|
|
|
7
7
|
// Use a shared global registry in the window to track all modals across instances
|
|
@@ -75,7 +75,7 @@ const props = withDefaults(
|
|
|
75
75
|
const eventBus = useEventBus()
|
|
76
76
|
|
|
77
77
|
const isOpen = ref<boolean>(false)
|
|
78
|
-
const modalRef =
|
|
78
|
+
const modalRef = shallowRef<HTMLElement | null>(null)
|
|
79
79
|
let previouslyFocusedElement: HTMLElement | null = null
|
|
80
80
|
let focusableElements: HTMLElement[] = []
|
|
81
81
|
|
|
@@ -91,6 +91,14 @@ const modalUniqueId = shallowRef('')
|
|
|
91
91
|
// Trap focus within modal for accessibility - memoize selector for better performance
|
|
92
92
|
const focusableSelector = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
|
|
93
93
|
|
|
94
|
+
// Mobile-optimized backdrop classes
|
|
95
|
+
const backdropClasses = 'flex fixed backdrop-blur-[8px] inset-0 flex-col items-center py-4 md:py-8 px-2 md:px-4 overflow-y-auto text-fv-neutral-800 dark:text-fv-neutral-300 bg-fv-neutral-900/[.20] dark:bg-fv-neutral-50/[.20]'
|
|
96
|
+
|
|
97
|
+
// Modal panel classes with mobile optimization
|
|
98
|
+
const modalPanelClasses = computed(() => {
|
|
99
|
+
return `relative ${props.mSize} max-w-[calc(100vw-1rem)] md:max-w-6xl max-h-[85vh] my-auto px-0 box-border bg-white rounded-lg shadow-xl dark:bg-fv-neutral-900 flex flex-col`
|
|
100
|
+
})
|
|
101
|
+
|
|
94
102
|
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
95
103
|
return Array.from(
|
|
96
104
|
element.querySelectorAll(focusableSelector),
|
|
@@ -99,12 +107,11 @@ function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
|
99
107
|
) as HTMLElement[]
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
useEventListener(document, 'keydown', handleKeyDown)
|
|
105
|
-
}
|
|
110
|
+
// Forward declare setModal to avoid use-before-define
|
|
111
|
+
let setModal: any
|
|
106
112
|
|
|
107
|
-
|
|
113
|
+
// Memoize keydown handler for better performance
|
|
114
|
+
const handleKeyDown = useDebounceFn((event: KeyboardEvent) => {
|
|
108
115
|
// Only handle events for the top-most modal
|
|
109
116
|
if (!isOpen.value) return
|
|
110
117
|
|
|
@@ -117,25 +124,28 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
117
124
|
// Close on escape
|
|
118
125
|
if (event.key === 'Escape') {
|
|
119
126
|
event.preventDefault()
|
|
120
|
-
|
|
121
|
-
isOpen.value = false
|
|
127
|
+
setModal(false)
|
|
122
128
|
return
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
// Handle tab trapping
|
|
131
|
+
// Handle tab trapping only if we have focusable elements
|
|
126
132
|
if (event.key === 'Tab' && focusableElements.length > 0) {
|
|
133
|
+
const firstElement = focusableElements[0]
|
|
134
|
+
const lastElement = focusableElements[focusableElements.length - 1]
|
|
135
|
+
const activeElement = document.activeElement
|
|
136
|
+
|
|
127
137
|
// If shift + tab on first element, focus last element
|
|
128
|
-
if (event.shiftKey &&
|
|
138
|
+
if (event.shiftKey && activeElement === firstElement) {
|
|
129
139
|
event.preventDefault()
|
|
130
|
-
|
|
140
|
+
lastElement.focus()
|
|
131
141
|
}
|
|
132
142
|
// If tab on last element, focus first element
|
|
133
|
-
else if (!event.shiftKey &&
|
|
143
|
+
else if (!event.shiftKey && activeElement === lastElement) {
|
|
134
144
|
event.preventDefault()
|
|
135
|
-
|
|
145
|
+
firstElement.focus()
|
|
136
146
|
}
|
|
137
147
|
}
|
|
138
|
-
}
|
|
148
|
+
}, 10)
|
|
139
149
|
|
|
140
150
|
// Check if this modal is the top-most (highest z-index)
|
|
141
151
|
function isTopMostModal(id: string): boolean {
|
|
@@ -152,7 +162,7 @@ function isTopMostModal(id: string): boolean {
|
|
|
152
162
|
return highestEntry[0] === id
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
|
|
165
|
+
setModal = useDebounceFn((value: boolean) => {
|
|
156
166
|
if (value === true) {
|
|
157
167
|
if (props.onOpen) props.onOpen()
|
|
158
168
|
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
@@ -176,7 +186,8 @@ const setModal = useDebounceFn((value: boolean) => {
|
|
|
176
186
|
// Set this modal's z-index
|
|
177
187
|
zIndex.value = newZIndex
|
|
178
188
|
|
|
179
|
-
|
|
189
|
+
// Set up keyboard listener directly
|
|
190
|
+
useEventListener(document, 'keydown', handleKeyDown)
|
|
180
191
|
}
|
|
181
192
|
if (value === false) {
|
|
182
193
|
if (props.onClose) props.onClose()
|
|
@@ -197,12 +208,13 @@ const setModal = useDebounceFn((value: boolean) => {
|
|
|
197
208
|
watch(isOpen, async (newVal) => {
|
|
198
209
|
if (newVal) {
|
|
199
210
|
await nextTick()
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
const modalEl = modalRef.value
|
|
212
|
+
if (modalEl) {
|
|
213
|
+
focusableElements = getFocusableElements(modalEl)
|
|
202
214
|
|
|
203
215
|
// Focus the close button or first focusable element
|
|
204
216
|
requestAnimationFrame(() => {
|
|
205
|
-
const closeButton =
|
|
217
|
+
const closeButton = modalEl.querySelector('button[aria-label="Close modal"]') as HTMLElement
|
|
206
218
|
if (closeButton) {
|
|
207
219
|
closeButton.focus()
|
|
208
220
|
}
|
|
@@ -211,7 +223,7 @@ watch(isOpen, async (newVal) => {
|
|
|
211
223
|
}
|
|
212
224
|
else {
|
|
213
225
|
// If no focusable elements, focus the modal itself
|
|
214
|
-
|
|
226
|
+
modalEl.focus()
|
|
215
227
|
}
|
|
216
228
|
})
|
|
217
229
|
}
|
|
@@ -243,10 +255,10 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
243
255
|
<template>
|
|
244
256
|
<ClientOnly>
|
|
245
257
|
<transition
|
|
246
|
-
enter-active-class="duration-
|
|
258
|
+
enter-active-class="duration-150 ease-out"
|
|
247
259
|
enter-from-class="opacity-0"
|
|
248
260
|
enter-to-class="opacity-100"
|
|
249
|
-
leave-active-class="duration-
|
|
261
|
+
leave-active-class="duration-100 ease-in"
|
|
250
262
|
leave-from-class="opacity-100"
|
|
251
263
|
leave-to-class="opacity-0"
|
|
252
264
|
>
|
|
@@ -262,14 +274,14 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
262
274
|
>
|
|
263
275
|
<!-- Backdrop with click to close functionality -->
|
|
264
276
|
<div
|
|
265
|
-
class="
|
|
277
|
+
:class="backdropClasses"
|
|
266
278
|
:style="{ zIndex }"
|
|
267
279
|
@click="handleBackdropClick"
|
|
268
280
|
>
|
|
269
281
|
<!-- Modal panel -->
|
|
270
282
|
<div
|
|
271
283
|
ref="modalRef"
|
|
272
|
-
:class="
|
|
284
|
+
:class="modalPanelClasses"
|
|
273
285
|
:style="{ zIndex }"
|
|
274
286
|
tabindex="-1"
|
|
275
287
|
@click.stop
|
|
@@ -277,17 +289,17 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
277
289
|
<!-- Header with title if provided -->
|
|
278
290
|
<div
|
|
279
291
|
v-if="title"
|
|
280
|
-
class="flex items-center justify-between p-2 w-full border-b rounded-t dark:border-fv-neutral-700"
|
|
292
|
+
class="flex items-center justify-between p-2 md:p-3 w-full border-b rounded-t dark:border-fv-neutral-700"
|
|
281
293
|
>
|
|
282
294
|
<slot name="before" />
|
|
283
295
|
<h2
|
|
284
296
|
v-if="title"
|
|
285
297
|
:id="`${props.id}-title`"
|
|
286
|
-
class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
|
|
298
|
+
class="text-lg md:text-xl font-semibold text-fv-neutral-900 dark:text-white"
|
|
287
299
|
v-html="title"
|
|
288
300
|
/>
|
|
289
301
|
<button
|
|
290
|
-
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"
|
|
302
|
+
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 transition-colors duration-200"
|
|
291
303
|
aria-label="Close modal"
|
|
292
304
|
@click="setModal(false)"
|
|
293
305
|
>
|
|
@@ -295,7 +307,7 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
295
307
|
</button>
|
|
296
308
|
</div>
|
|
297
309
|
<!-- Content area -->
|
|
298
|
-
<div :class="`p-2 space-y-3 flex-grow ${ofy}`">
|
|
310
|
+
<div :class="`p-2 md:p-4 space-y-3 flex-grow ${ofy}`">
|
|
299
311
|
<slot />
|
|
300
312
|
</div>
|
|
301
313
|
</div>
|
|
@@ -282,12 +282,13 @@ onMounted(() => {
|
|
|
282
282
|
</div>
|
|
283
283
|
<input
|
|
284
284
|
v-else
|
|
285
|
+
:id="`jump-input-${id}`"
|
|
285
286
|
v-model="jumpPageValue"
|
|
286
287
|
type="number"
|
|
287
288
|
:min="1"
|
|
288
289
|
:max="items.page_max"
|
|
289
290
|
:class="`pagination-jump-input ${inputWidthClass}`"
|
|
290
|
-
placeholder=""
|
|
291
|
+
placeholder="#"
|
|
291
292
|
@keydown="handleJumpInputKeydown"
|
|
292
293
|
@blur="showJumpInput = false; jumpPageValue = ''"
|
|
293
294
|
>
|
|
@@ -336,12 +337,13 @@ onMounted(() => {
|
|
|
336
337
|
</div>
|
|
337
338
|
<input
|
|
338
339
|
v-else
|
|
340
|
+
:id="`jump-input-${id}`"
|
|
339
341
|
v-model="jumpPageValue"
|
|
340
342
|
type="number"
|
|
341
343
|
:min="1"
|
|
342
344
|
:max="items.page_max"
|
|
343
345
|
:class="`pagination-jump-input ${inputWidthClass}`"
|
|
344
|
-
placeholder=""
|
|
346
|
+
placeholder="#"
|
|
345
347
|
@keydown="handleJumpInputKeydown"
|
|
346
348
|
@blur="showJumpInput = false; jumpPageValue = ''"
|
|
347
349
|
>
|
|
@@ -366,12 +368,13 @@ onMounted(() => {
|
|
|
366
368
|
</div>
|
|
367
369
|
<input
|
|
368
370
|
v-else
|
|
371
|
+
:id="`jump-input-${id}`"
|
|
369
372
|
v-model="jumpPageValue"
|
|
370
373
|
type="number"
|
|
371
374
|
:min="1"
|
|
372
375
|
:max="items.page_max"
|
|
373
376
|
:class="`pagination-jump-input ${inputWidthClass}`"
|
|
374
|
-
placeholder=""
|
|
377
|
+
placeholder="#"
|
|
375
378
|
@keydown="handleJumpInputKeydown"
|
|
376
379
|
@blur="showJumpInput = false; jumpPageValue = ''"
|
|
377
380
|
>
|