@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
|
@@ -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>
|