@redseed/redseed-ui-vue3 8.60.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/index.js CHANGED
@@ -42,6 +42,7 @@ export * from './src/components/Section'
42
42
  export * from './src/components/Skeleton'
43
43
  export * from './src/components/Social'
44
44
  export * from './src/components/Sorting'
45
+ export * from './src/components/StageDial'
45
46
  export * from './src/components/Switcher'
46
47
  export * from './src/components/TabSlider'
47
48
  export * from './src/components/Table'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.60.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,
@@ -0,0 +1,279 @@
1
+ <script setup>
2
+ import { ref, computed, onMounted } from 'vue'
3
+
4
+ // A circular stage indicator built from DISCRETE segments — one per named stage,
5
+ // filled up to the stage reached. Deliberately not ProgressCircle: a continuous
6
+ // ring filled two-thirds reads as "67%", where three separate segments with two
7
+ // filled reads as "the second of three named stages". For anything describing
8
+ // capability or maturity rather than completion, the continuous version turns a
9
+ // developmental stage into a score, which is the whole thing this avoids.
10
+ const props = defineProps({
11
+ // Ordered stage labels, lowest first (e.g. ['Emerging', 'Developing', 'Strong']).
12
+ stages: {
13
+ type: Array,
14
+ default: () => [],
15
+ },
16
+ // The stage reached — fills every segment up to and including it. Matched
17
+ // against `stages` case-insensitively and trimmed, so both 'Developing' and
18
+ // 'developing' resolve. No match (or an empty value) fills nothing, which is
19
+ // the honest rendering for "no stage yet" rather than defaulting to stage one.
20
+ current: {
21
+ type: String,
22
+ default: '',
23
+ },
24
+ // The name of a RUNTIME CSS custom property to use as the accent for the
25
+ // filled segments — applied as `var(--<color>, <neutral>)`, same convention
26
+ // as Pill's `color` prop. Pass a real runtime theme variable, e.g. the
27
+ // consuming app's palette (`color7` → `var(--color7)`). NOTE: RSUI's own
28
+ // Tailwind `@theme` color tokens (e.g. `--color-brand-500`) are inlined into
29
+ // utilities, not exposed as runtime vars, so they will NOT resolve here. An
30
+ // UNRESOLVABLE (undefined) token degrades to a neutral grey rather than an
31
+ // invisible dial. Optional — falls back to a neutral accent when omitted, so
32
+ // RSUI stays theme-agnostic.
33
+ color: {
34
+ type: String,
35
+ default: '',
36
+ },
37
+ // Arc degrees. The default 270 leaves a 90° gap at the bottom for a label,
38
+ // and the empty span is always centred on the bottom whatever the sweep.
39
+ sweep: {
40
+ type: Number,
41
+ default: 270,
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
+ },
55
+ // Opt-in draw-in: filled segments animate their dash length from 0, staggered
56
+ // by segment. No-ops under `prefers-reduced-motion` (handled in CSS).
57
+ animate: {
58
+ type: Boolean,
59
+ default: false,
60
+ },
61
+ // Extra delay in ms before this dial's first segment draws. Lets a row of
62
+ // dials be staggered into a wave by passing an increasing delay per dial.
63
+ delay: {
64
+ type: Number,
65
+ default: 0,
66
+ },
67
+ // Optional prefix for the generated accessible label, e.g. 'Lead self' →
68
+ // "Lead self: Developing, stage 2 of 3".
69
+ label: {
70
+ type: String,
71
+ default: '',
72
+ },
73
+ // Size ramp. Booleans, only one applies at a time — matches the RSUI
74
+ // size-prop convention. Defaults to md.
75
+ sm: {
76
+ type: Boolean,
77
+ default: false,
78
+ },
79
+ md: {
80
+ type: Boolean,
81
+ default: false,
82
+ },
83
+ lg: {
84
+ type: Boolean,
85
+ default: false,
86
+ },
87
+ })
88
+
89
+ // --- geometry ---------------------------------------------------------------
90
+ // Everything is drawn in a 100x100 viewBox so the whole dial scales with the
91
+ // CSS box: the stroke is in user units, so it scales too.
92
+ const RADIUS = 42
93
+ const STROKE = 9
94
+ // Preferred gap between segments, in path units. It MUST exceed the stroke
95
+ // width: with `stroke-linecap: round` the caps extend half the stroke width past
96
+ // each dash end, so a gap narrower than the stroke closes up and the segments
97
+ // render as one continuous sweep — exactly the gauge look this component exists
98
+ // to avoid. Twice the stroke is what reads unmistakably as separate stages.
99
+ const PREFERRED_GAP = 18
100
+ // Stagger between consecutive segments drawing in.
101
+ const SEGMENT_STAGGER = 130
102
+
103
+ const circumference = 2 * Math.PI * RADIUS
104
+
105
+ // Clamped so a nonsense sweep still renders something sane.
106
+ const safeSweep = computed(() => {
107
+ if (props.sweep < 30) return 30
108
+ if (props.sweep > 360) return 360
109
+
110
+ return props.sweep
111
+ })
112
+
113
+ const stageCount = computed(() => props.stages.length)
114
+
115
+ // Total path length the segments and gaps share.
116
+ const arcLength = computed(() => circumference * (safeSweep.value / 360))
117
+
118
+ // The gap shrinks below the preferred value rather than letting the gaps eat the
119
+ // arc, so a dial with many stages (or a tight sweep) still shows segments. Capped
120
+ // at a third of each stage's nominal share of the arc, which keeps segments at
121
+ // least twice the width of the gaps between them however many stages there are.
122
+ const gapLength = computed(() => {
123
+ if (stageCount.value < 2) return 0
124
+
125
+ return Math.min(PREFERRED_GAP, arcLength.value / (stageCount.value * 3))
126
+ })
127
+
128
+ const segmentLength = computed(() => {
129
+ if (! stageCount.value) return 0
130
+
131
+ const gapTotal = gapLength.value * (stageCount.value - 1)
132
+
133
+ return (arcLength.value - gapTotal) / stageCount.value
134
+ })
135
+
136
+ // An SVG circle's path starts at 3 o'clock and runs clockwise, so an un-rotated
137
+ // arc leaves its empty span centred at `sweep / 2 + 180`. Rotating the whole
138
+ // group by the difference moves that span to the bottom (90°), which is where the
139
+ // label goes — so the arc stays visually centred at any sweep.
140
+ const rotation = computed(() => (90 - (safeSweep.value / 2 + 180) + 360) % 360)
141
+
142
+ // --- stages -----------------------------------------------------------------
143
+ // Compared lowercased so a consumer can pass either the label or a lowercase key.
144
+ const normalisedStages = computed(() =>
145
+ props.stages.map((stage) => String(stage).trim().toLowerCase()),
146
+ )
147
+
148
+ const currentIndex = computed(() =>
149
+ normalisedStages.value.indexOf(String(props.current).trim().toLowerCase()),
150
+ )
151
+
152
+ // Segments up to and including the reached stage. Everything is derived from
153
+ // this leading run, so unreached segments are never rendered at all — a
154
+ // zero-length dash still paints a dot under round caps, so they have to be left
155
+ // out rather than drawn at length 0.
156
+ const filledCount = computed(() => currentIndex.value + 1)
157
+
158
+ const segments = computed(() =>
159
+ props.stages.map((stage, index) => ({
160
+ stage,
161
+ index,
162
+ // Each segment starts one segment-plus-gap further round than the last.
163
+ offset: -(index * (segmentLength.value + gapLength.value)),
164
+ transitionDelay: `${props.delay + index * SEGMENT_STAGGER}ms`,
165
+ })),
166
+ )
167
+
168
+ const filledSegments = computed(() => segments.value.slice(0, filledCount.value))
169
+
170
+ // --- animation --------------------------------------------------------------
171
+ // Starts drawn when not animating, so a static dial paints in one frame.
172
+ const isDrawn = ref(! props.animate)
173
+
174
+ onMounted(() => {
175
+ if (! props.animate) return
176
+
177
+ // A frame later, so the transition has an initial value to move away from.
178
+ requestAnimationFrame(() => {
179
+ isDrawn.value = true
180
+ })
181
+ })
182
+
183
+ const dashArray = computed(() =>
184
+ isDrawn.value
185
+ ? `${segmentLength.value} ${circumference}`
186
+ : `0 ${circumference}`,
187
+ )
188
+
189
+ const trackDashArray = computed(() => `${segmentLength.value} ${circumference}`)
190
+
191
+ // --- presentation -----------------------------------------------------------
192
+ // Largest flag wins so a consumer passing more than one still gets a defined
193
+ // result, and an unsized dial defaults to md.
194
+ const activeSize = computed(() => {
195
+ if (props.lg) return 'lg'
196
+ if (props.md) return 'md'
197
+ if (props.sm) return 'sm'
198
+
199
+ return 'md'
200
+ })
201
+
202
+ const stageDialClass = computed(() => [
203
+ 'rsui-stage-dial',
204
+ `rsui-stage-dial--${activeSize.value}`,
205
+ {
206
+ 'rsui-stage-dial--animate': props.animate,
207
+ 'rsui-stage-dial--on-color': props.onColor,
208
+ },
209
+ ])
210
+
211
+ // When no color is supplied we leave the property unset so the CSS neutral
212
+ // fallback applies. When a color IS supplied it carries an inline fallback to
213
+ // the neutral grey, so an UNRESOLVABLE (undefined) token degrades to grey rather
214
+ // than invisible segments. Note: var()'s fallback does not fire for a token that
215
+ // *resolves* to transparent/empty — passing a real colour is the consumer's
216
+ // responsibility.
217
+ const accentStyle = computed(() =>
218
+ props.color
219
+ ? { '--rsui-stage-dial-accent': `var(--${props.color}, var(--Colors-Grey-500))` }
220
+ : {},
221
+ )
222
+
223
+ // The dial is a picture of a stage, not a percentage, so it gets `role="img"`
224
+ // with a spelled-out label rather than progressbar semantics — an
225
+ // `aria-valuenow` would reintroduce the score reading in the accessibility tree.
226
+ const accessibleLabel = computed(() => {
227
+ if (! stageCount.value) return props.label
228
+
229
+ const reached = currentIndex.value
230
+ const stageText = reached === -1
231
+ ? `no stage reached, ${stageCount.value} stages`
232
+ : `${props.stages[reached]}, stage ${reached + 1} of ${stageCount.value}`
233
+
234
+ return props.label ? `${props.label}: ${stageText}` : stageText
235
+ })
236
+
237
+ // warn in development when the geometry will visually merge the segments
238
+ if (process.env.NODE_ENV !== 'production') {
239
+ if (stageCount.value > 1 && gapLength.value <= STROKE) {
240
+ console.warn(
241
+ '[RSUI] StageDial segments will render as one continuous sweep: the gap between them is narrower than the stroke width. Widen `sweep` or pass fewer stages.',
242
+ )
243
+ }
244
+ }
245
+ </script>
246
+ <template>
247
+ <div :class="stageDialClass"
248
+ :style="accentStyle"
249
+ role="img"
250
+ :aria-label="accessibleLabel"
251
+ >
252
+ <svg class="rsui-stage-dial__svg" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
253
+ <g :transform="`rotate(${rotation} 50 50)`">
254
+ <circle v-for="segment in segments"
255
+ :key="`track-${segment.index}`"
256
+ class="rsui-stage-dial__track"
257
+ cx="50"
258
+ cy="50"
259
+ :r="RADIUS"
260
+ :stroke-dasharray="trackDashArray"
261
+ :stroke-dashoffset="segment.offset"
262
+ ></circle>
263
+ <circle v-for="segment in filledSegments"
264
+ :key="`fill-${segment.index}`"
265
+ class="rsui-stage-dial__fill"
266
+ cx="50"
267
+ cy="50"
268
+ :r="RADIUS"
269
+ :stroke-dasharray="dashArray"
270
+ :stroke-dashoffset="segment.offset"
271
+ :style="{ transitionDelay: segment.transitionDelay }"
272
+ ></circle>
273
+ </g>
274
+ </svg>
275
+ <div class="rsui-stage-dial__content">
276
+ <slot></slot>
277
+ </div>
278
+ </div>
279
+ </template>
@@ -0,0 +1,5 @@
1
+ import StageDial from './StageDial.vue'
2
+
3
+ export {
4
+ StageDial,
5
+ }
@@ -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>