@happyvertical/smrt-ui 0.57.5 → 0.57.7

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.
@@ -20,12 +20,37 @@ export interface Props {
20
20
  title?: string;
21
21
  /** Locale used to format displayed numeric values. */
22
22
  locale?: string;
23
+ /**
24
+ * How much room the chart takes. `compact` is a sparkline for a card (a
25
+ * line with no axis, labels or readout); `tall` gives a page's main chart
26
+ * room. Default `regular`.
27
+ */
28
+ size?: 'compact' | 'regular' | 'tall';
29
+ /**
30
+ * Line: label the value axis (top, middle and bottom values, short form)
31
+ * with light gridlines. Default on for `tall`.
32
+ */
33
+ valueAxis?: boolean;
34
+ /**
35
+ * Line: let the pointer and the arrow keys read each value in a tooltip
36
+ * (a slider whose value text is the point). Default on for `tall`.
37
+ */
38
+ explore?: boolean;
39
+ /**
40
+ * Line: the peak and latest values under the chart. Default on; a page
41
+ * that shows its own headline figures turns it off.
42
+ */
43
+ readout?: boolean;
23
44
  }
24
45
  let {
25
46
  data,
26
47
  style: requestedStyle = 'bar',
27
48
  title = '',
28
49
  locale = 'en',
50
+ size = 'regular',
51
+ valueAxis,
52
+ explore,
53
+ readout = true,
29
54
  }: Props = $props();
30
55
 
31
56
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -33,6 +58,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
33
58
  }
34
59
 
35
60
  const { t } = useI18n();
61
+ const uid = $props.id();
36
62
  const MAX_POINTS = 60;
37
63
 
38
64
  const chart = $derived(isRecord(data) ? data : null);
@@ -55,6 +81,10 @@ const STYLES = ['bar', 'line', 'table', 'pie', 'stat'] as const;
55
81
  const style = $derived(
56
82
  STYLES.find((candidate) => candidate === requestedStyle) ?? 'bar',
57
83
  );
84
+ /** Whether the line draws its value axis (`showAxis`, readable before it). */
85
+ function requestedAxis(): boolean {
86
+ return size !== 'compact' && (valueAxis ?? size === 'tall');
87
+ }
58
88
  const fmt = (value: number): string =>
59
89
  formatWidgetValue(value, {
60
90
  format: chart?.format ?? 'integer',
@@ -63,7 +93,17 @@ const fmt = (value: number): string =>
63
93
  });
64
94
  const max = $derived(Math.max(0, ...points.map((point) => point.value)));
65
95
  const min = $derived(Math.min(0, ...points.map((point) => point.value)));
66
- const span = $derived(max - min || 1);
96
+ /** A round number at or above `value` (1, 2, 2.5 or 5 times a power of ten). */
97
+ function niceCeil(value: number): number {
98
+ if (value <= 0) return 0;
99
+ const power = 10 ** Math.floor(Math.log10(value));
100
+ const step = [1, 2, 2.5, 5, 10].find((n) => value / power <= n) ?? 10;
101
+ return step * power;
102
+ }
103
+ // The line's top: the peak, or with a value axis two round steps above zero
104
+ // (so the ticks read $0 / $50K / $100K and the peak never meets the frame).
105
+ const top = $derived(requestedAxis() && max > 0 ? niceCeil(max / 2) * 2 : max);
106
+ const span = $derived(top - min || 1);
67
107
  const polyline = $derived(
68
108
  points
69
109
  .map((point, index) => {
@@ -122,9 +162,83 @@ const deltaText = $derived(
122
162
  },
123
163
  ),
124
164
  );
165
+ // Line axis: at most three labels (first, middle, last), so long or many
166
+ // labels never overflow the chart's width.
167
+ const axisLabels = $derived.by(() => {
168
+ if (points.length <= 2) return points.map((point) => point.label);
169
+ const middle = points[Math.floor((points.length - 1) / 2)];
170
+ return [points[0].label, middle.label, points[points.length - 1].label];
171
+ });
172
+ const peak = $derived(
173
+ points.reduce<{ label: string; value: number } | null>(
174
+ (best, point) => (!best || point.value > best.value ? point : best),
175
+ null,
176
+ ),
177
+ );
125
178
  const summary = $derived(
126
179
  `${title}. ${t(M['ui.overview.chart.summary'], { count: points.length })}`,
127
180
  );
181
+
182
+ // Line extras: the value axis and the explorable readout.
183
+ const compact = $derived(size === 'compact');
184
+ const showAxis = $derived(requestedAxis());
185
+ const explorable = $derived(!compact && (explore ?? size === 'tall'));
186
+ const short = (value: number): string =>
187
+ formatWidgetValue(value, {
188
+ format: chart?.format ?? 'integer',
189
+ currency: chart?.currency,
190
+ locale,
191
+ compact: true,
192
+ });
193
+ const axisValues = $derived([top, (top + min) / 2, min]);
194
+ /** A point's place in the 100 x 40 plot. */
195
+ function place(index: number): { x: number; y: number } {
196
+ const point = points[index];
197
+ const x = points.length === 1 ? 50 : (index / (points.length - 1)) * 100;
198
+ const y = 36 - (((point?.value ?? 0) - min) / span) * 32;
199
+ return { x, y };
200
+ }
201
+ let active = $state<number | null>(null);
202
+ const activePoint = $derived(
203
+ active !== null && points[active]
204
+ ? { ...points[active], ...place(active) }
205
+ : null,
206
+ );
207
+ const activeText = $derived(
208
+ activePoint
209
+ ? t(M['ui.overview.chart.point'], {
210
+ label: activePoint.label,
211
+ value: fmt(activePoint.value),
212
+ })
213
+ : '',
214
+ );
215
+ function pointAt(event: PointerEvent) {
216
+ const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
217
+ if (box.width <= 0 || points.length === 0) return;
218
+ const ratio = Math.min(
219
+ 1,
220
+ Math.max(0, (event.clientX - box.left) / box.width),
221
+ );
222
+ active = Math.round(ratio * (points.length - 1));
223
+ }
224
+ function step(event: KeyboardEvent) {
225
+ const last = points.length - 1;
226
+ if (last < 0) return;
227
+ const current = active ?? -1;
228
+ const next =
229
+ event.key === 'ArrowRight' || event.key === 'ArrowUp'
230
+ ? Math.min(last, current + 1)
231
+ : event.key === 'ArrowLeft' || event.key === 'ArrowDown'
232
+ ? Math.max(0, current < 0 ? last : current - 1)
233
+ : event.key === 'Home'
234
+ ? 0
235
+ : event.key === 'End'
236
+ ? last
237
+ : null;
238
+ if (next === null) return;
239
+ event.preventDefault();
240
+ active = next;
241
+ }
128
242
  </script>
129
243
 
130
244
  {#snippet dataTable(hidden: boolean)}
@@ -144,6 +258,36 @@ const summary = $derived(
144
258
  </table>
145
259
  {/snippet}
146
260
 
261
+ {#snippet linePlot(interactive: boolean)}
262
+ <svg
263
+ class="smrt-chart__line"
264
+ viewBox="0 0 100 40"
265
+ preserveAspectRatio="none"
266
+ role={interactive ? undefined : 'img'}
267
+ aria-label={interactive ? undefined : summary}
268
+ aria-hidden={interactive ? 'true' : undefined}
269
+ >
270
+ {#if showAxis}
271
+ {#each [4, 20, 36] as y (y)}
272
+ <line class="smrt-chart__grid" x1="0" x2="100" y1={y} y2={y} vector-effect="non-scaling-stroke" />
273
+ {/each}
274
+ {/if}
275
+ <polyline points={polyline} fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke" />
276
+ {#if activePoint}
277
+ <line class="smrt-chart__guide" x1={activePoint.x} x2={activePoint.x} y1="0" y2="40" vector-effect="non-scaling-stroke" />
278
+ {/if}
279
+ </svg>
280
+ {#if activePoint}
281
+ <span class="smrt-chart__dot" style:inset-inline-start="{activePoint.x}%" style:inset-block-start="{(activePoint.y / 40) * 100}%" aria-hidden="true"></span>
282
+ <span
283
+ class="smrt-chart__tip"
284
+ data-edge={activePoint.x < 15 ? 'start' : activePoint.x > 85 ? 'end' : undefined}
285
+ style:inset-inline-start="{activePoint.x}%"
286
+ aria-hidden="true"
287
+ ><span>{activePoint.label}</span><strong>{fmt(activePoint.value)}</strong></span>
288
+ {/if}
289
+ {/snippet}
290
+
147
291
  {#if points.length === 0}
148
292
  <p class="smrt-chart__empty" data-smrt-component="Chart">{t(M['ui.overview.no_data'])}</p>
149
293
  {:else if style === 'table'}
@@ -200,14 +344,50 @@ const summary = $derived(
200
344
  <p class="smrt-chart__empty" data-smrt-component="Chart">{t(M['ui.overview.no_data'])}</p>
201
345
  {/if}
202
346
  {:else if style === 'line'}
203
- <div class="smrt-chart" data-smrt-component="Chart">
204
- <svg class="smrt-chart__line" viewBox="0 0 100 40" preserveAspectRatio="none" role="img" aria-label={summary}>
205
- <polyline points={polyline} fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke" />
206
- </svg>
207
- <p class="smrt-chart__axis" aria-hidden="true">
208
- <span>{points[0].label}</span>
209
- <span>{points[points.length - 1].label}</span>
210
- </p>
347
+ <div class="smrt-chart smrt-chart--line" data-size={size} data-smrt-component="Chart">
348
+ <div class="smrt-chart__frame" class:smrt-chart__frame--axis={showAxis}>
349
+ {#if showAxis}
350
+ <p class="smrt-chart__values" aria-hidden="true">
351
+ {#each axisValues as value, index (index)}<span>{short(value)}</span>{/each}
352
+ </p>
353
+ {/if}
354
+ <div class="smrt-chart__body">
355
+ {#if explorable}
356
+ <div
357
+ class="smrt-chart__plot"
358
+ role="slider"
359
+ tabindex="0"
360
+ aria-label={summary}
361
+ aria-describedby="{uid}-explore"
362
+ aria-orientation="horizontal"
363
+ aria-valuemin={0}
364
+ aria-valuemax={Math.max(0, points.length - 1)}
365
+ aria-valuenow={active ?? points.length - 1}
366
+ aria-valuetext={activeText || (latest ? t(M['ui.overview.chart.point'], { label: latest.label, value: fmt(latest.value) }) : '')}
367
+ onpointermove={pointAt}
368
+ onpointerleave={() => (active = null)}
369
+ onkeydown={step}
370
+ onblur={() => (active = null)}
371
+ >
372
+ {@render linePlot(true)}
373
+ </div>
374
+ {:else}
375
+ <div class="smrt-chart__plot">{@render linePlot(false)}</div>
376
+ {/if}
377
+ {#if !compact}
378
+ <p class="smrt-chart__axis" aria-hidden="true">
379
+ {#each axisLabels as label, index (index)}<span>{label}</span>{/each}
380
+ </p>
381
+ {/if}
382
+ </div>
383
+ </div>
384
+ {#if explorable}<span id="{uid}-explore" class="smrt-chart__table">{t(M['ui.overview.chart.explore'])}</span>{/if}
385
+ {#if readout && !compact && peak && latest}
386
+ <p class="smrt-chart__readout">
387
+ <span>{t(M['ui.overview.chart.peak'], { value: fmt(peak.value), label: peak.label })}</span>
388
+ <span>{t(M['ui.overview.chart.latest'], { value: fmt(latest.value), label: latest.label })}</span>
389
+ </p>
390
+ {/if}
211
391
  {@render dataTable(true)}
212
392
  </div>
213
393
  {:else}
@@ -228,8 +408,28 @@ const summary = $derived(
228
408
  @layer smrt.reset, smrt.structure, smrt.theme;
229
409
  @layer smrt.theme {
230
410
  .smrt-chart { display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-primary); }
231
- .smrt-chart__line { inline-size: 100%; block-size: 7rem; overflow: visible; }
232
- .smrt-chart__axis { display: flex; justify-content: space-between; margin: 0; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-label-large-size, 0.75rem); }
411
+ .smrt-chart__line { display: block; inline-size: 100%; block-size: 7rem; overflow: visible; }
412
+ .smrt-chart--line[data-size='compact'] .smrt-chart__line { block-size: 2.5rem; }
413
+ .smrt-chart--line[data-size='tall'] .smrt-chart__line { block-size: 14rem; }
414
+ .smrt-chart__frame { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--smrt-spacing-2); }
415
+ .smrt-chart__frame--axis { grid-template-columns: auto minmax(0, 1fr); }
416
+ .smrt-chart__body { display: grid; gap: var(--smrt-spacing-1); min-inline-size: 0; }
417
+ .smrt-chart__values { display: flex; flex-direction: column; justify-content: space-between; margin: 0; block-size: 14rem; padding-block: calc(14rem * 0.1 - 0.5lh) calc(14rem * 0.1 - 0.5lh); box-sizing: border-box; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-label-small-size); font-variant-numeric: tabular-nums; text-align: end; }
418
+ .smrt-chart--line:not([data-size='tall']) .smrt-chart__values { block-size: 7rem; padding-block: calc(7rem * 0.1 - 0.5lh); }
419
+ .smrt-chart__grid { stroke: var(--smrt-color-outline-variant); stroke-width: 1; stroke-dasharray: 2 3; }
420
+ .smrt-chart__plot { position: relative; border-radius: var(--smrt-radius-small); outline: none; touch-action: pan-y; }
421
+ .smrt-chart__plot[role='slider'] { cursor: crosshair; }
422
+ .smrt-chart__plot:focus-visible { outline: var(--smrt-focus-ring-width) solid var(--smrt-focus-ring-color); outline-offset: var(--smrt-focus-ring-offset); }
423
+ .smrt-chart__guide { stroke: var(--smrt-color-outline); stroke-width: 1; }
424
+ .smrt-chart__dot { position: absolute; inline-size: 0.625rem; block-size: 0.625rem; border-radius: var(--smrt-radius-full); background: var(--smrt-color-primary); outline: var(--smrt-focus-ring-width) solid var(--smrt-color-surface); transform: translate(-50%, -50%); pointer-events: none; }
425
+ .smrt-chart__tip { position: absolute; inset-block-start: 0; display: grid; gap: 0; padding: var(--smrt-spacing-1) var(--smrt-spacing-2); border-radius: var(--smrt-radius-small); background: var(--smrt-color-inverse-surface); color: var(--smrt-color-inverse-on-surface); font-size: var(--smrt-typography-label-medium-size); line-height: 1.3; white-space: nowrap; transform: translate(-50%, calc(-100% - var(--smrt-spacing-1))); pointer-events: none; box-shadow: var(--smrt-elevation-2); font-variant-numeric: tabular-nums; }
426
+ .smrt-chart__tip[data-edge='start'] { transform: translate(0, calc(-100% - var(--smrt-spacing-1))); }
427
+ .smrt-chart__tip[data-edge='end'] { transform: translate(-100%, calc(-100% - var(--smrt-spacing-1))); }
428
+
429
+ .smrt-chart__axis { display: flex; justify-content: space-between; gap: var(--smrt-spacing-2); margin: 0; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-label-large-size, 0.75rem); }
430
+ .smrt-chart__axis span { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
431
+ .smrt-chart__axis span:last-child { text-align: end; }
432
+ .smrt-chart__readout { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--smrt-spacing-1) var(--smrt-spacing-3); margin: 0; color: var(--smrt-color-on-surface); font-size: var(--smrt-typography-body-small-size, 0.75rem); font-variant-numeric: tabular-nums; }
233
433
  .smrt-chart__bars { display: grid; gap: var(--smrt-spacing-2); margin: 0; padding: 0; list-style: none; }
234
434
  .smrt-chart__row { display: grid; grid-template-columns: minmax(4rem, 30%) minmax(0, 1fr) auto; align-items: center; gap: var(--smrt-spacing-2); font-size: var(--smrt-typography-body-medium-size, 0.875rem); color: var(--smrt-color-on-surface); }
235
435
  .smrt-chart__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -8,6 +8,27 @@ export interface Props {
8
8
  title?: string;
9
9
  /** Locale used to format displayed numeric values. */
10
10
  locale?: string;
11
+ /**
12
+ * How much room the chart takes. `compact` is a sparkline for a card (a
13
+ * line with no axis, labels or readout); `tall` gives a page's main chart
14
+ * room. Default `regular`.
15
+ */
16
+ size?: 'compact' | 'regular' | 'tall';
17
+ /**
18
+ * Line: label the value axis (top, middle and bottom values, short form)
19
+ * with light gridlines. Default on for `tall`.
20
+ */
21
+ valueAxis?: boolean;
22
+ /**
23
+ * Line: let the pointer and the arrow keys read each value in a tooltip
24
+ * (a slider whose value text is the point). Default on for `tall`.
25
+ */
26
+ explore?: boolean;
27
+ /**
28
+ * Line: the peak and latest values under the chart. Default on; a page
29
+ * that shows its own headline figures turns it off.
30
+ */
31
+ readout?: boolean;
11
32
  }
12
33
  declare const Chart: import("svelte").Component<Props, {}, "">;
13
34
  type Chart = ReturnType<typeof Chart>;
@@ -1 +1 @@
1
- {"version":3,"file":"Chart.svelte.d.ts","sourceRoot":"","sources":["../../src/charts/Chart.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,MAAM,WAAW,KAAK;IACpB,mEAAmE;IACnE,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IACvB,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA2MD,QAAA,MAAM,KAAK,2CAAwC,CAAC;AACpD,KAAK,KAAK,GAAG,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;AACtC,eAAe,KAAK,CAAC"}
1
+ {"version":3,"file":"Chart.svelte.d.ts","sourceRoot":"","sources":["../../src/charts/Chart.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,MAAM,WAAW,KAAK;IACpB,mEAAmE;IACnE,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IACvB,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAAC;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AA+UD,QAAA,MAAM,KAAK,2CAAwC,CAAC;AACpD,KAAK,KAAK,GAAG,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;AACtC,eAAe,KAAK,CAAC"}
@@ -4,10 +4,22 @@ export interface FormatWidgetValueOptions {
4
4
  /** ISO 4217 code for `money` (default USD). */
5
5
  currency?: string;
6
6
  locale: string;
7
+ /** Short form for tight spaces such as an axis (`$83K`, `1.2M`). */
8
+ compact?: boolean;
7
9
  }
8
10
  /**
9
11
  * Format a metric value. `money` takes integer minor units, `percent` takes a
10
12
  * fraction (0.25 is 25%). Non-finite input renders as an en dash.
11
13
  */
12
- export declare function formatWidgetValue(value: unknown, { format, currency, locale }: FormatWidgetValueOptions): string;
14
+ export declare function formatWidgetValue(value: unknown, { format, currency, locale, compact, }: FormatWidgetValueOptions): string;
15
+ /** Time bucket granularities `formatTimeBucket` names. */
16
+ export type TimeBucketUnit = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
17
+ /**
18
+ * A time bucket's start (an ISO-8601 instant, or a SQL `YYYY-MM-DD HH:MM:SS`)
19
+ * as a short label for its granularity, in UTC, so buckets read the same
20
+ * everywhere: `Jan 2025` for a month, `Q1 2025`, `2025`, `Jan 6` for a day or
21
+ * week, `Jan 6, 14:00` for an hour or minute. Unparseable input is returned
22
+ * unchanged.
23
+ */
24
+ export declare function formatTimeBucket(value: string, unit: TimeBucketUnit, locale: string): string;
13
25
  //# sourceMappingURL=format.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/charts/format.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE5E,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAYD;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,EAAE,MAAkB,EAAE,QAAgB,EAAE,MAAM,EAAE,EAAE,wBAAwB,GACzE,MAAM,CA4BR"}
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/charts/format.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE5E,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAYD;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,EACE,MAAkB,EAClB,QAAgB,EAChB,MAAM,EACN,OAAe,GAChB,EAAE,wBAAwB,GAC1B,MAAM,CAkCR;AAED,0DAA0D;AAC1D,MAAM,MAAM,cAAc,GACtB,QAAQ,GACR,MAAM,GACN,KAAK,GACL,MAAM,GACN,OAAO,GACP,SAAS,GACT,MAAM,CAAC;AAEX;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,cAAc,EACpB,MAAM,EAAE,MAAM,GACb,MAAM,CA2BR"}
@@ -20,10 +20,13 @@ function safeLocale(locale) {
20
20
  * Format a metric value. `money` takes integer minor units, `percent` takes a
21
21
  * fraction (0.25 is 25%). Non-finite input renders as an en dash.
22
22
  */
23
- export function formatWidgetValue(value, { format = 'integer', currency = 'USD', locale }) {
23
+ export function formatWidgetValue(value, { format = 'integer', currency = 'USD', locale, compact = false, }) {
24
24
  if (typeof value !== 'number' || !Number.isFinite(value))
25
25
  return EN_DASH;
26
26
  const tag = safeLocale(locale);
27
+ const short = compact
28
+ ? { notation: 'compact', maximumFractionDigits: 1 }
29
+ : {};
27
30
  switch (format) {
28
31
  case 'money': {
29
32
  const code = /^[A-Za-z]{3}$/.test(currency)
@@ -33,6 +36,7 @@ export function formatWidgetValue(value, { format = 'integer', currency = 'USD',
33
36
  return new Intl.NumberFormat(tag, {
34
37
  style: 'currency',
35
38
  currency: code,
39
+ ...short,
36
40
  }).format(value / 10 ** digits);
37
41
  }
38
42
  case 'percent':
@@ -41,8 +45,47 @@ export function formatWidgetValue(value, { format = 'integer', currency = 'USD',
41
45
  maximumFractionDigits: 1,
42
46
  }).format(value);
43
47
  case 'decimal':
44
- return new Intl.NumberFormat(tag, { maximumFractionDigits: 2 }).format(value);
48
+ return new Intl.NumberFormat(tag, {
49
+ maximumFractionDigits: 2,
50
+ ...short,
51
+ }).format(value);
52
+ default:
53
+ return new Intl.NumberFormat(tag, {
54
+ maximumFractionDigits: 0,
55
+ ...short,
56
+ }).format(value);
57
+ }
58
+ }
59
+ /**
60
+ * A time bucket's start (an ISO-8601 instant, or a SQL `YYYY-MM-DD HH:MM:SS`)
61
+ * as a short label for its granularity, in UTC, so buckets read the same
62
+ * everywhere: `Jan 2025` for a month, `Q1 2025`, `2025`, `Jan 6` for a day or
63
+ * week, `Jan 6, 14:00` for an hour or minute. Unparseable input is returned
64
+ * unchanged.
65
+ */
66
+ export function formatTimeBucket(value, unit, locale) {
67
+ const date = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
68
+ if (Number.isNaN(date.getTime()))
69
+ return value;
70
+ const tag = safeLocale(locale);
71
+ const format = (options) => new Intl.DateTimeFormat(tag, { timeZone: 'UTC', ...options }).format(date);
72
+ switch (unit) {
73
+ case 'year':
74
+ return format({ year: 'numeric' });
75
+ case 'quarter':
76
+ return `Q${Math.floor(date.getUTCMonth() / 3) + 1} ${date.getUTCFullYear()}`;
77
+ case 'month':
78
+ return format({ month: 'short', year: 'numeric' });
79
+ case 'week':
80
+ case 'day':
81
+ return format({ month: 'short', day: 'numeric' });
45
82
  default:
46
- return new Intl.NumberFormat(tag, { maximumFractionDigits: 0 }).format(value);
83
+ return format({
84
+ month: 'short',
85
+ day: 'numeric',
86
+ hour: '2-digit',
87
+ minute: '2-digit',
88
+ hourCycle: 'h23',
89
+ });
47
90
  }
48
91
  }
@@ -1,5 +1,5 @@
1
1
  export { default as Chart } from './Chart.svelte';
2
- export type { FormatWidgetValueOptions, WidgetValueFormat } from './format.js';
3
- export { formatWidgetValue } from './format.js';
2
+ export type { FormatWidgetValueOptions, TimeBucketUnit, WidgetValueFormat, } from './format.js';
3
+ export { formatTimeBucket, formatWidgetValue } from './format.js';
4
4
  export type { ChartData } from './types.js';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/charts/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/charts/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,cAAc,EACd,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC"}
@@ -1,2 +1,2 @@
1
1
  export { default as Chart } from './Chart.svelte';
2
- export { formatWidgetValue } from './format.js';
2
+ export { formatTimeBucket, formatWidgetValue } from './format.js';
@@ -6,5 +6,9 @@ export declare const M: {
6
6
  readonly 'ui.overview.chart.table': "ui.overview.chart.table";
7
7
  readonly 'ui.overview.chart.label_column': "ui.overview.chart.label_column";
8
8
  readonly 'ui.overview.chart.value_column': "ui.overview.chart.value_column";
9
+ readonly 'ui.overview.chart.peak': "ui.overview.chart.peak";
10
+ readonly 'ui.overview.chart.latest': "ui.overview.chart.latest";
11
+ readonly 'ui.overview.chart.explore': "ui.overview.chart.explore";
12
+ readonly 'ui.overview.chart.point': "ui.overview.chart.point";
9
13
  };
10
14
  //# sourceMappingURL=strings.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../src/charts/strings.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,CAAC;;;;;;;;CAQZ,CAAC"}
1
+ {"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../src/charts/strings.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,CAAC;;;;;;;;;;;;CAaZ,CAAC"}
@@ -7,4 +7,8 @@ export const M = defineMessages({
7
7
  'ui.overview.chart.table': 'Chart data',
8
8
  'ui.overview.chart.label_column': 'Label',
9
9
  'ui.overview.chart.value_column': 'Value',
10
+ 'ui.overview.chart.peak': 'Peak {value} · {label}',
11
+ 'ui.overview.chart.latest': 'Latest {value} · {label}',
12
+ 'ui.overview.chart.explore': 'Use the arrow keys to read each value; Home and End jump to the first and last.',
13
+ 'ui.overview.chart.point': '{label}: {value}',
10
14
  });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Asking the page's assistant from anywhere on the page: a "New report" button,
3
+ * an empty state, a help hint. Pure DOM, so a widget can ask without knowing
4
+ * which assistant (if any) the host mounted, and the assistant can listen
5
+ * without importing the widget's package.
6
+ *
7
+ * The requester dispatches a bubbling, cancelable event with the prompt to put
8
+ * in the composer. Whoever takes it calls `preventDefault()`:
9
+ * - an app shell with an assistant dock opens the dock and re-dispatches the
10
+ * request from inside it, so a dock that mounts only when open still gets it;
11
+ * - the assistant dock fills its composer with the prompt and focuses it. The
12
+ * prompt is never sent on the person's behalf: they edit and send it.
13
+ *
14
+ * `requestAssistant` returns whether anything took it, so the requester can
15
+ * explain where to ask instead when no assistant is on the page.
16
+ */
17
+ /** The bubbling DOM event that asks the page's assistant. */
18
+ export declare const ASSISTANT_REQUEST_EVENT = "smrt-assistant-request";
19
+ export interface AssistantRequestDetail {
20
+ /** Text to put in the assistant's composer, for the person to edit and send. */
21
+ prompt: string;
22
+ }
23
+ /** Ask the page's assistant from `from`; `true` when something took the request. */
24
+ export declare function requestAssistant(from: Element, detail: AssistantRequestDetail): boolean;
25
+ //# sourceMappingURL=assistant-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assistant-request.d.ts","sourceRoot":"","sources":["../../../src/components/chat/assistant-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,6DAA6D;AAC7D,eAAO,MAAM,uBAAuB,2BAA2B,CAAC;AAEhE,MAAM,WAAW,sBAAsB;IACrC,gFAAgF;IAChF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,oFAAoF;AACpF,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,sBAAsB,GAC7B,OAAO,CAOT"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Asking the page's assistant from anywhere on the page: a "New report" button,
3
+ * an empty state, a help hint. Pure DOM, so a widget can ask without knowing
4
+ * which assistant (if any) the host mounted, and the assistant can listen
5
+ * without importing the widget's package.
6
+ *
7
+ * The requester dispatches a bubbling, cancelable event with the prompt to put
8
+ * in the composer. Whoever takes it calls `preventDefault()`:
9
+ * - an app shell with an assistant dock opens the dock and re-dispatches the
10
+ * request from inside it, so a dock that mounts only when open still gets it;
11
+ * - the assistant dock fills its composer with the prompt and focuses it. The
12
+ * prompt is never sent on the person's behalf: they edit and send it.
13
+ *
14
+ * `requestAssistant` returns whether anything took it, so the requester can
15
+ * explain where to ask instead when no assistant is on the page.
16
+ */
17
+ /** The bubbling DOM event that asks the page's assistant. */
18
+ export const ASSISTANT_REQUEST_EVENT = 'smrt-assistant-request';
19
+ /** Ask the page's assistant from `from`; `true` when something took the request. */
20
+ export function requestAssistant(from, detail) {
21
+ const event = new CustomEvent(ASSISTANT_REQUEST_EVENT, { bubbles: true, composed: true, cancelable: true, detail });
22
+ from.dispatchEvent(event);
23
+ return event.defaultPrevented;
24
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Chat primitives — subpath `@happyvertical/smrt-ui/chat`.
3
3
  *
4
- * MessageBubble (user/agent/system), ReactionPicker, TypingIndicator. Generic,
4
+ * MessageBubble (user/agent/system), ReactionPicker, TypingIndicator, and
5
+ * `requestAssistant` (ask the page's assistant from any element). Generic,
5
6
  * tokenised, a11y-checked building blocks for chat UIs (L3 #1422).
6
7
  */
8
+ export { ASSISTANT_REQUEST_EVENT, type AssistantRequestDetail, requestAssistant, } from './assistant-request.js';
7
9
  export { default as MessageBubble } from './MessageBubble.svelte';
8
10
  export { default as ReactionPicker } from './ReactionPicker.svelte';
9
11
  export { default as TypingIndicator } from './TypingIndicator.svelte';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/chat/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/chat/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACL,uBAAuB,EACvB,KAAK,sBAAsB,EAC3B,gBAAgB,GACjB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,0BAA0B,CAAC"}
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Chat primitives — subpath `@happyvertical/smrt-ui/chat`.
3
3
  *
4
- * MessageBubble (user/agent/system), ReactionPicker, TypingIndicator. Generic,
4
+ * MessageBubble (user/agent/system), ReactionPicker, TypingIndicator, and
5
+ * `requestAssistant` (ask the page's assistant from any element). Generic,
5
6
  * tokenised, a11y-checked building blocks for chat UIs (L3 #1422).
6
7
  */
8
+ export { ASSISTANT_REQUEST_EVENT, requestAssistant, } from './assistant-request.js';
7
9
  export { default as MessageBubble } from './MessageBubble.svelte';
8
10
  export { default as ReactionPicker } from './ReactionPicker.svelte';
9
11
  export { default as TypingIndicator } from './TypingIndicator.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.57.5",
3
+ "version": "0.57.7",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
6
6
  "type": "module",
@@ -164,7 +164,7 @@
164
164
  "access": "public"
165
165
  },
166
166
  "dependencies": {
167
- "@happyvertical/smrt-types": "0.57.5",
167
+ "@happyvertical/smrt-types": "0.57.7",
168
168
  "esm-env": "^1.2.2"
169
169
  },
170
170
  "peerDependencies": {