@humanforest/nuxt-layer 0.3.1 → 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.1",
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 THREE JOBS. Setting --forest-card-pad drives (a) the body padding,
15
- // so the box is inset by it, (b) the concentric radius, because forest.css derives a nested card's
16
- // corner as `outer − pad`, and (c) FKpi's bleed margin, which pulls by the same var. Set it once
17
- // here and all three stay in step; set the padding with a class instead and they silently diverge.
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 — two rules OUTSIDE this
40
- // component read it and no Tailwind class can reach either: forest.css derives the nested card's
41
- // concentric radius as `calc(--forest-card-radius - --forest-card-pad)`, and kpi.theme.ts pulls the
42
- // bleed by `-mx-[var(--forest-card-pad)]`. Measured: with the var set, box radii are 20/16/12 for
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.
@@ -149,7 +148,7 @@ if (import.meta.env.DEV) {
149
148
 
150
149
 
151
150
 
152
- // Set as a var rather than a class so the radius and FKpi's bleed follow it.
151
+ // Set as a var rather than a class so the radius follows it.
153
152
  const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
154
153
 
155
154
  const ui = computed(() => ({
@@ -204,17 +203,13 @@ const boxUi = computed(() => ({
204
203
 
205
204
  // ★ FOR `bare`, WHERE THE CHILD IS ITS OWN SURFACE (an FKpi). Exposed as a slot prop so the
206
205
  // call site passes it straight to the child's `ui` without needing to know any of this:
207
- // · the child pads its ROOT (FKpi's root IS the padded grid), unlike UCard which pads its body
208
- // · pb is left OFF so FKpi's `visual-fit="bleed"` keeps its own `pb-0` and the graphic reaches the
209
- // bottom edge a blanket `p-4` here silently re-adds it
210
- // · the bleed margin is restated to match the reading padding actually in force. FKpi pulls by
211
- // `-mx-[var(--forest-card-pad)]`, which is the GAP not the 16px this box pads by so without
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.
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`.
215
211
  const selfBoxUi = computed(() => ({
216
- root: `bg-[var(--forest-card-inset)] px-4 pt-4 ${PAD_CLASS[props.gap]}`,
217
- visual: '-mx-4',
212
+ root: `bg-[var(--forest-card-inset)] [--forest-kpi-pad:1rem] ${PAD_CLASS[props.gap]}`,
218
213
  }));
219
214
  </script>
220
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 four subgrid rows, and may become a tab or a toggle.
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
- // The row is a four-row grid and each tile spans all four with grid-template-rows: subgrid, so
5
- // labels, values, captions and visuals align across the row however long any one label runs.
6
- // Without it, a two-line label pushes its own value down and nothing lines up.
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
- <!-- Four content-sized rows. Not 1fr on the last one: the group is sized by its content, so 1fr
128
- resolves to whatever is left over nothing and a visual placed in that row overflows onto
129
- the caption above it. -->
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 grid-rows-[auto_auto_auto_auto] items-stretch"
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 itself, not a `size` prop. A tile in a
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 four rows of the group's grid, so labels,
8
- // values, captions and visuals line up across the row however long any one label runs.
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. p-4 is the card's own uniform figure.
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. The card's own body padding is display:contents here, so this is the only
21
- // padding on the tile.
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)] grid-rows-[auto_auto_auto_auto] gap-1 p-[var(--forest-card-pad)] @container/kpi',
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
- body: 'flex flex-wrap items-baseline gap-x-2 gap-y-1',
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
- * pins the tile's rows to the top with `content-start`: the tile is a subgrid item WITH
79
- * padding, so its four tracks are re-sized inside its own content box rather than inheriting
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-4 grid-rows-subgrid' },
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 {