@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.19.0",
3
+ "version": "8.20.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -6,7 +6,7 @@ defineOptions({
6
6
  <template>
7
7
  <div class="rsui-form-fieldset">
8
8
  <fieldset v-bind="$attrs">
9
- <legend>
9
+ <legend v-if="$slots.legend">
10
10
  <slot name="legend"></slot>
11
11
  </legend>
12
12
 
@@ -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 class="rsui-form-field-checkbox">
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
- // Always show the selected option label if there is one, regardless of dropdown state
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 === 1) {
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
- // TODO: Add keyboard navigation for options
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="$attrs.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
- class="rsui-form-field-combobox__option"
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
- class="rsui-form-field-combobox__option"
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
- <div class="rsui-form-field-password__icon"
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
- </div>
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 { ref } from 'vue'
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
- <slot name="label"></slot>
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
- <div v-for="option in options" :key="option" :class="['rsui-form-field-radio-group__item', {'rsui-form-field-radio-group__item--disabled': option.disabled}]"
39
- @click="!option.disabled && setValue(option.value)">
40
- <div class="rsui-form-field-radio-group__control" v-if="model !== option.value"></div>
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 :disabled="option.disabled" class="rsui-form-field-radio-group__item-native-control" type="radio" :value="option.value"
46
- v-model="model" :id="$attrs.id" :name="$attrs.name">
47
- <label :class="['rsui-form-field-radio-group__item-label', {'rsui-form-field-radio-group__item-label--disabled': option.disabled}]">{{ option.label }}</label>
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) setTimeout(() => calculateDropdownPosition(), 1)
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 handleSelectClick(event) {
45
- if (!isMobileDevice.value) {
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
- const selectElementBounding = useElementBounding(selectElement)
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 select element
128
+ * Get space above the anchor element
76
129
  */
77
- const spaceAboveSelectElement = selectElementBounding.top.value
78
- // console.log('spaceAboveSelectElement', spaceAboveSelectElement)
130
+ const spaceAbove = anchorBounding.top.value
79
131
 
80
132
  /**
81
- * Get space below the select element
133
+ * Get space below the anchor element
82
134
  */
83
- const spaceBelowSelectElement = viewportHeight - selectElementBounding.bottom.value
84
- // console.log('spaceBelowSelectElement', spaceBelowSelectElement)
135
+ const spaceBelow = viewportHeight - anchorBounding.bottom.value
85
136
 
86
- dropdownElement.value.style.width = `${selectElementBounding.width.value}px`
137
+ dropdownElement.value.style.width = `${anchorBounding.width.value}px`
87
138
 
88
- dropdownElement.value.style.left = `${selectElementBounding.left.value}px`
139
+ dropdownElement.value.style.left = `${anchorBounding.left.value}px`
89
140
 
90
- if (spaceAboveSelectElement <= dropdownElementHeight
91
- && spaceBelowSelectElement <= dropdownElementHeight) {
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 (spaceBelowSelectElement > dropdownElementHeight) {
96
- // console.log('space below >= dropdown height')
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 (spaceAboveSelectElement > dropdownElementHeight) {
102
- // console.log('space above > dropdown height')
150
+ } else if (spaceAbove > dropdownElementHeight) {
103
151
  dropdownElement.value.style.top = 'auto'
104
- dropdownElement.value.style.bottom = `${spaceBelowSelectElement + selectElementBounding.height.value + 8 - window.scrollY}px`
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', () => calculateDropdownPosition())
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="$attrs.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
- value=""
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
- class="rsui-form-field-select__option"
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="$attrs.id"
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
- <div class="rsui-form-field-uploader__action-icon"
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
- </div>
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="$attrs.id"
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']" class="formfield-input__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
+ }