@redseed/redseed-ui-vue3 8.35.0 → 8.36.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.36.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"
@@ -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)"