@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.18.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,
@@ -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
- // Always show the selected option label if there is one, regardless of dropdown state
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 === 1) {
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
- // 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
+ }
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="$attrs.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
- <!-- Options -->
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
- 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
+ ]"
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
- <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
+ }