@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 +1 -1
- package/src/components/Chart/HeatmapChart.vue +100 -0
- package/src/components/Chart/chartTheme.js +43 -1
- package/src/components/Chart/index.js +2 -0
- package/src/components/Section/SectionFooter.vue +2 -26
- package/src/components/Section/SectionSlider.vue +58 -11
- package/src/helpers/slots.js +59 -0
package/package.json
CHANGED
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, watchEffect, useSlots
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
function showHeaderActions() {
|
|
169
|
+
return hasCustomActions() || !props.hoverActions
|
|
170
|
+
}
|
|
132
171
|
|
|
133
|
-
|
|
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
|
+
}
|