@redseed/redseed-ui-vue3 8.35.0 → 8.37.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.35.0",
3
+ "version": "8.37.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -59,6 +59,22 @@ const props = defineProps({
59
59
  type: String,
60
60
  default: undefined,
61
61
  },
62
+ // Tri-state toggle state for the action layer. `null` (default) leaves the
63
+ // card a plain button; `true`/`false` turn it into a toggle button that
64
+ // exposes its state via `aria-pressed` (WCAG 4.1.2).
65
+ pressed: {
66
+ type: Boolean,
67
+ default: null,
68
+ },
69
+ // Marks the card as the current item within a set via `aria-current`.
70
+ // `false` (default) omits the attribute; `true` renders `aria-current="true"`;
71
+ // a string passes an aria-current token through (e.g. 'page', 'step').
72
+ current: {
73
+ type: [Boolean, String],
74
+ default: false,
75
+ },
76
+ // PostHog autocapture component label — emitted as data-ph-capture-attribute-
77
+ // component on the action layer so events can be grouped by component type.
62
78
  phComponent: {
63
79
  type: String,
64
80
  default: 'Card',
@@ -67,6 +83,22 @@ const props = defineProps({
67
83
 
68
84
  const emit = defineEmits(['click'])
69
85
 
86
+ // `aria-pressed` is only emitted when the consumer opts in (pressed !== null),
87
+ // so non-toggle clickable cards are unaffected.
88
+ const ariaPressed = computed(() => {
89
+ if (props.pressed === null || props.pressed === undefined) return undefined
90
+ return props.pressed ? 'true' : 'false'
91
+ })
92
+
93
+ const ariaCurrent = computed(() => {
94
+ if (!props.current) return undefined
95
+ return props.current === true ? 'true' : props.current
96
+ })
97
+
98
+ // Drives a non-colour selection cue (a ring) so the active state is not
99
+ // conveyed by colour alone (WCAG 1.4.1).
100
+ const isActive = computed(() => props.pressed === true || Boolean(props.current))
101
+
70
102
  const slots = useSlots()
71
103
 
72
104
  const cardElement = ref(null)
@@ -149,12 +181,14 @@ function onClick() {
149
181
  </svg>
150
182
  <div
151
183
  v-if="clickable"
152
- class="rsui-card__action-layer"
184
+ :class="['rsui-card__action-layer', { 'rsui-card__action-layer--active': isActive }]"
153
185
  role="button"
154
186
  :tabindex="disabled ? undefined : 0"
155
187
  :title="$attrs.title"
156
188
  :aria-label="!$slots['aria-label'] ? ariaLabel : undefined"
157
189
  :aria-disabled="disabled ? true : undefined"
190
+ :aria-pressed="ariaPressed"
191
+ :aria-current="ariaCurrent"
158
192
  :data-ph-capture-attribute-component="phComponent"
159
193
  :data-ph-capture-attribute-label="captureLabel"
160
194
  @click="onClick"
@@ -20,13 +20,23 @@ const props = defineProps({
20
20
  type: Boolean,
21
21
  default: false,
22
22
  },
23
+ // Active/selected state for toggle use (e.g. a metric-filter card). Maps to
24
+ // the underlying Card's `pressed` so a clickable card exposes `aria-pressed`
25
+ // (WCAG 4.1.2) and shows a non-colour selection ring (WCAG 1.4.1). `null`
26
+ // (default) leaves the card a plain card; pair with `clickable` to toggle.
27
+ active: {
28
+ type: Boolean,
29
+ default: null,
30
+ },
23
31
  })
24
32
  </script>
25
33
 
26
34
  <template>
27
35
  <Card class="rsui-metric-card"
36
+ :class="{ 'rsui-metric-card--active': active === true }"
28
37
  :clickable="props.clickable"
29
38
  :hoverable="props.hoverable"
39
+ :pressed="active"
30
40
  ph-component="MetricCard"
31
41
  >
32
42
  <template v-if="$slots['aria-label']" #aria-label>
@@ -17,6 +17,16 @@ const props = defineProps({
17
17
  type: Object,
18
18
  default: () => {},
19
19
  },
20
+ /**
21
+ * Accessible name for the default icon-only toggle button. The chevron has
22
+ * no visible text, so set this (e.g. to the section/disclosure title) to
23
+ * give the trigger a name (WCAG 4.1.2). Ignored when a custom #trigger slot
24
+ * is supplied — name that button yourself.
25
+ */
26
+ triggerLabel: {
27
+ type: String,
28
+ default: '',
29
+ },
20
30
  })
21
31
 
22
32
  const emit = defineEmits(['click'])
@@ -126,6 +136,7 @@ onMounted(() => {
126
136
  :xl="$attrs.xl"
127
137
  :2xl="$attrs['2xl']"
128
138
  :full="$attrs.full"
139
+ :aria-label="triggerLabel || undefined"
129
140
  :aria-expanded="isOpen"
130
141
  :aria-controls="contentId"
131
142
  @click.stop="handleTrigger"
@@ -22,6 +22,15 @@ const props = defineProps({
22
22
  type: Number,
23
23
  default: 2,
24
24
  },
25
+ dropdownWidth: {
26
+ // Fixes the suggestions dropdown to an exact width, decoupling it from
27
+ // the input's width. Useful when the input must be narrow for layout
28
+ // reasons but the results list needs more room (e.g. "Name | Location"
29
+ // rows). Accepts a number (treated as px) or any CSS length string.
30
+ // Unset = the dropdown follows the input width, as before.
31
+ type: [String, Number],
32
+ default: null,
33
+ },
25
34
  })
26
35
 
27
36
  const emit = defineEmits(['input', 'query-change', 'navigate', 'keyup-enter'])
@@ -32,6 +41,7 @@ const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
32
41
  const query = ref('')
33
42
  const isOpen = ref(false)
34
43
  const inputElement = ref(null)
44
+ const fieldElement = ref(null)
35
45
  const rootElement = ref(null)
36
46
  const dropdownElement = ref(null)
37
47
  const highlightedIndex = ref(-1)
@@ -218,28 +228,33 @@ onClickOutside(rootElement, () => {
218
228
  if (isOpen.value) close()
219
229
  })
220
230
 
221
- const inputElementBounding = useElementBounding(inputElement)
231
+ // Measure the whole field box (icon prefix + input), not the inner <input> —
232
+ // the input sits to the right of the prefix and is narrower, so anchoring to
233
+ // it would leave the dropdown shifted right and narrower than the field.
234
+ const fieldElementBounding = useElementBounding(fieldElement)
222
235
 
223
236
  function calculateDropdownPosition() {
224
237
  if (!dropdownElement.value) return
225
238
 
226
239
  const viewportHeight = window.innerHeight
227
240
  const dropdownHeight = dropdownElement.value.offsetHeight
228
- const spaceAbove = inputElementBounding.top.value
229
- const spaceBelow = viewportHeight - inputElementBounding.bottom.value
241
+ const spaceAbove = fieldElementBounding.top.value
242
+ const spaceBelow = viewportHeight - fieldElementBounding.bottom.value
230
243
 
231
- dropdownElement.value.style.width = `${inputElementBounding.width.value}px`
232
- dropdownElement.value.style.left = `${inputElementBounding.left.value}px`
244
+ dropdownElement.value.style.width = props.dropdownWidth != null
245
+ ? (typeof props.dropdownWidth === 'number' ? `${props.dropdownWidth}px` : props.dropdownWidth)
246
+ : `${fieldElementBounding.width.value}px`
247
+ dropdownElement.value.style.left = `${fieldElementBounding.left.value}px`
233
248
 
234
249
  if (spaceAbove <= dropdownHeight && spaceBelow <= dropdownHeight) {
235
250
  dropdownElement.value.style.top = '0'
236
251
  dropdownElement.value.style.bottom = 'auto'
237
252
  } else if (spaceBelow > dropdownHeight) {
238
- dropdownElement.value.style.top = `${inputElementBounding.bottom.value + window.scrollY}px`
253
+ dropdownElement.value.style.top = `${fieldElementBounding.bottom.value + window.scrollY}px`
239
254
  dropdownElement.value.style.bottom = 'auto'
240
255
  } else if (spaceAbove > dropdownHeight) {
241
256
  dropdownElement.value.style.top = 'auto'
242
- dropdownElement.value.style.bottom = `${spaceBelow + inputElementBounding.height.value + 8 - window.scrollY}px`
257
+ dropdownElement.value.style.bottom = `${spaceBelow + fieldElementBounding.height.value + 8 - window.scrollY}px`
243
258
  }
244
259
  }
245
260
 
@@ -274,7 +289,7 @@ defineExpose({
274
289
  <slot name="label"></slot>
275
290
  </template>
276
291
 
277
- <div class="rsui-form-field-text__group">
292
+ <div ref="fieldElement" class="rsui-form-field-text__group">
278
293
  <div
279
294
  class="rsui-form-field-text__prefix"
280
295
  aria-hidden="true"
@@ -27,7 +27,14 @@ const isOpen = ref(false)
27
27
  const isMobileDevice = ref(false)
28
28
  const highlightedIndex = ref(-1)
29
29
 
30
- const effectiveId = computed(() => inputId.value || attrs.id)
30
+ // Fall back to a locally-generated unique id when this Select isn't wrapped by
31
+ // a FormFieldSlot (no injected inputId) and no id attr is supplied. Without it,
32
+ // effectiveId is undefined and the listbox/option ids — and the trigger's
33
+ // aria-controls — interpolate the literal string "undefined" (e.g.
34
+ // "undefined-listbox"), a dangling reference and a duplicate id across
35
+ // instances. Mirrors FormFieldSlot's _.uniqueId('form-field-') mechanism.
36
+ const autoId = _.uniqueId('form-field-')
37
+ const effectiveId = computed(() => inputId.value || attrs.id || autoId)
31
38
 
32
39
  function toggleOptions() {
33
40
  isOpen.value = !isOpen.value
@@ -228,7 +235,7 @@ defineExpose({
228
235
  <template>
229
236
  <FormFieldSlot
230
237
  ref="formFieldSelectElement"
231
- :id="$attrs.id"
238
+ :id="effectiveId"
232
239
  :class="[
233
240
  $attrs.class,
234
241
  'rsui-form-field-select',
@@ -254,7 +261,7 @@ defineExpose({
254
261
  ]"
255
262
  role="combobox"
256
263
  :aria-activedescendant="highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
257
- :aria-controls="`${effectiveId}-listbox`"
264
+ :aria-controls="isOpen ? `${effectiveId}-listbox` : undefined"
258
265
  :aria-describedby="ariaDescribedby"
259
266
  :aria-expanded="isOpen"
260
267
  :aria-haspopup="'listbox'"
@@ -100,6 +100,7 @@ const imageClass = computed(() => [
100
100
  <div :class="imageClass">
101
101
  <div v-if="isEmpty"
102
102
  class="rsui-image__empty"
103
+ aria-hidden="true"
103
104
  >
104
105
  <slot name="empty-icon">
105
106
  <Icon xxl disabled>
@@ -16,6 +16,10 @@ const props = defineProps({
16
16
  type: Boolean,
17
17
  default: false,
18
18
  },
19
+ label: {
20
+ type: String,
21
+ default: 'Loading',
22
+ },
19
23
  })
20
24
 
21
25
  const defaultColor = computed(() => !props.primary && !props.secondary && !props.white)
@@ -36,5 +40,9 @@ useLottie(loaderElement, LottieCubes)
36
40
  <template>
37
41
  <div ref="loaderElement"
38
42
  :class="loaderClass"
39
- ></div>
43
+ role="status"
44
+ aria-busy="true"
45
+ >
46
+ <span v-if="label" class="rsui-loader__label">{{ label }}</span>
47
+ </div>
40
48
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref } from 'vue'
2
+ import { ref, computed } from 'vue'
3
3
  import Icon from '../Icon/Icon.vue'
4
4
  import { XMarkIcon } from '@heroicons/vue/24/outline'
5
5
 
@@ -12,6 +12,10 @@ const props = defineProps({
12
12
  type: Boolean,
13
13
  default: false,
14
14
  },
15
+ closeLabel: {
16
+ type: String,
17
+ default: 'Close',
18
+ },
15
19
  variant: {
16
20
  type: String,
17
21
  default: 'default',
@@ -23,6 +27,20 @@ const emit = defineEmits(['close'])
23
27
 
24
28
  const isClosed = ref(props.closed)
25
29
 
30
+ /**
31
+ * Announce the box as a live region so flash / MOTD content reaches assistive
32
+ * tech (WCAG 4.1.3 Status Messages). `alert` is assertive — reserved for errors
33
+ * that must interrupt; advisory variants use polite `status`. The neutral
34
+ * `default` variant is a plain container, not a status message, so it gets no
35
+ * live role (avoids announcing arbitrary static content). Both roles carry an
36
+ * implicit aria-live, so no separate attribute is needed.
37
+ */
38
+ const liveRole = computed(() => {
39
+ if (props.variant === 'error') return 'alert'
40
+ if (['info', 'success', 'warning'].includes(props.variant)) return 'status'
41
+ return undefined
42
+ })
43
+
26
44
  function close() {
27
45
  isClosed.value = true
28
46
  emit('close')
@@ -34,17 +52,22 @@ function close() {
34
52
  'rsui-message-box',
35
53
  `rsui-message-box--${variant}`
36
54
  ]"
55
+ :role="liveRole"
37
56
  >
38
57
  <div v-if="$slots.title" class="rsui-message-box__head">
39
58
  <div class="rsui-message-box__title">
40
59
  <slot name="title"></slot>
41
60
  </div>
42
61
  <div v-if="closeable" class="rsui-message-box__close">
43
- <div class="rsui-message-box__close-icon">
62
+ <button type="button"
63
+ class="rsui-message-box__close-icon"
64
+ :aria-label="closeLabel"
65
+ @click="close"
66
+ >
44
67
  <Icon disabled>
45
- <XMarkIcon @click="close"></XMarkIcon>
68
+ <XMarkIcon aria-hidden="true"></XMarkIcon>
46
69
  </Icon>
47
- </div>
70
+ </button>
48
71
  </div>
49
72
  </div>
50
73
  <div class="rsui-message-box__content">
@@ -52,11 +75,15 @@ function close() {
52
75
  <slot></slot>
53
76
  </div>
54
77
  <div v-if="closeable && !$slots.title" class="rsui-message-box__close">
55
- <div class="rsui-message-box__close-icon">
78
+ <button type="button"
79
+ class="rsui-message-box__close-icon"
80
+ :aria-label="closeLabel"
81
+ @click="close"
82
+ >
56
83
  <Icon disabled>
57
- <XMarkIcon @click="close"></XMarkIcon>
84
+ <XMarkIcon aria-hidden="true"></XMarkIcon>
58
85
  </Icon>
59
- </div>
86
+ </button>
60
87
  </div>
61
88
  </div>
62
89
  </div>
@@ -8,6 +8,10 @@ const props = defineProps({
8
8
  type: Boolean,
9
9
  default: false
10
10
  },
11
+ helpLabel: {
12
+ type: String,
13
+ default: 'More information'
14
+ },
11
15
  inline: {
12
16
  type: Boolean,
13
17
  default: false
@@ -42,16 +46,18 @@ const emit = defineEmits(['click'])
42
46
  <div class="rsui-meta-info__label">
43
47
  <slot name="label"></slot>
44
48
  </div>
45
- <div v-if="help"
49
+ <button v-if="help"
50
+ type="button"
46
51
  class="rsui-meta-info__help"
52
+ :aria-label="helpLabel"
47
53
  @click="$emit('click')"
48
54
  >
49
55
  <slot name="help-icon">
50
56
  <Icon sm secondary>
51
- <QuestionMarkCircleIcon />
57
+ <QuestionMarkCircleIcon aria-hidden="true" />
52
58
  </Icon>
53
59
  </slot>
54
- </div>
60
+ </button>
55
61
  </div>
56
62
  <div class="rsui-meta-info__value">
57
63
  <slot></slot>
@@ -26,6 +26,17 @@ const props = defineProps({
26
26
  type: Boolean,
27
27
  default: true,
28
28
  },
29
+ /**
30
+ * Heading level (1-6) for the title. When set, the title renders as a real
31
+ * <h1>-<h6> so consuming pages get a correct document outline (WCAG 1.3.1,
32
+ * 2.4.6). Left unset it stays a non-heading <span>, keeping existing
33
+ * consumers unaffected — pick the level that fits the page's heading order.
34
+ */
35
+ headingLevel: {
36
+ type: [Number, String],
37
+ default: null,
38
+ validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
39
+ },
29
40
  })
30
41
 
31
42
  const sectionHeaderElement = ref(null)
@@ -50,6 +61,12 @@ const showToolbar = computed(() => {
50
61
  return props.showActions
51
62
  && (slots.actions || showMoreActions)
52
63
  })
64
+
65
+ /**
66
+ * Element used to render the title — a real heading when a level is supplied,
67
+ * otherwise a non-heading <span> (preserves the previous, level-less markup).
68
+ */
69
+ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` : 'span')
53
70
  </script>
54
71
  <template>
55
72
  <div ref="sectionHeaderElement"
@@ -77,7 +94,9 @@ const showToolbar = computed(() => {
77
94
 
78
95
  <!-- Title slot, default slot -->
79
96
  <div class="rsui-section-header__title">
80
- <slot></slot>
97
+ <component :is="titleTag" class="rsui-section-header__title-text">
98
+ <slot></slot>
99
+ </component>
81
100
  <div v-if="$slots.badge" class="rsui-section-header__badge">
82
101
  <slot name="badge"></slot>
83
102
  </div>
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, useSlots } from 'vue'
3
- import { useScroll, useEventListener } from '@vueuse/core'
3
+ import { useScroll } from '@vueuse/core'
4
4
  import { Icon } from '../Icon'
5
5
  import Section from './Section.vue'
6
6
  import SectionHeader from './SectionHeader.vue'
@@ -20,6 +20,16 @@ const props = defineProps({
20
20
  type: Boolean,
21
21
  default: false,
22
22
  },
23
+ /**
24
+ * Heading level (1-6) for the slider title. When set, the title renders
25
+ * as a real <h1>-<h6> for a correct document outline; left unset it stays
26
+ * a non-heading <span> so existing consumers are unaffected.
27
+ */
28
+ headingLevel: {
29
+ type: [Number, String],
30
+ default: null,
31
+ validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
32
+ },
23
33
  })
24
34
 
25
35
  const sectionVariant = computed(() => {
@@ -56,14 +66,6 @@ function generateItemId(index) {
56
66
  */
57
67
  const sliderContainerRef = ref(null)
58
68
 
59
- /**
60
- * Listener for keyboard events on the document
61
- */
62
- useEventListener(document, 'keydown', (e) => {
63
- if (e.key === 'ArrowRight') e.preventDefault()
64
- if (e.key === 'ArrowLeft') e.preventDefault()
65
- })
66
-
67
69
  /**
68
70
  * Whether the slider container is scrolling
69
71
  */
@@ -170,7 +172,7 @@ function showPreviousSlide() {
170
172
  v-bind="$attrs"
171
173
  >
172
174
  <template #header>
173
- <SectionHeader :showDivider="false" :showMoreActions="false">
175
+ <SectionHeader :showDivider="false" :showMoreActions="false" :headingLevel="headingLevel">
174
176
  <template v-if="slots.icon" #icon>
175
177
  <slot name="icon"></slot>
176
178
  </template>
@@ -193,8 +195,9 @@ function showPreviousSlide() {
193
195
  :disabledNextButton="disabledNextButton"
194
196
  >
195
197
  <button class="rsui-section-slider__action"
196
- :disabled="disabledPrevButton"
198
+ type="button"
197
199
  aria-label="Previous"
200
+ :disabled="disabledPrevButton"
198
201
  data-ph-capture-attribute-component="SectionSliderAction"
199
202
  data-ph-capture-attribute-label="Previous"
200
203
  @click="showPreviousSlide"
@@ -205,8 +208,9 @@ function showPreviousSlide() {
205
208
  </button>
206
209
 
207
210
  <button class="rsui-section-slider__action"
208
- :disabled="disabledNextButton"
211
+ type="button"
209
212
  aria-label="Next"
213
+ :disabled="disabledNextButton"
210
214
  data-ph-capture-attribute-component="SectionSliderAction"
211
215
  data-ph-capture-attribute-label="Next"
212
216
  @click="showNextSlide"
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, watch, onBeforeUnmount } from 'vue'
3
- import { useEventListener, useElementVisibility } from '@vueuse/core'
3
+ import { useElementVisibility } from '@vueuse/core'
4
4
 
5
5
  const props = defineProps({
6
6
  id: {
@@ -31,13 +31,6 @@ watch(isVisible, (newVal) => {
31
31
  onBeforeUnmount(() => {
32
32
  emit('hidden', props.id)
33
33
  })
34
-
35
- /**
36
- * Listener for keyboard events on the item
37
- */
38
- useEventListener(itemRef, 'keydown', (e) => {
39
- if (e.key === 'Tab') e.preventDefault()
40
- })
41
34
  </script>
42
35
 
43
36
  <template>
@@ -66,6 +66,10 @@ const props = defineProps({
66
66
  type: Boolean,
67
67
  default: false,
68
68
  },
69
+ label: {
70
+ type: String,
71
+ default: '',
72
+ },
69
73
  })
70
74
 
71
75
  const shape = computed(() => {
@@ -99,7 +103,12 @@ const positionClass = computed(() => {
99
103
  </script>
100
104
 
101
105
  <template>
102
- <div :class="['rsui-skeleton', widthClass, positionClass]">
106
+ <div
107
+ :class="['rsui-skeleton', widthClass, positionClass]"
108
+ :role="label ? 'status' : undefined"
109
+ :aria-busy="label ? 'true' : undefined"
110
+ >
111
+ <span v-if="label" class="rsui-skeleton__label">{{ label }}</span>
103
112
  <div
104
113
  :class="[
105
114
  shape === 'line' && 'rsui-skeleton__line',
@@ -169,6 +169,7 @@ const visibleColumns = computed(() => {
169
169
  <Tr v-for="row in rows"
170
170
  :key="row.id"
171
171
  :clickable="row.clickable ?? clickableRows"
172
+ :aria-label="row.ariaLabel"
172
173
  @click="$emit('click:row', row)"
173
174
  >
174
175
  <Td v-for="column in visibleColumns"
@@ -88,16 +88,18 @@ function handleSort(event) {
88
88
  ]"
89
89
  :scope="scope"
90
90
  :aria-sort="ariaSort"
91
- :tabindex="sortable ? 0 : undefined"
92
- @click="handleSort($event)"
93
- @keydown.enter.self.prevent="handleSort($event)"
94
- @keydown.space.self.prevent="handleSort($event)"
95
91
  >
96
- <div class="rsui-th__content">
92
+ <!-- Sortable headers wrap their content in a real <button> so AT announces
93
+ them as activatable (4.1.2) and Enter/Space work natively. The <th>
94
+ keeps its columnheader role + aria-sort (valid only on that role). -->
95
+ <button v-if="sortable"
96
+ type="button"
97
+ class="rsui-th__button"
98
+ @click="handleSort($event)"
99
+ >
97
100
  <slot></slot>
98
101
 
99
- <div v-if="sortable"
100
- class="rsui-th__sort"
102
+ <span class="rsui-th__sort"
101
103
  aria-hidden="true"
102
104
  >
103
105
  <Icon v-if="!isAsc && !isDesc"
@@ -118,7 +120,11 @@ function handleSort(event) {
118
120
  >
119
121
  <ArrowUpIcon />
120
122
  </Icon>
121
- </div>
123
+ </span>
124
+ </button>
125
+
126
+ <div v-else class="rsui-th__content">
127
+ <slot></slot>
122
128
  </div>
123
129
  </th>
124
130
  </template>
@@ -4,6 +4,14 @@ const props = defineProps({
4
4
  type: Boolean,
5
5
  default: false,
6
6
  },
7
+ // Accessible name for the clickable row. The row keeps its native table-row
8
+ // role; without a label, assistive tech falls back to the row's cell text,
9
+ // so set this to a concise action label (e.g. "View John Doe") for clearer
10
+ // AT output.
11
+ ariaLabel: {
12
+ type: String,
13
+ default: undefined,
14
+ },
7
15
  })
8
16
 
9
17
  const emit = defineEmits(['click'])
@@ -23,6 +31,7 @@ function handleClick(event) {
23
31
  { 'rsui-tr--clickable': clickable },
24
32
  ]"
25
33
  :tabindex="clickable ? 0 : undefined"
34
+ :aria-label="clickable ? ariaLabel : undefined"
26
35
  @click="handleClick($event)"
27
36
  @keydown.enter.self.prevent="handleClick($event)"
28
37
  @keydown.space.self.prevent="handleClick($event)"