@redseed/redseed-ui-vue3 8.61.0 → 8.62.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.
@@ -7,7 +7,7 @@ A comprehensive Vue 3 component library designed for building modern, accessible
7
7
  ### 1. [Layout Components](./01-layout-components.md)
8
8
  Structural components for organizing page content, including layouts, headers, sections, and page structure.
9
9
 
10
- **Key Components:** SingleColumnLayout, TwoColumnLayout, PageHeader, SectionHeader, HeroSection
10
+ **Key Components:** SingleColumnLayout, TwoColumnLayout, PageHeader, SectionHeader
11
11
 
12
12
  ### 2. [Button Components](./02-button-components.md)
13
13
  Interactive elements for user actions, with various styles and grouping options.
@@ -116,34 +116,6 @@ Layout components provide the structural foundation for organizing content on pa
116
116
  </template>
117
117
  ```
118
118
 
119
- ### HeroSection
120
- **When to use:** For landing pages, marketing pages, or any page that needs a prominent introduction section with header content and optional button cards.
121
- **Implementation:**
122
- ```vue
123
- <template>
124
- <HeroSection>
125
- <template #header>
126
- <SectionHeader>
127
- Section header
128
- <template #subtitle>
129
- Section header subtitle
130
- </template>
131
- </SectionHeader>
132
- </template>
133
-
134
- <ButtonCard>
135
- <template #icon>
136
- <FaceSmileIcon />
137
- </template>
138
- Button card title
139
- <template #supporting-text>
140
- Supporting text
141
- </template>
142
- </ButtonCard>
143
- </HeroSection>
144
- </template>
145
- ```
146
-
147
119
  ## Best Practices
148
120
 
149
121
  1. **Consistent Structure:** Use layout components consistently across your application to maintain visual hierarchy and user experience.
@@ -147,19 +147,24 @@ Media components provide consistent ways to display images, logos, and other vis
147
147
 
148
148
  ## Common Use Cases
149
149
 
150
- ### Hero Section with Image
150
+ ### Banner Section with Image
151
151
  ```vue
152
152
  <template>
153
- <HeroSection
154
- title="Welcome to Our Platform"
155
- subtitle="The best solution for your needs"
156
- >
157
- <Image16By9
158
- :src="heroImageUrl"
159
- :alt="heroImageAlt"
160
- class="hero-image"
153
+ <Section>
154
+ <template #header>
155
+ <SectionHeader>
156
+ Welcome to Our Platform
157
+ <template #subtitle>
158
+ The best solution for your needs
159
+ </template>
160
+ </SectionHeader>
161
+ </template>
162
+
163
+ <Image16By9
164
+ :src="bannerImageUrl"
165
+ :alt="bannerImageAlt"
161
166
  />
162
- </HeroSection>
167
+ </Section>
163
168
  </template>
164
169
  ```
165
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.61.0",
3
+ "version": "8.62.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -28,12 +28,27 @@ const props = defineProps({
28
28
  type: Boolean,
29
29
  default: null,
30
30
  },
31
+ // Puts the tile on a tonal surface with ink to match, using the same named set as
32
+ // PageHeader's `tone` so a tile inside a toned header can be given the matching name.
33
+ // Named tones rather than a colour because white-labelling means the component cannot
34
+ // assume light-on-dark: surface, ink, muted ink and rule move together.
35
+ //
36
+ // Not combinable with Card's semantic variants (brand, success, …) — pick one.
37
+ tone: {
38
+ type: String,
39
+ default: null,
40
+ validator: value => ['green', 'teal', 'light'].includes(value),
41
+ },
31
42
  })
32
43
  </script>
33
44
 
34
45
  <template>
35
46
  <Card class="rsui-metric-card"
36
- :class="{ 'rsui-metric-card--active': active === true }"
47
+ :class="{
48
+ 'rsui-metric-card--active': active === true,
49
+ 'rsui-metric-card--toned': !!props.tone,
50
+ [`rsui-metric-card--tone-${props.tone}`]: !!props.tone,
51
+ }"
37
52
  :clickable="props.clickable"
38
53
  :hoverable="props.hoverable"
39
54
  :pressed="active"
@@ -22,64 +22,66 @@ const props = defineProps({
22
22
  const formRef = ref(props.form)
23
23
  </script>
24
24
  <template>
25
- <slot name="top-fields" :formRef="formRef"></slot>
25
+ <div class="rsui-form-fields-login">
26
+ <slot name="top-fields" :formRef="formRef"></slot>
26
27
 
27
- <slot name="email-username" :formRef="formRef">
28
- <FormFieldEmail
29
- v-model="formRef.email"
30
- id="email"
31
- name="email"
28
+ <slot name="email-username" :formRef="formRef">
29
+ <FormFieldEmail
30
+ v-model="formRef.email"
31
+ id="email"
32
+ name="email"
33
+ >
34
+ <template #label>
35
+ <slot name="email-label">
36
+ Email
37
+ </slot>
38
+ </template>
39
+
40
+ <template #error v-if="formRef.errors.email">
41
+ {{ formRef.errors.email }}
42
+ </template>
43
+ </FormFieldEmail>
44
+ </slot>
45
+
46
+ <FormFieldPassword
47
+ v-model="formRef.password"
48
+ id="password"
49
+ name="password"
32
50
  >
33
51
  <template #label>
34
- <slot name="email-label">
35
- Email
52
+ <slot name="password-label">
53
+ Password
36
54
  </slot>
37
55
  </template>
38
56
 
39
- <template #error v-if="formRef.errors.email">
40
- {{ formRef.errors.email }}
57
+ <template #error v-if="formRef.errors.password">
58
+ {{ formRef.errors.password }}
41
59
  </template>
42
- </FormFieldEmail>
43
- </slot>
44
-
45
- <FormFieldPassword
46
- v-model="formRef.password"
47
- id="password"
48
- name="password"
49
- >
50
- <template #label>
51
- <slot name="password-label">
52
- Password
53
- </slot>
54
- </template>
60
+ </FormFieldPassword>
55
61
 
56
- <template #error v-if="formRef.errors.password">
57
- {{ formRef.errors.password }}
58
- </template>
59
- </FormFieldPassword>
62
+ <slot name="bottom-fields" :formRef="formRef"></slot>
60
63
 
61
- <slot name="bottom-fields" :formRef="formRef"></slot>
62
-
63
- <FormFieldSlot v-if="$slots.forgot"
64
- class="rsui-form-field-forgot"
65
- >
66
- <slot name="forgot"></slot>
67
- </FormFieldSlot>
64
+ <FormFieldSlot v-if="$slots.forgot"
65
+ class="rsui-form-field-forgot"
66
+ >
67
+ <slot name="forgot"></slot>
68
+ </FormFieldSlot>
68
69
 
69
- <slot name="submit"
70
- :formRef="formRef"
71
- >
72
- <ButtonPrimaryFull lg
73
- :disabled="form.processing"
74
- type="submit"
70
+ <slot name="submit"
71
+ :formRef="formRef"
75
72
  >
76
- <slot name="submit-label">
77
- Log in
78
- </slot>
79
- </ButtonPrimaryFull>
80
- </slot>
73
+ <ButtonPrimaryFull lg
74
+ :disabled="form.processing"
75
+ type="submit"
76
+ >
77
+ <slot name="submit-label">
78
+ Log in
79
+ </slot>
80
+ </ButtonPrimaryFull>
81
+ </slot>
81
82
 
82
- <div v-if="$slots.signup" class="rsui-form-login__signup">
83
- <slot name="signup"></slot>
83
+ <div v-if="$slots.signup" class="rsui-form-login__signup">
84
+ <slot name="signup"></slot>
85
+ </div>
84
86
  </div>
85
87
  </template>
@@ -25,93 +25,95 @@ const props = defineProps({
25
25
  const formRef = ref(props.form)
26
26
  </script>
27
27
  <template>
28
- <slot name="top-fields" :formRef="formRef"></slot>
28
+ <div class="rsui-form-fields-register">
29
+ <slot name="top-fields" :formRef="formRef"></slot>
29
30
 
30
- <slot name="name-fields"
31
- :formRef="formRef"
32
- >
33
- <FormFieldText
34
- v-model="formRef.name"
35
- id="name"
36
- name="name"
31
+ <slot name="name-fields"
32
+ :formRef="formRef"
33
+ >
34
+ <FormFieldText
35
+ v-model="formRef.name"
36
+ id="name"
37
+ name="name"
38
+ >
39
+ <template #label>
40
+ <slot name="name-label">
41
+ Name
42
+ </slot>
43
+ </template>
44
+
45
+ <template #error v-if="formRef.errors.name">
46
+ {{ formRef.errors.name }}
47
+ </template>
48
+ </FormFieldText>
49
+ </slot>
50
+
51
+ <FormFieldEmail
52
+ v-model="formRef.email"
53
+ id="email"
54
+ name="email"
37
55
  >
38
56
  <template #label>
39
- <slot name="name-label">
40
- Name
57
+ <slot name="email-label">
58
+ Email
41
59
  </slot>
42
60
  </template>
43
61
 
44
- <template #error v-if="formRef.errors.name">
45
- {{ formRef.errors.name }}
62
+ <template #error v-if="formRef.errors.email">
63
+ {{ formRef.errors.email }}
46
64
  </template>
47
- </FormFieldText>
48
- </slot>
49
-
50
- <FormFieldEmail
51
- v-model="formRef.email"
52
- id="email"
53
- name="email"
54
- >
55
- <template #label>
56
- <slot name="email-label">
57
- Email
58
- </slot>
59
- </template>
65
+ </FormFieldEmail>
60
66
 
61
- <template #error v-if="formRef.errors.email">
62
- {{ formRef.errors.email }}
63
- </template>
64
- </FormFieldEmail>
65
-
66
- <FormFieldPasswordToggle
67
- v-model="formRef.password"
68
- id="password"
69
- name="password"
70
- >
71
- <template #label>
72
- <slot name="password-label">
73
- Password
74
- </slot>
75
- </template>
67
+ <FormFieldPasswordToggle
68
+ v-model="formRef.password"
69
+ id="password"
70
+ name="password"
71
+ >
72
+ <template #label>
73
+ <slot name="password-label">
74
+ Password
75
+ </slot>
76
+ </template>
76
77
 
77
- <template #error v-if="formRef.errors.password">
78
- {{ formRef.errors.password }}
79
- </template>
80
- </FormFieldPasswordToggle>
78
+ <template #error v-if="formRef.errors.password">
79
+ {{ formRef.errors.password }}
80
+ </template>
81
+ </FormFieldPasswordToggle>
81
82
 
82
- <slot name="bottom-fields" :formRef="formRef"></slot>
83
+ <slot name="bottom-fields" :formRef="formRef"></slot>
83
84
 
84
- <FormFieldCheckbox
85
- v-if="$slots.terms"
86
- v-model="formRef.terms"
87
- id="terms"
88
- name="terms"
89
- >
90
- <template #label>
91
- <slot name="terms"></slot>
92
- </template>
85
+ <FormFieldCheckbox
86
+ v-if="$slots.terms"
87
+ v-model="formRef.terms"
88
+ id="terms"
89
+ name="terms"
90
+ >
91
+ <template #label>
92
+ <slot name="terms"></slot>
93
+ </template>
93
94
 
94
- <template #error v-if="formRef.errors.terms">
95
- {{ formRef.errors.terms }}
96
- </template>
97
- </FormFieldCheckbox>
95
+ <template #error v-if="formRef.errors.terms">
96
+ {{ formRef.errors.terms }}
97
+ </template>
98
+ </FormFieldCheckbox>
98
99
 
99
- <slot name="recaptcha" :formRef="formRef"></slot>
100
+ <slot name="recaptcha" :formRef="formRef"></slot>
100
101
 
101
- <slot name="submit"
102
- :formRef="formRef"
103
- >
104
- <ButtonPrimaryFull lg
105
- :disabled="form.processing"
106
- type="submit"
102
+ <slot name="submit"
103
+ :formRef="formRef"
107
104
  >
108
- <slot name="submit-label">
109
- Register
110
- </slot>
111
- </ButtonPrimaryFull>
112
- </slot>
105
+ <ButtonPrimaryFull lg
106
+ :disabled="form.processing"
107
+ type="submit"
108
+ >
109
+ <slot name="submit-label">
110
+ Register
111
+ </slot>
112
+ </ButtonPrimaryFull>
113
+ </slot>
113
114
 
114
- <div v-if="$slots.signin" class="rsui-form-register__signin">
115
- <slot name="signin"></slot>
115
+ <div v-if="$slots.signin" class="rsui-form-register__signin">
116
+ <slot name="signin"></slot>
117
+ </div>
116
118
  </div>
117
119
  </template>
@@ -8,6 +8,18 @@ const props = defineProps({
8
8
  type: Boolean,
9
9
  default: false,
10
10
  },
11
+ // Puts the header on a tonal band and flips its ink to match. Named tones rather than a
12
+ // background colour, because white-labelling means the component cannot assume
13
+ // light-on-dark: hand it a colour alone and a client who picks a light one gets white
14
+ // text on white. Background, ink, muted ink and rule move together as a set.
15
+ //
16
+ // A toned header supplies its own padding, so place it outside the page's content
17
+ // gutter and let the band bleed. Omit the prop and nothing changes.
18
+ tone: {
19
+ type: String,
20
+ default: null,
21
+ validator: value => ['green', 'teal', 'light'].includes(value),
22
+ },
11
23
  })
12
24
 
13
25
  const showMetaModal = ref(false)
@@ -16,6 +28,8 @@ const pageHeaderClass = computed(() => [
16
28
  'rsui-page-header',
17
29
  {
18
30
  'rsui-page-header--profile': props.profile,
31
+ 'rsui-page-header--toned': !!props.tone,
32
+ [`rsui-page-header--tone-${props.tone}`]: !!props.tone,
19
33
  },
20
34
  ])
21
35
  </script>
@@ -17,6 +17,29 @@ const props = defineProps({
17
17
  type: Boolean,
18
18
  default: false,
19
19
  },
20
+ // Draws a coloured rail down the leading edge, so a list of mixed kinds can be scanned
21
+ // by colour. Semantic names rather than brand ones, matching MessageBox's vocabulary —
22
+ // the rail is decoration, so it only has to clear WCAG 1.4.11 at 3:1.
23
+ accent: {
24
+ type: String,
25
+ default: null,
26
+ validator: value => ['warning', 'info', 'success', 'error', 'neutral'].includes(value),
27
+ },
28
+ // Keeps meta and description rendered at every width. They are dropped below 480px by
29
+ // default, which is right for a dense list but wrong for a row whose second line carries
30
+ // the detail that makes it actionable.
31
+ alwaysShowBody: {
32
+ type: Boolean,
33
+ default: false,
34
+ },
35
+ // Size of the default action button. Defaults to the xs it has always used, which
36
+ // measures 34px. `lg` is the first size that clears the 44px touch target a phone
37
+ // wants — measured 46px; `md` is 42px and does not.
38
+ actionSize: {
39
+ type: String,
40
+ default: 'xs',
41
+ validator: value => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value),
42
+ },
20
43
  })
21
44
 
22
45
  const listItemElement = ref(null)
@@ -28,7 +51,7 @@ const isWide = computed(() => !!responsiveWidth.value.specific)
28
51
  const slots = useSlots()
29
52
 
30
53
  const shouldShowBody = computed(() => {
31
- return isWide.value && (slots.meta || slots.description)
54
+ return (isWide.value || props.alwaysShowBody) && (slots.meta || slots.description)
32
55
  })
33
56
 
34
57
  const listItemClass = computed(() => [
@@ -37,6 +60,8 @@ const listItemClass = computed(() => [
37
60
  'rsui-list-item--clickable': props.clickable,
38
61
  'rsui-list-item--avatar': props.avatar,
39
62
  'rsui-list-item--wide': isWide.value,
63
+ 'rsui-list-item--accented': !!props.accent,
64
+ [`rsui-list-item--accent-${props.accent}`]: !!props.accent,
40
65
  }
41
66
  ])
42
67
 
@@ -75,6 +100,15 @@ function clickItem(event) {
75
100
  The peer class is used to show hover effect on the whole item with the bg div.
76
101
  -->
77
102
  <div class="rsui-list-item__header peer">
103
+ <!--
104
+ The icon sits inline at the start of the row, unlike the avatar below, which
105
+ hangs outside the row box on a negative offset. Two different jobs: an avatar
106
+ identifies a person, an icon classifies the row, so they are separate slots
107
+ and can be used together.
108
+ -->
109
+ <div v-if="$slots.icon" class="rsui-list-item__icon" aria-hidden="true">
110
+ <slot name="icon"></slot>
111
+ </div>
78
112
  <div v-if="avatar" class="rsui-list-item__avatar">
79
113
  <slot name="avatar">
80
114
  <div class="rsui-list-item__avatar-placeholder">
@@ -92,7 +126,14 @@ function clickItem(event) {
92
126
  </div>
93
127
  <div v-if="action" class="rsui-list-item__action">
94
128
  <slot name="action" :clickAction="clickAction">
95
- <ButtonTertiary xs @click="clickAction">
129
+ <ButtonTertiary
130
+ :xs="props.actionSize === 'xs'"
131
+ :sm="props.actionSize === 'sm'"
132
+ :md="props.actionSize === 'md'"
133
+ :lg="props.actionSize === 'lg'"
134
+ :xl="props.actionSize === 'xl'"
135
+ @click="clickAction"
136
+ >
96
137
  <slot name="action-label">Action</slot>
97
138
  </ButtonTertiary>
98
139
  </slot>
@@ -19,7 +19,7 @@ const props = defineProps({
19
19
  variant: {
20
20
  type: String,
21
21
  default: 'default',
22
- validator: (value) => ['default', 'info', 'success', 'warning', 'error'].includes(value)
22
+ validator: (value) => ['default', 'info', 'success', 'warning', 'error', 'ai'].includes(value)
23
23
  }
24
24
  })
25
25
 
@@ -34,6 +34,10 @@ const isClosed = ref(props.closed)
34
34
  * `default` variant is a plain container, not a status message, so it gets no
35
35
  * live role (avoids announcing arbitrary static content). Both roles carry an
36
36
  * implicit aria-live, so no separate attribute is needed.
37
+ *
38
+ * `ai` gets no live role either, and deliberately: an AI insight is content the
39
+ * reader chooses to read, not a status change. Announcing one on mount would
40
+ * interrupt for something nobody asked about.
37
41
  */
38
42
  const liveRole = computed(() => {
39
43
  if (props.variant === 'error') return 'alert'
@@ -140,7 +140,10 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
140
140
  <slot name="more-actions"
141
141
  :handleMoreActionsClick="handleMoreActionsClick"
142
142
  >
143
- <ButtonTertiary @click="handleMoreActionsClick">
143
+ <!-- sm, not the md default: this is the header's own overflow control, so it
144
+ follows the same action sizing the header documents and the stories use.
145
+ Left md it stood 42px against a 36px title and 38px sibling actions. -->
146
+ <ButtonTertiary sm @click="handleMoreActionsClick">
144
147
  <slot name="more-actions-label">
145
148
  <Icon>
146
149
  <EllipsisVerticalIcon />
@@ -1,4 +1,3 @@
1
- import HeroSection from './HeroSection.vue'
2
1
  import Section from './Section.vue'
3
2
  import SectionFooter from './SectionFooter.vue'
4
3
  import SectionHeader from './SectionHeader.vue'
@@ -6,7 +5,6 @@ import SectionSlider from './SectionSlider.vue'
6
5
  import SectionSliderItem from './SectionSliderItem.vue'
7
6
 
8
7
  export {
9
- HeroSection,
10
8
  Section,
11
9
  SectionFooter,
12
10
  SectionHeader,
@@ -40,6 +40,18 @@ const props = defineProps({
40
40
  type: Number,
41
41
  default: 270,
42
42
  },
43
+ // Set when the dial sits on a coloured or dark surface — a tonal PageHeader band, say.
44
+ // The centre content has no colour of its own and inherits the page ink, which on a dark
45
+ // band is near-invisible; this flips it to the on-colour token. Named to match
46
+ // BodyText's `onColor` rather than PageHeader's `tone`, because the dial has no surface
47
+ // of its own: it is transparent and only ever reacts to what is behind it, so the three
48
+ // tone names would be two behaviours wearing three labels.
49
+ //
50
+ // The track is deliberately untouched — see stage_dial.css for the measurements.
51
+ onColor: {
52
+ type: Boolean,
53
+ default: false,
54
+ },
43
55
  // Opt-in draw-in: filled segments animate their dash length from 0, staggered
44
56
  // by segment. No-ops under `prefers-reduced-motion` (handled in CSS).
45
57
  animate: {
@@ -192,6 +204,7 @@ const stageDialClass = computed(() => [
192
204
  `rsui-stage-dial--${activeSize.value}`,
193
205
  {
194
206
  'rsui-stage-dial--animate': props.animate,
207
+ 'rsui-stage-dial--on-color': props.onColor,
195
208
  },
196
209
  ])
197
210
 
@@ -2,6 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import Icon from '../Icon/Icon.vue'
4
4
  import SwitcherItem from './SwitcherItem.vue'
5
+ import { useTablistNavigation } from '../../composables/useTablistNavigation.js'
5
6
 
6
7
  const props = defineProps({
7
8
  items: {
@@ -12,12 +13,29 @@ const props = defineProps({
12
13
  type: Boolean,
13
14
  default: false,
14
15
  },
16
+ // Renders the group as a filter chip set instead of a segmented control: no tonal
17
+ // container, pill-shaped items, and one row that scrolls sideways rather than stacking
18
+ // on a narrow screen. Opt-in, so existing consumers keep the segmented layout.
19
+ chips: {
20
+ type: Boolean,
21
+ default: false,
22
+ },
23
+ // Names the tablist for assistive technology. A tablist that only ever appears once on
24
+ // a page can go without, but any page carrying two of them needs each one labelled.
25
+ ariaLabel: {
26
+ type: String,
27
+ default: undefined,
28
+ },
15
29
  })
16
30
 
17
31
  const emit = defineEmits(['change'])
18
32
 
19
33
  const activeItem = ref(props.items.find(item => item.active) || props.items[0])
20
34
 
35
+ // Arrow / Home / End key handling for the tabs pattern — see useTablistNavigation for why
36
+ // focus moves without changing the selection.
37
+ const { tablistElement, handleKeydown } = useTablistNavigation()
38
+
21
39
  function setActiveItem(item) {
22
40
  if (activeItem.value == item) return
23
41
  activeItem.value = item
@@ -25,15 +43,22 @@ function setActiveItem(item) {
25
43
  }
26
44
  </script>
27
45
  <template>
28
- <div class="rsui-switcher"
46
+ <div ref="tablistElement"
47
+ class="rsui-switcher"
29
48
  :class="{
30
49
  'rsui-switcher--full': props.full,
50
+ 'rsui-switcher--chips': props.chips,
31
51
  }"
52
+ role="tablist"
53
+ :aria-label="props.ariaLabel"
54
+ @keydown="handleKeydown"
32
55
  >
33
56
  <SwitcherItem v-for="item in items"
34
57
  :key="item.id"
35
58
  :active="activeItem.id == item.id"
36
59
  :disabled="item.disabled"
60
+ :tabId="item.tabId"
61
+ :panelId="item.panelId"
37
62
  @click="setActiveItem(item)"
38
63
  >
39
64
  <Icon v-if="item.icon">
@@ -8,6 +8,19 @@ const props = defineProps({
8
8
  type: Boolean,
9
9
  default: false,
10
10
  },
11
+ // ID applied to the tab button itself, so the panel can point back at it with
12
+ // aria-labelledby and be named by its tab. Optional, like panelId.
13
+ tabId: {
14
+ type: String,
15
+ default: undefined,
16
+ },
17
+ // ID of the element this tab controls. Optional because Switcher does not own the
18
+ // panel — the consumer renders it and gives it `role="tabpanel"`. Supplying it here
19
+ // completes the tabs pattern for assistive technology.
20
+ panelId: {
21
+ type: String,
22
+ default: undefined,
23
+ },
11
24
  })
12
25
 
13
26
  const emit = defineEmits(['click'])
@@ -19,16 +32,23 @@ function handleClick() {
19
32
  }
20
33
  </script>
21
34
  <template>
22
- <div class="rsui-switcher-item"
35
+ <button type="button"
36
+ :id="props.tabId"
37
+ class="rsui-switcher-item"
23
38
  :class="{
24
39
  'rsui-switcher-item--active': props.active && !props.disabled,
25
40
  'rsui-switcher-item--disabled': props.disabled,
26
41
  }"
42
+ role="tab"
43
+ :aria-selected="props.active"
44
+ :aria-disabled="props.disabled ? true : undefined"
45
+ :aria-controls="props.panelId"
46
+ :tabindex="props.active ? 0 : -1"
27
47
  @click="handleClick"
28
48
  >
29
49
  <slot></slot>
30
50
  <div v-if="$slots.badge" class="rsui-switcher-item__badge">
31
51
  <slot name="badge"></slot>
32
52
  </div>
33
- </div>
53
+ </button>
34
54
  </template>
@@ -2,6 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import Icon from '../Icon/Icon.vue'
4
4
  import TabSliderItem from './TabSliderItem.vue'
5
+ import { useTablistNavigation } from '../../composables/useTablistNavigation.js'
5
6
 
6
7
  const props = defineProps({
7
8
  items: {
@@ -12,12 +13,22 @@ const props = defineProps({
12
13
  type: Boolean,
13
14
  default: false,
14
15
  },
16
+ // Names the tablist for assistive technology. A tablist that only ever appears once on
17
+ // a page can go without, but any page carrying two of them needs each one labelled.
18
+ ariaLabel: {
19
+ type: String,
20
+ default: undefined,
21
+ },
15
22
  })
16
23
 
17
24
  const emit = defineEmits(['change'])
18
25
 
19
26
  const activeItem = ref(props.items.find(item => item.active) || props.items[0])
20
27
 
28
+ // Arrow / Home / End key handling for the tabs pattern — see useTablistNavigation for why
29
+ // focus moves without changing the selection.
30
+ const { tablistElement, handleKeydown } = useTablistNavigation()
31
+
21
32
  function setActiveItem(item) {
22
33
  if (activeItem.value == item) return
23
34
  activeItem.value = item
@@ -25,15 +36,21 @@ function setActiveItem(item) {
25
36
  }
26
37
  </script>
27
38
  <template>
28
- <div class="rsui-tab-slider"
39
+ <div ref="tablistElement"
40
+ class="rsui-tab-slider"
29
41
  :class="{
30
42
  'rsui-tab-slider--full': props.full,
31
43
  }"
44
+ role="tablist"
45
+ :aria-label="props.ariaLabel"
46
+ @keydown="handleKeydown"
32
47
  >
33
48
  <TabSliderItem v-for="item in items"
34
49
  :key="item.id"
35
50
  :active="activeItem.id == item.id"
36
51
  :disabled="item.disabled"
52
+ :tabId="item.tabId"
53
+ :panelId="item.panelId"
37
54
  @click="setActiveItem(item)"
38
55
  >
39
56
  <Icon v-if="item.icon">
@@ -8,6 +8,19 @@ const props = defineProps({
8
8
  type: Boolean,
9
9
  default: false,
10
10
  },
11
+ // ID applied to the tab button itself, so the panel can point back at it with
12
+ // aria-labelledby and be named by its tab. Optional, like panelId.
13
+ tabId: {
14
+ type: String,
15
+ default: undefined,
16
+ },
17
+ // ID of the element this tab controls. Optional because TabSlider does not own the
18
+ // panel — the consumer renders it and gives it `role="tabpanel"`. Supplying it here
19
+ // completes the tabs pattern for assistive technology.
20
+ panelId: {
21
+ type: String,
22
+ default: undefined,
23
+ },
11
24
  })
12
25
 
13
26
  const emit = defineEmits(['click'])
@@ -19,16 +32,23 @@ function handleClick() {
19
32
  }
20
33
  </script>
21
34
  <template>
22
- <div class="rsui-tab-slider-item"
35
+ <button type="button"
36
+ :id="props.tabId"
37
+ class="rsui-tab-slider-item"
23
38
  :class="{
24
39
  'rsui-tab-slider-item--active': props.active && !props.disabled,
25
40
  'rsui-tab-slider-item--disabled': props.disabled,
26
41
  }"
42
+ role="tab"
43
+ :aria-selected="props.active"
44
+ :aria-disabled="props.disabled ? true : undefined"
45
+ :aria-controls="props.panelId"
46
+ :tabindex="props.active ? 0 : -1"
27
47
  @click="handleClick"
28
48
  >
29
49
  <slot></slot>
30
50
  <div v-if="$slots.badge" class="rsui-tab-slider-item__badge">
31
51
  <slot name="badge"></slot>
32
52
  </div>
33
- </div>
53
+ </button>
34
54
  </template>
@@ -0,0 +1,107 @@
1
+ import { ref } from 'vue'
2
+
3
+ /**
4
+ * Keyboard navigation for the WAI-ARIA tabs pattern, shared by TabSlider and Switcher.
5
+ *
6
+ * Both components render a `role="tablist"` whose children are `role="tab"` buttons using
7
+ * a roving tabindex: the selected tab carries `tabindex="0"` and the rest `tabindex="-1"`,
8
+ * so the tablist is a single Tab stop and the arrow keys move between tabs inside it.
9
+ *
10
+ * MANUAL ACTIVATION: the arrow keys move focus only — they do not change the selection.
11
+ * The APG permits either this or automatic activation (selection follows focus). Manual is
12
+ * the right default for a shared library: consumers commonly load data in response to
13
+ * `change`, and automatic activation would emit `change` for every tab arrowed past on the
14
+ * way to the intended one. Enter and Space activate the focused tab, and because each tab
15
+ * is a native <button> those come for free with no key handler of our own.
16
+ *
17
+ * Disabled tabs are marked `aria-disabled` rather than given the `disabled` attribute — the
18
+ * APG keeps them discoverable — but they are skipped by the arrow keys so keyboard users
19
+ * are not parked on a tab they cannot activate.
20
+ *
21
+ * Both arrow axes are handled because these two tablists are not both horizontal:
22
+ * `.rsui-switcher` is `flex-col sm:flex-row`, so it stacks vertically on small screens.
23
+ * Home and End jump to the first and last enabled tab. Navigation wraps at both ends.
24
+ */
25
+ export function useTablistNavigation() {
26
+ const tablistElement = ref(null)
27
+
28
+ const NAVIGATION_KEYS = [
29
+ 'ArrowRight',
30
+ 'ArrowLeft',
31
+ 'ArrowDown',
32
+ 'ArrowUp',
33
+ 'Home',
34
+ 'End',
35
+ ]
36
+
37
+ /**
38
+ * Every tab the arrow keys are allowed to land on, in DOM order.
39
+ */
40
+ function enabledTabs() {
41
+ if (!tablistElement.value) return []
42
+
43
+ return Array.from(
44
+ tablistElement.value.querySelectorAll('[role="tab"]:not([aria-disabled="true"])')
45
+ )
46
+ }
47
+
48
+ /**
49
+ * The tab that currently holds focus. Resolved with closest() because focus can sit on a
50
+ * descendant of the button (an icon, a badge) rather than the button itself.
51
+ */
52
+ function focusedTabIndex(tabs) {
53
+ const active = document.activeElement
54
+
55
+ if (!active) return -1
56
+
57
+ return tabs.indexOf(active.closest('[role="tab"]'))
58
+ }
59
+
60
+ function handleKeydown(event) {
61
+ if (!NAVIGATION_KEYS.includes(event.key)) return
62
+
63
+ const tabs = enabledTabs()
64
+
65
+ if (!tabs.length) return
66
+
67
+ const currentIndex = focusedTabIndex(tabs)
68
+
69
+ // Focus sits outside the tablist (or on a disabled tab) — start from the first tab
70
+ // rather than computing an offset from a meaningless position.
71
+ if (currentIndex === -1) {
72
+ event.preventDefault()
73
+ tabs[0].focus()
74
+
75
+ return
76
+ }
77
+
78
+ const lastIndex = tabs.length - 1
79
+ let nextIndex = currentIndex
80
+
81
+ if (event.key === 'Home') {
82
+ nextIndex = 0
83
+ }
84
+
85
+ if (event.key === 'End') {
86
+ nextIndex = lastIndex
87
+ }
88
+
89
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
90
+ nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
91
+ }
92
+
93
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
94
+ nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
95
+ }
96
+
97
+ // Claim the key only once a move is actually happening, so an unhandled arrow still
98
+ // scrolls the page as the user expects.
99
+ event.preventDefault()
100
+ tabs[nextIndex].focus()
101
+ }
102
+
103
+ return {
104
+ tablistElement,
105
+ handleKeydown,
106
+ }
107
+ }
@@ -1,35 +0,0 @@
1
- <script setup>
2
- defineProps({
3
- showContent: {
4
- type: Boolean,
5
- default: true,
6
- },
7
- })
8
- </script>
9
- <template>
10
- <div class="rsui-hero-section">
11
- <slot name="header"></slot>
12
-
13
- <div v-if="$slots.content"
14
- :class="[
15
- 'rsui-hero-section__content',
16
- {
17
- 'rsui-hero-section__content--hidden': !showContent,
18
- }
19
- ]"
20
- >
21
- <slot name="content"></slot>
22
- </div>
23
-
24
- <div v-if="$slots.default"
25
- :class="[
26
- 'rsui-hero-section__cards',
27
- {
28
- 'rsui-hero-section__cards--hidden': !showContent,
29
- }
30
- ]"
31
- >
32
- <slot></slot>
33
- </div>
34
- </div>
35
- </template>