@redseed/redseed-ui-vue3 8.18.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 +159 -21
- 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,
|
|
@@ -34,16 +35,25 @@ const props = defineProps({
|
|
|
34
35
|
minSearchLength: {
|
|
35
36
|
type: Number,
|
|
36
37
|
default: 2
|
|
38
|
+
},
|
|
39
|
+
navigable: {
|
|
40
|
+
type: Boolean,
|
|
41
|
+
default: false
|
|
37
42
|
}
|
|
38
43
|
})
|
|
39
44
|
|
|
40
|
-
const emit = defineEmits(['input', 'change', 'keyup-enter'])
|
|
45
|
+
const emit = defineEmits(['input', 'change', 'keyup-enter', 'navigate'])
|
|
46
|
+
|
|
47
|
+
const attrs = useAttrs()
|
|
48
|
+
const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
|
|
41
49
|
|
|
42
50
|
const isOpen = ref(false)
|
|
43
51
|
const searchText = ref('')
|
|
52
|
+
const hasEdited = ref(false)
|
|
44
53
|
const inputElement = ref(null)
|
|
45
54
|
const comboboxElement = ref(null)
|
|
46
55
|
const dropdownElement = ref(null)
|
|
56
|
+
const highlightedIndex = ref(-1)
|
|
47
57
|
|
|
48
58
|
// Async search state
|
|
49
59
|
const isLoading = ref(false)
|
|
@@ -51,6 +61,19 @@ const searchError = ref(null)
|
|
|
51
61
|
const asyncOptions = ref([])
|
|
52
62
|
const debounceTimeout = ref(null)
|
|
53
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
|
+
|
|
54
77
|
// Filter options based on search text
|
|
55
78
|
const filteredOptions = computed(() => {
|
|
56
79
|
// If using async search, return async results
|
|
@@ -72,6 +95,26 @@ const filteredOptions = computed(() => {
|
|
|
72
95
|
)
|
|
73
96
|
})
|
|
74
97
|
|
|
98
|
+
// Group filtered options by their group field
|
|
99
|
+
const groupedOptions = computed(() => {
|
|
100
|
+
const options = filteredOptions.value
|
|
101
|
+
if (!options.some(o => o.group)) return null
|
|
102
|
+
|
|
103
|
+
const groups = []
|
|
104
|
+
let currentGroup = null
|
|
105
|
+
|
|
106
|
+
for (const option of options) {
|
|
107
|
+
const groupName = option.group || ''
|
|
108
|
+
if (!currentGroup || currentGroup.name !== groupName) {
|
|
109
|
+
currentGroup = { name: groupName, options: [] }
|
|
110
|
+
groups.push(currentGroup)
|
|
111
|
+
}
|
|
112
|
+
currentGroup.options.push(option)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return groups
|
|
116
|
+
})
|
|
117
|
+
|
|
75
118
|
// Computed property to determine what to show in dropdown
|
|
76
119
|
const dropdownContent = computed(() => {
|
|
77
120
|
if (props.searchFunction) {
|
|
@@ -99,15 +142,17 @@ const dropdownContent = computed(() => {
|
|
|
99
142
|
}
|
|
100
143
|
})
|
|
101
144
|
|
|
102
|
-
// 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).
|
|
103
148
|
const displayText = computed(() => {
|
|
104
|
-
|
|
149
|
+
if (isOpen.value && hasEdited.value) return searchText.value
|
|
150
|
+
|
|
105
151
|
const selectedOption = props.options.find(option => option.value === model.value)
|
|
106
152
|
if (selectedOption) {
|
|
107
153
|
return selectedOption.label
|
|
108
154
|
}
|
|
109
|
-
|
|
110
|
-
// If no option is selected, show search text when open or model value when closed
|
|
155
|
+
|
|
111
156
|
if (isOpen.value) return searchText.value
|
|
112
157
|
return model.value
|
|
113
158
|
})
|
|
@@ -123,6 +168,8 @@ function toggleDropdown() {
|
|
|
123
168
|
function open() {
|
|
124
169
|
isOpen.value = true
|
|
125
170
|
searchText.value = ''
|
|
171
|
+
hasEdited.value = false
|
|
172
|
+
highlightedIndex.value = -1
|
|
126
173
|
setTimeout(() => {
|
|
127
174
|
inputElement.value?.focus()
|
|
128
175
|
calculateDropdownPosition()
|
|
@@ -132,7 +179,9 @@ function open() {
|
|
|
132
179
|
function close() {
|
|
133
180
|
isOpen.value = false
|
|
134
181
|
searchText.value = ''
|
|
135
|
-
|
|
182
|
+
hasEdited.value = false
|
|
183
|
+
highlightedIndex.value = -1
|
|
184
|
+
|
|
136
185
|
// Clean up async search state
|
|
137
186
|
if (debounceTimeout.value) {
|
|
138
187
|
clearTimeout(debounceTimeout.value)
|
|
@@ -144,6 +193,12 @@ function close() {
|
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
function choose(option) {
|
|
196
|
+
if (props.navigable) {
|
|
197
|
+
emit('navigate', option.value, option)
|
|
198
|
+
close()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
147
202
|
// If clicking on the currently selected option, deselect it
|
|
148
203
|
if (option.value === model.value) {
|
|
149
204
|
model.value = ''
|
|
@@ -192,6 +247,7 @@ function debouncedSearch(query) {
|
|
|
192
247
|
|
|
193
248
|
function handleInput(event) {
|
|
194
249
|
searchText.value = event.target.value
|
|
250
|
+
hasEdited.value = true
|
|
195
251
|
if (!isOpen.value) {
|
|
196
252
|
isOpen.value = true
|
|
197
253
|
setTimeout(() => calculateDropdownPosition(), 1)
|
|
@@ -207,7 +263,9 @@ function handleInput(event) {
|
|
|
207
263
|
|
|
208
264
|
function handleKeyup(event) {
|
|
209
265
|
if (event.key === 'Enter') {
|
|
210
|
-
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) {
|
|
211
269
|
// If only one option matches, select it
|
|
212
270
|
choose(filteredOptions.value[0])
|
|
213
271
|
} else if (props.allowCustomValue && searchText.value) {
|
|
@@ -227,14 +285,35 @@ function handleKeydown(event) {
|
|
|
227
285
|
event.preventDefault()
|
|
228
286
|
if (!isOpen.value) {
|
|
229
287
|
open()
|
|
288
|
+
} else if (filteredOptions.value.length > 0) {
|
|
289
|
+
highlightedIndex.value = Math.min(
|
|
290
|
+
highlightedIndex.value + 1,
|
|
291
|
+
filteredOptions.value.length - 1
|
|
292
|
+
)
|
|
230
293
|
}
|
|
231
|
-
// TODO: Add keyboard navigation for options
|
|
232
294
|
} else if (event.key === 'ArrowUp') {
|
|
233
295
|
event.preventDefault()
|
|
234
|
-
|
|
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
|
+
}
|
|
235
309
|
}
|
|
236
310
|
}
|
|
237
311
|
|
|
312
|
+
// Reset highlighted index when filtered options change
|
|
313
|
+
watch(filteredOptions, () => {
|
|
314
|
+
highlightedIndex.value = -1
|
|
315
|
+
})
|
|
316
|
+
|
|
238
317
|
onClickOutside(comboboxElement, () => {
|
|
239
318
|
if (isOpen.value) {
|
|
240
319
|
// If we're closing and there's search text but no exact match and custom values are allowed
|
|
@@ -313,13 +392,23 @@ defineExpose({
|
|
|
313
392
|
<slot name="prefix"></slot>
|
|
314
393
|
</div>
|
|
315
394
|
|
|
316
|
-
<input
|
|
395
|
+
<input
|
|
317
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"
|
|
318
407
|
:value="displayText"
|
|
319
408
|
:autocomplete="$attrs.autocomplete"
|
|
320
409
|
:autofocus="$attrs.autofocus"
|
|
321
410
|
:disabled="$attrs.disabled"
|
|
322
|
-
:id="
|
|
411
|
+
:id="effectiveId"
|
|
323
412
|
:name="$attrs.name"
|
|
324
413
|
:placeholder="$slots.placeholder ? '' : 'Type to search or select...'"
|
|
325
414
|
:required="$attrs.required"
|
|
@@ -331,9 +420,10 @@ defineExpose({
|
|
|
331
420
|
>
|
|
332
421
|
|
|
333
422
|
<!-- Placeholder slot for custom placeholder rendering -->
|
|
334
|
-
<div
|
|
423
|
+
<div
|
|
335
424
|
v-if="$slots.placeholder && !displayText && !isOpen"
|
|
336
425
|
class="rsui-form-field-combobox__placeholder"
|
|
426
|
+
aria-hidden="true"
|
|
337
427
|
@click="open()"
|
|
338
428
|
>
|
|
339
429
|
<slot name="placeholder"></slot>
|
|
@@ -348,9 +438,11 @@ defineExpose({
|
|
|
348
438
|
leave-from-class="leave-from-class"
|
|
349
439
|
leave-to-class="leave-to-class"
|
|
350
440
|
>
|
|
351
|
-
<div
|
|
441
|
+
<div
|
|
352
442
|
ref="dropdownElement"
|
|
353
443
|
v-show="isOpen"
|
|
444
|
+
:id="`${effectiveId}-listbox`"
|
|
445
|
+
role="listbox"
|
|
354
446
|
:class="[
|
|
355
447
|
'rsui-form-field-combobox__options',
|
|
356
448
|
{ 'rsui-form-field-combobox__options--open': isOpen }
|
|
@@ -409,21 +501,58 @@ defineExpose({
|
|
|
409
501
|
</div>
|
|
410
502
|
</div>
|
|
411
503
|
|
|
412
|
-
<!--
|
|
504
|
+
<!-- Grouped options -->
|
|
505
|
+
<template v-else-if="dropdownContent === 'options' && groupedOptions">
|
|
506
|
+
<template v-for="group in groupedOptions" :key="group.name">
|
|
507
|
+
<div v-if="group.name" class="rsui-form-field-combobox__group-header" role="presentation">
|
|
508
|
+
{{ group.name }}
|
|
509
|
+
</div>
|
|
510
|
+
<div
|
|
511
|
+
v-for="option in group.options"
|
|
512
|
+
:key="option.value"
|
|
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
|
+
]"
|
|
520
|
+
@click="choose(option)"
|
|
521
|
+
>
|
|
522
|
+
<div
|
|
523
|
+
class="rsui-form-field-combobox__option-label"
|
|
524
|
+
:title="option.label"
|
|
525
|
+
>
|
|
526
|
+
{{ option.label }}
|
|
527
|
+
</div>
|
|
528
|
+
<div class="rsui-form-field-combobox__option-icon" aria-hidden="true">
|
|
529
|
+
<CheckIcon v-if="!navigable && option.value === model"></CheckIcon>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
</template>
|
|
533
|
+
</template>
|
|
534
|
+
|
|
535
|
+
<!-- Flat options -->
|
|
413
536
|
<template v-else-if="dropdownContent === 'options'">
|
|
414
|
-
<div
|
|
415
|
-
v-for="option in filteredOptions"
|
|
537
|
+
<div
|
|
538
|
+
v-for="(option, index) in filteredOptions"
|
|
416
539
|
:key="option.value"
|
|
417
|
-
|
|
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
|
+
]"
|
|
418
547
|
@click="choose(option)"
|
|
419
548
|
>
|
|
420
|
-
<div
|
|
549
|
+
<div
|
|
421
550
|
class="rsui-form-field-combobox__option-label"
|
|
422
551
|
:title="option.label"
|
|
423
552
|
>
|
|
424
553
|
{{ option.label }}
|
|
425
554
|
</div>
|
|
426
|
-
<div class="rsui-form-field-combobox__option-icon">
|
|
555
|
+
<div class="rsui-form-field-combobox__option-icon" aria-hidden="true">
|
|
427
556
|
<CheckIcon v-if="option.value === model"></CheckIcon>
|
|
428
557
|
</div>
|
|
429
558
|
</div>
|
|
@@ -439,15 +568,24 @@ defineExpose({
|
|
|
439
568
|
<slot name="suffix"></slot>
|
|
440
569
|
</div>
|
|
441
570
|
|
|
442
|
-
<div
|
|
571
|
+
<div
|
|
443
572
|
:class="[
|
|
444
573
|
'rsui-form-field-combobox__icon',
|
|
445
574
|
{ 'rsui-form-field-combobox__icon--open': isOpen }
|
|
446
575
|
]"
|
|
576
|
+
aria-hidden="true"
|
|
447
577
|
@click="toggleDropdown"
|
|
448
578
|
>
|
|
449
579
|
<ChevronDownIcon></ChevronDownIcon>
|
|
450
580
|
</div>
|
|
581
|
+
|
|
582
|
+
<div
|
|
583
|
+
aria-live="polite"
|
|
584
|
+
aria-atomic="true"
|
|
585
|
+
class="sr-only"
|
|
586
|
+
>
|
|
587
|
+
{{ liveAnnouncement }}
|
|
588
|
+
</div>
|
|
451
589
|
</div>
|
|
452
590
|
|
|
453
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
|
+
}
|