@humanforest/nuxt-layer 0.3.2 → 0.3.4

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.2",
4
+ "version": "0.3.4",
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": [
@@ -18,6 +18,7 @@ import { VisXYContainer, VisLine, VisArea } from '@unovis/vue';
18
18
  import { CurveType } from '@unovis/ts';
19
19
  import { motionDuration } from './motionDuration';
20
20
  import { useThemeVersion } from './useThemeVersion';
21
+ import { sparklineDomain } from './sparklineDomain';
21
22
 
22
23
  const props = withDefaults(
23
24
  defineProps<{
@@ -50,6 +51,12 @@ const props = withDefaults(
50
51
  area?: boolean;
51
52
  /** Marks the last point, so the series has an end rather than running off the edge. */
52
53
  endpoint?: boolean;
54
+ /**
55
+ * Takes the height its container gives it, with `height` as the floor. For a graphic in a space
56
+ * its surroundings have made taller (a KPI's chart row that a stretched card or a group has given
57
+ * more height), where the chart is the part that should grow. Works for both marks.
58
+ */
59
+ fill?: boolean;
53
60
  /**
54
61
  * Monotone interpolation, on by default. Monotone rather than a plain spline: it stays inside
55
62
  * the data's own range, so no peak appears between two points that neither of them reached.
@@ -71,7 +78,7 @@ const props = withDefaults(
71
78
  */
72
79
  mark?: 'line' | 'bar';
73
80
  }>(),
74
- { height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
81
+ { height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, fill: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
75
82
  );
76
83
 
77
84
  if (import.meta.env.DEV) {
@@ -94,23 +101,18 @@ type Point = { i: number; v: number | null; c: number | null };
94
101
  const points = computed<Point[]>(() =>
95
102
  props.data.map((v, i) => ({ i, v, c: props.compare?.[i] ?? null })),
96
103
  );
104
+ // Readings are plotted as their height above the frame's floor, which is 0 except under `area`.
97
105
  const x = (d: Point) => d.i;
98
- const y = (d: Point) => d.v;
99
- const yCompare = (d: Point) => d.c;
106
+ const y = (d: Point) => (d.v === null ? null : d.v - frame.value.floor);
107
+ const yCompare = (d: Point) => (d.c === null ? null : d.c - frame.value.floor);
100
108
 
101
109
  const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !== undefined));
102
110
 
103
111
  // The line marks share one scale, framed by the floor and ceiling of everything drawn on it — a
104
- // line reads as a shape, and a shape wants its own range. Set by hand because an Unovis area is
105
- // measured from a baseline: left to itself, `area` pulls the floor to zero and flattens the line it
106
- // is filling under. `null` when there is nothing to draw, so the container keeps its own fallback.
107
- const domain = computed<[number, number] | null>(() => {
108
- const vals = [...props.data, ...(props.compare ?? [])].filter((v): v is number => typeof v === 'number');
109
- return vals.length ? [Math.min(...vals), Math.max(...vals)] : null;
110
- });
111
- // An area is a band from its baseline to baseline + y, not a fill up to y, so reaching the line
112
- // from the floor of the frame means passing the height above that floor.
113
- const yFromFloor = (d: Point) => (d.v === null || !domain.value ? null : d.v - domain.value[0]);
112
+ // line reads as a shape, and a shape wants its own range. The frame, and why `area` shifts it to
113
+ // start at 0, live in sparklineDomain. An area is a band from its baseline to baseline + y, so with
114
+ // the floor at 0 it runs from 0 up to each reading's height above the floor.
115
+ const frame = computed(() => sparklineDomain(props.data, props.compare, props.area));
114
116
 
115
117
  // Bars are the exception: they are measured from zero, not from the data's own floor. A bar reads
116
118
  // as a LENGTH, and a bar that starts anywhere but zero has a length no longer proportional to its
@@ -148,21 +150,21 @@ const last = computed(() => {
148
150
  });
149
151
  // Fraction of the height the final point sits at, for the endpoint dot.
150
152
  const endpointTop = computed(() => {
151
- const [lo, hi] = domain.value ?? [0, 0];
153
+ const [lo, hi] = frame.value.domain ?? [0, 0];
152
154
  if (last.value === null || hi === lo) return 50;
153
- return (1 - (last.value - lo) / (hi - lo)) * 100;
155
+ return (1 - (last.value - frame.value.floor - lo) / (hi - lo)) * 100;
154
156
  });
155
157
  </script>
156
158
 
157
159
  <template>
158
- <div class="relative w-full" :style="{ height: `${height}px`, color }">
160
+ <div class="relative w-full" :class="fill ? 'h-full' : ''" :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }">
159
161
  <!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
160
162
  container, so a flex row of columns is the whole implementation and costs no chart runtime.
161
163
  The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
162
164
  pixels leaves (w - 2(n - 1)) / n each. A tile 280px wide holds 12 buckets at 21px and 60 at
163
165
  2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
164
166
  texture, where the measure belongs to a line instead. -->
165
- <div v-if="mark === 'bar'" class="flex h-full w-full items-end gap-0.5" aria-hidden="true">
167
+ <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
168
  <span
167
169
  v-for="(v, i) in data"
168
170
  :key="i"
@@ -180,14 +182,19 @@ const endpointTop = computed(() => {
180
182
  <!-- No bottom margin: the graphic sits on the tile's own bottom edge, and 2px of clearance
181
183
  there reads as a misaligned card rather than as breathing room. The top keeps its 2px so a
182
184
  peak is not clipped by the stroke's own width, and the right reserves room for the endpoint
183
- dot when there is one. -->
185
+ dot when there is one. Under `fill` the container is laid over the root rather than sizing
186
+ it: the root takes the space it is given, floored at `height`, and Unovis measures that box,
187
+ never its own SVG (which would feed back) and never its 300px fallback for an empty box.
188
+ An inline style, not a utility: Unovis's own stylesheet pins `.unovis-xy-container` to
189
+ `position: relative`, and a class loses to it. -->
184
190
  <VisXYContainer
185
191
  v-else
186
192
  :key="themeVersion"
193
+ :style="fill ? { position: 'absolute', inset: '0' } : undefined"
187
194
  :data="points"
188
- :height="height"
195
+ :height="fill ? undefined : height"
189
196
  :margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
190
- :y-domain="domain ?? undefined"
197
+ :y-domain="frame.domain ?? undefined"
191
198
  :duration="motionDuration('slow')"
192
199
  >
193
200
  <!-- Comparison first, so the current series draws over it. Dashed and thinner: the dash says
@@ -203,7 +210,7 @@ const endpointTop = computed(() => {
203
210
  :opacity="0.45"
204
211
  :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear"
205
212
  />
206
- <VisArea v-if="area" :x="x" :y="yFromFloor" :baseline="domain?.[0] ?? 0" :color="color" :opacity="0.12" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
213
+ <VisArea v-if="area" :x="x" :y="y" :baseline="0" :color="color" :opacity="0.12" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
207
214
  <VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
208
215
  </VisXYContainer>
209
216
  <span
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The frame a sparkline's line marks are plotted in: the floor and ceiling of everything drawn,
3
+ * shared by the series and its comparison, or null when there is nothing to draw so the container
4
+ * keeps its own fallback. Set by hand because an Unovis area is measured from a baseline: left to
5
+ * itself, `area` pulls the floor to zero and flattens the line it is filling under.
6
+ *
7
+ * Under `area` the frame starts at 0 and every reading is plotted as its height above the floor.
8
+ * Unovis insets a line's scale by half its stroke unless the domain starts at 0, and the area shares
9
+ * that scale, so from a non-zero floor the fill stopped 0.75px above the bottom edge: a hairline of
10
+ * card under the chart. From 0 the fill meets the edge, and the line's lowest point gives the lower
11
+ * half of its stroke to that edge, where the graphic sits anyway. A plain line keeps its range and
12
+ * its whole stroke.
13
+ */
14
+ export function sparklineDomain(
15
+ data: (number | null)[],
16
+ compare: (number | null)[] | undefined,
17
+ area: boolean,
18
+ ): { floor: number; domain: [number, number] | null } {
19
+ const vals = [...data, ...(compare ?? [])].filter((v): v is number => typeof v === 'number');
20
+ if (!vals.length) return { floor: 0, domain: null };
21
+ const lo = Math.min(...vals);
22
+ const hi = Math.max(...vals);
23
+ const floor = area ? lo : 0;
24
+ return { floor, domain: [lo - floor, hi - floor] };
25
+ }
@@ -117,13 +117,20 @@ const props = withDefaults(
117
117
  * body rather than the card's.
118
118
  */
119
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 chart is a
123
+ * `fill` sparkline grows into it. A fixed-height chart, a table or a line of text cannot, and the spare height
124
+ * lands as a gap under it.
125
+ */
126
+ stretch?: boolean;
120
127
 
121
128
  /** Footer action label. `to` makes it a link; without one it is a button. */
122
129
  action?: string;
123
130
  to?: string;
124
131
 
125
132
  }>(),
126
- { gap: 8, outlined: true, bare: false, flush: false },
133
+ { gap: 8, outlined: true, bare: false, flush: false, stretch: false },
127
134
  );
128
135
 
129
136
  const slots = useSlots();
@@ -151,8 +158,11 @@ if (import.meta.env.DEV) {
151
158
  // Set as a var rather than a class so the radius follows it.
152
159
  const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
153
160
 
161
+ // Under `stretch` the body takes whatever height the card is given beyond its strip.
154
162
  const ui = computed(() => ({
155
- 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(' '),
156
166
  header: STRIP.header,
157
167
  footer: STRIP.footer,
158
168
  }));
@@ -175,13 +185,15 @@ const ui = computed(() => ({
175
185
  // ★ `h-fit` IS LOAD-BEARING, not a layout preference. A grid or flex row stretches its children by
176
186
  // default, and a stretched card grows past its content — the extra height lands BELOW the box, so
177
187
  // the even inset turns into a lopsided gap at the bottom and the frame stops reading as a frame.
178
- // 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.
179
191
  // ⚠ NOT `self-start`, which was the first cut: `align-self` acts on the CROSS axis, so in a flex
180
192
  // COLUMN it stops the card filling the width instead of capping its height — the card shrank to its
181
193
  // content in every column layout. `h-fit` caps the height and leaves width alone, which is the axis
182
194
  // actually at issue.
183
195
  const rootClass = computed(() =>
184
- ['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(' '),
185
197
  );
186
198
 
187
199
  // ★ THE BOX IS ALWAYS RAISED — there is no ramp option, on purpose. Letting the nesting ladder fill
@@ -197,7 +209,7 @@ const rootClass = computed(() =>
197
209
  // pads at ≥640px. The box keeps its own overflow-hidden either way, which is what clips a flush
198
210
  // child to the rounded corner; a child that must scroll (UTable) already scrolls itself.
199
211
  const boxUi = computed(() => ({
200
- 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' : ''}`,
201
213
  ...(props.flush ? { body: 'p-0 sm:p-0' } : {}),
202
214
  }));
203
215
 
@@ -209,7 +221,7 @@ const boxUi = computed(() => ({
209
221
  // expression, so `visual-fit="bleed"` still zeroes the bottom and meets both edges. A padding
210
222
  // class cannot do this: FKpi merges `ui.root` last, so a `pb-4` here would beat the bleed's `pb-0`.
211
223
  const selfBoxUi = computed(() => ({
212
- root: `bg-[var(--forest-card-inset)] [--forest-kpi-pad:1rem] ${PAD_CLASS[props.gap]}`,
224
+ root: `bg-[var(--forest-card-inset)] [--forest-kpi-pad:1rem] ${PAD_CLASS[props.gap]}${props.stretch ? ' h-full' : ''}`,
213
225
  }));
214
226
  </script>
215
227
 
@@ -22,8 +22,8 @@ export const kpiTheme = tv({
22
22
  // bleed pulls by the same expression, so the graphic meets the edge the padding leaves.
23
23
  //
24
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`).
25
+ // no empty track under its caption, and no gap in front of one. A visual adds a flexible last
26
+ // row (`visualFit`); a grouped tile takes its rows from the group instead (`aligned`).
27
27
  //
28
28
  // One explicit minmax(0,1fr) column: without it the implicit column sizes to max-content, and a
29
29
  // chart with an intrinsic width (Unovis defaults to 300px) makes the tile's own grid wider than
@@ -78,19 +78,22 @@ export const kpiTheme = tv({
78
78
  * carry only the first — which left every other visual undoing it with utilities at the call
79
79
  * site.
80
80
  *
81
- * `bleed` (the default) is for a graphic that IS data and nothing else, a sparkline being the
82
- * case: it runs through the tile's gutter to both side edges and sits on the bottom edge, which
81
+ * `bleed` (the default) is for a graphic that IS data and nothing else, a line or area sparkline
82
+ * being the case: it runs through the tile's gutter to both side edges and sits on the bottom edge, which
83
83
  * the tile reaches by zeroing its own bottom padding rather than by a negative margin — a
84
- * 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.
85
88
  *
86
- * `inset` is for a graphic carrying text, FDistributionBar's legend being the case. Text set
87
- * against the card's edge reads as a mistake, so the padding stays. It also top-aligns, and
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.
89
+ * `inset` is for bars, and for a graphic carrying text, FDistributionBar's legend being the
90
+ * case. Text set against the card's edge reads as a mistake, so the padding stays. It shares
91
+ * bleed's last, flexible row, with the graphic at the top of it: spare height lands below a
92
+ * graphic that cannot grow, and a `fill` sparkline grows into it.
90
93
  */
91
94
  visualFit: {
92
- bleed: { root: 'pb-0', visual: 'self-end -mx-[var(--forest-kpi-pad,var(--forest-card-pad))]' },
93
- inset: { root: 'content-start', visual: 'self-start' },
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))]' },
96
+ inset: { root: 'grid-rows-[auto_auto_auto_1fr]', visual: 'self-stretch flex flex-col justify-start' },
94
97
  },
95
98
  // A grouped tile spans exactly the rows its group declares (kpiGroupRows), so a group with no
96
99
  // visual has no empty last track and no group gap under its captions.