@redseed/redseed-ui-vue3 8.68.0 → 8.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.68.0",
3
+ "version": "8.70.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -66,6 +66,39 @@ const props = defineProps({
66
66
  type: Boolean,
67
67
  default: false,
68
68
  },
69
+ // CATEGORY colour, which is a different job from the semantic variants above.
70
+ // `brand`/`success`/`error` and friends say what STATE a thing is in; they
71
+ // cannot say which taxonomy a thing BELONGS to (a capability domain, a team, a
72
+ // location, a pathway), and there are always more categories than the semantic
73
+ // set has slots.
74
+ //
75
+ // Takes the name of a RUNTIME CSS custom property, applied as
76
+ // `var(--<color>, <neutral>)` — the same convention and the same neutral
77
+ // degrade as Pill's `color` prop, so the two components stay consistent and
78
+ // RSUI stays theme-agnostic. Pass a real runtime theme variable, e.g. the
79
+ // consuming app's palette (`color7` -> `var(--color7)`). NOTE: RSUI's own
80
+ // Tailwind `@theme` tokens (e.g. `--color-brand-500`) are inlined into
81
+ // utilities, not exposed as runtime vars, so they will NOT resolve here. An
82
+ // UNRESOLVABLE (undefined) token degrades to a neutral grey rather than an
83
+ // invisible bar; a token that *resolves* to `transparent`/empty is not caught.
84
+ //
85
+ // On its own this prop paints nothing — it needs `accent` to say which edge
86
+ // carries it.
87
+ color: {
88
+ type: String,
89
+ default: '',
90
+ },
91
+ // Which edge carries the category accent bar. Default 'none', so nothing
92
+ // changes for any existing consumer. Thickness is NOT a consumer decision —
93
+ // it comes from a token (see --rsui-card-accent-w in card.css). That is the
94
+ // whole point of the prop: the same concept was hand-rolled twice on one page
95
+ // at two different weights (4px and 3px) precisely because nothing told the
96
+ // author what the weight should be.
97
+ accent: {
98
+ type: String,
99
+ default: 'none',
100
+ validator: (value) => ['left', 'top', 'none'].includes(value),
101
+ },
69
102
  ariaLabel: {
70
103
  type: String,
71
104
  default: undefined,
@@ -110,6 +143,16 @@ const ariaCurrent = computed(() => {
110
143
  // conveyed by colour alone (WCAG 1.4.1).
111
144
  const isActive = computed(() => props.pressed === true || Boolean(props.current))
112
145
 
146
+ // The accent token is only set when a color is supplied; otherwise the property
147
+ // is left unset so the CSS neutral fallback applies. When it IS supplied it
148
+ // carries an inline fallback to the neutral grey, so an unresolvable token
149
+ // degrades to grey rather than to an invisible edge. Mirrors Pill exactly.
150
+ const accentStyle = computed(() =>
151
+ props.color
152
+ ? { '--rsui-card-accent': `var(--${props.color}, var(--Colors-Grey-500))` }
153
+ : {},
154
+ )
155
+
113
156
  const slots = useSlots()
114
157
 
115
158
  const cardElement = ref(null)
@@ -130,6 +173,8 @@ const cardClass = computed(() => [
130
173
  'rsui-card--warning': props.warning,
131
174
  'rsui-card--error': props.error,
132
175
  'rsui-card--draft': props.draft,
176
+ 'rsui-card--accent-left': props.accent === 'left',
177
+ 'rsui-card--accent-top': props.accent === 'top',
133
178
  'rsui-card--2xs': responsiveWidth.value['2xs'],
134
179
  'rsui-card--xs': responsiveWidth.value['xs'],
135
180
  'rsui-card--sm': responsiveWidth.value['sm'],
@@ -170,7 +215,7 @@ function onClick() {
170
215
  }
171
216
  </script>
172
217
  <template>
173
- <div ref="cardElement" :class="cardClass" data-testid="card">
218
+ <div ref="cardElement" :class="cardClass" :style="accentStyle" data-testid="card">
174
219
  <svg
175
220
  v-if="draft"
176
221
  class="rsui-card__draft-border"
@@ -1,4 +1,6 @@
1
1
  <script setup>
2
+ import { computed, useId } from 'vue'
3
+ import { ArrowUpRightIcon, ArrowDownRightIcon, ArrowRightIcon } from '@heroicons/vue/24/outline'
2
4
  import Card from './Card.vue'
3
5
  import FlexContainer from '../FlexContainer/FlexContainer.vue'
4
6
 
@@ -39,7 +41,70 @@ const props = defineProps({
39
41
  default: null,
40
42
  validator: value => ['green', 'teal', 'light'].includes(value),
41
43
  },
44
+ // DIRECTION of travel — which arrow. Deliberately separate from `sentiment`,
45
+ // because an arrow direction and a colour are not the same decision:
46
+ // attrition up is bad, completions up is good. One prop driving both would
47
+ // render every metric where rising is bad in green.
48
+ trend: {
49
+ type: String,
50
+ default: null,
51
+ validator: value => ['up', 'down', 'flat'].includes(value),
52
+ },
53
+ // Whether that direction is GOOD NEWS — colour only. Defaults to deriving
54
+ // from `trend` (up => positive) so the common case stays terse, but it is
55
+ // independently overridable, which is the entire point of the split.
56
+ sentiment: {
57
+ type: String,
58
+ default: null,
59
+ validator: value => ['positive', 'negative', 'neutral'].includes(value),
60
+ },
61
+ // WHERE the comparison sits relative to the figure.
62
+ //
63
+ // below the comparison is a caption under the number (the default, and
64
+ // what every existing tile already renders).
65
+ // inline the comparison sits on the number's own row, immediately beside
66
+ // it, centred against its line box.
67
+ //
68
+ // A string rather than an `inlineComparison` boolean because the reference
69
+ // carries a third shape too — the change as a bordered chip pushed to the far
70
+ // right of the number row — and a boolean would have to be retired to add it.
71
+ //
72
+ // Inline is the tighter tile: it buys back a line of height, which is what
73
+ // makes a dense dashboard row of these work. It costs horizontal room, so the
74
+ // row wraps back to two lines rather than overflowing when the tile is narrow.
75
+ comparisonPlacement: {
76
+ type: String,
77
+ default: 'below',
78
+ validator: value => ['below', 'inline'].includes(value),
79
+ },
42
80
  })
81
+
82
+ // The terse default. `sentiment` is read first so an explicit value always wins,
83
+ // including 'neutral', which is why this tests for null rather than falsiness.
84
+ const TREND_SENTIMENT = {
85
+ up: 'positive',
86
+ down: 'negative',
87
+ flat: 'neutral',
88
+ }
89
+
90
+ const resolvedSentiment = computed(() => {
91
+ if (props.sentiment !== null) return props.sentiment
92
+
93
+ return TREND_SENTIMENT[props.trend] ?? 'neutral'
94
+ })
95
+
96
+ const TREND_ICON = {
97
+ up: ArrowUpRightIcon,
98
+ down: ArrowDownRightIcon,
99
+ flat: ArrowRightIcon,
100
+ }
101
+
102
+ const trendIcon = computed(() => TREND_ICON[props.trend] ?? null)
103
+
104
+ // Ties the comparison to the number for assistive tech, so the trend is not read
105
+ // as a loose fragment floating after the figure. DOM order already puts them
106
+ // adjacent; aria-describedby is what survives the number being queried alone.
107
+ const comparisonId = useId()
43
108
  </script>
44
109
 
45
110
  <template>
@@ -69,19 +134,82 @@ const props = defineProps({
69
134
  <div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
70
135
  <slot name="icon"></slot>
71
136
  </div>
72
- <div class="rsui-metric-card__label">
73
- <slot name="label"></slot>
137
+ <div class="rsui-metric-card__heading-text">
138
+ <div class="rsui-metric-card__label">
139
+ <slot name="label"></slot>
140
+ </div>
141
+
142
+ <div v-if="$slots.description" class="rsui-metric-card__description">
143
+ <slot name="description"></slot>
144
+ </div>
74
145
  </div>
75
146
  </div>
76
147
 
148
+ <!-- The figure and the thing that qualifies it, as one unit. The wrapper
149
+ stacks them by default, which renders exactly as the two siblings did
150
+ before it existed, and turns into a row when comparisonPlacement is
151
+ inline. Keeping both placements on the same two elements is what lets
152
+ the a11y wiring, the sentiment modifiers and the tonal overrides stay
153
+ placement-agnostic — only the direction of this one box changes. -->
77
154
  <div :class="[
78
- 'rsui-metric-card__number',
155
+ 'rsui-metric-card__figure',
156
+ `rsui-metric-card__figure--${props.comparisonPlacement}`,
79
157
  { 'rsui-metric-card--center': alignment === 'center' },
80
158
  { 'rsui-metric-card--left': alignment === 'left' },
81
159
  { 'rsui-metric-card--right': alignment === 'right' },
82
160
  ]"
83
161
  >
84
- <slot name="number"></slot>
162
+ <div :class="[
163
+ 'rsui-metric-card__number',
164
+ { 'rsui-metric-card--center': alignment === 'center' },
165
+ { 'rsui-metric-card--left': alignment === 'left' },
166
+ { 'rsui-metric-card--right': alignment === 'right' },
167
+ ]"
168
+ :aria-describedby="$slots.comparison ? comparisonId : undefined"
169
+ >
170
+ <slot name="number"></slot>
171
+ </div>
172
+
173
+ <!-- With the number at every labelFirst setting, matching the reference
174
+ shape: the comparison qualifies the figure, not the label, so it
175
+ stays with the figure wherever the label goes. -->
176
+ <div v-if="$slots.comparison"
177
+ :class="[
178
+ 'rsui-metric-card__comparison-row',
179
+ { 'rsui-metric-card--center': alignment === 'center' },
180
+ { 'rsui-metric-card--left': alignment === 'left' },
181
+ { 'rsui-metric-card--right': alignment === 'right' },
182
+ ]"
183
+ >
184
+ <span :id="comparisonId"
185
+ :class="[
186
+ 'rsui-metric-card__comparison',
187
+ `rsui-metric-card__comparison--${resolvedSentiment}`,
188
+ ]"
189
+ >
190
+ <span v-if="trendIcon" class="rsui-metric-card__comparison-icon" aria-hidden="true">
191
+ <component :is="trendIcon" />
192
+ </span>
193
+
194
+ <slot name="comparison"></slot>
195
+
196
+ <!-- The quieter half of the reference's "Change and text": the change
197
+ carries the sentiment colour, the phrase that qualifies it drops to
198
+ tertiary ink so the eye lands on the figure first.
199
+
200
+ A second slot rather than asking the consumer to wrap the phrase in
201
+ a class themselves. The split is a colour decision, which belongs to
202
+ the design system; the words and their translation stay with the
203
+ consumer, exactly as the comparison itself does.
204
+
205
+ Inside the comparison span, so it sits within the region the number's
206
+ aria-describedby points at — "175.95% Increase vs last month" is one
207
+ description of the figure, not a fragment plus an orphan. -->
208
+ <span v-if="$slots.comparisonContext" class="rsui-metric-card__comparison-context">
209
+ <slot name="comparisonContext"></slot>
210
+ </span>
211
+ </span>
212
+ </div>
85
213
  </div>
86
214
 
87
215
  <div v-if="!labelFirst"
@@ -95,8 +223,14 @@ const props = defineProps({
95
223
  <div v-if="$slots.icon" class="rsui-metric-card__icon" aria-hidden="true">
96
224
  <slot name="icon"></slot>
97
225
  </div>
98
- <div class="rsui-metric-card__label">
99
- <slot name="label"></slot>
226
+ <div class="rsui-metric-card__heading-text">
227
+ <div class="rsui-metric-card__label">
228
+ <slot name="label"></slot>
229
+ </div>
230
+
231
+ <div v-if="$slots.description" class="rsui-metric-card__description">
232
+ <slot name="description"></slot>
233
+ </div>
100
234
  </div>
101
235
  </div>
102
236
  </FlexContainer>