basalt-ui 2.1.0 → 2.3.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/MIGRATING.md +46 -12
  3. package/configs/oxlint.json +1 -0
  4. package/dist/charts/BandStrip.d.ts +7 -7
  5. package/dist/charts/BandStrip.d.ts.map +1 -1
  6. package/dist/charts/Bars.d.ts +4 -4
  7. package/dist/charts/Bars.d.ts.map +1 -1
  8. package/dist/charts/ChartCard.d.ts +10 -10
  9. package/dist/charts/ChartCard.d.ts.map +1 -1
  10. package/dist/charts/ChartCard.js +2 -1
  11. package/dist/charts/Donut.d.ts +5 -5
  12. package/dist/charts/Donut.d.ts.map +1 -1
  13. package/dist/charts/DualPanel.d.ts +9 -9
  14. package/dist/charts/DualPanel.d.ts.map +1 -1
  15. package/dist/charts/Heatmap.d.ts +18 -16
  16. package/dist/charts/Heatmap.d.ts.map +1 -1
  17. package/dist/charts/Heatmap.js +8 -2
  18. package/dist/charts/MirroredBars.d.ts +9 -9
  19. package/dist/charts/MirroredBars.d.ts.map +1 -1
  20. package/dist/charts/Sparkline.d.ts +9 -5
  21. package/dist/charts/Sparkline.d.ts.map +1 -1
  22. package/dist/charts/Sparkline.js +18 -12
  23. package/dist/charts/StackedArea.d.ts +2 -2
  24. package/dist/charts/StackedArea.d.ts.map +1 -1
  25. package/dist/charts/compile.d.ts +17 -1
  26. package/dist/charts/compile.d.ts.map +1 -1
  27. package/dist/charts/compile.js +56 -2
  28. package/dist/charts/index.d.ts +1 -0
  29. package/dist/charts/index.d.ts.map +1 -1
  30. package/dist/charts/index.js +3 -1
  31. package/dist/charts/state.d.ts +3 -0
  32. package/dist/charts/state.d.ts.map +1 -1
  33. package/dist/charts/state.js +14 -3
  34. package/dist/charts/types.d.ts +64 -61
  35. package/dist/charts/types.d.ts.map +1 -1
  36. package/dist/cli/doctor.js +2 -2
  37. package/dist/dashboard/index.d.ts +1 -1
  38. package/dist/dashboard/index.d.ts.map +1 -1
  39. package/dist/dashboard/index.js +3 -1
  40. package/dist/dashboard/stat-card.d.ts +16 -1
  41. package/dist/dashboard/stat-card.d.ts.map +1 -1
  42. package/dist/dashboard/stat-card.js +14 -1
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/llms.txt +2 -2
  46. package/package.json +1 -1
  47. package/src/charts/BandStrip.tsx +12 -10
  48. package/src/charts/Bars.tsx +4 -4
  49. package/src/charts/ChartCard.tsx +17 -11
  50. package/src/charts/Donut.tsx +5 -5
  51. package/src/charts/DualPanel.tsx +13 -6
  52. package/src/charts/Heatmap.tsx +23 -15
  53. package/src/charts/MirroredBars.tsx +8 -8
  54. package/src/charts/Sparkline.tsx +14 -6
  55. package/src/charts/StackedArea.tsx +2 -2
  56. package/src/charts/compile.ts +92 -1
  57. package/src/charts/index.ts +1 -0
  58. package/src/charts/state.tsx +15 -3
  59. package/src/charts/types.ts +70 -57
  60. package/src/cli/doctor.ts +2 -2
  61. package/src/dashboard/index.ts +6 -1
  62. package/src/dashboard/stat-card.tsx +34 -3
  63. package/src/index.ts +1 -0
@@ -45,6 +45,9 @@ export function ChartPlaceholder(props: {
45
45
  state: ChartState | undefined
46
46
  height?: ChartBoxHeight
47
47
  action?: ReactNode
48
+ /** For a box too small to hold a sentence (a sparkline): error and empty show a `—` and carry the
49
+ * label as the accessible name and the hover title. */
50
+ compact?: boolean
48
51
  }): ReactElement | null {
49
52
  const kind = resolveChartState(props.state)
50
53
  if (kind === null) return null
@@ -60,20 +63,29 @@ export function ChartPlaceholder(props: {
60
63
  kind === 'error'
61
64
  ? toErrorMessage(props.state?.error, 'The chart failed to load.')
62
65
  : emptyLabel(props.state)
63
- const body = (
66
+ const body = props.compact ? (
67
+ <span aria-hidden="true">—</span>
68
+ ) : (
64
69
  <>
65
70
  <span>{label}</span>
66
71
  {props.action}
67
72
  </>
68
73
  )
74
+ const name = props.compact ? { 'aria-label': label, title: label } : {}
69
75
  if (kind === 'error')
70
76
  return fit(
71
- <div role="alert" data-basalt-chart-state={kind} className={stateClass} style={style}>
77
+ <div
78
+ role="alert"
79
+ data-basalt-chart-state={kind}
80
+ className={stateClass}
81
+ style={style}
82
+ {...name}
83
+ >
72
84
  {body}
73
85
  </div>,
74
86
  )
75
87
  return fit(
76
- <output data-basalt-chart-state={kind} className={stateClass} style={style}>
88
+ <output data-basalt-chart-state={kind} className={stateClass} style={style} {...name}>
77
89
  {body}
78
90
  </output>,
79
91
  )
@@ -24,35 +24,44 @@ export type ChartSeries<T> = {
24
24
  * series whose `getValue` is always `null` is a legend-only entry (a zone or reference key). */
25
25
  getValue: (d: T) => number | null
26
26
  /** The legend swatch shape. Default: the kind's own mark (`line` in `Lines`, `bar` in `Bars`). */
27
- mark?: 'line' | 'bar' | 'area'
27
+ mark?: 'line' | 'bar' | 'area' | undefined
28
28
  /** Default `'solid'`; `'dashed'` is `VX.dashArray` on the line, the legend and the tooltip. */
29
- dash?: 'solid' | 'dashed'
29
+ dash?: 'solid' | 'dashed' | undefined
30
30
  /** Interpolation between points. Default `'monotone'`. A piecewise-constant quantity (a price
31
31
  * tier, a config value) is a step: a smoothed curve draws values that never existed. */
32
- curve?: CurveName
33
- strokeWidth?: number
32
+ curve?: CurveName | undefined
33
+ strokeWidth?: number | undefined
34
34
  /** Dims the plotted stroke (a faint companion line). Strokes only. */
35
- strokeOpacity?: number
35
+ strokeOpacity?: number | undefined
36
36
  /** Bars and areas. */
37
- fillOpacity?: number
38
- /** `Lines`: a soft gradient fill under the line — `true` in the series color, or a color ref. */
39
- area?: boolean | string
37
+ fillOpacity?: number | undefined
38
+ /** `Lines`: a soft gradient fill under the line — `true` in the series color, a color ref, or
39
+ * `'zones'`: a vertical gradient coloured by the chart's `zones` on this series' axis, hard stops
40
+ * at the zone boundaries (no zone in reach: the series color). Linear axes only. */
41
+ area?: boolean | 'zones' | (string & {}) | undefined
40
42
  /** Which y axis the series is measured against. `'right'` needs the chart's `y2`. */
41
- axis?: 'left' | 'right'
43
+ axis?: 'left' | 'right' | undefined
42
44
  /** Legend grouping: series → overlay → reference. Default `'series'`. */
43
- role?: 'series' | 'overlay' | 'reference'
45
+ role?: 'series' | 'overlay' | 'reference' | undefined
44
46
  /** `false` keeps it out of the legend (a companion line folded under `parent`). Default `true`. */
45
- legend?: boolean
47
+ legend?: boolean | undefined
46
48
  /** `false` draws and legends it but never lists it as a tooltip row. Default `true`. */
47
- tooltip?: boolean
49
+ tooltip?: boolean | undefined
48
50
  /** A companion (an MA line) names its parent, so a legend toggle and hover keep the pair. */
49
- parent?: string
51
+ parent?: string | undefined
50
52
  /** A short qualifier after the label in the legend and the tooltip row, in muted text. */
51
- note?: string
53
+ note?: string | undefined
52
54
  /** Per-series tooltip value format; receives the datum so a row can read more than the value. */
53
- formatValue?: (v: number, d: T) => string
55
+ formatValue?: ((v: number, d: T) => string) | undefined
54
56
  /** A marker at a point (a PR star, a status dot); `null` for none. */
55
- getMarker?: (d: T) => { color?: string; r?: number; fillOpacity?: number; ring?: boolean } | null
57
+ getMarker?:
58
+ | ((d: T) => {
59
+ color?: string | undefined
60
+ r?: number | undefined
61
+ fillOpacity?: number | undefined
62
+ ring?: boolean | undefined
63
+ } | null)
64
+ | undefined
56
65
  }
57
66
 
58
67
  /** One y axis. */
@@ -66,23 +75,24 @@ export type AxisConfig<T> = {
66
75
  | readonly [number, number]
67
76
  | 'auto'
68
77
  | ((data: readonly T[], visible: readonly ChartSeries<T>[]) => readonly [number, number])
78
+ | undefined
69
79
  /** When `'auto'`: the raw upper bound is at least this; padding applies after. */
70
- autoMaxFloor?: number
80
+ autoMaxFloor?: number | undefined
71
81
  /** When `'auto'`: the lower bound is at most this. Default `0` (a zero baseline); `Infinity` pads
72
82
  * down from the data minimum instead. Ignored on a log axis. */
73
- autoMinCeil?: number
83
+ autoMinCeil?: number | undefined
74
84
  /** When `'auto'`: padding multiplier away from the data. Default `1.1`. */
75
- autoPad?: number
85
+ autoPad?: number | undefined
76
86
  /** A tick count, or the exact tick values. Default: one per ~48px of plot height, ≤ 5. */
77
- ticks?: number | readonly number[]
87
+ ticks?: number | readonly number[] | undefined
78
88
  /** Tick and tooltip formatter. Default: compact numbers (`12.5k`). */
79
- format?: (v: number) => string
89
+ format?: ((v: number) => string) | undefined
80
90
  /** Horizontal grid rules. Default: on for `y`, off for `y2`. */
81
- grid?: boolean
91
+ grid?: boolean | undefined
82
92
  /** Round the domain outward to nice ticks. Default `false` (a fixed domain stays fixed). */
83
- nice?: boolean
93
+ nice?: boolean | undefined
84
94
  /** Default `'linear'`. A log axis floors at the smallest positive visible value. */
85
- scale?: 'linear' | 'log'
95
+ scale?: 'linear' | 'log' | undefined
86
96
  }
87
97
 
88
98
  /** A horizontal band on one y axis. `±Infinity` reaches the plot edge. */
@@ -90,7 +100,7 @@ export type ZoneSpec = {
90
100
  from: number
91
101
  to: number
92
102
  fill: string
93
- axisSide?: 'left' | 'right'
103
+ axisSide?: 'left' | 'right' | undefined
94
104
  }
95
105
 
96
106
  /**
@@ -99,18 +109,18 @@ export type ZoneSpec = {
99
109
  * from one hour to another, both hours included).
100
110
  */
101
111
  export type XZoneSpec<X extends ChartX = ChartX> = {
102
- from?: X
103
- to?: X
112
+ from?: X | undefined
113
+ to?: X | undefined
104
114
  fill: string
105
- align?: 'center' | 'edge'
115
+ align?: 'center' | 'edge' | undefined
106
116
  }
107
117
 
108
118
  /** A horizontal reference line — annotation only, no domain role beyond staying inside the plot. */
109
119
  export type RefLine = {
110
120
  value: number
111
121
  color: string
112
- dashed?: boolean
113
- axis?: 'left' | 'right'
122
+ dashed?: boolean | undefined
123
+ axis?: 'left' | 'right' | undefined
114
124
  }
115
125
 
116
126
  /** A fill between the line and a value, on one side of it, tracking the line. */
@@ -134,43 +144,46 @@ export type ChartTooltipRow = {
134
144
  export type ChartTooltipConfig<T, X extends ChartX = ChartX> = {
135
145
  /** The tooltip title. Default: the x value through `formatX`'s long form (a date reads
136
146
  * `Mon Sep 1 2026`). */
137
- formatHeader?: (x: X, d: T) => string
147
+ formatHeader?: ((x: X, d: T) => string) | undefined
138
148
  /** Rows after the per-series rows (a total, a zone badge, a context line). `visible`/`hidden` are
139
149
  * the legend's split, so a row never names a series the plot no longer draws. */
140
- rows?: (
141
- d: T,
142
- ctx: { visible: readonly ChartSeries<T>[]; hidden: readonly ChartSeries<T>[] },
143
- ) => readonly ChartTooltipRow[]
150
+ rows?:
151
+ | ((
152
+ d: T,
153
+ ctx: { visible: readonly ChartSeries<T>[]; hidden: readonly ChartSeries<T>[] },
154
+ ) => readonly ChartTooltipRow[])
155
+ | undefined
144
156
  /** Show this chart's tooltip while it FOLLOWS a sibling's cursor. Default `false`: only the chart
145
157
  * under the pointer or keyboard focus shows a tooltip and a live region. */
146
- onFollow?: boolean
158
+ onFollow?: boolean | undefined
147
159
  }
148
160
 
149
161
  export type ChartLegendConfig = {
150
162
  /** Default `'bottom'`. `'header'` draws the legend at the top of the chart body, spaced as the
151
163
  * enclosing `ChartCard`'s header legend row (in the chart's own markup, so it is in the server
152
164
  * HTML and the header never grows); outside a card it is `'top'`. */
153
- placement?: 'top' | 'bottom' | 'header'
165
+ placement?: 'top' | 'bottom' | 'header' | undefined
154
166
  /** Visually separate roles: series | overlay | reference. */
155
- groups?: boolean
167
+ groups?: boolean | undefined
156
168
  /** Clicking an entry hides that series. Default: on whenever there are two or more entries. */
157
- toggle?: boolean
169
+ toggle?: boolean | undefined
158
170
  }
159
171
 
160
172
  /** A height per container class, `base` below `regular`. A plain number is fixed. */
161
173
  export type ResponsiveChartHeight = {
162
174
  base: number
163
- regular?: number
164
- wide?: number
175
+ regular?: number | undefined
176
+ wide?: number | undefined
165
177
  }
166
178
 
167
179
  /** The three "nothing to draw" states, pending → error → empty. */
168
180
  export type ChartState = {
169
- pending?: boolean
181
+ pending?: boolean | undefined
170
182
  /** Truthy = the query failed; an `Error`'s message is the label. */
171
183
  error?: unknown
172
- /** `true`, or the empty label itself. */
173
- empty?: boolean | string
184
+ /** `true`, or the empty label itself. The label is the one way to say WHY (`'No runs match the
185
+ * filter'` vs `'No runs yet'`): § 9 fixes the state shape, so there is no reason field. */
186
+ empty?: boolean | string | undefined
174
187
  }
175
188
 
176
189
  /** A row of `plot.rows(key)`: one per datum, `y: null` kept as a gap. */
@@ -214,27 +227,27 @@ export type CartesianChartProps<T, X extends ChartX = ChartX> = BasaltProps & {
214
227
  series: readonly ChartSeries<T>[]
215
228
  /** The plot's marks. Draw `plot.rows(key)` / `plot.visible`, never `series` directly. */
216
229
  marks: (plot: PlotContext<T, X>) => ChartMarks
217
- y?: AxisConfig<T>
230
+ y?: AxisConfig<T> | undefined
218
231
  /** A right axis; series with `axis: 'right'` and marks bound to `plot.y2` read it. */
219
- y2?: AxisConfig<T>
232
+ y2?: AxisConfig<T> | undefined
220
233
  /** A tick count, or the exact x tick values. Default: by container class. */
221
- xTicks?: number | readonly X[]
234
+ xTicks?: number | readonly X[] | undefined
222
235
  /** Tick label for an x value. Default: `DD.MM` for dates, compact numbers, strings verbatim. */
223
- formatX?: (x: X) => string
224
- zones?: readonly ZoneSpec[]
225
- xZones?: readonly XZoneSpec<X>[]
226
- refLines?: readonly RefLine[]
236
+ formatX?: ((x: X) => string) | undefined
237
+ zones?: readonly ZoneSpec[] | undefined
238
+ xZones?: readonly XZoneSpec<X>[] | undefined
239
+ refLines?: readonly RefLine[] | undefined
227
240
  /** `false` hides the legend. */
228
- legend?: ChartLegendConfig | false
241
+ legend?: ChartLegendConfig | false | undefined
229
242
  /** Default: the height law (measured width × class ratio, 160–420px, ≤ 45vh on touch). */
230
- height?: number | ResponsiveChartHeight
231
- state?: ChartState
243
+ height?: number | ResponsiveChartHeight | undefined
244
+ state?: ChartState | undefined
232
245
  /** Joins a named cursor group instead of the page-shared default. */
233
- cursorGroup?: string
246
+ cursorGroup?: string | undefined
234
247
  /** Default `'nearest'`. `'leading'` for a series keyed by a bucket's leading edge (a weekly
235
248
  * value keyed by its Monday). */
236
- cursorResolution?: CursorResolution
237
- tooltip?: ChartTooltipConfig<T, X> | false
249
+ cursorResolution?: CursorResolution | undefined
250
+ tooltip?: ChartTooltipConfig<T, X> | false | undefined
238
251
  }
239
252
 
240
253
  /** The x channel of a `plot.rows()` row, as an accessor (a field name does not type-check against a
package/src/cli/doctor.ts CHANGED
@@ -395,7 +395,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
395
395
  ],
396
396
  [
397
397
  'dropped-subpaths: no import of a basalt-ui subpath the install lacks',
398
- droppedSubpathsCheck(cwd, cfg, install),
398
+ droppedSubpathsCheck(appDir, cfg, install),
399
399
  ],
400
400
  [
401
401
  'app-wiring: the entry mounts BasaltProvider and the router tree is generated',
@@ -404,7 +404,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
404
404
  ['exact-peers: installed exact-pinned peers match basalt-ui', exactPeersCheck(cwd, install)],
405
405
  [
406
406
  'subpath-peers: every imported basalt-ui subpath has its required peers installed',
407
- subpathPeersCheck(cwd, cfg, install),
407
+ subpathPeersCheck(appDir, cfg, install),
408
408
  ],
409
409
  [
410
410
  'config-keys: the basalt config block sets only keys basalt-ui reads',
@@ -1,7 +1,12 @@
1
1
  export { WidgetHeader } from './widget-header'
2
2
  export { DeltaBadge } from './delta-badge'
3
3
  export { Section } from './section'
4
- export { StatCard, type StatCardBreakdownRow, type StatCardTone } from './stat-card'
4
+ export {
5
+ StatCard,
6
+ type StatCardBreakdownRow,
7
+ type StatCardSparklineSize,
8
+ type StatCardTone,
9
+ } from './stat-card'
5
10
  export { StatGroup } from './stat-group'
6
11
  export { EmptyState } from './empty-state'
7
12
  export { SettingsSection, SettingsRow, DangerZone } from './settings-section'
@@ -23,7 +23,9 @@
23
23
  * here. Pass your own sparkline at the call site (basalt ships no `Sparkline` kind in 2.0.0).
24
24
  *
25
25
  * `query` hands the card's pending/error/empty branch to `QueryState` at the `'section'` tier,
26
- * rendered under the header rather than over it — the title row is chrome and must not flicker.
26
+ * rendered under the header rather than over it — the title row is chrome and must not flicker. The
27
+ * same query also reaches a render-prop `sparkline` as `size.state`, so `<Sparkline {...size} />`
28
+ * shows its sized pending / error placeholder with no hand-rolled `'—'`.
27
29
  *
28
30
  * `tone` marks the card as past a threshold with an accent rail down its leading edge. It exists
29
31
  * because `value` is typed `string`: without it, a card whose number has crossed a threshold reads
@@ -106,6 +108,7 @@ import { cx } from '../common/props'
106
108
  import type { BasaltProps, SlotStylesProps, Tone } from '../common/props'
107
109
  import { QueryState } from './query-state'
108
110
  import type { QueryStateLike } from './query-state'
111
+ import { assertQueryStateLike } from '../common/query-state-like'
109
112
  import classes from './stat-card.module.css'
110
113
 
111
114
  /**
@@ -160,6 +163,13 @@ const SPARKLINE_RIGHT_HEIGHT = 26
160
163
  export type StatCardSparklineSize = {
161
164
  width: number
162
165
  height: number
166
+ /**
167
+ * The card's `query` as a chart state — present only while the sparkline has nothing to draw
168
+ * (pending, or failed with no data), so `<Sparkline {...size} />` forwards it and the tile shows a
169
+ * sized placeholder instead of a hand-rolled `'—'`. Structurally a `ChartState`: this module
170
+ * stays chart-free. Absent without a `query`, and once data has arrived.
171
+ */
172
+ state?: { pending?: boolean; error?: unknown; empty?: boolean }
163
173
  }
164
174
 
165
175
  /**
@@ -199,6 +209,18 @@ function useSlotWidth(active: boolean): {
199
209
  }
200
210
  }
201
211
 
212
+ /** The `query` as a sparkline state, by the same precedence as {@link QueryState}: an error with no
213
+ * data is an error, no data and not idle is pending, anything else draws. */
214
+ function sparklineState(
215
+ query: QueryStateLike<unknown> | undefined,
216
+ ): StatCardSparklineSize['state'] {
217
+ if (query === undefined) return undefined
218
+ assertQueryStateLike('StatCard', query)
219
+ if (query.data !== undefined) return undefined
220
+ if (query.isError) return { error: query.error ?? true }
221
+ return query.fetchStatus === 'idle' ? undefined : { pending: true }
222
+ }
223
+
202
224
  /** The rail's width, in px. Wide enough to read as a deliberate mark at a glance, narrow enough
203
225
  * that it cannot be mistaken for the card's own edge. */
204
226
  const TONE_RAIL_WIDTH = 3
@@ -270,8 +292,12 @@ export type StatCardProps = BasaltProps &
270
292
  * measured width is the card's own inner width including the bled inset; with `'right'` it is the
271
293
  * fixed 72×26 slot (see {@link SPARKLINE_RIGHT_WIDTH} for why that one is not measured).
272
294
  *
295
+ * A render prop is a sanctioned pattern here, so `react/no-unstable-nested-components` is
296
+ * configured with `allowAsProps` in the shipped preset. Spread the size (`{...size}`) to forward
297
+ * `state` along with the box.
298
+ *
273
299
  * @example
274
- * sparkline={({ width, height }) => <MySparkline data={history} width={width} height={height} />}
300
+ * sparkline={(size) => <Sparkline ariaLabel="Active users" data={history} {...size} />}
275
301
  */
276
302
  sparkline?: ReactNode | ((size: StatCardSparklineSize) => ReactNode)
277
303
  /** Where `sparkline` sits. `'bleed'` (default) is today's full-width row bled to the card edges;
@@ -313,7 +339,12 @@ export function StatCard({
313
339
  const bleeds = sparklinePlacement === 'bleed'
314
340
  // Only a render prop needs a measurement; a plain node sizes itself.
315
341
  const { ref: slotRef, width: slotWidth } = useSlotWidth(isRender)
316
- const measured: StatCardSparklineSize = { width: slotWidth, height: SPARKLINE_RIGHT_HEIGHT }
342
+ const state = sparklineState(query)
343
+ const measured: StatCardSparklineSize = {
344
+ width: slotWidth,
345
+ height: SPARKLINE_RIGHT_HEIGHT,
346
+ ...(state !== undefined && { state }),
347
+ }
317
348
 
318
349
  // A `<dl>`, not a table and not a stack of divs: each row IS a term and its value, which is the
319
350
  // one semantic that survives a reader meeting the card out of context.
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  Section,
41
41
  StatCard,
42
42
  type StatCardBreakdownRow,
43
+ type StatCardSparklineSize,
43
44
  type StatCardTone,
44
45
  StatGroup,
45
46
  EmptyState,