@humanforest/nuxt-layer 0.3.0 → 0.3.2
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.2",
|
|
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": [
|
|
@@ -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.
|
|
@@ -110,12 +109,21 @@ const props = withDefaults(
|
|
|
110
109
|
icon?: string;
|
|
111
110
|
title?: string;
|
|
112
111
|
meta?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The box draws no padding of its own, so the child can run to its edge and supply whatever
|
|
114
|
+
* gutters it wants. For content that already pads itself — a UTable pads its own cells, a map
|
|
115
|
+
* or a chart wants every pixel — where the box's 16px would sit outside the child's and read as
|
|
116
|
+
* a doubled margin. This is FlushCard's argument applied one level in: same lever, the box's
|
|
117
|
+
* body rather than the card's.
|
|
118
|
+
*/
|
|
119
|
+
flush?: boolean;
|
|
120
|
+
|
|
113
121
|
/** Footer action label. `to` makes it a link; without one it is a button. */
|
|
114
122
|
action?: string;
|
|
115
123
|
to?: string;
|
|
116
124
|
|
|
117
125
|
}>(),
|
|
118
|
-
{ gap: 8, outlined: true, bare: false },
|
|
126
|
+
{ gap: 8, outlined: true, bare: false, flush: false },
|
|
119
127
|
);
|
|
120
128
|
|
|
121
129
|
const slots = useSlots();
|
|
@@ -131,12 +139,16 @@ if (import.meta.env.DEV) {
|
|
|
131
139
|
watchEffect(() => {
|
|
132
140
|
if (hasHeader.value && hasFooter.value)
|
|
133
141
|
console.warn('[FInsetCard] `header` and `footer` together — this pattern takes one strip, not both.');
|
|
142
|
+
// `flush` un-pads the box; `bare` means there is no box to un-pad. Silent inertness is the kind
|
|
143
|
+
// of thing someone debugs for ten minutes, so say it.
|
|
144
|
+
if (props.bare && props.flush)
|
|
145
|
+
console.warn('[FInsetCard] `flush` does nothing under `bare` — bare draws no box, so there is no padding to remove.');
|
|
134
146
|
});
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
|
|
138
150
|
|
|
139
|
-
// Set as a var rather than a class so the radius
|
|
151
|
+
// Set as a var rather than a class so the radius follows it.
|
|
140
152
|
const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
|
|
141
153
|
|
|
142
154
|
const ui = computed(() => ({
|
|
@@ -181,21 +193,23 @@ const rootClass = computed(() =>
|
|
|
181
193
|
//
|
|
182
194
|
// The box takes no padding of its own: UCard already pads its BODY slot, and adding p-* to the root
|
|
183
195
|
// stacks on top of it for double the intended space.
|
|
184
|
-
|
|
196
|
+
// Both breakpoints on the flush body: the card fragment re-pins `sm:p-4`, so a bare `p-0` still
|
|
197
|
+
// pads at ≥640px. The box keeps its own overflow-hidden either way, which is what clips a flush
|
|
198
|
+
// child to the rounded corner; a child that must scroll (UTable) already scrolls itself.
|
|
199
|
+
const boxUi = computed(() => ({
|
|
200
|
+
root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}`,
|
|
201
|
+
...(props.flush ? { body: 'p-0 sm:p-0' } : {}),
|
|
202
|
+
}));
|
|
185
203
|
|
|
186
204
|
// ★ FOR `bare`, WHERE THE CHILD IS ITS OWN SURFACE (an FKpi). Exposed as a slot prop so the
|
|
187
205
|
// call site passes it straight to the child's `ui` without needing to know any of this:
|
|
188
|
-
// · the
|
|
189
|
-
// ·
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// this the graphic stops short on both sides.
|
|
194
|
-
// A workaround for FKpi's coupling to --forest-card-pad; the real fix belongs in kpi.theme.ts, at
|
|
195
|
-
// which point the `visual` line here can go.
|
|
206
|
+
// · the card pad is the GAP, restated on the child for the corner — the same reason as `boxUi`
|
|
207
|
+
// · the child's own padding goes through --forest-kpi-pad, because FKpi otherwise pads by the card
|
|
208
|
+
// pad, which is now the gap. The hook sets all four sides, and FKpi's bleed pulls by the same
|
|
209
|
+
// expression, so `visual-fit="bleed"` still zeroes the bottom and meets both edges. A padding
|
|
210
|
+
// class cannot do this: FKpi merges `ui.root` last, so a `pb-4` here would beat the bleed's `pb-0`.
|
|
196
211
|
const selfBoxUi = computed(() => ({
|
|
197
|
-
root: `bg-[var(--forest-card-inset)]
|
|
198
|
-
visual: '-mx-4',
|
|
212
|
+
root: `bg-[var(--forest-card-inset)] [--forest-kpi-pad:1rem] ${PAD_CLASS[props.gap]}`,
|
|
199
213
|
}));
|
|
200
214
|
</script>
|
|
201
215
|
|
|
@@ -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 grouped tile takes its rows
|
|
26
|
+
// 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.
|
|
@@ -75,18 +85,29 @@ export const kpiTheme = tv({
|
|
|
75
85
|
*
|
|
76
86
|
* `inset` is for a graphic carrying text, FDistributionBar's legend being the case. Text set
|
|
77
87
|
* 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.
|
|
88
|
+
* packs the tile's rows to the top with `content-start`, so a tile stretched taller than its
|
|
89
|
+
* content keeps the extra height below the graphic rather than spread between its rows.
|
|
83
90
|
*/
|
|
84
91
|
visualFit: {
|
|
85
|
-
bleed: { root: 'pb-0', visual: 'self-end -mx-[var(--forest-card-pad)]' },
|
|
92
|
+
bleed: { root: 'pb-0', visual: 'self-end -mx-[var(--forest-kpi-pad,var(--forest-card-pad))]' },
|
|
86
93
|
inset: { root: 'content-start', visual: 'self-start' },
|
|
87
94
|
},
|
|
95
|
+
// A grouped tile spans exactly the rows its group declares (kpiGroupRows), so a group with no
|
|
96
|
+
// visual has no empty last track and no group gap under its captions.
|
|
88
97
|
aligned: {
|
|
89
|
-
true: { root: 'row-span-
|
|
98
|
+
true: { root: 'row-span-(--forest-kpi-rows) grid-rows-subgrid' },
|
|
99
|
+
},
|
|
100
|
+
/**
|
|
101
|
+
* The label is visually hidden but stays the figure's accessible name — for a tile whose title
|
|
102
|
+
* is drawn OUTSIDE it, such as FInsetCard's header strip. `sr-only` goes on the LABEL, and the
|
|
103
|
+
* ROW only collapses to zero height: a grouped tile shares its rows with its neighbours, and
|
|
104
|
+
* a row taken out of the flow would pull the figure, caption and visual up a track, out of line
|
|
105
|
+
* with every tile whose label shows. `-mt-1` cancels the gap after the zero-height row, so a
|
|
106
|
+
* standalone figure sits on the tile's top padding; it must equal the root's `gap-1`. A grouped
|
|
107
|
+
* figure keeps its row (compound below).
|
|
108
|
+
*/
|
|
109
|
+
hideLabel: {
|
|
110
|
+
true: { header: 'h-0 overflow-hidden', label: 'sr-only', body: '-mt-1' },
|
|
90
111
|
},
|
|
91
112
|
selectable: {
|
|
92
113
|
true: {
|
|
@@ -148,6 +169,9 @@ export const kpiTheme = tv({
|
|
|
148
169
|
// tile paints amber and the value's own colour carries the rest.
|
|
149
170
|
{ emphasis: 'surface', status: 'warn', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
|
|
150
171
|
{ emphasis: 'surface', status: 'critical', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
|
|
172
|
+
// Inside a group the figure keeps its row: the group lays every tile on shared subgrid rows, so
|
|
173
|
+
// pulling one figure up into the gap would misalign it against a neighbour whose label shows.
|
|
174
|
+
{ hideLabel: true, aligned: true, class: { body: 'mt-0' } },
|
|
151
175
|
],
|
|
152
176
|
defaultVariants: {
|
|
153
177
|
status: 'ok',
|
|
@@ -155,6 +179,15 @@ export const kpiTheme = tv({
|
|
|
155
179
|
},
|
|
156
180
|
});
|
|
157
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The group's row count, which every grouped tile spans: label, figure and caption, one more when any
|
|
184
|
+
* tile has a visual, one more when any has a footer. Declared on the group because the rows are
|
|
185
|
+
* shared, so no tile can count them alone, and counted with `:has()` so it follows the slots that
|
|
186
|
+
* actually render.
|
|
187
|
+
*/
|
|
188
|
+
export const kpiGroupRows =
|
|
189
|
+
'[--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]';
|
|
190
|
+
|
|
158
191
|
export type KpiSlot = keyof ReturnType<typeof kpiTheme>;
|
|
159
192
|
|
|
160
193
|
export interface KpiGroupContext {
|