@fy-/fws-vue 2.2.3 → 2.2.41

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,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, onUnmounted, ref } from 'vue'
2
+ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
3
3
  import { useEventBus } from '../../composables/event-bus'
4
4
  import DefaultModal from './DefaultModal.vue'
5
5
 
@@ -7,34 +7,53 @@ const eventBus = useEventBus()
7
7
  const title = ref<string | null>(null)
8
8
  const desc = ref<string | null>(null)
9
9
  const onConfirm = ref<Function | null>(null)
10
+ const isOpen = ref<boolean>(false)
11
+ const modalRef = ref<HTMLElement | null>(null)
12
+ let previouslyFocusedElement: HTMLElement | null = null
13
+
10
14
  interface ConfirmModalData {
11
15
  title: string
12
16
  desc: string
13
17
  onConfirm: Function
14
18
  }
19
+
15
20
  async function _onConfirm() {
16
21
  if (onConfirm.value) {
17
22
  await onConfirm.value()
18
23
  }
19
24
  resetConfirm()
20
25
  }
26
+
21
27
  function resetConfirm() {
22
28
  title.value = null
23
29
  desc.value = null
24
30
  onConfirm.value = null
31
+ isOpen.value = false
25
32
  eventBus.emit('confirmModal', false)
33
+ if (previouslyFocusedElement) {
34
+ previouslyFocusedElement.focus()
35
+ }
26
36
  }
37
+
27
38
  function showConfirm(data: ConfirmModalData) {
28
39
  title.value = data.title
29
40
  desc.value = data.desc
30
41
  onConfirm.value = data.onConfirm
42
+ isOpen.value = true
31
43
  eventBus.emit('confirmModal', true)
44
+ nextTick(() => {
45
+ previouslyFocusedElement = document.activeElement as HTMLElement
46
+ if (modalRef.value) {
47
+ modalRef.value.focus()
48
+ }
49
+ })
32
50
  }
33
51
 
34
52
  onMounted(() => {
35
53
  eventBus.on('resetConfirm', resetConfirm)
36
54
  eventBus.on('showConfirm', showConfirm)
37
55
  })
56
+
38
57
  onUnmounted(() => {
39
58
  eventBus.off('resetConfirm', resetConfirm)
40
59
  eventBus.off('showConfirm', showConfirm)
@@ -42,25 +61,40 @@ onUnmounted(() => {
42
61
  </script>
43
62
 
44
63
  <template>
45
- <DefaultModal id="confirm">
64
+ <DefaultModal
65
+ id="confirm"
66
+
67
+ ref="modalRef"
68
+ >
46
69
  <div
47
70
  class="relative bg-fv-neutral-200 rounded-lg shadow dark:bg-fv-neutral-900"
71
+ :aria-labelledby="title ? 'confirm-modal-title' : undefined"
72
+ :aria-describedby="desc ? 'confirm-modal-desc' : undefined"
73
+ aria-modal="true"
74
+ role="dialog"
75
+ tabindex="-1"
48
76
  >
49
- <div class="p-1.5 lg:p-5 text-center">
77
+ <div
78
+ class="p-1.5 lg:p-5 text-center max-h-[80vh] overflow-y-auto cool-scroll"
79
+ >
80
+ <h2
81
+ v-if="title"
82
+ id="confirm-modal-title"
83
+ class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
84
+ >
85
+ {{ title }}
86
+ </h2>
50
87
  <p
51
- class="mb-3 !text-left prose prose-invert prose-sm !min-w-full"
52
- v-html="
53
- desc ? `<h2>${title}</h2>${desc}` : `<h2>${title}</h2>`
54
- "
88
+ v-if="desc"
89
+ id="confirm-modal-desc"
90
+ class="mb-3 text-left prose prose-invert prose-sm min-w-full"
91
+ v-html="desc"
55
92
  />
56
93
  <div class="flex justify-between gap-3 mt-4">
57
94
  <button class="btn danger defaults" @click="_onConfirm()">
58
95
  {{ $t("confirm_modal_cta_confirm") }}
59
96
  </button>
60
- <button
61
- class="btn neutral defaults"
62
- @click="$eventBus.emit('confirmModal', false)"
63
- >
97
+ <button class="btn neutral defaults" @click="resetConfirm()">
64
98
  {{ $t("confirm_modal_cta_cancel") }}
65
99
  </button>
66
100
  </div>
@@ -5,7 +5,7 @@ import {
5
5
  TransitionRoot,
6
6
  } from '@headlessui/vue'
7
7
  import { XCircleIcon } from '@heroicons/vue/24/solid'
8
- import { h, onMounted, onUnmounted, ref } from 'vue'
8
+ import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
9
9
  import { useEventBus } from '../../composables/event-bus'
10
10
 
11
11
  const props = withDefaults(
@@ -28,12 +28,19 @@ const props = withDefaults(
28
28
  const eventBus = useEventBus()
29
29
 
30
30
  const isOpen = ref<boolean>(false)
31
+ const modalRef = ref<HTMLElement | null>(null)
32
+ let previouslyFocusedElement: HTMLElement | null = null
33
+
31
34
  function setModal(value: boolean) {
32
35
  if (value === true) {
33
36
  if (props.onOpen) props.onOpen()
37
+ previouslyFocusedElement = document.activeElement as HTMLElement
34
38
  }
35
39
  if (value === false) {
36
40
  if (props.onClose) props.onClose()
41
+ if (previouslyFocusedElement) {
42
+ previouslyFocusedElement.focus()
43
+ }
37
44
  }
38
45
  isOpen.value = value
39
46
  }
@@ -41,9 +48,17 @@ function setModal(value: boolean) {
41
48
  onMounted(() => {
42
49
  eventBus.on(`${props.id}Modal`, setModal)
43
50
  })
51
+
44
52
  onUnmounted(() => {
45
53
  eventBus.off(`${props.id}Modal`, setModal)
46
54
  })
55
+
56
+ watch(isOpen, async (newVal) => {
57
+ if (newVal) {
58
+ await nextTick()
59
+ modalRef.value?.focus()
60
+ }
61
+ })
47
62
  </script>
48
63
 
49
64
  <template>
@@ -61,9 +76,14 @@ onUnmounted(() => {
61
76
  :open="isOpen"
62
77
  class="fixed inset-0 overflow-y-auto"
63
78
  style="z-index: 40"
79
+ aria-modal="true"
80
+ role="dialog"
81
+ :aria-labelledby="title ? `${props.id}-title` : undefined"
64
82
  @close="setModal"
65
83
  >
66
84
  <DialogPanel
85
+ ref="modalRef"
86
+ tabindex="-1"
67
87
  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]"
68
88
  style="z-index: 41"
69
89
  >
@@ -78,11 +98,13 @@ onUnmounted(() => {
78
98
  <slot name="before" />
79
99
  <h2
80
100
  v-if="title"
101
+ :id="`${props.id}-title`"
81
102
  class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
82
103
  v-html="title"
83
104
  />
84
105
  <button
85
106
  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"
107
+ aria-label="Close modal"
86
108
  @click="setModal(false)"
87
109
  >
88
110
  <component :is="closeIcon" class="w-7 h-7" />
@@ -58,6 +58,20 @@ const model = computed({
58
58
  },
59
59
  })
60
60
 
61
+ /**
62
+ * Compute aria-describedby IDs if help or error exist
63
+ */
64
+ const describedByIds = computed(() => {
65
+ const ids: any[] = []
66
+ if (props.help) {
67
+ ids.push(`help_tags_${props.id}`)
68
+ }
69
+ if (props.error) {
70
+ ids.push(`error_tags_${props.id}`)
71
+ }
72
+ return ids.join(' ')
73
+ })
74
+
61
75
  /**
62
76
  * Watch the model to see if maxTags is reached
63
77
  */
@@ -216,21 +230,30 @@ function handlePaste(e: ClipboardEvent) {
216
230
  <!-- Optional label -->
217
231
  <label
218
232
  v-if="label"
233
+ :id="`label_tags_${id}`"
219
234
  :for="`tags_${id}`"
220
235
  class="block text-sm font-medium dark:text-white"
221
236
  >
222
237
  {{ label }}
223
- <!-- optional help text -->
224
- <span v-if="help" class="ml-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-300">{{ help }}</span>
238
+ <!-- Optional help text -->
239
+ <span
240
+ v-if="help"
241
+ :id="`help_tags_${id}`"
242
+ class="ml-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-300"
243
+ >
244
+ {{ help }}
245
+ </span>
225
246
  </label>
226
247
 
227
248
  <div
228
- class="tags-input" :class="[
249
+ class="tags-input"
250
+ :class="[
229
251
  $props.error ? 'error' : '',
230
252
  isMaxReached ? 'pointer-events-none opacity-75' : '',
231
253
  ]"
232
254
  role="textbox"
233
- :aria-label="label || 'Tags input'"
255
+ :aria-labelledby="`label_tags_${id}`"
256
+ :aria-describedby="describedByIds || undefined"
234
257
  :aria-invalid="$props.error ? 'true' : 'false'"
235
258
  @click="focusInput"
236
259
  @keydown.delete.prevent="removeLastTag"
@@ -240,6 +263,7 @@ function handlePaste(e: ClipboardEvent) {
240
263
  <span
241
264
  v-for="(tag, index) in model"
242
265
  :key="`${tag}-${index}`"
266
+ role="listitem"
243
267
  class="tag"
244
268
  :class="{
245
269
  red: maxLenghtPerTag > 0 && tag.length > maxLenghtPerTag,
@@ -250,11 +274,11 @@ function handlePaste(e: ClipboardEvent) {
250
274
  <button
251
275
  type="button"
252
276
  class="flex items-center"
253
- aria-label="Remove tag"
277
+ :aria-label="`Remove tag ${tag}`"
254
278
  @click.prevent="removeTag(index)"
255
279
  >
256
280
  <svg
257
- class="w-3 h-3"
281
+ class="w-4 h-4"
258
282
  xmlns="http://www.w3.org/2000/svg"
259
283
  fill="none"
260
284
  viewBox="0 0 24 24"
@@ -275,17 +299,26 @@ function handlePaste(e: ClipboardEvent) {
275
299
  :id="`tags_${id}`"
276
300
  ref="textInput"
277
301
  contenteditable="true"
302
+ tabindex="0"
278
303
  class="input"
279
304
  :placeholder="isMaxReached
280
305
  ? 'Max tags reached'
281
306
  : 'Type or paste and press Enter...'"
307
+ :aria-placeholder="isMaxReached
308
+ ? 'Max tags reached'
309
+ : 'Type or paste and press Enter...'"
282
310
  @input="handleInput"
283
311
  @paste.prevent="handlePaste"
284
312
  />
285
313
  </div>
286
314
 
287
315
  <!-- Inline error display if needed -->
288
- <p v-if="$props.error" class="text-xs text-red-500 mt-1">
316
+ <p
317
+ v-if="$props.error"
318
+ :id="`error_tags_${id}`"
319
+ class="text-xs text-red-500 mt-1"
320
+ aria-live="assertive"
321
+ >
289
322
  {{ $props.error }}
290
323
  </p>
291
324
 
@@ -308,7 +341,7 @@ function handlePaste(e: ClipboardEvent) {
308
341
  @apply w-full flex flex-wrap gap-2 items-center shadow-sm bg-fv-neutral-50
309
342
  border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-sm
310
343
  focus-within:ring-fv-primary-500 focus-within:border-fv-primary-500
311
- p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
344
+ px-2.5 py-1 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
312
345
  dark:placeholder-fv-neutral-400 dark:text-white
313
346
  dark:focus-within:ring-fv-primary-500 dark:focus-within:border-fv-primary-500;
314
347
  cursor: text;
@@ -322,7 +355,7 @@ function handlePaste(e: ClipboardEvent) {
322
355
  /* Tag styling */
323
356
  .tag {
324
357
  @apply inline-flex gap-1 items-center
325
- font-medium px-2.5 py-0.5 rounded text-black
358
+ font-medium px-2.5 py-1 rounded text-black
326
359
  dark:text-white cursor-default;
327
360
  }
328
361
 
@@ -346,6 +379,15 @@ function handlePaste(e: ClipboardEvent) {
346
379
  @apply bg-fv-neutral-400 dark:bg-fv-neutral-900;
347
380
  }
348
381
 
382
+ /* Increase the clickable target for remove buttons (improves mobile accessibility) */
383
+ .tag button {
384
+ min-width: 44px;
385
+ min-height: 44px;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ }
390
+
349
391
  /* The editable input area for new tags */
350
392
  .input {
351
393
  @apply flex-grow min-w-[100px] outline-none border-none break-words;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.3",
3
+ "version": "2.2.41",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",