@fy-/fws-vue 2.2.3 → 2.2.4
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
<!--
|
|
224
|
-
<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"
|
|
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-
|
|
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,7 +274,7 @@ 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
|
|
@@ -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
|
|
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
|
|
|
@@ -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;
|