@redseed/redseed-ui-vue3 8.19.0 → 8.20.0
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.
- package/package.json +1 -1
- package/src/components/Form/FormFieldset.vue +1 -1
- package/src/components/Form/FormWrapperBuild.vue +1 -1
- package/src/components/FormField/FormFieldCheckbox.vue +16 -3
- package/src/components/FormField/FormFieldCombobox.vue +103 -20
- package/src/components/FormField/FormFieldPasswordToggle.vue +9 -4
- package/src/components/FormField/FormFieldRadioGroup.vue +38 -10
- package/src/components/FormField/FormFieldSearch.vue +2 -1
- package/src/components/FormField/FormFieldSelect.vue +123 -40
- package/src/components/FormField/FormFieldSlot.vue +29 -2
- package/src/components/FormField/FormFieldText.vue +7 -3
- package/src/components/FormField/FormFieldTextarea.vue +7 -1
- package/src/components/FormField/FormFieldUploaderWrapper.vue +7 -4
- package/src/components/Toggle/Toggle.vue +42 -4
- package/src/composables/useFormFieldA11y.js +28 -0
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import LogoRedSeedBuild from '../Logo/LogoRedSeedBuild.vue'
|
|
|
6
6
|
<template>
|
|
7
7
|
<FormSlot class="rsui-form-wrapper-build">
|
|
8
8
|
<template #image>
|
|
9
|
-
<LogoRedSeedBuild class="rsui-form-wrapper-build__image"></LogoRedSeedBuild>
|
|
9
|
+
<LogoRedSeedBuild class="rsui-form-wrapper-build__image" aria-hidden="true"></LogoRedSeedBuild>
|
|
10
10
|
</template>
|
|
11
11
|
|
|
12
12
|
<template #top v-if="$slots.top">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref, watch } from 'vue'
|
|
3
3
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
4
4
|
import { CheckIcon } from '@heroicons/vue/24/outline'
|
|
5
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
5
6
|
|
|
6
7
|
defineOptions({
|
|
7
8
|
inheritAttrs: false,
|
|
@@ -17,6 +18,8 @@ watch(() => model.value, () => checked.value = model.value)
|
|
|
17
18
|
|
|
18
19
|
const emit = defineEmits(['input'])
|
|
19
20
|
|
|
21
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
22
|
+
|
|
20
23
|
function check(event) {
|
|
21
24
|
checked.value = !checked.value
|
|
22
25
|
model.value = checked.value
|
|
@@ -24,17 +27,24 @@ function check(event) {
|
|
|
24
27
|
}
|
|
25
28
|
</script>
|
|
26
29
|
<template>
|
|
27
|
-
<FormFieldSlot
|
|
30
|
+
<FormFieldSlot
|
|
31
|
+
:id="$attrs.id"
|
|
32
|
+
:required="$attrs.required"
|
|
33
|
+
class="rsui-form-field-checkbox"
|
|
34
|
+
>
|
|
28
35
|
<template #label>
|
|
29
36
|
<div class="rsui-form-field-checkbox__checkbox">
|
|
30
37
|
<div class="rsui-form-field-checkbox__check">
|
|
31
|
-
<CheckIcon v-if="checked"></CheckIcon>
|
|
38
|
+
<CheckIcon v-if="checked" aria-hidden="true"></CheckIcon>
|
|
32
39
|
<input
|
|
33
40
|
v-model="checked"
|
|
34
41
|
type="checkbox"
|
|
42
|
+
:aria-describedby="ariaDescribedby"
|
|
43
|
+
:aria-invalid="ariaInvalid"
|
|
44
|
+
:aria-required="$attrs.required || undefined"
|
|
35
45
|
:autofocus="$attrs.autofocus"
|
|
36
46
|
:disabled="$attrs.disabled"
|
|
37
|
-
:id="$attrs.id"
|
|
47
|
+
:id="inputId || $attrs.id"
|
|
38
48
|
:name="$attrs.name"
|
|
39
49
|
:required="$attrs.required"
|
|
40
50
|
@input="check"
|
|
@@ -50,6 +60,9 @@ function check(event) {
|
|
|
50
60
|
</div>
|
|
51
61
|
</div>
|
|
52
62
|
</template>
|
|
63
|
+
<template #help v-if="$slots.help">
|
|
64
|
+
<slot name="help"></slot>
|
|
65
|
+
</template>
|
|
53
66
|
<template #error v-if="$slots.error">
|
|
54
67
|
<slot name="error"></slot>
|
|
55
68
|
</template>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
2
|
+
import { ref, computed, onMounted, onUnmounted, watch, useAttrs } from 'vue'
|
|
3
3
|
import { onClickOutside, useElementBounding } from '@vueuse/core'
|
|
4
4
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
5
5
|
import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
|
|
6
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
6
7
|
|
|
7
8
|
defineOptions({
|
|
8
9
|
inheritAttrs: false,
|
|
@@ -43,11 +44,16 @@ const props = defineProps({
|
|
|
43
44
|
|
|
44
45
|
const emit = defineEmits(['input', 'change', 'keyup-enter', 'navigate'])
|
|
45
46
|
|
|
47
|
+
const attrs = useAttrs()
|
|
48
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
49
|
+
|
|
46
50
|
const isOpen = ref(false)
|
|
47
51
|
const searchText = ref('')
|
|
52
|
+
const hasEdited = ref(false)
|
|
48
53
|
const inputElement = ref(null)
|
|
49
54
|
const comboboxElement = ref(null)
|
|
50
55
|
const dropdownElement = ref(null)
|
|
56
|
+
const highlightedIndex = ref(-1)
|
|
51
57
|
|
|
52
58
|
// Async search state
|
|
53
59
|
const isLoading = ref(false)
|
|
@@ -55,6 +61,19 @@ const searchError = ref(null)
|
|
|
55
61
|
const asyncOptions = ref([])
|
|
56
62
|
const debounceTimeout = ref(null)
|
|
57
63
|
|
|
64
|
+
const effectiveId = computed(() => inputId.value || attrs.id)
|
|
65
|
+
|
|
66
|
+
// Live region announcement for screen readers
|
|
67
|
+
const liveAnnouncement = computed(() => {
|
|
68
|
+
if (!isOpen.value) return ''
|
|
69
|
+
if (isLoading.value) return 'Loading results'
|
|
70
|
+
if (searchError.value) return 'Search failed'
|
|
71
|
+
if (filteredOptions.value.length > 0) {
|
|
72
|
+
return `${filteredOptions.value.length} result${filteredOptions.value.length === 1 ? '' : 's'} available`
|
|
73
|
+
}
|
|
74
|
+
return 'No results found'
|
|
75
|
+
})
|
|
76
|
+
|
|
58
77
|
// Filter options based on search text
|
|
59
78
|
const filteredOptions = computed(() => {
|
|
60
79
|
// If using async search, return async results
|
|
@@ -123,15 +142,17 @@ const dropdownContent = computed(() => {
|
|
|
123
142
|
}
|
|
124
143
|
})
|
|
125
144
|
|
|
126
|
-
// Get display text for the input
|
|
145
|
+
// Get display text for the input — when the dropdown is open and the user has started
|
|
146
|
+
// editing, show their searchText so backspace/typing works naturally; otherwise show
|
|
147
|
+
// the selected option's label (or the raw model value for custom entries).
|
|
127
148
|
const displayText = computed(() => {
|
|
128
|
-
|
|
149
|
+
if (isOpen.value && hasEdited.value) return searchText.value
|
|
150
|
+
|
|
129
151
|
const selectedOption = props.options.find(option => option.value === model.value)
|
|
130
152
|
if (selectedOption) {
|
|
131
153
|
return selectedOption.label
|
|
132
154
|
}
|
|
133
|
-
|
|
134
|
-
// If no option is selected, show search text when open or model value when closed
|
|
155
|
+
|
|
135
156
|
if (isOpen.value) return searchText.value
|
|
136
157
|
return model.value
|
|
137
158
|
})
|
|
@@ -147,6 +168,8 @@ function toggleDropdown() {
|
|
|
147
168
|
function open() {
|
|
148
169
|
isOpen.value = true
|
|
149
170
|
searchText.value = ''
|
|
171
|
+
hasEdited.value = false
|
|
172
|
+
highlightedIndex.value = -1
|
|
150
173
|
setTimeout(() => {
|
|
151
174
|
inputElement.value?.focus()
|
|
152
175
|
calculateDropdownPosition()
|
|
@@ -156,7 +179,9 @@ function open() {
|
|
|
156
179
|
function close() {
|
|
157
180
|
isOpen.value = false
|
|
158
181
|
searchText.value = ''
|
|
159
|
-
|
|
182
|
+
hasEdited.value = false
|
|
183
|
+
highlightedIndex.value = -1
|
|
184
|
+
|
|
160
185
|
// Clean up async search state
|
|
161
186
|
if (debounceTimeout.value) {
|
|
162
187
|
clearTimeout(debounceTimeout.value)
|
|
@@ -222,6 +247,7 @@ function debouncedSearch(query) {
|
|
|
222
247
|
|
|
223
248
|
function handleInput(event) {
|
|
224
249
|
searchText.value = event.target.value
|
|
250
|
+
hasEdited.value = true
|
|
225
251
|
if (!isOpen.value) {
|
|
226
252
|
isOpen.value = true
|
|
227
253
|
setTimeout(() => calculateDropdownPosition(), 1)
|
|
@@ -237,7 +263,9 @@ function handleInput(event) {
|
|
|
237
263
|
|
|
238
264
|
function handleKeyup(event) {
|
|
239
265
|
if (event.key === 'Enter') {
|
|
240
|
-
if (filteredOptions.value.length
|
|
266
|
+
if (highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length) {
|
|
267
|
+
choose(filteredOptions.value[highlightedIndex.value])
|
|
268
|
+
} else if (filteredOptions.value.length === 1) {
|
|
241
269
|
// If only one option matches, select it
|
|
242
270
|
choose(filteredOptions.value[0])
|
|
243
271
|
} else if (props.allowCustomValue && searchText.value) {
|
|
@@ -257,14 +285,35 @@ function handleKeydown(event) {
|
|
|
257
285
|
event.preventDefault()
|
|
258
286
|
if (!isOpen.value) {
|
|
259
287
|
open()
|
|
288
|
+
} else if (filteredOptions.value.length > 0) {
|
|
289
|
+
highlightedIndex.value = Math.min(
|
|
290
|
+
highlightedIndex.value + 1,
|
|
291
|
+
filteredOptions.value.length - 1
|
|
292
|
+
)
|
|
260
293
|
}
|
|
261
|
-
// TODO: Add keyboard navigation for options
|
|
262
294
|
} else if (event.key === 'ArrowUp') {
|
|
263
295
|
event.preventDefault()
|
|
264
|
-
|
|
296
|
+
if (isOpen.value && filteredOptions.value.length > 0) {
|
|
297
|
+
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
|
298
|
+
}
|
|
299
|
+
} else if (event.key === 'Home') {
|
|
300
|
+
event.preventDefault()
|
|
301
|
+
if (isOpen.value && filteredOptions.value.length > 0) {
|
|
302
|
+
highlightedIndex.value = 0
|
|
303
|
+
}
|
|
304
|
+
} else if (event.key === 'End') {
|
|
305
|
+
event.preventDefault()
|
|
306
|
+
if (isOpen.value && filteredOptions.value.length > 0) {
|
|
307
|
+
highlightedIndex.value = filteredOptions.value.length - 1
|
|
308
|
+
}
|
|
265
309
|
}
|
|
266
310
|
}
|
|
267
311
|
|
|
312
|
+
// Reset highlighted index when filtered options change
|
|
313
|
+
watch(filteredOptions, () => {
|
|
314
|
+
highlightedIndex.value = -1
|
|
315
|
+
})
|
|
316
|
+
|
|
268
317
|
onClickOutside(comboboxElement, () => {
|
|
269
318
|
if (isOpen.value) {
|
|
270
319
|
// If we're closing and there's search text but no exact match and custom values are allowed
|
|
@@ -343,13 +392,23 @@ defineExpose({
|
|
|
343
392
|
<slot name="prefix"></slot>
|
|
344
393
|
</div>
|
|
345
394
|
|
|
346
|
-
<input
|
|
395
|
+
<input
|
|
347
396
|
ref="inputElement"
|
|
397
|
+
role="combobox"
|
|
398
|
+
:aria-activedescendant="isOpen && dropdownContent === 'options' && highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
|
|
399
|
+
:aria-autocomplete="'list'"
|
|
400
|
+
:aria-busy="isLoading || undefined"
|
|
401
|
+
:aria-controls="`${effectiveId}-listbox`"
|
|
402
|
+
:aria-describedby="ariaDescribedby"
|
|
403
|
+
:aria-expanded="isOpen"
|
|
404
|
+
:aria-haspopup="'listbox'"
|
|
405
|
+
:aria-invalid="ariaInvalid"
|
|
406
|
+
:aria-required="$attrs.required || undefined"
|
|
348
407
|
:value="displayText"
|
|
349
408
|
:autocomplete="$attrs.autocomplete"
|
|
350
409
|
:autofocus="$attrs.autofocus"
|
|
351
410
|
:disabled="$attrs.disabled"
|
|
352
|
-
:id="
|
|
411
|
+
:id="effectiveId"
|
|
353
412
|
:name="$attrs.name"
|
|
354
413
|
:placeholder="$slots.placeholder ? '' : 'Type to search or select...'"
|
|
355
414
|
:required="$attrs.required"
|
|
@@ -361,9 +420,10 @@ defineExpose({
|
|
|
361
420
|
>
|
|
362
421
|
|
|
363
422
|
<!-- Placeholder slot for custom placeholder rendering -->
|
|
364
|
-
<div
|
|
423
|
+
<div
|
|
365
424
|
v-if="$slots.placeholder && !displayText && !isOpen"
|
|
366
425
|
class="rsui-form-field-combobox__placeholder"
|
|
426
|
+
aria-hidden="true"
|
|
367
427
|
@click="open()"
|
|
368
428
|
>
|
|
369
429
|
<slot name="placeholder"></slot>
|
|
@@ -378,9 +438,11 @@ defineExpose({
|
|
|
378
438
|
leave-from-class="leave-from-class"
|
|
379
439
|
leave-to-class="leave-to-class"
|
|
380
440
|
>
|
|
381
|
-
<div
|
|
441
|
+
<div
|
|
382
442
|
ref="dropdownElement"
|
|
383
443
|
v-show="isOpen"
|
|
444
|
+
:id="`${effectiveId}-listbox`"
|
|
445
|
+
role="listbox"
|
|
384
446
|
:class="[
|
|
385
447
|
'rsui-form-field-combobox__options',
|
|
386
448
|
{ 'rsui-form-field-combobox__options--open': isOpen }
|
|
@@ -442,13 +504,19 @@ defineExpose({
|
|
|
442
504
|
<!-- Grouped options -->
|
|
443
505
|
<template v-else-if="dropdownContent === 'options' && groupedOptions">
|
|
444
506
|
<template v-for="group in groupedOptions" :key="group.name">
|
|
445
|
-
<div v-if="group.name" class="rsui-form-field-combobox__group-header">
|
|
507
|
+
<div v-if="group.name" class="rsui-form-field-combobox__group-header" role="presentation">
|
|
446
508
|
{{ group.name }}
|
|
447
509
|
</div>
|
|
448
510
|
<div
|
|
449
511
|
v-for="option in group.options"
|
|
450
512
|
:key="option.value"
|
|
451
|
-
|
|
513
|
+
:id="`${effectiveId}-option-${filteredOptions.indexOf(option)}`"
|
|
514
|
+
role="option"
|
|
515
|
+
:aria-selected="option.value === model"
|
|
516
|
+
:class="[
|
|
517
|
+
'rsui-form-field-combobox__option',
|
|
518
|
+
{ 'rsui-form-field-combobox__option--highlighted': filteredOptions.indexOf(option) === highlightedIndex }
|
|
519
|
+
]"
|
|
452
520
|
@click="choose(option)"
|
|
453
521
|
>
|
|
454
522
|
<div
|
|
@@ -457,7 +525,7 @@ defineExpose({
|
|
|
457
525
|
>
|
|
458
526
|
{{ option.label }}
|
|
459
527
|
</div>
|
|
460
|
-
<div class="rsui-form-field-combobox__option-icon">
|
|
528
|
+
<div class="rsui-form-field-combobox__option-icon" aria-hidden="true">
|
|
461
529
|
<CheckIcon v-if="!navigable && option.value === model"></CheckIcon>
|
|
462
530
|
</div>
|
|
463
531
|
</div>
|
|
@@ -467,9 +535,15 @@ defineExpose({
|
|
|
467
535
|
<!-- Flat options -->
|
|
468
536
|
<template v-else-if="dropdownContent === 'options'">
|
|
469
537
|
<div
|
|
470
|
-
v-for="option in filteredOptions"
|
|
538
|
+
v-for="(option, index) in filteredOptions"
|
|
471
539
|
:key="option.value"
|
|
472
|
-
|
|
540
|
+
:id="`${effectiveId}-option-${index}`"
|
|
541
|
+
role="option"
|
|
542
|
+
:aria-selected="option.value === model"
|
|
543
|
+
:class="[
|
|
544
|
+
'rsui-form-field-combobox__option',
|
|
545
|
+
{ 'rsui-form-field-combobox__option--highlighted': index === highlightedIndex }
|
|
546
|
+
]"
|
|
473
547
|
@click="choose(option)"
|
|
474
548
|
>
|
|
475
549
|
<div
|
|
@@ -478,7 +552,7 @@ defineExpose({
|
|
|
478
552
|
>
|
|
479
553
|
{{ option.label }}
|
|
480
554
|
</div>
|
|
481
|
-
<div class="rsui-form-field-combobox__option-icon">
|
|
555
|
+
<div class="rsui-form-field-combobox__option-icon" aria-hidden="true">
|
|
482
556
|
<CheckIcon v-if="option.value === model"></CheckIcon>
|
|
483
557
|
</div>
|
|
484
558
|
</div>
|
|
@@ -494,15 +568,24 @@ defineExpose({
|
|
|
494
568
|
<slot name="suffix"></slot>
|
|
495
569
|
</div>
|
|
496
570
|
|
|
497
|
-
<div
|
|
571
|
+
<div
|
|
498
572
|
:class="[
|
|
499
573
|
'rsui-form-field-combobox__icon',
|
|
500
574
|
{ 'rsui-form-field-combobox__icon--open': isOpen }
|
|
501
575
|
]"
|
|
576
|
+
aria-hidden="true"
|
|
502
577
|
@click="toggleDropdown"
|
|
503
578
|
>
|
|
504
579
|
<ChevronDownIcon></ChevronDownIcon>
|
|
505
580
|
</div>
|
|
581
|
+
|
|
582
|
+
<div
|
|
583
|
+
aria-live="polite"
|
|
584
|
+
aria-atomic="true"
|
|
585
|
+
class="sr-only"
|
|
586
|
+
>
|
|
587
|
+
{{ liveAnnouncement }}
|
|
588
|
+
</div>
|
|
506
589
|
</div>
|
|
507
590
|
|
|
508
591
|
<template #help v-if="$slots.help">
|
|
@@ -31,12 +31,17 @@ defineExpose({
|
|
|
31
31
|
</template>
|
|
32
32
|
|
|
33
33
|
<template #suffix>
|
|
34
|
-
<
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
class="rsui-form-field-password__icon"
|
|
37
|
+
:aria-label="show ? 'Hide password' : 'Show password'"
|
|
38
|
+
:aria-pressed="show"
|
|
39
|
+
:disabled="$attrs.disabled || undefined"
|
|
35
40
|
@click.prevent="togglePassword"
|
|
36
41
|
>
|
|
37
|
-
<EyeIcon v-if="!show"></EyeIcon>
|
|
38
|
-
<EyeSlashIcon v-if="show"></EyeSlashIcon>
|
|
39
|
-
</
|
|
42
|
+
<EyeIcon v-if="!show" aria-hidden="true"></EyeIcon>
|
|
43
|
+
<EyeSlashIcon v-if="show" aria-hidden="true"></EyeSlashIcon>
|
|
44
|
+
</button>
|
|
40
45
|
</template>
|
|
41
46
|
|
|
42
47
|
<template #help v-if="$slots.help">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import {
|
|
2
|
+
import { computed, useAttrs } from 'vue'
|
|
3
3
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
4
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
4
5
|
|
|
5
6
|
defineOptions({
|
|
6
7
|
inheritAttrs: false,
|
|
@@ -17,6 +18,11 @@ const props = defineProps({
|
|
|
17
18
|
|
|
18
19
|
const emit = defineEmits(['input'])
|
|
19
20
|
|
|
21
|
+
const attrs = useAttrs()
|
|
22
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
23
|
+
|
|
24
|
+
const effectiveId = computed(() => inputId.value || attrs.id)
|
|
25
|
+
|
|
20
26
|
function setValue(value) {
|
|
21
27
|
model.value = value
|
|
22
28
|
emit('input', value)
|
|
@@ -32,19 +38,41 @@ function setValue(value) {
|
|
|
32
38
|
:compact="$attrs.compact"
|
|
33
39
|
>
|
|
34
40
|
<template #label v-if="$slots.label">
|
|
35
|
-
<
|
|
41
|
+
<span :id="`${effectiveId}-label`">
|
|
42
|
+
<slot name="label"></slot>
|
|
43
|
+
</span>
|
|
36
44
|
</template>
|
|
37
|
-
<div class="rsui-form-field-radio-group__items"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
<div class="rsui-form-field-radio-group__items"
|
|
46
|
+
role="radiogroup"
|
|
47
|
+
:aria-labelledby="`${effectiveId}-label`"
|
|
48
|
+
:aria-describedby="ariaDescribedby"
|
|
49
|
+
:aria-invalid="ariaInvalid"
|
|
50
|
+
:aria-required="$attrs.required || undefined"
|
|
51
|
+
>
|
|
52
|
+
<div v-for="(option, index) in options"
|
|
53
|
+
:key="option.value"
|
|
54
|
+
:class="['rsui-form-field-radio-group__item', {'rsui-form-field-radio-group__item--disabled': option.disabled}]"
|
|
55
|
+
>
|
|
56
|
+
<div class="rsui-form-field-radio-group__control" v-if="model !== option.value" aria-hidden="true"></div>
|
|
41
57
|
<div class="rsui-form-field-radio-group__control rsui-form-field-radio-group__control--active"
|
|
42
|
-
v-if="model == option.value">
|
|
58
|
+
v-if="model == option.value" aria-hidden="true">
|
|
43
59
|
<div></div>
|
|
44
60
|
</div>
|
|
45
|
-
<input
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
<input
|
|
62
|
+
:disabled="option.disabled"
|
|
63
|
+
class="rsui-form-field-radio-group__item-native-control"
|
|
64
|
+
type="radio"
|
|
65
|
+
:value="option.value"
|
|
66
|
+
v-model="model"
|
|
67
|
+
:id="`${effectiveId}-option-${index}`"
|
|
68
|
+
:name="$attrs.name"
|
|
69
|
+
>
|
|
70
|
+
<label
|
|
71
|
+
:for="`${effectiveId}-option-${index}`"
|
|
72
|
+
:class="['rsui-form-field-radio-group__item-label', {'rsui-form-field-radio-group__item-label--disabled': option.disabled}]"
|
|
73
|
+
>
|
|
74
|
+
{{ option.label }}
|
|
75
|
+
</label>
|
|
48
76
|
</div>
|
|
49
77
|
</div>
|
|
50
78
|
<template #help v-if="$slots.help">
|
|
@@ -14,13 +14,14 @@ defineExpose({
|
|
|
14
14
|
<template>
|
|
15
15
|
<FormFieldText ref="formFieldTextElement"
|
|
16
16
|
class="rsui-form-field-search"
|
|
17
|
+
type="search"
|
|
17
18
|
>
|
|
18
19
|
<template #label v-if="$slots.label">
|
|
19
20
|
<slot name="label"></slot>
|
|
20
21
|
</template>
|
|
21
22
|
|
|
22
23
|
<template #prefix>
|
|
23
|
-
<MagnifyingGlassIcon class="rsui-form-field-search__icon"></MagnifyingGlassIcon>
|
|
24
|
+
<MagnifyingGlassIcon class="rsui-form-field-search__icon" aria-hidden="true"></MagnifyingGlassIcon>
|
|
24
25
|
</template>
|
|
25
26
|
|
|
26
27
|
<template #help v-if="$slots.help">
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, onMounted, onUnmounted } from 'vue'
|
|
2
|
+
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue'
|
|
3
3
|
import { onClickOutside, useElementBounding } from '@vueuse/core'
|
|
4
4
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
5
5
|
import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
|
|
6
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
6
7
|
|
|
7
8
|
defineOptions({
|
|
8
9
|
inheritAttrs: false,
|
|
@@ -19,16 +20,29 @@ const props = defineProps({
|
|
|
19
20
|
|
|
20
21
|
const emit = defineEmits(['change'])
|
|
21
22
|
|
|
23
|
+
const attrs = useAttrs()
|
|
24
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
25
|
+
|
|
22
26
|
const isOpen = ref(false)
|
|
23
27
|
const isMobileDevice = ref(false)
|
|
28
|
+
const highlightedIndex = ref(-1)
|
|
29
|
+
|
|
30
|
+
const effectiveId = computed(() => inputId.value || attrs.id)
|
|
24
31
|
|
|
25
32
|
function toggleOptions() {
|
|
26
33
|
isOpen.value = !isOpen.value
|
|
27
|
-
if (isOpen.value)
|
|
34
|
+
if (isOpen.value) {
|
|
35
|
+
const selectedIndex = props.options.findIndex(o => o.value === model.value)
|
|
36
|
+
highlightedIndex.value = selectedIndex >= 0 ? selectedIndex : 0
|
|
37
|
+
setTimeout(() => calculateDropdownPosition(), 1)
|
|
38
|
+
} else {
|
|
39
|
+
highlightedIndex.value = -1
|
|
40
|
+
}
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
function close() {
|
|
31
44
|
isOpen.value = false
|
|
45
|
+
highlightedIndex.value = -1
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
function choose(option) {
|
|
@@ -41,20 +55,61 @@ function nativeChoose(event) {
|
|
|
41
55
|
emit('change', event.target.value)
|
|
42
56
|
}
|
|
43
57
|
|
|
44
|
-
function
|
|
45
|
-
if (!
|
|
58
|
+
function handleKeydown(event) {
|
|
59
|
+
if (!isOpen.value && (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ')) {
|
|
60
|
+
if (event.repeat) return
|
|
46
61
|
event.preventDefault()
|
|
47
62
|
toggleOptions()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!isOpen.value) return
|
|
67
|
+
|
|
68
|
+
switch (event.key) {
|
|
69
|
+
case 'ArrowDown':
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
highlightedIndex.value = Math.min(highlightedIndex.value + 1, props.options.length - 1)
|
|
72
|
+
break
|
|
73
|
+
case 'ArrowUp':
|
|
74
|
+
event.preventDefault()
|
|
75
|
+
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
|
76
|
+
break
|
|
77
|
+
case 'Enter':
|
|
78
|
+
case ' ':
|
|
79
|
+
if (event.repeat) return
|
|
80
|
+
event.preventDefault()
|
|
81
|
+
if (highlightedIndex.value >= 0) {
|
|
82
|
+
choose(props.options[highlightedIndex.value])
|
|
83
|
+
}
|
|
84
|
+
break
|
|
85
|
+
case 'Escape':
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
close()
|
|
88
|
+
break
|
|
89
|
+
case 'Tab':
|
|
90
|
+
close()
|
|
91
|
+
break
|
|
92
|
+
case 'Home':
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
highlightedIndex.value = 0
|
|
95
|
+
break
|
|
96
|
+
case 'End':
|
|
97
|
+
event.preventDefault()
|
|
98
|
+
highlightedIndex.value = props.options.length - 1
|
|
99
|
+
break
|
|
48
100
|
}
|
|
49
101
|
}
|
|
50
102
|
|
|
51
103
|
const formFieldSelectElement = ref(null)
|
|
104
|
+
const triggerElement = ref(null)
|
|
52
105
|
const selectElement = ref(null)
|
|
53
106
|
const dropdownElement = ref(null)
|
|
54
107
|
|
|
55
108
|
onClickOutside(formFieldSelectElement, () => close())
|
|
56
109
|
|
|
57
|
-
|
|
110
|
+
// Anchor dropdown to the visible trigger (button on desktop, select on mobile)
|
|
111
|
+
const anchorElement = computed(() => triggerElement.value || selectElement.value)
|
|
112
|
+
const anchorBounding = useElementBounding(anchorElement)
|
|
58
113
|
|
|
59
114
|
function calculateDropdownPosition() {
|
|
60
115
|
if (!dropdownElement.value) return
|
|
@@ -63,62 +118,54 @@ function calculateDropdownPosition() {
|
|
|
63
118
|
* Get the viewport height
|
|
64
119
|
*/
|
|
65
120
|
const viewportHeight = window.innerHeight
|
|
66
|
-
// console.log('viewportHeight', viewportHeight)
|
|
67
121
|
|
|
68
122
|
/**
|
|
69
123
|
* Get the dropdown element height
|
|
70
124
|
*/
|
|
71
125
|
const dropdownElementHeight = dropdownElement.value.offsetHeight
|
|
72
|
-
// console.log('dropdownElementHeight', dropdownElementHeight)
|
|
73
126
|
|
|
74
127
|
/**
|
|
75
|
-
* Get space above the
|
|
128
|
+
* Get space above the anchor element
|
|
76
129
|
*/
|
|
77
|
-
const
|
|
78
|
-
// console.log('spaceAboveSelectElement', spaceAboveSelectElement)
|
|
130
|
+
const spaceAbove = anchorBounding.top.value
|
|
79
131
|
|
|
80
132
|
/**
|
|
81
|
-
* Get space below the
|
|
133
|
+
* Get space below the anchor element
|
|
82
134
|
*/
|
|
83
|
-
const
|
|
84
|
-
// console.log('spaceBelowSelectElement', spaceBelowSelectElement)
|
|
135
|
+
const spaceBelow = viewportHeight - anchorBounding.bottom.value
|
|
85
136
|
|
|
86
|
-
dropdownElement.value.style.width = `${
|
|
137
|
+
dropdownElement.value.style.width = `${anchorBounding.width.value}px`
|
|
87
138
|
|
|
88
|
-
dropdownElement.value.style.left = `${
|
|
139
|
+
dropdownElement.value.style.left = `${anchorBounding.left.value}px`
|
|
89
140
|
|
|
90
|
-
if (
|
|
91
|
-
&&
|
|
141
|
+
if (spaceAbove <= dropdownElementHeight
|
|
142
|
+
&& spaceBelow <= dropdownElementHeight) {
|
|
92
143
|
dropdownElement.value.style.top = '0'
|
|
93
144
|
dropdownElement.value.style.bottom = 'auto'
|
|
94
145
|
return
|
|
95
|
-
} else if (
|
|
96
|
-
|
|
97
|
-
dropdownElement.value.style.top = `${selectElementBounding.bottom.value + window.scrollY}px`
|
|
98
|
-
// console.log('dropdownElement.value.style.top', dropdownElement.value.style.top)
|
|
146
|
+
} else if (spaceBelow > dropdownElementHeight) {
|
|
147
|
+
dropdownElement.value.style.top = `${anchorBounding.bottom.value + window.scrollY}px`
|
|
99
148
|
dropdownElement.value.style.bottom = 'auto'
|
|
100
149
|
return
|
|
101
|
-
} else if (
|
|
102
|
-
// console.log('space above > dropdown height')
|
|
150
|
+
} else if (spaceAbove > dropdownElementHeight) {
|
|
103
151
|
dropdownElement.value.style.top = 'auto'
|
|
104
|
-
dropdownElement.value.style.bottom = `${
|
|
105
|
-
// console.log('dropdownElement.value.style.bottom', dropdownElement.value.style.bottom)
|
|
152
|
+
dropdownElement.value.style.bottom = `${spaceBelow + anchorBounding.height.value + 8 - window.scrollY}px`
|
|
106
153
|
return
|
|
107
154
|
}
|
|
108
155
|
}
|
|
109
156
|
|
|
157
|
+
function handleResize() {
|
|
158
|
+
calculateDropdownPosition()
|
|
159
|
+
}
|
|
160
|
+
|
|
110
161
|
onMounted(() => {
|
|
111
|
-
// Check if the device is a mobile device
|
|
112
|
-
// by checking if the device has a touch screen
|
|
113
|
-
// or if the device has a max touch points
|
|
114
162
|
isMobileDevice.value = 'ontouchstart' in window
|
|
115
163
|
|| (navigator.maxTouchPoints && navigator.maxTouchPoints > 0)
|
|
116
|
-
|
|
117
|
-
window.addEventListener('resize', () => calculateDropdownPosition())
|
|
164
|
+
window.addEventListener('resize', handleResize)
|
|
118
165
|
})
|
|
119
166
|
|
|
120
167
|
onUnmounted(() => {
|
|
121
|
-
window.removeEventListener('resize',
|
|
168
|
+
window.removeEventListener('resize', handleResize)
|
|
122
169
|
})
|
|
123
170
|
|
|
124
171
|
defineExpose({
|
|
@@ -144,16 +191,42 @@ defineExpose({
|
|
|
144
191
|
<slot name="label"></slot>
|
|
145
192
|
</template>
|
|
146
193
|
<div class="rsui-form-field-select__group">
|
|
194
|
+
<!-- Desktop-only keyboard-accessible trigger -->
|
|
195
|
+
<button
|
|
196
|
+
ref="triggerElement"
|
|
197
|
+
v-if="!isMobileDevice"
|
|
198
|
+
type="button"
|
|
199
|
+
class="rsui-form-field-select__trigger peer"
|
|
200
|
+
role="combobox"
|
|
201
|
+
:aria-activedescendant="highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
|
|
202
|
+
:aria-controls="`${effectiveId}-listbox`"
|
|
203
|
+
:aria-describedby="ariaDescribedby"
|
|
204
|
+
:aria-expanded="isOpen"
|
|
205
|
+
:aria-haspopup="'listbox'"
|
|
206
|
+
:aria-invalid="ariaInvalid"
|
|
207
|
+
:aria-required="$attrs.required || undefined"
|
|
208
|
+
:disabled="$attrs.disabled"
|
|
209
|
+
:id="effectiveId"
|
|
210
|
+
@click="toggleOptions"
|
|
211
|
+
@keydown="handleKeydown"
|
|
212
|
+
>
|
|
213
|
+
{{ options.find(o => o.value === model)?.label || 'Select an option' }}
|
|
214
|
+
</button>
|
|
215
|
+
|
|
147
216
|
<select ref="selectElement"
|
|
148
|
-
class="peer"
|
|
217
|
+
:class="['peer', { 'sr-only': !isMobileDevice }]"
|
|
149
218
|
v-model="model"
|
|
219
|
+
:aria-describedby="isMobileDevice ? ariaDescribedby : undefined"
|
|
220
|
+
:aria-invalid="isMobileDevice ? ariaInvalid : undefined"
|
|
221
|
+
:aria-required="isMobileDevice ? ($attrs.required || undefined) : undefined"
|
|
222
|
+
:aria-hidden="!isMobileDevice || undefined"
|
|
223
|
+
:tabindex="!isMobileDevice ? -1 : undefined"
|
|
150
224
|
:autocomplete="$attrs.autocomplete"
|
|
151
|
-
:autofocus="$attrs.autofocus"
|
|
225
|
+
:autofocus="isMobileDevice ? $attrs.autofocus : undefined"
|
|
152
226
|
:disabled="$attrs.disabled"
|
|
153
|
-
:id="
|
|
227
|
+
:id="isMobileDevice ? effectiveId : undefined"
|
|
154
228
|
:name="$attrs.name"
|
|
155
229
|
:required="$attrs.required"
|
|
156
|
-
@click="handleSelectClick"
|
|
157
230
|
@change.prevent="nativeChoose"
|
|
158
231
|
>
|
|
159
232
|
<option value="" disabled>
|
|
@@ -182,13 +255,17 @@ defineExpose({
|
|
|
182
255
|
>
|
|
183
256
|
<div ref="dropdownElement"
|
|
184
257
|
v-show="isOpen"
|
|
258
|
+
:id="`${effectiveId}-listbox`"
|
|
259
|
+
role="listbox"
|
|
185
260
|
:class="[
|
|
186
261
|
'rsui-form-field-select__options',
|
|
187
262
|
{ 'rsui-form-field-select__options--open': isOpen }
|
|
188
263
|
]"
|
|
189
264
|
>
|
|
190
265
|
<div class="rsui-form-field-select__option rsui-form-field-select__option--disabled"
|
|
191
|
-
|
|
266
|
+
role="option"
|
|
267
|
+
aria-selected="false"
|
|
268
|
+
aria-disabled="true"
|
|
192
269
|
>
|
|
193
270
|
<div class="rsui-form-field-select__option-label">
|
|
194
271
|
<slot name="default-option">
|
|
@@ -196,9 +273,15 @@ defineExpose({
|
|
|
196
273
|
</slot>
|
|
197
274
|
</div>
|
|
198
275
|
</div>
|
|
199
|
-
<div v-for="option in options"
|
|
276
|
+
<div v-for="(option, index) in options"
|
|
200
277
|
:key="option.value"
|
|
201
|
-
|
|
278
|
+
:id="`${effectiveId}-option-${index}`"
|
|
279
|
+
role="option"
|
|
280
|
+
:aria-selected="option.value === model"
|
|
281
|
+
:class="[
|
|
282
|
+
'rsui-form-field-select__option',
|
|
283
|
+
{ 'rsui-form-field-select__option--highlighted': index === highlightedIndex }
|
|
284
|
+
]"
|
|
202
285
|
@click="choose(option)"
|
|
203
286
|
>
|
|
204
287
|
<div class="rsui-form-field-select__option-label"
|
|
@@ -206,7 +289,7 @@ defineExpose({
|
|
|
206
289
|
>
|
|
207
290
|
{{ option.label }}
|
|
208
291
|
</div>
|
|
209
|
-
<div class="rsui-form-field-select__option-icon">
|
|
292
|
+
<div class="rsui-form-field-select__option-icon" aria-hidden="true">
|
|
210
293
|
<CheckIcon v-if="option.value === model"></CheckIcon>
|
|
211
294
|
</div>
|
|
212
295
|
</div>
|
|
@@ -217,7 +300,7 @@ defineExpose({
|
|
|
217
300
|
<div :class="[
|
|
218
301
|
'rsui-form-field-select__icon',
|
|
219
302
|
{ 'rsui-form-field-select__icon--open': isOpen }
|
|
220
|
-
]">
|
|
303
|
+
]" aria-hidden="true">
|
|
221
304
|
<ChevronDownIcon></ChevronDownIcon>
|
|
222
305
|
</div>
|
|
223
306
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, useAttrs } from 'vue'
|
|
2
|
+
import { computed, useAttrs, useSlots, useId, provide } from 'vue'
|
|
3
|
+
import { FormFieldA11yKey } from '../../composables/useFormFieldA11y.js'
|
|
3
4
|
|
|
4
5
|
const props = defineProps({
|
|
5
6
|
compact: {
|
|
@@ -17,6 +18,29 @@ defineOptions({
|
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
const attrs = useAttrs()
|
|
21
|
+
const slots = useSlots()
|
|
22
|
+
|
|
23
|
+
// Generate a stable unique ID, prefer consumer-provided id
|
|
24
|
+
const autoId = useId()
|
|
25
|
+
const inputId = computed(() => attrs.id || autoId)
|
|
26
|
+
|
|
27
|
+
// Compute aria-describedby from rendered help/error slots
|
|
28
|
+
const ariaDescribedby = computed(() => {
|
|
29
|
+
const ids = []
|
|
30
|
+
if (slots.help) ids.push(`${inputId.value}-help`)
|
|
31
|
+
if (slots.error) ids.push(`${inputId.value}-error`)
|
|
32
|
+
return ids.length > 0 ? ids.join(' ') : undefined
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// aria-invalid when error slot is present
|
|
36
|
+
const ariaInvalid = computed(() => slots.error ? true : undefined)
|
|
37
|
+
|
|
38
|
+
// Provide a11y values for child components to inject
|
|
39
|
+
provide(FormFieldA11yKey, {
|
|
40
|
+
inputId,
|
|
41
|
+
ariaDescribedby,
|
|
42
|
+
ariaInvalid,
|
|
43
|
+
})
|
|
20
44
|
|
|
21
45
|
const formFieldSlotClass = computed(() => [
|
|
22
46
|
attrs.class,
|
|
@@ -41,7 +65,7 @@ const formFieldSlotLabelClass = computed(() => [
|
|
|
41
65
|
:class="formFieldSlotLabelClass"
|
|
42
66
|
>
|
|
43
67
|
<label
|
|
44
|
-
:for="
|
|
68
|
+
:for="inputId"
|
|
45
69
|
>
|
|
46
70
|
<slot name="label"></slot>
|
|
47
71
|
</label>
|
|
@@ -51,12 +75,15 @@ const formFieldSlotLabelClass = computed(() => [
|
|
|
51
75
|
>
|
|
52
76
|
<slot></slot>
|
|
53
77
|
<div v-if="$slots.help"
|
|
78
|
+
:id="`${inputId}-help`"
|
|
54
79
|
class="rsui-form-field-slot__help"
|
|
55
80
|
>
|
|
56
81
|
<slot name="help"></slot>
|
|
57
82
|
</div>
|
|
58
83
|
<div v-if="$slots.error"
|
|
84
|
+
:id="`${inputId}-error`"
|
|
59
85
|
class="rsui-form-field-slot__error"
|
|
86
|
+
role="alert"
|
|
60
87
|
>
|
|
61
88
|
<slot name="error"></slot>
|
|
62
89
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
4
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
4
5
|
|
|
5
6
|
defineOptions({
|
|
6
7
|
inheritAttrs: false,
|
|
@@ -10,6 +11,8 @@ defineEmits(['input', 'keyup-enter'])
|
|
|
10
11
|
|
|
11
12
|
const model = defineModel()
|
|
12
13
|
|
|
14
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
15
|
+
|
|
13
16
|
const inputElement = ref(null)
|
|
14
17
|
|
|
15
18
|
defineExpose({
|
|
@@ -32,17 +35,19 @@ defineExpose({
|
|
|
32
35
|
<div class="rsui-form-field-text__group">
|
|
33
36
|
<div v-if="$slots.prefix"
|
|
34
37
|
class="rsui-form-field-text__prefix"
|
|
35
|
-
@click.prevent="inputElement.focus()"
|
|
36
38
|
>
|
|
37
39
|
<slot name="prefix"></slot>
|
|
38
40
|
</div>
|
|
39
41
|
|
|
40
42
|
<input ref="inputElement"
|
|
41
43
|
v-model="model"
|
|
44
|
+
:aria-describedby="ariaDescribedby"
|
|
45
|
+
:aria-invalid="ariaInvalid"
|
|
46
|
+
:aria-required="$attrs.required || undefined"
|
|
42
47
|
:autocomplete="$attrs.autocomplete"
|
|
43
48
|
:autofocus="$attrs.autofocus"
|
|
44
49
|
:disabled="$attrs.disabled"
|
|
45
|
-
:id="$attrs.id"
|
|
50
|
+
:id="inputId || $attrs.id"
|
|
46
51
|
:inputmode="$attrs.inputmode"
|
|
47
52
|
:max="$attrs.max"
|
|
48
53
|
:maxlength="$attrs.maxlength"
|
|
@@ -60,7 +65,6 @@ defineExpose({
|
|
|
60
65
|
|
|
61
66
|
<div v-if="$slots.suffix"
|
|
62
67
|
class="rsui-form-field-text__suffix"
|
|
63
|
-
@click.prevent="$refs.inputElement.focus()"
|
|
64
68
|
>
|
|
65
69
|
<slot name="suffix"></slot>
|
|
66
70
|
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import FormFieldSlot from './FormFieldSlot.vue'
|
|
4
4
|
import { useTextareaAutosize } from '@vueuse/core'
|
|
5
|
+
import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
|
|
5
6
|
|
|
6
7
|
defineOptions({
|
|
7
8
|
inheritAttrs: false,
|
|
@@ -16,6 +17,8 @@ const props = defineProps({
|
|
|
16
17
|
|
|
17
18
|
const emit = defineEmits(['input', 'select', 'keydown:enter'])
|
|
18
19
|
|
|
20
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
21
|
+
|
|
19
22
|
const { textarea, input } = useTextareaAutosize({ styleProp: 'minHeight' })
|
|
20
23
|
|
|
21
24
|
const model = defineModel()
|
|
@@ -59,9 +62,12 @@ defineExpose({
|
|
|
59
62
|
|
|
60
63
|
<textarea ref="textarea"
|
|
61
64
|
v-model="input"
|
|
65
|
+
:aria-describedby="ariaDescribedby"
|
|
66
|
+
:aria-invalid="ariaInvalid"
|
|
67
|
+
:aria-required="$attrs.required || undefined"
|
|
62
68
|
:autofocus="$attrs.autofocus"
|
|
63
69
|
:disabled="$attrs.disabled"
|
|
64
|
-
:id="$attrs.id"
|
|
70
|
+
:id="inputId || $attrs.id"
|
|
65
71
|
:maxlength="$attrs.maxlength"
|
|
66
72
|
:minlength="$attrs.minlength"
|
|
67
73
|
:name="$attrs.name"
|
|
@@ -119,7 +119,7 @@ function removeAction() {
|
|
|
119
119
|
|
|
120
120
|
<div :class="uploaderContainerClass">
|
|
121
121
|
<div :class="uploaderContentClass">
|
|
122
|
-
<div :class="uploaderStateIconClass">
|
|
122
|
+
<div :class="uploaderStateIconClass" aria-hidden="true">
|
|
123
123
|
<slot name="state-icon-default" v-if="stateDefault && !hasMedia">
|
|
124
124
|
<PlusIcon />
|
|
125
125
|
</slot>
|
|
@@ -177,13 +177,16 @@ function removeAction() {
|
|
|
177
177
|
<div v-if="showRemoveAction"
|
|
178
178
|
class="rsui-form-field-uploader__action"
|
|
179
179
|
>
|
|
180
|
-
<
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class="rsui-form-field-uploader__action-icon"
|
|
183
|
+
aria-label="Remove uploaded file"
|
|
181
184
|
@click="removeAction"
|
|
182
185
|
>
|
|
183
186
|
<slot name="state-action">
|
|
184
|
-
<XMarkIcon />
|
|
187
|
+
<XMarkIcon aria-hidden="true" />
|
|
185
188
|
</slot>
|
|
186
|
-
</
|
|
189
|
+
</button>
|
|
187
190
|
</div>
|
|
188
191
|
</div>
|
|
189
192
|
</FormFieldSlot>
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, watch } from 'vue'
|
|
2
|
+
import { computed, watch, useId, useSlots, useAttrs } from 'vue'
|
|
3
|
+
|
|
4
|
+
defineOptions({
|
|
5
|
+
inheritAttrs: false,
|
|
6
|
+
})
|
|
3
7
|
|
|
4
8
|
const props = defineProps({
|
|
5
9
|
disabled: {
|
|
@@ -24,12 +28,36 @@ const model = defineModel();
|
|
|
24
28
|
|
|
25
29
|
const emit = defineEmits(['update'])
|
|
26
30
|
|
|
31
|
+
const attrs = useAttrs()
|
|
32
|
+
const slots = useSlots()
|
|
33
|
+
|
|
34
|
+
// Generate a stable unique ID for label/input association
|
|
35
|
+
const autoId = useId()
|
|
36
|
+
const toggleId = computed(() => attrs.id || autoId)
|
|
37
|
+
|
|
38
|
+
// Compute aria-describedby from rendered help/error slots
|
|
39
|
+
const ariaDescribedby = computed(() => {
|
|
40
|
+
const ids = []
|
|
41
|
+
if (slots.help) ids.push(`${toggleId.value}-help`)
|
|
42
|
+
if (slots.error) ids.push(`${toggleId.value}-error`)
|
|
43
|
+
return ids.length > 0 ? ids.join(' ') : undefined
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// aria-invalid when error slot is present
|
|
47
|
+
const ariaInvalid = computed(() => slots.error ? true : undefined)
|
|
48
|
+
|
|
27
49
|
watch(model, (val) => {
|
|
28
50
|
emit('update', val)
|
|
29
51
|
})
|
|
30
52
|
|
|
31
53
|
const defaultSize = computed(() => !props.sm && !props.md)
|
|
32
54
|
|
|
55
|
+
// Filter out id and aria-label from $attrs — id goes on <input>, aria-label is handled explicitly
|
|
56
|
+
const rootAttrs = computed(() => {
|
|
57
|
+
const { id, 'aria-label': ariaLabel, ...rest } = attrs
|
|
58
|
+
return rest
|
|
59
|
+
})
|
|
60
|
+
|
|
33
61
|
const toggleClasses = computed(() => [
|
|
34
62
|
'rsui-toggle',
|
|
35
63
|
{
|
|
@@ -47,25 +75,35 @@ const controlClasses = computed(() => [
|
|
|
47
75
|
])
|
|
48
76
|
</script>
|
|
49
77
|
<template>
|
|
50
|
-
<div :class="toggleClasses">
|
|
78
|
+
<div :class="toggleClasses" v-bind="rootAttrs">
|
|
51
79
|
<div :class="controlClasses">
|
|
52
80
|
<label v-if="$slots['label']"
|
|
53
|
-
:for="
|
|
81
|
+
:for="toggleId"
|
|
54
82
|
>
|
|
55
83
|
<slot name="label"></slot>
|
|
56
84
|
</label>
|
|
57
85
|
<input
|
|
86
|
+
:id="toggleId"
|
|
58
87
|
:disabled="disabled"
|
|
88
|
+
:aria-describedby="ariaDescribedby"
|
|
89
|
+
:aria-invalid="ariaInvalid"
|
|
90
|
+
:aria-label="!$slots['label'] ? $attrs['aria-label'] : undefined"
|
|
91
|
+
:aria-required="$attrs.required || undefined"
|
|
59
92
|
v-model="model"
|
|
60
93
|
type="checkbox"
|
|
61
94
|
>
|
|
62
95
|
</div>
|
|
63
96
|
<div v-if="$slots['help']"
|
|
97
|
+
:id="`${toggleId}-help`"
|
|
64
98
|
class="rsui-toggle__help"
|
|
65
99
|
>
|
|
66
100
|
<slot name="help"></slot>
|
|
67
101
|
</div>
|
|
68
|
-
<div v-if="$slots['error']"
|
|
102
|
+
<div v-if="$slots['error']"
|
|
103
|
+
:id="`${toggleId}-error`"
|
|
104
|
+
role="alert"
|
|
105
|
+
class="rsui-toggle__error"
|
|
106
|
+
>
|
|
69
107
|
<slot name="error"></slot>
|
|
70
108
|
</div>
|
|
71
109
|
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { inject, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
// Injection key used by FormFieldSlot to provide a11y values
|
|
4
|
+
export const FormFieldA11yKey = Symbol('FormFieldA11y')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Consumer side of a provide/inject pattern for WCAG a11y attributes.
|
|
8
|
+
*
|
|
9
|
+
* FormFieldSlot (the wrapper) provides three reactive values under FormFieldA11yKey:
|
|
10
|
+
* - inputId: a unique ID (from useId()) for <label for> / <input id> association
|
|
11
|
+
* - ariaDescribedby: computed string linking to help/error text element IDs
|
|
12
|
+
* - ariaInvalid: computed boolean, true when an error slot is present
|
|
13
|
+
*
|
|
14
|
+
* Child input components (FormFieldText, Checkbox, Select, Toggle, etc.) call this
|
|
15
|
+
* composable to inject those values and bind them to their <input> elements.
|
|
16
|
+
*
|
|
17
|
+
* Safe to use outside FormFieldSlot — returns ref(null) fallbacks so ARIA attributes
|
|
18
|
+
* simply won't render when there's no wrapper providing help/error context.
|
|
19
|
+
*/
|
|
20
|
+
export function useFormFieldA11y() {
|
|
21
|
+
const a11y = inject(FormFieldA11yKey, null)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
inputId: a11y?.inputId ?? ref(null),
|
|
25
|
+
ariaDescribedby: a11y?.ariaDescribedby ?? ref(null),
|
|
26
|
+
ariaInvalid: a11y?.ariaInvalid ?? ref(null),
|
|
27
|
+
}
|
|
28
|
+
}
|