@redseed/redseed-ui-vue3 8.66.0 → 8.67.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.66.0",
3
+ "version": "8.67.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,100 @@
1
+ <script setup>
2
+ // A magnitude grid: one cell per (row, column), shaded light-to-dark by the
3
+ // size of its value. The GitHub contributions grid is the familiar example —
4
+ // days down, weeks across.
5
+ //
6
+ // ApexCharts touches window/DOM, so this renders client-side only (guarded by
7
+ // the `mounted` ref), matching the other rsui charts.
8
+ //
9
+ // Colour comes from the rsui sequential scale, never from the consumer: pass
10
+ // `ranges` as plain numeric buckets and each one takes the next step of the
11
+ // ramp, so a consuming app still writes no colours of its own.
12
+ import { ref, computed, onMounted } from 'vue'
13
+ import VueApexCharts from 'vue3-apexcharts'
14
+ import { resolveTheme, baseChartOptions, deepMergeOptions } from './chartTheme'
15
+
16
+ const props = defineProps({
17
+ // ApexCharts heatmap series: [{ name: <row label>, data: [{ x: <column
18
+ // label>, y: <value> }] }]. Rows render bottom-up, so pass them in the
19
+ // order you want read from the bottom of the grid.
20
+ series: {
21
+ type: Array,
22
+ default: () => [],
23
+ },
24
+ // Value buckets, lowest first: [{ from, to, name }]. Each takes the next
25
+ // step of the rsui scale, so five is the most that shade distinctly.
26
+ // Omitted, ApexCharts shades continuously from one colour.
27
+ ranges: {
28
+ type: Array,
29
+ default: () => [],
30
+ },
31
+ height: {
32
+ type: [Number, String],
33
+ default: 240,
34
+ },
35
+ // Escape hatch — deep-merged over the rsui defaults
36
+ options: {
37
+ type: Object,
38
+ default: () => ({}),
39
+ },
40
+ })
41
+
42
+ const root = ref(null)
43
+ const mounted = ref(false)
44
+ const theme = ref(resolveTheme(null))
45
+
46
+ onMounted(() => {
47
+ theme.value = resolveTheme(root.value)
48
+ mounted.value = true
49
+ })
50
+
51
+ // Buckets past the end of the ramp reuse its darkest step rather than coming
52
+ // back undefined, which ApexCharts renders as a black cell.
53
+ const shadedRanges = computed(() =>
54
+ props.ranges.map((range, index) => ({
55
+ ...range,
56
+ color: theme.value.scale[index] ?? theme.value.scale[theme.value.scale.length - 1],
57
+ })),
58
+ )
59
+
60
+ const hasRanges = computed(() => props.ranges.length > 0)
61
+
62
+ const chartOptions = computed(() =>
63
+ deepMergeOptions(
64
+ deepMergeOptions(
65
+ baseChartOptions({
66
+ type: 'heatmap',
67
+ theme: theme.value,
68
+ height: props.height,
69
+ }),
70
+ hasRanges.value
71
+ ? {
72
+ plotOptions: {
73
+ heatmap: {
74
+ shadeIntensity: 0,
75
+ enableShades: false,
76
+ colorScale: { ranges: shadedRanges.value },
77
+ },
78
+ },
79
+ }
80
+ : {},
81
+ ),
82
+ props.options,
83
+ ),
84
+ )
85
+ </script>
86
+
87
+ <template>
88
+ <div
89
+ ref="root"
90
+ class="rsui-chart"
91
+ >
92
+ <VueApexCharts
93
+ v-if="mounted"
94
+ type="heatmap"
95
+ :height="height"
96
+ :series="series"
97
+ :options="chartOptions"
98
+ />
99
+ </div>
100
+ </template>
@@ -31,9 +31,24 @@ const PALETTE_TOKENS = [
31
31
  { name: '--Colors-RedSeed-Purple-600', fallback: 'hsla(271, 55%, 54%, 1)' },
32
32
  ]
33
33
 
34
+ // Sequential light-to-dark ramp for magnitude charts (heatmap). Ordered, not
35
+ // categorical: step 0 is the "nothing here" ground and every step after it
36
+ // reads as more. Grey rather than the palest brand at the bottom so an empty
37
+ // cell cannot be mistaken for a small value.
38
+ const SCALE_TOKENS = [
39
+ { name: '--Colors-Grey-100', fallback: '#f5f5f5' },
40
+ { name: '--Colors-Brand-100', fallback: '#c8eadf' },
41
+ { name: '--Colors-Brand-300', fallback: '#53c6a3' },
42
+ { name: '--Colors-Brand-400', fallback: '#279b78' },
43
+ { name: '--Colors-Brand-500', fallback: '#136c52' },
44
+ ]
45
+
34
46
  const TEXT_TOKEN = { name: '--Colors-Grey-900', fallback: 'hsla(220, 24%, 12%, 1)' }
35
47
  const TEXT_MUTED_TOKEN = { name: '--Colors-Grey-700', fallback: 'hsla(220, 11%, 29%, 1)' }
36
48
  const GRID_TOKEN = { name: '--Colors-Grey-200', fallback: 'hsla(220, 5%, 92%, 1)' }
49
+ // The surface the chart sits on. Heatmap cells tile edge to edge, so the gaps
50
+ // between them are painted in this colour rather than left as holes.
51
+ const SURFACE_TOKEN = { name: '--color-background-primary', fallback: 'hsla(0, 0%, 100%, 1)' }
37
52
  const FONT_TOKEN = { name: '--font-redseed', fallback: "'Inter', sans-serif" }
38
53
 
39
54
  function readToken(el, token) {
@@ -50,9 +65,11 @@ function readToken(el, token) {
50
65
  export function resolveTheme(el) {
51
66
  return {
52
67
  colors: PALETTE_TOKENS.map((token) => readToken(el, token)),
68
+ scale: SCALE_TOKENS.map((token) => readToken(el, token)),
53
69
  text: readToken(el, TEXT_TOKEN),
54
70
  textMuted: readToken(el, TEXT_MUTED_TOKEN),
55
71
  grid: readToken(el, GRID_TOKEN),
72
+ surface: readToken(el, SURFACE_TOKEN),
56
73
  fontFamily: readToken(el, FONT_TOKEN),
57
74
  }
58
75
  }
@@ -61,7 +78,7 @@ export function resolveTheme(el) {
61
78
  * Build the rsui ApexCharts defaults for a given chart type. Consumers layer
62
79
  * their own `options` over the result via deepMergeOptions().
63
80
  *
64
- * @param {'line'|'bar'|'donut'} type
81
+ * @param {'line'|'bar'|'donut'|'heatmap'} type
65
82
  * @param {object} theme result of resolveTheme()
66
83
  * @param {number|string} height
67
84
  * @param {string[]} [categories] x-axis labels (line/bar)
@@ -118,6 +135,31 @@ export function baseChartOptions({ type, theme, height, categories, labels, stac
118
135
  options.yaxis = { labels: { style: { colors: theme.textMuted } } }
119
136
  }
120
137
 
138
+ if (type === 'heatmap') {
139
+ // The cell's own value is the reading, so no axis line, no grid and no
140
+ // legend — the colour scale carries the key instead.
141
+ //
142
+ // No entry animation either: ApexCharts fades a heatmap in cell by cell,
143
+ // which reads as a loading grid rather than a chart. The whole point of
144
+ // the grid is the shape you see at a glance, so show it at once.
145
+ options.chart.animations = { enabled: false }
146
+ options.legend = { show: false }
147
+ // Cells tile edge to edge, so the 2px gutter is a stroke painted in the
148
+ // surface colour — a transparent stroke paints nothing and the cells
149
+ // read as one solid block.
150
+ options.stroke = { width: 2, colors: [theme.surface] }
151
+ // No grid lines, but keep a gutter between the row labels and the first
152
+ // column so the key on the left is not jammed against the cells.
153
+ options.grid = { show: false, padding: { left: 8, right: 0 } }
154
+ options.xaxis = {
155
+ ...axis(categories, theme),
156
+ axisBorder: { show: false },
157
+ axisTicks: { show: false },
158
+ tooltip: { enabled: false },
159
+ }
160
+ options.yaxis = { labels: { style: { colors: theme.textMuted } } }
161
+ }
162
+
121
163
  if (type === 'donut') {
122
164
  options.labels = labels || []
123
165
  options.stroke = { width: 0 }
@@ -1,9 +1,11 @@
1
1
  import LineChart from './LineChart.vue'
2
2
  import BarChart from './BarChart.vue'
3
3
  import DoughnutChart from './DoughnutChart.vue'
4
+ import HeatmapChart from './HeatmapChart.vue'
4
5
 
5
6
  export {
6
7
  LineChart,
7
8
  BarChart,
8
9
  DoughnutChart,
10
+ HeatmapChart,
9
11
  }
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
- import { computed, watchEffect, useSlots, Comment, Fragment } from 'vue'
2
+ import { computed, watchEffect, useSlots } from 'vue'
3
3
  import { isSafeHref } from '../../helpers/href'
4
+ import { rendersContent } from '../../helpers/slots'
4
5
 
5
6
  const props = defineProps({
6
7
  showContent: {
@@ -62,31 +63,6 @@ const showViewAll = computed(() => hasViewAllLabel.value && hasViewAllHref.value
62
63
 
63
64
  const slots = useSlots()
64
65
 
65
- /**
66
- * Whether a slot renders anything at all. Slot *presence* is not the same
67
- * question: `<template #default><Button v-if="canManage" /></template>` leaves a
68
- * truthy slot function that renders a comment placeholder, which would still
69
- * claim a `flex-1` region and shove the centred link into one half of the footer.
70
- */
71
- function rendersContent(slot) {
72
- const nodes = slot?.()
73
-
74
- if (!nodes) return false
75
-
76
- // Comment vnodes are what a falsy `v-if` leaves behind. Fragments wrap lists
77
- // and `<template v-if>` blocks, so a truthy wrapper around a falsy child is a
78
- // Fragment whose only child is a Comment — counting its length would call that
79
- // content and render an empty region claiming `flex-1`.
80
- return nodes.some(rendersNode)
81
- }
82
-
83
- function rendersNode(node) {
84
- if (node.type === Comment) return false
85
- if (node.type === Fragment) return Array.isArray(node.children) && node.children.some(rendersNode)
86
-
87
- return true
88
- }
89
-
90
66
  /**
91
67
  * Whether the view-all has the footer to itself, which is the case that wants
92
68
  * centring. With anything alongside it, a greedy region would split the row with
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, watch, watchEffect, useSlots } from 'vue'
3
+ import { rendersContent } from '../../helpers/slots'
3
4
  import { useScroll, useEventListener, watchDebounced } from '@vueuse/core'
4
5
  import Section from './Section.vue'
5
6
  import SectionHeader from './SectionHeader.vue'
@@ -124,13 +125,64 @@ const slots = useSlots()
124
125
  /**
125
126
  * A consumer-supplied `actions` slot always wins and always renders in the
126
127
  * header — that slot exists so consumers can put their own controls up there.
127
- * The built-in arrows go to whichever placement `hoverActions` selects.
128
+ * The built-in arrows go to whichever placement `hoverActions` selects, unless
129
+ * a consumer `actions` slot displaces them from the header, in which case header
130
+ * mode renders none at all.
131
+ *
132
+ * Rendered output, not slot presence, and a plain function rather than a
133
+ * `computed`. Both matter, and getting either wrong breaks the combination this
134
+ * component exists to support — a consumer control in the header with the arrows
135
+ * on the rail:
136
+ *
137
+ * - `Boolean(slots.actions)` is true for `<template #actions><Link v-if="hasMore"
138
+ * /></template>` even when `hasMore` is false, because the slot function still
139
+ * exists and just returns a comment. The arrows are this slot's FALLBACK
140
+ * content, so Vue renders them back into the header the moment the slot
141
+ * resolves to nothing — while `hoverActions` has already put a pair on the
142
+ * rail. Two pairs on one rail, identical accessible names, and disagreeing
143
+ * disabled states. Measured at four buttons.
144
+ * - `useSlots()` is not reactive, so a `computed` over it has no dependency and
145
+ * caches its first answer for the life of the component. A consumer writing
146
+ * `<template v-if="hasMore" #actions>` against a count that arrives with a
147
+ * fetch would never see their control appear at all.
148
+ *
149
+ * Same reasoning and the same helper as SectionFooter — see helpers/slots.
128
150
  */
129
- const hasCustomActions = computed(() => Boolean(slots.actions))
151
+ /**
152
+ * The scope the `actions` slot is handed. Built once so the probe below and the
153
+ * real `<slot>` in the template receive the same object — a consumer template
154
+ * that destructures these compiles to a function destructuring its only
155
+ * parameter, so probing bare threw and rendered nothing at all.
156
+ */
157
+ const actionsSlotScope = computed(() => ({
158
+ showNextSlide,
159
+ showPreviousSlide,
160
+ disabledPrevButton: disabledPrevButton.value,
161
+ disabledNextButton: disabledNextButton.value,
162
+ }))
163
+
164
+ function hasCustomActions() {
165
+ return rendersContent(slots.actions, actionsSlotScope.value)
166
+ }
130
167
 
131
- const showHeaderActions = computed(() => hasCustomActions.value || !props.hoverActions)
168
+ function showHeaderActions() {
169
+ return hasCustomActions() || !props.hoverActions
170
+ }
132
171
 
133
- const showOverlayActions = computed(() => !hasCustomActions.value && props.hoverActions)
172
+ /**
173
+ * Placement is `hoverActions`' decision alone. A custom `actions` slot does not
174
+ * suppress the overlay: the arrows are that slot's fallback content, so
175
+ * overriding it has already taken them out of the header — which is exactly when
176
+ * the rail needs them. Guarding on `hasCustomActions` here left the one
177
+ * combination consumers reach for (own header control, arrows on the rail) with
178
+ * no navigation at all from `lg` up, where the track is `overflow-x-hidden` and
179
+ * the arrows are the only way to move it. `hoverActions` exists to free the
180
+ * header for such a control in the first place.
181
+ *
182
+ * Safe as a `computed`: it reads only a prop. The header side is what has to
183
+ * track a slot.
184
+ */
185
+ const showOverlayActions = computed(() => props.hoverActions)
134
186
 
135
187
  /**
136
188
  * Unique id for the slider
@@ -548,13 +600,8 @@ function showPreviousSlide() {
548
600
  <slot name="see-all"></slot>
549
601
  </template>
550
602
 
551
- <template v-if="showHeaderActions" #actions>
552
- <slot name="actions"
553
- :showNextSlide="showNextSlide"
554
- :showPreviousSlide="showPreviousSlide"
555
- :disabledPrevButton="disabledPrevButton"
556
- :disabledNextButton="disabledNextButton"
557
- >
603
+ <template v-if="showHeaderActions()" #actions>
604
+ <slot name="actions" v-bind="actionsSlotScope">
558
605
  <SectionSliderAction direction="prev"
559
606
  :disabled="disabledPrevButton"
560
607
  :invert="featured"
@@ -0,0 +1,59 @@
1
+ import { Comment, Fragment } from 'vue'
2
+
3
+ /**
4
+ * Whether a slot renders anything at all.
5
+ *
6
+ * Internal on purpose — not re-exported from `helpers/index.js`. It walks vnode
7
+ * internals, which is a detail of how this library composes its own regions
8
+ * rather than something consuming apps should depend on.
9
+ *
10
+ * Slot *presence* is a different question, and the wrong one in almost every
11
+ * case. `Boolean(slots.actions)` is true for
12
+ * `<template #actions><Button v-if="canManage" /></template>` even when
13
+ * `canManage` is false, because the slot function still exists — it just returns
14
+ * a comment placeholder. Components that use presence to decide whether to stand
15
+ * their own content down then stand it down for a slot that renders nothing, and
16
+ * the region ends up empty, or the fallback it was suppressing comes back.
17
+ *
18
+ * Two callers, for two versions of that bug: SectionFooter, where an empty region
19
+ * claimed `flex-1` and shoved a centred link into one half of the footer, and
20
+ * SectionSlider, where an `#actions` slot rendering nothing let the arrows return
21
+ * to the header while `hoverActions` had already put a pair on the rail — two
22
+ * pairs on one rail, with identical accessible names.
23
+ *
24
+ * Call this from the template or from a plain function, never from a `computed`:
25
+ * `useSlots()` returns a non-reactive object, so a computed over it caches its
26
+ * first answer for the life of the component and a conditionally-supplied slot
27
+ * would never appear.
28
+ *
29
+ * `scope` is REQUIRED for a scoped slot, and passing it is the caller's job. A
30
+ * consumer template that destructures its scoped props — `<template #actions="{
31
+ * showPreviousSlide }">` — compiles to a function that destructures its only
32
+ * parameter, so probing it bare throws `Cannot destructure property … of
33
+ * 'undefined'` and takes the whole component down with it. Hand it the same
34
+ * object the real `<slot>` receives, so the probe and the render agree. A
35
+ * non-scoped slot ignores the argument, so passing nothing stays correct there.
36
+ *
37
+ * Deliberately not wrapped in try/catch: that would swallow a genuine error
38
+ * thrown by the consumer's own slot content and leave a silently empty region.
39
+ */
40
+ export function rendersContent(slot, scope) {
41
+ const nodes = slot?.(scope)
42
+
43
+ if (!nodes) return false
44
+
45
+ return nodes.some(rendersNode)
46
+ }
47
+
48
+ /**
49
+ * Comment vnodes are what a falsy `v-if` leaves behind. Fragments wrap lists and
50
+ * `<template v-if>` blocks, so a truthy wrapper around a falsy child is a
51
+ * Fragment whose only child is a Comment — counting length would treat that as
52
+ * content.
53
+ */
54
+ function rendersNode(node) {
55
+ if (node.type === Comment) return false
56
+ if (node.type === Fragment) return Array.isArray(node.children) && node.children.some(rendersNode)
57
+
58
+ return true
59
+ }