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