bo-grid 0.8.0 → 0.21.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 (48) hide show
  1. package/README.md +202 -9
  2. package/dist/bo-grid.FilterMenu-BHI6rILc.js +154 -0
  3. package/dist/bo-grid.ToolPanel-C3u-4YKc.js +34 -0
  4. package/dist/bo-grid.element-DPnHUXMa.js +6623 -0
  5. package/dist/bo-grid.element.js +4 -0
  6. package/dist/charts/BarChart.svelte +50 -0
  7. package/dist/charts/BarChart.svelte.d.ts +16 -0
  8. package/dist/charts/DonutChart.svelte +54 -0
  9. package/dist/charts/DonutChart.svelte.d.ts +18 -0
  10. package/dist/charts/Legend.svelte +47 -0
  11. package/dist/charts/Legend.svelte.d.ts +12 -0
  12. package/dist/charts/LineChart.svelte +59 -0
  13. package/dist/charts/LineChart.svelte.d.ts +14 -0
  14. package/dist/charts/StackedBarChart.svelte +56 -0
  15. package/dist/charts/StackedBarChart.svelte.d.ts +18 -0
  16. package/dist/charts/chart-math.d.ts +57 -0
  17. package/dist/charts/chart-math.js +174 -0
  18. package/dist/charts/index.d.ts +8 -0
  19. package/dist/charts/index.js +11 -0
  20. package/dist/charts/palette.d.ts +4 -0
  21. package/dist/charts/palette.js +14 -0
  22. package/dist/format/format.d.ts +6 -0
  23. package/dist/format/format.js +41 -0
  24. package/dist/grid/Cell.svelte +247 -8
  25. package/dist/grid/Cell.svelte.d.ts +6 -0
  26. package/dist/grid/FilterMenu.svelte +7 -0
  27. package/dist/grid/Grid.svelte +307 -85
  28. package/dist/grid/Grid.svelte.d.ts +19 -0
  29. package/dist/grid/GroupRow.svelte +5 -2
  30. package/dist/grid/Pager.svelte +4 -0
  31. package/dist/grid/RowMenu.svelte +65 -2
  32. package/dist/grid/ToolPanel.svelte +5 -0
  33. package/dist/grid/column.d.ts +133 -0
  34. package/dist/grid/column.js +133 -4
  35. package/dist/grid/colvirt.d.ts +15 -0
  36. package/dist/grid/colvirt.js +43 -0
  37. package/dist/grid/export.js +5 -2
  38. package/dist/grid/filtering.d.ts +5 -2
  39. package/dist/grid/filtering.js +5 -4
  40. package/dist/grid/grouping.d.ts +30 -0
  41. package/dist/grid/grouping.js +33 -0
  42. package/dist/grid/theme.d.ts +15 -0
  43. package/dist/grid/theme.js +78 -0
  44. package/dist/grid/tree.d.ts +19 -7
  45. package/dist/grid/tree.js +16 -11
  46. package/dist/index.d.ts +5 -4
  47. package/dist/index.js +2 -2
  48. package/package.json +12 -2
@@ -23,3 +23,44 @@ export function fmtDate(ms, style = 'medium') {
23
23
  : { month: 'short', day: 'numeric', year: 'numeric' };
24
24
  return new Date(ms).toLocaleDateString('en-US', opts);
25
25
  }
26
+ /** Localized currency (e.g. `$1,234.50`). Falls back to a fixed-decimal number
27
+ if the ISO `currency` code is unsupported. */
28
+ export function fmtCurrency(v, currency = 'USD', locale = 'en-US', decimals) {
29
+ if (!Number.isFinite(v))
30
+ return '';
31
+ const fd = decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {};
32
+ try {
33
+ return new Intl.NumberFormat(locale, { style: 'currency', currency, ...fd }).format(v);
34
+ }
35
+ catch {
36
+ return v.toFixed(decimals ?? 2);
37
+ }
38
+ }
39
+ // Coarse relative-time thresholds (seconds → unit divisor + label).
40
+ const RT = [
41
+ [3600, 60, 'min'],
42
+ [86_400, 3600, 'hour'],
43
+ [604_800, 86_400, 'day'],
44
+ [2_629_800, 604_800, 'week'],
45
+ [31_557_600, 2_629_800, 'month'],
46
+ [Infinity, 31_557_600, 'year'],
47
+ ];
48
+ /** Human relative time vs `now` (default: real now) — e.g. `3 hours ago`,
49
+ `in 2 days`. `ms` is an epoch timestamp. Deterministic when `now` is passed. */
50
+ export function relativeTime(ms, now = Date.now()) {
51
+ if (!Number.isFinite(ms))
52
+ return '';
53
+ const secs = Math.round((now - ms) / 1000); // >0 = past
54
+ const past = secs >= 0;
55
+ const a = Math.abs(secs);
56
+ if (a < 45)
57
+ return past ? 'just now' : 'soon';
58
+ for (const [ceil, div, unit] of RT) {
59
+ if (a < ceil) {
60
+ const n = Math.floor(a / div);
61
+ const label = `${n} ${unit}${n === 1 ? '' : 's'}`;
62
+ return past ? `${label} ago` : `in ${label}`;
63
+ }
64
+ }
65
+ return '';
66
+ }
@@ -1,7 +1,18 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { ColumnDef, GridRow } from './column';
4
- import { formatCell, colStyle, candlesOf, isNumeric } from './column';
4
+ import {
5
+ formatCell,
6
+ colStyle,
7
+ candlesOf,
8
+ isNumeric,
9
+ cellValue,
10
+ dataBarGeometry,
11
+ colorScaleBackground,
12
+ pickIcon,
13
+ toneColor,
14
+ safeHref,
15
+ } from './column';
5
16
  import { heatColor } from './heatmap';
6
17
  import Sparkline from '../sparkline/Sparkline.svelte';
7
18
 
@@ -21,6 +32,7 @@
21
32
  seed = null,
22
33
  fillCorner = false,
23
34
  fillpreview = false,
35
+ cfRange = null,
24
36
  colIndex,
25
37
  cellId,
26
38
  cellSnippet,
@@ -54,6 +66,9 @@
54
66
  fillCorner?: boolean;
55
67
  /** This cell is inside the in-progress fill drag's preview range. */
56
68
  fillpreview?: boolean;
69
+ /** Conditional-formatting data extent (min/max over the view) for this
70
+ column; null when the column has no `dataBar`/`colorScale`. */
71
+ cfRange?: { min: number; max: number } | null;
57
72
  colIndex?: number;
58
73
  cellId?: string;
59
74
  cellSnippet?: Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>;
@@ -70,6 +85,16 @@
70
85
  onFillStart?: () => void;
71
86
  } = $props();
72
87
 
88
+ // Avatar initials: first letters of the first two words.
89
+ function initials(name: string): string {
90
+ return name
91
+ .split(/\s+/)
92
+ .filter(Boolean)
93
+ .slice(0, 2)
94
+ .map((w) => w[0]?.toUpperCase() ?? '')
95
+ .join('');
96
+ }
97
+
73
98
  let cancelled = false;
74
99
  function focusSelect(node: HTMLInputElement) {
75
100
  node.focus();
@@ -103,8 +128,9 @@
103
128
 
104
129
  // Dynamic field read. row is a runes class instance, so row[col.key] still
105
130
  // goes through the $state getter — fine-grained reactivity is preserved even
106
- // though the key is only known at runtime.
107
- const value = $derived(row[col.key]);
131
+ // though the key is only known at runtime. Computed columns derive from the
132
+ // whole row via cellValue (their value() reads the row's $state getters too).
133
+ const value = $derived(cellValue(col, row));
108
134
  // Typed inline editor: date columns edit with a date picker, numeric columns
109
135
  // with a numeric input; everything else stays a text input.
110
136
  const editorType = $derived(col.type === 'date' ? 'date' : isNumeric(col) ? 'number' : 'text');
@@ -113,7 +139,9 @@
113
139
  ? new Date(Number(value)).toISOString().slice(0, 10)
114
140
  : String(value ?? ''),
115
141
  );
116
- const kind = $derived(col.type === 'text' ? 'text' : col.type === 'sparkline' ? 'spark' : 'num');
142
+ // Alignment kind: numbers right-align (tabular); sparkline + text-like rich
143
+ // types (tags/badge/boolean/avatar) left-align.
144
+ const kind = $derived(col.type === 'sparkline' ? 'spark' : isNumeric(col) ? 'num' : 'text');
117
145
  // Optional per-column cell class (static string or value/row function).
118
146
  const extraClass = $derived(
119
147
  typeof col.cellClass === 'function' ? (col.cellClass(value, row) ?? '') : (col.cellClass ?? ''),
@@ -125,14 +153,36 @@
125
153
  : undefined,
126
154
  );
127
155
 
156
+ // ---- Conditional formatting (v0.10): data bar + icon set ----
157
+ // Geometry/threshold logic lives in column.ts (pure, unit-tested); here we map
158
+ // it to CSS (left/width %, tone → colour).
159
+ const hasCf = $derived(!!col.dataBar || !!col.icons);
160
+ const bar = $derived.by(() => {
161
+ if (!col.dataBar || !cfRange) return null;
162
+ const g = dataBarGeometry(value, cfRange, col.dataBar);
163
+ if (!g) return null;
164
+ const color = g.negative ? (col.dataBar.negative ?? 'var(--bo-down)') : (col.dataBar.color ?? 'var(--bo-up)');
165
+ return { left: `${g.left * 100}%`, width: `${g.width * 100}%`, color };
166
+ });
167
+ const icon = $derived.by(() => {
168
+ const pick = col.icons ? pickIcon(value, col.icons) : null;
169
+ return pick ? { icon: pick.icon, color: toneColor(pick.tone) } : null;
170
+ });
171
+ // Colour-scale cell tint (applied as a cell background in cellStyle).
172
+ const scaleBg = $derived(col.colorScale && cfRange ? colorScaleBackground(value, cfRange, col.colorScale) : null);
173
+
128
174
  function cellStyle(): string {
129
175
  let s = width != null ? `flex:0 0 ${width}px;width:${width}px;` : colStyle(col);
130
- if (col.type === 'heatmap') s += `background:${heatColor(Number(value), col.min, col.max)};`;
176
+ // Conditional background: heatmap type, else a colour-scale tint (both translucent).
177
+ const bg = col.type === 'heatmap' ? heatColor(Number(value), col.min, col.max) : scaleBg;
131
178
  if (pinned) {
132
179
  s += `position:sticky;${pinSide}:${pinOffset}px;z-index:1;`;
133
- // Pinned cells must be opaque to cover scrolled content. Heatmap already
134
- // set a background; otherwise match the (alternating) row colour.
135
- if (col.type !== 'heatmap') s += `background:var(${alt ? '--bo-row-a' : '--bo-row-b'});`;
180
+ // Pinned cells must be opaque to cover scrolled content layer any
181
+ // translucent tint over the (alternating) row colour.
182
+ const rowBg = `var(${alt ? '--bo-row-a' : '--bo-row-b'})`;
183
+ s += bg ? `background:linear-gradient(${bg},${bg}),${rowBg};` : `background:${rowBg};`;
184
+ } else if (bg) {
185
+ s += `background:${bg};`;
136
186
  }
137
187
  return s;
138
188
  }
@@ -223,8 +273,55 @@
223
273
  {#if cellSnippet}{@render cellSnippet({ row, column: col, value })}{:else}{value ?? ''}{/if}
224
274
  {:else if col.type === 'sparkline'}
225
275
  <Sparkline candles={candlesOf(row, col.sparkKey)} />
276
+ {:else if col.type === 'progress'}
277
+ {@const lo = col.min ?? 0}
278
+ {@const pct = Math.max(0, Math.min(100, (((Number(value) || 0) - lo) / (((col.max ?? 100) - lo) || 1)) * 100))}
279
+ <span class="bo-progress" title={String(value ?? '')}>
280
+ <span class="bo-progress-fill" style="width:{pct}%"></span>
281
+ </span>
282
+ {:else if col.type === 'rating'}
283
+ {@const rmax = col.max ?? 5}
284
+ {@const r = Math.max(0, Math.min(rmax, Math.round(Number(value) || 0)))}
285
+ <span class="bo-rating" aria-label="{r} out of {rmax}">
286
+ <span class="bo-stars-on">{'★'.repeat(r)}</span><span class="bo-stars-off">{'★'.repeat(rmax - r)}</span>
287
+ </span>
288
+ {:else if col.type === 'tags'}
289
+ {@const tags = Array.isArray(value) ? value : String(value ?? '').split(',').map((s) => s.trim()).filter(Boolean)}
290
+ <span class="bo-tags">{#each tags as t (t)}<span class="bo-tag">{t}</span>{/each}</span>
291
+ {:else if col.type === 'badge'}
292
+ <span class="bo-badge bo-badge-{col.tones?.[String(value)] ?? 'neutral'}">{value ?? ''}</span>
293
+ {:else if col.type === 'boolean'}
294
+ {#if value}
295
+ <span class="bo-bool bo-bool-yes">✓{#if col.trueLabel}&nbsp;{col.trueLabel}{/if}</span>
296
+ {:else}
297
+ <span class="bo-bool bo-bool-no">✕{#if col.falseLabel}&nbsp;{col.falseLabel}{/if}</span>
298
+ {/if}
299
+ {:else if col.type === 'avatar'}
300
+ <span class="bo-avatar" aria-hidden="true">{initials(String(value ?? ''))}</span>
301
+ <span class="bo-avatar-name">{value ?? ''}{#if col.sub}<em>{row[col.sub]}</em>{/if}</span>
302
+ {:else if col.type === 'link'}
303
+ {@const href = safeHref(col.href ? col.href(row) : String(value ?? ''))}
304
+ {#if href}<a
305
+ class="bo-link"
306
+ {href}
307
+ target={col.newTab ? '_blank' : undefined}
308
+ rel={col.newTab ? 'noopener noreferrer' : undefined}
309
+ onpointerdown={(e) => e.stopPropagation()}
310
+ onclick={(e) => e.stopPropagation()}>{value ?? ''}</a>{:else}{value ?? ''}{/if}
226
311
  {:else if col.type === 'text'}
227
312
  <strong>{formatCell(col, value, row)}</strong>{#if col.sub}<em>{row[col.sub]}</em>{/if}
313
+ {:else if hasCf}
314
+ {#if bar}<span class="bo-databar" style="left:{bar.left};width:{bar.width};background:{bar.color}"></span>{/if}
315
+ {#key col.flash ? row.flashSeq : 0}
316
+ <span
317
+ class="bo-cf-val"
318
+ class:flash={col.flash}
319
+ class:up={col.flash && row.flashDir === 'up'}
320
+ class:down={col.flash && row.flashDir === 'down'}
321
+ >
322
+ {#if icon}<span class="bo-cf-icon" style="color:{icon.color}">{icon.icon}</span>{/if}{formatCell(col, value, row)}
323
+ </span>
324
+ {/key}
228
325
  {:else if col.flash}
229
326
  {#key row.flashSeq}
230
327
  <span class="flash {row.flashDir}">{formatCell(col, value, row)}</span>
@@ -267,6 +364,141 @@
267
364
  .text {
268
365
  gap: 6px;
269
366
  }
367
+
368
+ /* ---- Rich cell types (v0.9) — all colours from theme tokens ---- */
369
+ .bo-progress {
370
+ flex: 1;
371
+ min-width: 36px;
372
+ height: 6px;
373
+ border-radius: 999px;
374
+ background: var(--bo-row-hover);
375
+ overflow: hidden;
376
+ }
377
+ .bo-progress-fill {
378
+ display: block;
379
+ height: 100%;
380
+ background: var(--bo-up);
381
+ border-radius: 999px;
382
+ }
383
+ .bo-rating {
384
+ letter-spacing: 1px;
385
+ white-space: nowrap;
386
+ }
387
+ .bo-stars-on {
388
+ color: var(--bo-amber);
389
+ }
390
+ .bo-stars-off {
391
+ color: var(--bo-border);
392
+ }
393
+ .bo-tags {
394
+ display: flex;
395
+ gap: 4px;
396
+ overflow: hidden;
397
+ }
398
+ .bo-tag {
399
+ padding: 1px 7px;
400
+ font-size: 11px;
401
+ color: var(--bo-text-dim);
402
+ background: var(--bo-row-hover);
403
+ border: 0.5px solid var(--bo-border);
404
+ border-radius: 999px;
405
+ white-space: nowrap;
406
+ }
407
+ .bo-badge {
408
+ padding: 2px 9px;
409
+ font-size: 11px;
410
+ font-weight: 600;
411
+ border-radius: 999px;
412
+ white-space: nowrap;
413
+ }
414
+ .bo-badge-up {
415
+ color: var(--bo-up);
416
+ background: color-mix(in srgb, var(--bo-up) 15%, transparent);
417
+ }
418
+ .bo-badge-down {
419
+ color: var(--bo-down);
420
+ background: color-mix(in srgb, var(--bo-down) 15%, transparent);
421
+ }
422
+ .bo-badge-amber {
423
+ color: var(--bo-amber);
424
+ background: color-mix(in srgb, var(--bo-amber) 15%, transparent);
425
+ }
426
+ .bo-badge-info {
427
+ color: var(--bo-sel-border);
428
+ background: color-mix(in srgb, var(--bo-sel-border) 15%, transparent);
429
+ }
430
+ .bo-badge-neutral {
431
+ color: var(--bo-text-dim);
432
+ background: var(--bo-row-hover);
433
+ }
434
+ .bo-link {
435
+ color: var(--bo-sel-border);
436
+ text-decoration: none;
437
+ overflow: hidden;
438
+ text-overflow: ellipsis;
439
+ }
440
+ .bo-link:hover {
441
+ text-decoration: underline;
442
+ }
443
+ .bo-bool-yes {
444
+ color: var(--bo-up);
445
+ font-weight: 600;
446
+ }
447
+ .bo-bool-no {
448
+ color: var(--bo-text-dim);
449
+ }
450
+ .bo-avatar {
451
+ display: inline-flex;
452
+ align-items: center;
453
+ justify-content: center;
454
+ flex: 0 0 auto;
455
+ width: 22px;
456
+ height: 22px;
457
+ font-size: 9px;
458
+ font-weight: 700;
459
+ color: var(--bo-bg);
460
+ background: var(--bo-text-dim);
461
+ border-radius: 50%;
462
+ }
463
+ .bo-avatar-name {
464
+ min-width: 0;
465
+ overflow: hidden;
466
+ text-overflow: ellipsis;
467
+ }
468
+ .bo-avatar-name em {
469
+ margin-left: 6px;
470
+ font-style: normal;
471
+ font-size: 11px;
472
+ color: var(--bo-text-dim);
473
+ }
474
+
475
+ /* ---- Conditional formatting (v0.10): data bars + icon sets ---- */
476
+ .bo-databar {
477
+ position: absolute;
478
+ top: 50%;
479
+ height: 62%;
480
+ transform: translateY(-50%);
481
+ border-radius: 2px;
482
+ opacity: 0.22;
483
+ z-index: 0;
484
+ pointer-events: none;
485
+ }
486
+ /* Value sits above its data bar; carries the (optional) flash colour. */
487
+ .bo-cf-val {
488
+ position: relative;
489
+ z-index: 1;
490
+ display: inline-flex;
491
+ align-items: center;
492
+ gap: 5px;
493
+ min-width: 0;
494
+ overflow: hidden;
495
+ text-overflow: ellipsis;
496
+ }
497
+ .bo-cf-icon {
498
+ flex: none;
499
+ font-size: 11px;
500
+ line-height: 1;
501
+ }
270
502
  .text strong {
271
503
  font-family: var(--bo-mono);
272
504
  font-weight: 600;
@@ -301,6 +533,13 @@
301
533
  color: var(--bo-text);
302
534
  background: var(--bo-row-hover);
303
535
  }
536
+ /* Visible keyboard focus (WCAG 2.4.7). */
537
+ .tree-toggle:focus-visible,
538
+ .bo-link:focus-visible {
539
+ outline: 2px solid var(--bo-sel-border);
540
+ outline-offset: -1px;
541
+ border-radius: 3px;
542
+ }
304
543
  .tree-leaf {
305
544
  display: inline-block;
306
545
  width: 18px;
@@ -21,6 +21,12 @@ type $$ComponentProps = {
21
21
  fillCorner?: boolean;
22
22
  /** This cell is inside the in-progress fill drag's preview range. */
23
23
  fillpreview?: boolean;
24
+ /** Conditional-formatting data extent (min/max over the view) for this
25
+ column; null when the column has no `dataBar`/`colorScale`. */
26
+ cfRange?: {
27
+ min: number;
28
+ max: number;
29
+ } | null;
24
30
  colIndex?: number;
25
31
  cellId?: string;
26
32
  cellSnippet?: Snippet<[{
@@ -255,6 +255,13 @@
255
255
  .bo-fm-btn:hover {
256
256
  color: var(--bo-text);
257
257
  }
258
+ /* Visible keyboard focus (WCAG 2.4.7) for the menu's custom buttons. */
259
+ .bo-fm-btn:focus-visible,
260
+ .bo-fm-link:focus-visible {
261
+ outline: 2px solid var(--bo-sel-border);
262
+ outline-offset: 1px;
263
+ border-radius: 5px;
264
+ }
258
265
  .bo-fm-apply {
259
266
  color: #0a0a0a;
260
267
  background: var(--bo-up);