@redseed/redseed-ui-vue3 8.21.1 → 8.22.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityFeeds/FeedItem.vue +6 -1
  3. package/src/components/Breadcrumbs/Breadcrumbs.vue +27 -13
  4. package/src/components/Button/ButtonSlot.vue +16 -3
  5. package/src/components/Card/Card.vue +5 -0
  6. package/src/components/Card/CardHeader.vue +5 -1
  7. package/src/components/Card/CheckboxCard.vue +7 -10
  8. package/src/components/Card/MetricCard.vue +5 -2
  9. package/src/components/Card/RadioCard.vue +9 -2
  10. package/src/components/CardGroup/CardGroup.vue +43 -0
  11. package/src/components/Disclosure/Disclosure.vue +4 -0
  12. package/src/components/DropdownMenu/DropdownMenu.vue +47 -4
  13. package/src/components/DropdownMenu/DropdownOption.vue +9 -1
  14. package/src/components/FormField/FormFieldCheckbox.vue +2 -2
  15. package/src/components/FormField/FormFieldCombobox.vue +35 -15
  16. package/src/components/FormField/FormFieldRadioGroup.vue +2 -1
  17. package/src/components/FormField/FormFieldSelect.vue +74 -6
  18. package/src/components/FormField/FormFieldSlot.vue +2 -2
  19. package/src/components/FormField/FormFieldTextarea.vue +1 -0
  20. package/src/components/FormField/FormFieldUploaderWrapper.vue +9 -1
  21. package/src/components/Link/LinkSlot.vue +30 -3
  22. package/src/components/LinkedList/LinkedListItem.vue +3 -1
  23. package/src/components/List/ListItem.vue +10 -5
  24. package/src/components/Modal/Modal.vue +31 -2
  25. package/src/components/Pagination/PaginationItem.vue +7 -3
  26. package/src/components/Pagination/PaginationItemNext.vue +2 -1
  27. package/src/components/Pagination/PaginationItemPrevious.vue +2 -1
  28. package/src/components/Progress/ProgressBar.vue +6 -1
  29. package/src/components/Progress/ProgressCircle.vue +7 -2
  30. package/src/components/Progress/ProgressTrackerStep.vue +5 -0
  31. package/src/components/Table/Table.vue +12 -3
  32. package/src/components/Table/TdUser.vue +1 -1
  33. package/src/components/Table/Th.vue +5 -4
  34. package/src/components/Table/Tr.vue +5 -5
  35. package/src/components/Toggle/Toggle.vue +5 -3
  36. package/src/components/Tooltip/Tooltip.vue +21 -12
  37. package/src/composables/useFormFieldA11y.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.21.1",
3
+ "version": "8.22.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -40,7 +40,11 @@ function handleClick() {
40
40
  'rsui-feed-item--padded': padded,
41
41
  }
42
42
  ]"
43
+ :tabindex="clickable ? 0 : undefined"
44
+ :role="clickable ? 'button' : undefined"
43
45
  @click="handleClick"
46
+ @keydown.enter.self.prevent="!$event.repeat && handleClick()"
47
+ @keydown.space.self.prevent="!$event.repeat && handleClick()"
44
48
  >
45
49
 
46
50
  <div class="rsui-feed-item__avatar">
@@ -48,7 +52,7 @@ function handleClick() {
48
52
  <slot name="icon">
49
53
  <IconCircleBackground lg invert>
50
54
  <slot name="avatar">
51
- <UserIcon />
55
+ <UserIcon aria-hidden="true" />
52
56
  </slot>
53
57
  </IconCircleBackground>
54
58
  </slot>
@@ -56,6 +60,7 @@ function handleClick() {
56
60
  <div v-if="status !== false"
57
61
  class="rsui-feed-item__avatar-indicator"
58
62
  >
63
+ <span class="sr-only">{{ status }}</span>
59
64
  <slot name="avatar-indicator"
60
65
  :status="status"
61
66
  >
@@ -14,22 +14,36 @@ const props = defineProps({
14
14
  }
15
15
  })
16
16
 
17
+ const isLastItem = (index) => index === props.items.length - 1
18
+
17
19
  function clickItem(item) {
18
20
  if (typeof item.action === 'function') item.action()
19
21
  }
20
22
  </script>
21
23
  <template>
22
- <div class="rsui-breadcrumbs">
23
- <div v-for="(item, index) in items"
24
- :key="index"
25
- :class="[
26
- 'rsui-breadcrumbs__item',
27
- { 'rsui-breadcrumbs__item--action': typeof item.action === 'function' }
28
- ]"
29
- >
30
- <span @click="clickItem(item)">
31
- {{ item.label }}
32
- </span>
33
- </div>
34
- </div>
24
+ <nav aria-label="Breadcrumb" class="rsui-breadcrumbs">
25
+ <ol class="rsui-breadcrumbs__list">
26
+ <li v-for="(item, index) in items"
27
+ :key="index"
28
+ :class="[
29
+ 'rsui-breadcrumbs__item',
30
+ { 'rsui-breadcrumbs__item--action': typeof item.action === 'function' }
31
+ ]"
32
+ :aria-current="isLastItem(index) ? 'page' : undefined"
33
+ >
34
+ <span v-if="typeof item.action === 'function'"
35
+ role="button"
36
+ tabindex="0"
37
+ @click="clickItem(item)"
38
+ @keydown.enter.prevent="!$event.repeat && clickItem(item)"
39
+ @keydown.space.prevent="!$event.repeat && clickItem(item)"
40
+ >
41
+ {{ item.label }}
42
+ </span>
43
+ <span v-if="typeof item.action !== 'function'">
44
+ {{ item.label }}
45
+ </span>
46
+ </li>
47
+ </ol>
48
+ </nav>
35
49
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed } from 'vue'
2
+ import { ref, computed, onMounted, watchEffect } from 'vue'
3
3
 
4
4
  const props = defineProps({
5
5
  xs: {
@@ -42,8 +42,8 @@ defineEmits(['click'])
42
42
  // button element ref
43
43
  const buttonElementRef = ref(null)
44
44
 
45
- // check if the button is an icon only button
46
- const iconOnly = computed(() => buttonElementRef.value && !buttonElementRef.value.textContent)
45
+ // check if the button has no visible text (icon-only state)
46
+ const iconOnly = computed(() => buttonElementRef.value && !buttonElementRef.value.textContent.trim())
47
47
 
48
48
  // button sizes
49
49
  const sizes = {
@@ -96,6 +96,15 @@ const buttonSlotClass = computed(() => [
96
96
  'rsui-button-slot--right': props.alignment === 'right',
97
97
  },
98
98
  ])
99
+
100
+ // warn in development when an icon-only button lacks an accessible name
101
+ onMounted(() => {
102
+ watchEffect(() => {
103
+ if (process.env.NODE_ENV !== 'production' && iconOnly.value && !buttonElementRef.value.getAttribute('aria-label') && !buttonElementRef.value.getAttribute('aria-labelledby')) {
104
+ console.warn('[RSUI] Icon-only button detected without aria-label. Add aria-label for accessibility.')
105
+ }
106
+ })
107
+ })
99
108
  </script>
100
109
  <template>
101
110
  <button
@@ -104,6 +113,10 @@ const buttonSlotClass = computed(() => [
104
113
  type="button"
105
114
  @click="$emit('click', $event)"
106
115
  >
116
+ <span v-if="$slots.icon" aria-hidden="true">
117
+ <slot name="icon"></slot>
118
+ </span>
119
+
107
120
  <slot></slot>
108
121
  </button>
109
122
  </template>
@@ -55,6 +55,10 @@ const props = defineProps({
55
55
  type: Boolean,
56
56
  default: false,
57
57
  },
58
+ ariaLabel: {
59
+ type: String,
60
+ default: undefined,
61
+ },
58
62
  })
59
63
 
60
64
  const emit = defineEmits(['click'])
@@ -121,6 +125,7 @@ function onClick() {
121
125
  role="button"
122
126
  :tabindex="disabled ? undefined : 0"
123
127
  :title="$attrs.title"
128
+ :aria-label="!$slots['aria-label'] ? ariaLabel : undefined"
124
129
  :aria-disabled="disabled ? true : undefined"
125
130
  @click="onClick"
126
131
  @keydown.enter.prevent="!$event.repeat && onClick()"
@@ -74,6 +74,10 @@ function handleMoreActionsClick() {
74
74
  'rsui-card-header--clickable': isClickable,
75
75
  }
76
76
  ]"
77
+ :role="isClickable ? 'button' : undefined"
78
+ :tabindex="isClickable ? 0 : undefined"
79
+ @keydown.enter.self.prevent="isClickable && !$event.repeat && $emit('click', $event)"
80
+ @keydown.space.self.prevent="isClickable && !$event.repeat && $emit('click', $event)"
77
81
  >
78
82
  <div :class="[
79
83
  'rsui-card-header__header',
@@ -141,7 +145,7 @@ function handleMoreActionsClick() {
141
145
  <slot name="more-actions"
142
146
  :handleMoreActionsClick="handleMoreActionsClick"
143
147
  >
144
- <ButtonTertiary @click="handleMoreActionsClick">
148
+ <ButtonTertiary aria-label="More actions" @click="handleMoreActionsClick">
145
149
  <slot name="more-actions-label">
146
150
  <Icon>
147
151
  <EllipsisVerticalIcon />
@@ -4,6 +4,8 @@ import Card from './Card.vue'
4
4
  import CardHeader from './CardHeader.vue'
5
5
  import FormFieldCheckbox from '../FormField/FormFieldCheckbox.vue'
6
6
 
7
+ const checkboxId = _.uniqueId('checkbox-card-')
8
+
7
9
  const props = defineProps({
8
10
  checked: {
9
11
  type: Boolean,
@@ -35,7 +37,6 @@ watch(() => props.checked, (val) => {
35
37
  isChecked.value = val
36
38
  })
37
39
 
38
-
39
40
  function toggleChecked() {
40
41
  if (props.disabled) return
41
42
  isChecked.value = !isChecked.value
@@ -43,11 +44,6 @@ function toggleChecked() {
43
44
  emit(isChecked.value ? 'check' : 'uncheck', isChecked.value)
44
45
  }
45
46
 
46
- function onTitleClick() {
47
- if (props.disabled) return
48
- toggleChecked()
49
- }
50
-
51
47
  function onFieldInput() {
52
48
  emit('change', isChecked.value)
53
49
  emit(isChecked.value ? 'check' : 'uncheck', isChecked.value)
@@ -73,16 +69,17 @@ function onFieldInput() {
73
69
  >
74
70
  <div class="rsui-checkbox-card__title-row">
75
71
  <div class="rsui-checkbox-card__field">
76
- <FormFieldCheckbox v-model="isChecked" :disabled="disabled" @input="onFieldInput" />
72
+ <FormFieldCheckbox :id="checkboxId" v-model="isChecked" :disabled="disabled" @input="onFieldInput" />
77
73
  </div>
78
- <span :class="[
74
+ <label :for="checkboxId"
75
+ :class="[
79
76
  'rsui-checkbox-card__title-text',
80
77
  { 'rsui-checkbox-card__title-text--strikethrough': strikethrough }
81
78
  ]"
82
- @click.stop.prevent="onTitleClick"
79
+ @click.stop
83
80
  >
84
81
  <slot name="title"></slot>
85
- </span>
82
+ </label>
86
83
  </div>
87
84
 
88
85
  <template #subtitle v-if="$slots.subtitle">
@@ -28,6 +28,9 @@ const props = defineProps({
28
28
  :clickable="props.clickable"
29
29
  :hoverable="props.hoverable"
30
30
  >
31
+ <template v-if="$slots['aria-label']" #aria-label>
32
+ <slot name="aria-label"></slot>
33
+ </template>
31
34
  <FlexContainer flexCol justifyCenter>
32
35
  <div v-if="labelFirst"
33
36
  :class="[
@@ -37,7 +40,7 @@ const props = defineProps({
37
40
  { 'rsui-metric-card--right': alignment === 'right' },
38
41
  ]"
39
42
  >
40
- <div v-if="$slots.icon" class="rsui-metric-card__icon">
43
+ <div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
41
44
  <slot name="icon"></slot>
42
45
  </div>
43
46
  <div class="rsui-metric-card__label">
@@ -63,7 +66,7 @@ const props = defineProps({
63
66
  { 'rsui-metric-card--right': alignment === 'right' },
64
67
  ]"
65
68
  >
66
- <div v-if="$slots.icon" class="rsui-metric-card__icon">
69
+ <div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
67
70
  <slot name="icon"></slot>
68
71
  </div>
69
72
  <div class="rsui-metric-card__label">
@@ -29,6 +29,8 @@ const props = defineProps({
29
29
  },
30
30
  })
31
31
 
32
+ const titleId = _.uniqueId('radio-card-title-')
33
+
32
34
  const isSelected = ref(props.selected)
33
35
 
34
36
  watch(() => props.selected, () => {
@@ -90,11 +92,16 @@ function handleClick(event) {
90
92
  <template>
91
93
  <div :class="componentClass"
92
94
  @click="handleSelect"
95
+ @keydown.enter.self.prevent="!$event.repeat && handleSelect()"
96
+ @keydown.space.self.prevent="!$event.repeat && handleSelect()"
93
97
  role="radio"
94
- tabindex="0"
98
+ :aria-checked="isSelected ? 'true' : 'false'"
99
+ :aria-disabled="disabled || undefined"
100
+ :aria-labelledby="titleId"
101
+ :tabindex="disabled ? -1 : 0"
95
102
  >
96
103
  <div :class="titleRowClass">
97
- <div class="rsui-radio-card__title">
104
+ <div :id="titleId" class="rsui-radio-card__title">
98
105
  <slot name="title"></slot>
99
106
  </div>
100
107
  </div>
@@ -187,6 +187,43 @@ function cleanupDragState() {
187
187
  dragFromIndex.value = null
188
188
  }
189
189
 
190
+ function onHandleKeyDown(e) {
191
+ if (e.repeat) return
192
+ const child = getCardChild(e.target)
193
+ if (!child) return
194
+ const container = cardsContainerElement.value
195
+ if (!container) return
196
+ const children = Array.from(container.children)
197
+ const index = children.indexOf(child)
198
+ if (index === -1) return
199
+
200
+ if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
201
+ e.preventDefault()
202
+ if (index > 0) {
203
+ emit('reorder', { fromIndex: index, toIndex: index - 1 })
204
+ // Re-focus the handle after DOM update
205
+ requestAnimationFrame(() => {
206
+ const updatedChildren = Array.from(container.children)
207
+ const handle = updatedChildren[index - 1]?.querySelector('.rsui-card-group__drag-handle')
208
+ if (handle) handle.focus()
209
+ })
210
+ }
211
+ }
212
+
213
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
214
+ e.preventDefault()
215
+ if (index < children.length - 1) {
216
+ emit('reorder', { fromIndex: index, toIndex: index + 1 })
217
+ // Re-focus the handle after DOM update
218
+ requestAnimationFrame(() => {
219
+ const updatedChildren = Array.from(container.children)
220
+ const handle = updatedChildren[index + 1]?.querySelector('.rsui-card-group__drag-handle')
221
+ if (handle) handle.focus()
222
+ })
223
+ }
224
+ }
225
+ }
226
+
190
227
  function syncHandles() {
191
228
  const container = cardsContainerElement.value
192
229
  if (!container) return
@@ -196,14 +233,19 @@ function syncHandles() {
196
233
  if (props.reorderable && !hasHandle) {
197
234
  const handle = document.createElement('div')
198
235
  handle.className = `rsui-card-group__drag-handle rsui-card-group__drag-handle--${props.handleAlignment}`
236
+ handle.setAttribute('role', 'button')
237
+ handle.setAttribute('tabindex', '0')
238
+ handle.setAttribute('aria-label', 'Reorder')
199
239
  handle.addEventListener('mousedown', onHandleMouseDown)
200
240
  handle.addEventListener('mouseup', onHandleMouseUp)
241
+ handle.addEventListener('keydown', onHandleKeyDown)
201
242
  handle.appendChild(createHandleSvg())
202
243
  child.style.position = 'relative'
203
244
  child.appendChild(handle)
204
245
  } else if (!props.reorderable && hasHandle) {
205
246
  hasHandle.removeEventListener('mousedown', onHandleMouseDown)
206
247
  hasHandle.removeEventListener('mouseup', onHandleMouseUp)
248
+ hasHandle.removeEventListener('keydown', onHandleKeyDown)
207
249
  hasHandle.remove()
208
250
  }
209
251
  })
@@ -217,6 +259,7 @@ function cleanupHandles() {
217
259
  if (handle) {
218
260
  handle.removeEventListener('mousedown', onHandleMouseDown)
219
261
  handle.removeEventListener('mouseup', onHandleMouseUp)
262
+ handle.removeEventListener('keydown', onHandleKeyDown)
220
263
  handle.remove()
221
264
  }
222
265
  child.removeAttribute('draggable')
@@ -116,6 +116,8 @@ onMounted(() => {
116
116
  >
117
117
  <slot name="trigger"
118
118
  :handleTrigger="handleTrigger"
119
+ :isOpen="isOpen"
120
+ :contentId="contentId"
119
121
  >
120
122
  <ButtonTertiary
121
123
  :sm="$attrs.sm"
@@ -124,6 +126,8 @@ onMounted(() => {
124
126
  :xl="$attrs.xl"
125
127
  :2xl="$attrs['2xl']"
126
128
  :full="$attrs.full"
129
+ :aria-expanded="isOpen"
130
+ :aria-controls="contentId"
127
131
  @click.stop="handleTrigger"
128
132
  >
129
133
  <Icon v-bind="iconProps">
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, onMounted, onUnmounted, ref } from 'vue'
2
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
3
3
  import ButtonTertiary from '../Button/ButtonTertiary.vue'
4
4
 
5
5
  const props = defineProps({
@@ -26,27 +26,67 @@ const containerClass = computed(() => [
26
26
  },
27
27
  ])
28
28
 
29
+ const containerRef = ref(null)
30
+
29
31
  function open() {
30
32
  isOpen.value = true
31
33
  }
32
34
 
35
+ // Focus first menuitem when menu opens
36
+ watch(isOpen, (value) => {
37
+ if (value) {
38
+ nextTick(() => {
39
+ if (!containerRef.value) return
40
+ const firstItem = containerRef.value.querySelector('[role="menuitem"]')
41
+ if (firstItem) firstItem.focus()
42
+ })
43
+ }
44
+ })
45
+
33
46
  function close() {
34
47
  isOpen.value = false
35
48
  }
36
49
 
37
50
  function closeOnEscape(e) {
38
- if (isOpen.value && e.key === 'Escape') {
51
+ if (isOpen.value && e.key === 'Escape' && !e.repeat) {
39
52
  isOpen.value = false
40
53
  }
41
54
  }
42
55
 
56
+ // Arrow key navigation between menu items (WAI-ARIA menu pattern)
57
+ function handleMenuKeydown(event) {
58
+ const items = containerRef.value?.querySelectorAll('[role="menuitem"]')
59
+ if (!items?.length) return
60
+
61
+ const currentIndex = Array.from(items).indexOf(document.activeElement)
62
+ let nextIndex = -1
63
+
64
+ if (event.key === 'ArrowDown') {
65
+ nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
66
+ }
67
+ if (event.key === 'ArrowUp') {
68
+ nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
69
+ }
70
+ if (event.key === 'Home') {
71
+ nextIndex = 0
72
+ }
73
+ if (event.key === 'End') {
74
+ nextIndex = items.length - 1
75
+ }
76
+
77
+ if (nextIndex >= 0) {
78
+ event.preventDefault()
79
+ items[nextIndex].focus()
80
+ }
81
+ }
82
+
43
83
  onMounted(() => document.addEventListener('keydown', closeOnEscape))
44
84
  onUnmounted(() => document.removeEventListener('keydown', closeOnEscape))
45
85
  </script>
46
86
  <template>
47
87
  <div class="rsui-dropdown-menu">
48
- <slot name="trigger" :open="open">
49
- <ButtonTertiary @click="open">
88
+ <slot name="trigger" :open="open" :isOpen="isOpen">
89
+ <ButtonTertiary :aria-expanded="isOpen" @click="open">
50
90
  <slot name="trigger-label"></slot>
51
91
  </ButtonTertiary>
52
92
  </slot>
@@ -67,7 +107,10 @@ onUnmounted(() => document.removeEventListener('keydown', closeOnEscape))
67
107
  >
68
108
  <div
69
109
  v-show="isOpen"
110
+ ref="containerRef"
70
111
  :class="containerClass"
112
+ role="menu"
113
+ @keydown="handleMenuKeydown"
71
114
  @click="close"
72
115
  >
73
116
  <slot></slot>
@@ -1,9 +1,17 @@
1
1
  <script setup>
2
2
  const emit = defineEmits(['click'])
3
+
4
+ function handleClick() {
5
+ emit('click')
6
+ }
3
7
  </script>
4
8
  <template>
5
9
  <div class="rsui-dropdown-option"
6
- @click="$emit('click')"
10
+ role="menuitem"
11
+ tabindex="0"
12
+ @click="handleClick"
13
+ @keydown.enter.prevent="!$event.repeat && handleClick()"
14
+ @keydown.space.prevent="!$event.repeat && handleClick()"
7
15
  >
8
16
  <slot></slot>
9
17
  </div>
@@ -37,7 +37,7 @@ function check(event) {
37
37
  <div class="rsui-form-field-checkbox__check">
38
38
  <CheckIcon v-if="checked" aria-hidden="true"></CheckIcon>
39
39
  <input
40
- v-model="checked"
40
+ :checked="checked"
41
41
  type="checkbox"
42
42
  :aria-describedby="ariaDescribedby"
43
43
  :aria-invalid="ariaInvalid"
@@ -47,7 +47,7 @@ function check(event) {
47
47
  :id="inputId || $attrs.id"
48
48
  :name="$attrs.name"
49
49
  :required="$attrs.required"
50
- @input="check"
50
+ @change="check"
51
51
  >
52
52
  </div>
53
53
  <div v-if="$slots.label"
@@ -449,9 +449,12 @@ defineExpose({
449
449
  ]"
450
450
  >
451
451
  <!-- Loading state -->
452
- <div
452
+ <div
453
453
  v-if="dropdownContent === 'loading'"
454
454
  class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled"
455
+ role="option"
456
+ aria-disabled="true"
457
+ aria-selected="false"
455
458
  >
456
459
  <div class="rsui-form-field-combobox__option-label">
457
460
  <slot name="loading-text">Searching...</slot>
@@ -462,9 +465,12 @@ defineExpose({
462
465
  </div>
463
466
 
464
467
  <!-- Error state -->
465
- <div
466
- v-else-if="dropdownContent === 'error'"
468
+ <div
469
+ v-if="dropdownContent === 'error'"
467
470
  class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled rsui-form-field-combobox__option--error"
471
+ role="option"
472
+ aria-disabled="true"
473
+ aria-selected="false"
468
474
  >
469
475
  <div class="rsui-form-field-combobox__option-label">
470
476
  <slot name="error-text">Search failed</slot>
@@ -472,9 +478,12 @@ defineExpose({
472
478
  </div>
473
479
 
474
480
  <!-- Start typing message -->
475
- <div
476
- v-else-if="dropdownContent === 'start-typing'"
481
+ <div
482
+ v-if="dropdownContent === 'start-typing'"
477
483
  class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled rsui-form-field-combobox__option--hint"
484
+ role="option"
485
+ aria-disabled="true"
486
+ aria-selected="false"
478
487
  >
479
488
  <div class="rsui-form-field-combobox__option-label">
480
489
  <slot name="start-typing-text">Start typing to search</slot>
@@ -482,9 +491,12 @@ defineExpose({
482
491
  </div>
483
492
 
484
493
  <!-- Minimum length message -->
485
- <div
486
- v-else-if="dropdownContent === 'min-length'"
494
+ <div
495
+ v-if="dropdownContent === 'min-length'"
487
496
  class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled"
497
+ role="option"
498
+ aria-disabled="true"
499
+ aria-selected="false"
488
500
  >
489
501
  <div class="rsui-form-field-combobox__option-label">
490
502
  Type at least {{ minSearchLength }} characters to search
@@ -492,9 +504,12 @@ defineExpose({
492
504
  </div>
493
505
 
494
506
  <!-- No results -->
495
- <div
496
- v-else-if="dropdownContent === 'no-results'"
507
+ <div
508
+ v-if="dropdownContent === 'no-results'"
497
509
  class="rsui-form-field-combobox__option rsui-form-field-combobox__option--disabled"
510
+ role="option"
511
+ aria-disabled="true"
512
+ aria-selected="false"
498
513
  >
499
514
  <div class="rsui-form-field-combobox__option-label">
500
515
  <slot name="no-results-text">No results found</slot>
@@ -502,9 +517,14 @@ defineExpose({
502
517
  </div>
503
518
 
504
519
  <!-- 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">
520
+ <template v-if="dropdownContent === 'options' && groupedOptions">
521
+ <div
522
+ v-for="group in groupedOptions"
523
+ :key="group.name"
524
+ role="group"
525
+ :aria-label="group.name || undefined"
526
+ >
527
+ <div v-if="group.name" class="rsui-form-field-combobox__group-header" aria-hidden="true">
508
528
  {{ group.name }}
509
529
  </div>
510
530
  <div
@@ -529,11 +549,11 @@ defineExpose({
529
549
  <CheckIcon v-if="!navigable && option.value === model"></CheckIcon>
530
550
  </div>
531
551
  </div>
532
- </template>
552
+ </div>
533
553
  </template>
534
554
 
535
555
  <!-- Flat options -->
536
- <template v-else-if="dropdownContent === 'options'">
556
+ <template v-if="dropdownContent === 'options' && !groupedOptions">
537
557
  <div
538
558
  v-for="(option, index) in filteredOptions"
539
559
  :key="option.value"
@@ -582,7 +602,7 @@ defineExpose({
582
602
  <div
583
603
  aria-live="polite"
584
604
  aria-atomic="true"
585
- class="sr-only"
605
+ class="rsui-form-field-combobox__live-region"
586
606
  >
587
607
  {{ liveAnnouncement }}
588
608
  </div>
@@ -65,7 +65,8 @@ function setValue(value) {
65
65
  :value="option.value"
66
66
  v-model="model"
67
67
  :id="`${effectiveId}-option-${index}`"
68
- :name="$attrs.name"
68
+ :name="$attrs.name || effectiveId"
69
+ :aria-describedby="ariaDescribedby"
69
70
  >
70
71
  <label
71
72
  :for="`${effectiveId}-option-${index}`"