@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(() => props.nav.map((item, index) => {
31
- if (!item.to) {
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
- '@type': 'ListItem',
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
- <ol class="inline-flex items-center flex-wrap">
68
- <template v-for="(item, index) in nav" :key="`bc_${index.toString()}`">
69
- <li class="inline-flex items-center">
70
- <ChevronRightIcon
71
- v-if="index !== 0"
72
- :class="
73
- index === 0
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
- <span>{{ item.name }}</span>
97
- </router-link>
98
- <span
99
- v-else
100
- class="text-xs font-medium text-fv-neutral-500 dark:text-fv-neutral-200"
101
- >
102
- {{ item.name }}
103
- </span>
104
- </li>
105
- </template>
106
- </ol>
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>
@@ -387,7 +387,7 @@ defineExpose({ focus, blur, getInputRef })
387
387
  @blur="handleBlur"
388
388
  >
389
389
  <option
390
- v-for="opt in options"
390
+ v-for="opt in (options || [])"
391
391
  :key="opt[0]?.toString()"
392
392
  :value="opt[0]"
393
393
  >
@@ -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 = ref<HTMLElement | null>(null)
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
- // Use VueUse's useEventListener for better event handling
103
- function setupKeydownListener() {
104
- useEventListener(document, 'keydown', handleKeyDown)
105
- }
110
+ // Forward declare setModal to avoid use-before-define
111
+ let setModal: any
106
112
 
107
- function handleKeyDown(event: KeyboardEvent) {
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
- // Use direct state to avoid the use-before-define issue
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 && document.activeElement === focusableElements[0]) {
138
+ if (event.shiftKey && activeElement === firstElement) {
129
139
  event.preventDefault()
130
- focusableElements[focusableElements.length - 1].focus()
140
+ lastElement.focus()
131
141
  }
132
142
  // If tab on last element, focus first element
133
- else if (!event.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) {
143
+ else if (!event.shiftKey && activeElement === lastElement) {
134
144
  event.preventDefault()
135
- focusableElements[0].focus()
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
- const setModal = useDebounceFn((value: boolean) => {
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
- setupKeydownListener()
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
- if (modalRef.value) {
201
- focusableElements = getFocusableElements(modalRef.value)
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 = modalRef.value?.querySelector('button[aria-label="Close modal"]') as HTMLElement
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
- modalRef.value?.focus()
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-50 ease-out"
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-50 ease-in"
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="flex fixed backdrop-blur-[8px] inset-0 flex-col items-center py-8 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]"
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="`relative ${mSize} max-w-6xl max-h-[85vh] my-auto px-0 box-border bg-white rounded-lg shadow dark:bg-fv-neutral-900 flex flex-col`"
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
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.3.63",
3
+ "version": "2.3.64",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",