@humanforest/nuxt-layer 0.3.1 → 0.3.3
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanforest/nuxt-layer",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.3",
|
|
5
5
|
"description": "Forest design system as a Nuxt layer — extend it to inherit the theme, colour roles, icons and brand fonts in one line.",
|
|
6
6
|
"main": "./nuxt.config.ts",
|
|
7
7
|
"files": [
|
|
@@ -50,6 +50,12 @@ const props = withDefaults(
|
|
|
50
50
|
area?: boolean;
|
|
51
51
|
/** Marks the last point, so the series has an end rather than running off the edge. */
|
|
52
52
|
endpoint?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Takes the height its container gives it, with `height` as the floor. For a graphic in a space
|
|
55
|
+
* its surroundings have made taller (a KPI bleed row that a stretched card or a group has given
|
|
56
|
+
* more height), where the chart is the part that should grow. Works for both marks.
|
|
57
|
+
*/
|
|
58
|
+
fill?: boolean;
|
|
53
59
|
/**
|
|
54
60
|
* Monotone interpolation, on by default. Monotone rather than a plain spline: it stays inside
|
|
55
61
|
* the data's own range, so no peak appears between two points that neither of them reached.
|
|
@@ -71,7 +77,7 @@ const props = withDefaults(
|
|
|
71
77
|
*/
|
|
72
78
|
mark?: 'line' | 'bar';
|
|
73
79
|
}>(),
|
|
74
|
-
{ height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
|
|
80
|
+
{ height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, fill: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
|
|
75
81
|
);
|
|
76
82
|
|
|
77
83
|
if (import.meta.env.DEV) {
|
|
@@ -155,14 +161,14 @@ const endpointTop = computed(() => {
|
|
|
155
161
|
</script>
|
|
156
162
|
|
|
157
163
|
<template>
|
|
158
|
-
<div class="relative w-full" :style="{ height: `${height}px`, color }">
|
|
164
|
+
<div class="relative w-full" :class="fill ? 'h-full' : ''" :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }">
|
|
159
165
|
<!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
|
|
160
166
|
container, so a flex row of columns is the whole implementation and costs no chart runtime.
|
|
161
167
|
The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
|
|
162
168
|
pixels leaves (w - 2(n - 1)) / n each. A tile 280px wide holds 12 buckets at 21px and 60 at
|
|
163
169
|
2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
|
|
164
170
|
texture, where the measure belongs to a line instead. -->
|
|
165
|
-
<div v-if="mark === 'bar'" class="flex
|
|
171
|
+
<div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'" aria-hidden="true">
|
|
166
172
|
<span
|
|
167
173
|
v-for="(v, i) in data"
|
|
168
174
|
:key="i"
|
|
@@ -180,12 +186,17 @@ const endpointTop = computed(() => {
|
|
|
180
186
|
<!-- No bottom margin: the graphic sits on the tile's own bottom edge, and 2px of clearance
|
|
181
187
|
there reads as a misaligned card rather than as breathing room. The top keeps its 2px so a
|
|
182
188
|
peak is not clipped by the stroke's own width, and the right reserves room for the endpoint
|
|
183
|
-
dot when there is one.
|
|
189
|
+
dot when there is one. Under `fill` the container is laid over the root rather than sizing
|
|
190
|
+
it: the root takes the space it is given, floored at `height`, and Unovis measures that box,
|
|
191
|
+
never its own SVG (which would feed back) and never its 300px fallback for an empty box.
|
|
192
|
+
An inline style, not a utility: Unovis's own stylesheet pins `.unovis-xy-container` to
|
|
193
|
+
`position: relative`, and a class loses to it. -->
|
|
184
194
|
<VisXYContainer
|
|
185
195
|
v-else
|
|
186
196
|
:key="themeVersion"
|
|
197
|
+
:style="fill ? { position: 'absolute', inset: '0' } : undefined"
|
|
187
198
|
:data="points"
|
|
188
|
-
:height="height"
|
|
199
|
+
:height="fill ? undefined : height"
|
|
189
200
|
:margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
|
|
190
201
|
:y-domain="domain ?? undefined"
|
|
191
202
|
:duration="motionDuration('slow')"
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// every card in the system. Same reasoning as FlushCard, which is the other end of this axis: it
|
|
12
12
|
// zeroes the body padding so a table runs to the card's edge; this one shrinks it to a visible gap.
|
|
13
13
|
//
|
|
14
|
-
// ★ THE GAP IS ONE VALUE DOING
|
|
15
|
-
//
|
|
16
|
-
// corner as `outer − pad
|
|
17
|
-
//
|
|
14
|
+
// ★ THE GAP IS ONE VALUE DOING TWO JOBS. Setting --forest-card-pad drives (a) the body padding, so
|
|
15
|
+
// the box is inset by it, and (b) the concentric radius, because forest.css derives a nested card's
|
|
16
|
+
// corner as `outer − pad`. Set it once here and both stay in step; set the padding with a class
|
|
17
|
+
// instead and they silently diverge.
|
|
18
18
|
//
|
|
19
19
|
// ★ THE BODY DROPS ITS PADDING ON THE SIDE THE STRIP IS ON. The box is inset on three sides; on the
|
|
20
20
|
// fourth it meets the strip directly, and the strip owns all the space there. Two consequences, both
|
|
@@ -36,11 +36,10 @@ import { computed, useSlots, watchEffect } from 'vue';
|
|
|
36
36
|
|
|
37
37
|
type Gap = 4 | 8 | 12;
|
|
38
38
|
|
|
39
|
-
// ★ ONE SOURCE OF TRUTH FOR THE GAP. The var is not a stylistic choice —
|
|
40
|
-
// component
|
|
41
|
-
// concentric radius as `calc(--forest-card-radius - --forest-card-pad)
|
|
42
|
-
//
|
|
43
|
-
// gaps 4/8/12; without it they all stay at 8.
|
|
39
|
+
// ★ ONE SOURCE OF TRUTH FOR THE GAP. The var is not a stylistic choice — a rule OUTSIDE this
|
|
40
|
+
// component reads it and no Tailwind class can reach it: forest.css derives the nested card's
|
|
41
|
+
// concentric radius as `calc(--forest-card-radius - --forest-card-pad)`. Measured: with the var set,
|
|
42
|
+
// box radii are 20/16/12 for gaps 4/8/12; without it they all stay at 8.
|
|
44
43
|
// So the padding CLASS reads the same var rather than restating the number — otherwise the value
|
|
45
44
|
// lives in two places and a future edit can move one and not the other. That also makes the body
|
|
46
45
|
// classes gap-independent: only `pad` varies.
|
|
@@ -118,13 +117,20 @@ const props = withDefaults(
|
|
|
118
117
|
* body rather than the card's.
|
|
119
118
|
*/
|
|
120
119
|
flush?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* The card fills the height its row gives it instead of hugging its content, so cards in a row
|
|
122
|
+
* end level. Only for content that can take the extra height: an FKpi whose bleed chart is a
|
|
123
|
+
* `fill` sparkline grows into it. A bar, a table or a line of text cannot, and the spare height
|
|
124
|
+
* lands as a gap under it.
|
|
125
|
+
*/
|
|
126
|
+
stretch?: boolean;
|
|
121
127
|
|
|
122
128
|
/** Footer action label. `to` makes it a link; without one it is a button. */
|
|
123
129
|
action?: string;
|
|
124
130
|
to?: string;
|
|
125
131
|
|
|
126
132
|
}>(),
|
|
127
|
-
{ gap: 8, outlined: true, bare: false, flush: false },
|
|
133
|
+
{ gap: 8, outlined: true, bare: false, flush: false, stretch: false },
|
|
128
134
|
);
|
|
129
135
|
|
|
130
136
|
const slots = useSlots();
|
|
@@ -149,11 +155,14 @@ if (import.meta.env.DEV) {
|
|
|
149
155
|
|
|
150
156
|
|
|
151
157
|
|
|
152
|
-
// Set as a var rather than a class so the radius
|
|
158
|
+
// Set as a var rather than a class so the radius follows it.
|
|
153
159
|
const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
|
|
154
160
|
|
|
161
|
+
// Under `stretch` the body takes whatever height the card is given beyond its strip.
|
|
155
162
|
const ui = computed(() => ({
|
|
156
|
-
body: hasFooter.value ? BODY.footer : hasHeader.value ? BODY.header : BODY.none,
|
|
163
|
+
body: [hasFooter.value ? BODY.footer : hasHeader.value ? BODY.header : BODY.none, props.stretch ? 'flex-1' : '']
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join(' '),
|
|
157
166
|
header: STRIP.header,
|
|
158
167
|
footer: STRIP.footer,
|
|
159
168
|
}));
|
|
@@ -176,13 +185,15 @@ const ui = computed(() => ({
|
|
|
176
185
|
// ★ `h-fit` IS LOAD-BEARING, not a layout preference. A grid or flex row stretches its children by
|
|
177
186
|
// default, and a stretched card grows past its content — the extra height lands BELOW the box, so
|
|
178
187
|
// the even inset turns into a lopsided gap at the bottom and the frame stops reading as a frame.
|
|
179
|
-
// The card must hug its content vertically.
|
|
188
|
+
// The card must hug its content vertically. `stretch` drops it for a row whose cards should end
|
|
189
|
+
// level: the card becomes a flex column and its body takes the spare height, which only reads well
|
|
190
|
+
// when the content can grow into it.
|
|
180
191
|
// ⚠ NOT `self-start`, which was the first cut: `align-self` acts on the CROSS axis, so in a flex
|
|
181
192
|
// COLUMN it stops the card filling the width instead of capping its height — the card shrank to its
|
|
182
193
|
// content in every column layout. `h-fit` caps the height and leaves width alone, which is the axis
|
|
183
194
|
// actually at issue.
|
|
184
195
|
const rootClass = computed(() =>
|
|
185
|
-
['h-fit', props.outlined ? 'ring ring-[var(--forest-card-ring)]' : ''].filter(Boolean).join(' '),
|
|
196
|
+
[props.stretch ? 'flex flex-col' : 'h-fit', props.outlined ? 'ring ring-[var(--forest-card-ring)]' : ''].filter(Boolean).join(' '),
|
|
186
197
|
);
|
|
187
198
|
|
|
188
199
|
// ★ THE BOX IS ALWAYS RAISED — there is no ramp option, on purpose. Letting the nesting ladder fill
|
|
@@ -198,23 +209,19 @@ const rootClass = computed(() =>
|
|
|
198
209
|
// pads at ≥640px. The box keeps its own overflow-hidden either way, which is what clips a flush
|
|
199
210
|
// child to the rounded corner; a child that must scroll (UTable) already scrolls itself.
|
|
200
211
|
const boxUi = computed(() => ({
|
|
201
|
-
root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}`,
|
|
212
|
+
root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}${props.stretch ? ' h-full' : ''}`,
|
|
202
213
|
...(props.flush ? { body: 'p-0 sm:p-0' } : {}),
|
|
203
214
|
}));
|
|
204
215
|
|
|
205
216
|
// ★ FOR `bare`, WHERE THE CHILD IS ITS OWN SURFACE (an FKpi). Exposed as a slot prop so the
|
|
206
217
|
// call site passes it straight to the child's `ui` without needing to know any of this:
|
|
207
|
-
// · the
|
|
208
|
-
// ·
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// this the graphic stops short on both sides.
|
|
213
|
-
// A workaround for FKpi's coupling to --forest-card-pad; the real fix belongs in kpi.theme.ts, at
|
|
214
|
-
// which point the `visual` line here can go.
|
|
218
|
+
// · the card pad is the GAP, restated on the child for the corner — the same reason as `boxUi`
|
|
219
|
+
// · the child's own padding goes through --forest-kpi-pad, because FKpi otherwise pads by the card
|
|
220
|
+
// pad, which is now the gap. The hook sets all four sides, and FKpi's bleed pulls by the same
|
|
221
|
+
// expression, so `visual-fit="bleed"` still zeroes the bottom and meets both edges. A padding
|
|
222
|
+
// class cannot do this: FKpi merges `ui.root` last, so a `pb-4` here would beat the bleed's `pb-0`.
|
|
215
223
|
const selfBoxUi = computed(() => ({
|
|
216
|
-
root: `bg-[var(--forest-card-inset)]
|
|
217
|
-
visual: '-mx-4',
|
|
224
|
+
root: `bg-[var(--forest-card-inset)] [--forest-kpi-pad:1rem] ${PAD_CLASS[props.gap]}${props.stretch ? ' h-full' : ''}`,
|
|
218
225
|
}));
|
|
219
226
|
</script>
|
|
220
227
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// were both taken.
|
|
7
7
|
//
|
|
8
8
|
// The tile is a UCard, so it inherits the card surface and moves when cards move. Inside a group it
|
|
9
|
-
// registers itself, spans
|
|
9
|
+
// registers itself, spans the group's subgrid rows, and may become a tab or a toggle.
|
|
10
10
|
import { computed, inject, onBeforeUnmount, onMounted, useId, useSlots } from 'vue';
|
|
11
11
|
import { kpiTheme, KPI_GROUP_KEY, type KpiSlot } from './kpi.theme';
|
|
12
12
|
import { formatMetric, joinCaption, type KpiFormat } from './kpiFormat';
|
|
@@ -37,6 +37,14 @@ const props = withDefaults(
|
|
|
37
37
|
/** Forces the figure's face. Unset lets the context's --fx-metric traits decide. */
|
|
38
38
|
face?: 'tabular' | 'display';
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Visually hides the label while keeping it the figure's accessible name — for a tile whose
|
|
42
|
+
* title is drawn outside it, such as FInsetCard's header strip. Use this rather than
|
|
43
|
+
* `ui.header: 'sr-only'`, which takes the row out of the grid — in a group, that pulls the
|
|
44
|
+
* figure out of line with its neighbours.
|
|
45
|
+
*/
|
|
46
|
+
hideLabel?: boolean;
|
|
47
|
+
|
|
40
48
|
/**
|
|
41
49
|
* How the `visual` slot meets the card. `bleed` runs the graphic to the side and bottom edges —
|
|
42
50
|
* right for a sparkline, which is data and nothing else. `inset` keeps the card's padding and
|
|
@@ -154,6 +162,7 @@ const styles = computed(() =>
|
|
|
154
162
|
visualFit: slots.visual ? props.visualFit : undefined,
|
|
155
163
|
intent: delta.value?.intent,
|
|
156
164
|
face: props.face,
|
|
165
|
+
hideLabel: props.hideLabel,
|
|
157
166
|
status: props.status,
|
|
158
167
|
emphasis: props.emphasis,
|
|
159
168
|
dimmed: isBlank.value,
|
|
@@ -244,9 +253,9 @@ const onKeydown = (e: KeyboardEvent) => {
|
|
|
244
253
|
<span v-if="caveat" :id="caveatId">{{ captionLead ? ' · ' : '' }}{{ caveat }}</span>
|
|
245
254
|
</p>
|
|
246
255
|
|
|
247
|
-
<div v-if="$slots.visual" :class="cls('visual')"><slot name="visual" /></div>
|
|
256
|
+
<div v-if="$slots.visual" :class="cls('visual')" data-kpi-visual><slot name="visual" /></div>
|
|
248
257
|
<slot name="insight" />
|
|
249
|
-
<div v-if="$slots.links || $slots.footer" :class="cls('footer')">
|
|
258
|
+
<div v-if="$slots.links || $slots.footer" :class="cls('footer')" data-kpi-footer>
|
|
250
259
|
<slot name="links" />
|
|
251
260
|
<slot name="footer" />
|
|
252
261
|
</div>
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// A row of KPI tiles.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Each tile spans the group's rows with grid-template-rows: subgrid, so labels, values, captions and
|
|
5
|
+
// visuals align across the row however long any one label runs. Without it, a two-line label pushes
|
|
6
|
+
// its own value down and nothing lines up.
|
|
7
7
|
//
|
|
8
8
|
// Selection: single mode is a tablist with manual activation, pointing at the chart it controls;
|
|
9
9
|
// multiple mode is a set of aria-pressed toggles, because more than one selected tile is not a tab
|
|
10
10
|
// row. One live region for the whole row — a per-tile region turns a refresh into a chorus.
|
|
11
11
|
import { computed, provide, reactive, ref, watch } from 'vue';
|
|
12
|
-
import { KPI_GROUP_KEY, type KpiGroupContext } from './kpi.theme';
|
|
12
|
+
import { KPI_GROUP_KEY, kpiGroupRows, type KpiGroupContext } from './kpi.theme';
|
|
13
13
|
import type { KpiFormat } from './kpiFormat';
|
|
14
14
|
import {
|
|
15
15
|
createKpiRegistry,
|
|
@@ -124,15 +124,17 @@ const gridStyle = computed(() =>
|
|
|
124
124
|
|
|
125
125
|
<template>
|
|
126
126
|
<div>
|
|
127
|
-
<!--
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
<!-- No row template: the rows are implicit and content-sized. Never 1fr, which resolves to nothing
|
|
128
|
+
in a content-sized group and lets a visual overflow onto the caption above it. Each tile spans
|
|
129
|
+
kpiGroupRows of them, a count that grows only for parts some tile actually has, so a group
|
|
130
|
+
with no visual keeps no empty row and no gap in front of one. -->
|
|
130
131
|
<div
|
|
131
132
|
:role="selectable && !multiple ? 'tablist' : selectable ? 'group' : undefined"
|
|
132
133
|
:aria-label="label"
|
|
133
134
|
:aria-orientation="selectable && !multiple ? orientation : undefined"
|
|
134
|
-
class="grid
|
|
135
|
+
class="grid items-stretch"
|
|
135
136
|
:class="[
|
|
137
|
+
kpiGroupRows,
|
|
136
138
|
divided ? 'gap-px' : 'gap-4',
|
|
137
139
|
// Ring outset, not inset: `divided` rules are this element's background through 1px gaps,
|
|
138
140
|
// so the tiles sit flush to its border box and an inset stroke lands on them.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Presentation for FKpi. The surface comes from UCard — this recipe owns only what sits inside it.
|
|
2
2
|
//
|
|
3
|
-
// The value's type size is a container query on the tile
|
|
4
|
-
// narrow column is narrow, and the number should follow the space it has rather than a breakpoint
|
|
3
|
+
// The value's type size is a container query on the tile's figure row, not a `size` prop. A tile in
|
|
4
|
+
// a narrow column is narrow, and the number should follow the space it has rather than a breakpoint
|
|
5
5
|
// the author guessed at.
|
|
6
6
|
//
|
|
7
|
-
// Row alignment is subgrid: inside a group the tile spans
|
|
8
|
-
//
|
|
7
|
+
// Row alignment is subgrid: inside a group the tile spans the group's rows, so labels, values,
|
|
8
|
+
// captions and visuals line up across the row however long any one label runs.
|
|
9
9
|
import type { InjectionKey } from 'vue';
|
|
10
10
|
import { tv } from 'tailwind-variants';
|
|
11
11
|
import type { KpiFormat } from './kpiFormat';
|
|
@@ -13,21 +13,31 @@ import type { KpiFormat } from './kpiFormat';
|
|
|
13
13
|
export const kpiTheme = tv({
|
|
14
14
|
slots: {
|
|
15
15
|
// The card's own body is display:contents so the four parts can join the group's subgrid, which
|
|
16
|
-
// means the padding has to live here instead
|
|
16
|
+
// means the padding has to live here instead, and it is the only padding on the tile.
|
|
17
17
|
// Padding rides --forest-card-pad, the same var the card's concentric-radius maths reads: the
|
|
18
18
|
// rounder the card, the roomier the inset, and a context that steps one steps both (1rem at
|
|
19
19
|
// rest, 1.25rem in mobile and marketing). A hardcoded p-4 would leave the tile tight inside a
|
|
20
|
-
// 32px-radius card.
|
|
21
|
-
//
|
|
20
|
+
// 32px-radius card. --forest-kpi-pad overrides it where the card pad means something else:
|
|
21
|
+
// FInsetCard pins the card pad to its gap for the corner, and sets the tile's padding here. The
|
|
22
|
+
// bleed pulls by the same expression, so the graphic meets the edge the padding leaves.
|
|
23
|
+
//
|
|
24
|
+
// No row template: the rows are implicit, one per part present, so a tile without a visual has
|
|
25
|
+
// no empty track under its caption, and no gap in front of one. A bleed visual adds a flexible
|
|
26
|
+
// last row (`visualFit`); a grouped tile takes its rows from the group instead (`aligned`).
|
|
22
27
|
//
|
|
23
28
|
// One explicit minmax(0,1fr) column: without it the implicit column sizes to max-content, and a
|
|
24
29
|
// chart with an intrinsic width (Unovis defaults to 300px) makes the tile's own grid wider than
|
|
25
30
|
// the tile, clipping the right end of the graphic.
|
|
26
|
-
root: 'relative grid grid-cols-[minmax(0,1fr)]
|
|
31
|
+
root: 'relative grid grid-cols-[minmax(0,1fr)] gap-1 p-[var(--forest-kpi-pad,var(--forest-card-pad))]',
|
|
27
32
|
header: 'flex min-w-0 items-center gap-1.5',
|
|
28
33
|
label: 'type-overline truncate text-muted',
|
|
29
34
|
hint: 'shrink-0 text-dimmed',
|
|
30
|
-
|
|
35
|
+
// ★ THE SIZE QUERIES' CONTAINER IS THE BODY, NOT THE ROOT. A container query is layout
|
|
36
|
+
// containment, and a layout-contained grid cannot be a subgrid: on the root, a grouped tile's
|
|
37
|
+
// `grid-template-rows: subgrid` computes to `none`, each tile sizes its own rows, and a group
|
|
38
|
+
// lines up only where its tiles happen to match. The body spans the tile's content width and
|
|
39
|
+
// holds every part that queries it.
|
|
40
|
+
body: 'flex flex-wrap items-baseline gap-x-2 gap-y-1 @container/kpi',
|
|
31
41
|
// Face and transform ride the --fx-metric traits, so a context re-points them on the card root
|
|
32
42
|
// and the figure follows — the same channel the card title uses for --fx-title. tabular-nums is
|
|
33
43
|
// unconditional: it does what it can on the sans cut and is inert on Mohr, which has no tnum.
|
|
@@ -71,22 +81,36 @@ export const kpiTheme = tv({
|
|
|
71
81
|
* `bleed` (the default) is for a graphic that IS data and nothing else, a sparkline being the
|
|
72
82
|
* case: it runs through the tile's gutter to both side edges and sits on the bottom edge, which
|
|
73
83
|
* the tile reaches by zeroing its own bottom padding rather than by a negative margin — a
|
|
74
|
-
* margin would shrink the grid row and pull the graphic up over the caption.
|
|
84
|
+
* margin would shrink the grid row and pull the graphic up over the caption. Its row is the
|
|
85
|
+
* tile's last and flexible one, and the graphic spans it bottom-aligned: a tile given more
|
|
86
|
+
* height than it needs — a group's shared row, a stretched FInsetCard — grows a `fill`
|
|
87
|
+
* sparkline into the spare height and leaves any other graphic on the bottom edge.
|
|
75
88
|
*
|
|
76
89
|
* `inset` is for a graphic carrying text, FDistributionBar's legend being the case. Text set
|
|
77
90
|
* against the card's edge reads as a mistake, so the padding stays. It also top-aligns, and
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* the group's positions. Two tiles then distribute their slack differently — a legend that
|
|
81
|
-
* wraps in one and not its neighbour leaves the two bars at different heights — and packing
|
|
82
|
-
* the rows to the top is what makes them agree. Measured: 15.6px apart without it, level with.
|
|
91
|
+
* packs the tile's rows to the top with `content-start`, so a tile stretched taller than its
|
|
92
|
+
* content keeps the extra height below the graphic rather than spread between its rows.
|
|
83
93
|
*/
|
|
84
94
|
visualFit: {
|
|
85
|
-
bleed: { root: 'pb-0', visual: 'self-end -mx-[var(--forest-card-pad)]' },
|
|
95
|
+
bleed: { root: 'pb-0 grid-rows-[auto_auto_auto_1fr]', visual: 'self-stretch flex flex-col justify-end -mx-[var(--forest-kpi-pad,var(--forest-card-pad))]' },
|
|
86
96
|
inset: { root: 'content-start', visual: 'self-start' },
|
|
87
97
|
},
|
|
98
|
+
// A grouped tile spans exactly the rows its group declares (kpiGroupRows), so a group with no
|
|
99
|
+
// visual has no empty last track and no group gap under its captions.
|
|
88
100
|
aligned: {
|
|
89
|
-
true: { root: 'row-span-
|
|
101
|
+
true: { root: 'row-span-(--forest-kpi-rows) grid-rows-subgrid' },
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* The label is visually hidden but stays the figure's accessible name — for a tile whose title
|
|
105
|
+
* is drawn OUTSIDE it, such as FInsetCard's header strip. `sr-only` goes on the LABEL, and the
|
|
106
|
+
* ROW only collapses to zero height: a grouped tile shares its rows with its neighbours, and
|
|
107
|
+
* a row taken out of the flow would pull the figure, caption and visual up a track, out of line
|
|
108
|
+
* with every tile whose label shows. `-mt-1` cancels the gap after the zero-height row, so a
|
|
109
|
+
* standalone figure sits on the tile's top padding; it must equal the root's `gap-1`. A grouped
|
|
110
|
+
* figure keeps its row (compound below).
|
|
111
|
+
*/
|
|
112
|
+
hideLabel: {
|
|
113
|
+
true: { header: 'h-0 overflow-hidden', label: 'sr-only', body: '-mt-1' },
|
|
90
114
|
},
|
|
91
115
|
selectable: {
|
|
92
116
|
true: {
|
|
@@ -148,6 +172,9 @@ export const kpiTheme = tv({
|
|
|
148
172
|
// tile paints amber and the value's own colour carries the rest.
|
|
149
173
|
{ emphasis: 'surface', status: 'warn', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
|
|
150
174
|
{ emphasis: 'surface', status: 'critical', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
|
|
175
|
+
// Inside a group the figure keeps its row: the group lays every tile on shared subgrid rows, so
|
|
176
|
+
// pulling one figure up into the gap would misalign it against a neighbour whose label shows.
|
|
177
|
+
{ hideLabel: true, aligned: true, class: { body: 'mt-0' } },
|
|
151
178
|
],
|
|
152
179
|
defaultVariants: {
|
|
153
180
|
status: 'ok',
|
|
@@ -155,6 +182,15 @@ export const kpiTheme = tv({
|
|
|
155
182
|
},
|
|
156
183
|
});
|
|
157
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The group's row count, which every grouped tile spans: label, figure and caption, one more when any
|
|
187
|
+
* tile has a visual, one more when any has a footer. Declared on the group because the rows are
|
|
188
|
+
* shared, so no tile can count them alone, and counted with `:has()` so it follows the slots that
|
|
189
|
+
* actually render.
|
|
190
|
+
*/
|
|
191
|
+
export const kpiGroupRows =
|
|
192
|
+
'[--forest-kpi-rows:3] has-[[data-kpi-visual]]:[--forest-kpi-rows:4] has-[[data-kpi-footer]]:[--forest-kpi-rows:4] has-[[data-kpi-visual]]:has-[[data-kpi-footer]]:[--forest-kpi-rows:5]';
|
|
193
|
+
|
|
158
194
|
export type KpiSlot = keyof ReturnType<typeof kpiTheme>;
|
|
159
195
|
|
|
160
196
|
export interface KpiGroupContext {
|