bo-grid 0.7.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.
- package/README.md +236 -17
- package/dist/bo-grid.FilterMenu-BHI6rILc.js +154 -0
- package/dist/bo-grid.ToolPanel-C3u-4YKc.js +34 -0
- package/dist/bo-grid.element-DPnHUXMa.js +6623 -0
- package/dist/bo-grid.element.js +4 -0
- package/dist/charts/BarChart.svelte +50 -0
- package/dist/charts/BarChart.svelte.d.ts +16 -0
- package/dist/charts/DonutChart.svelte +54 -0
- package/dist/charts/DonutChart.svelte.d.ts +18 -0
- package/dist/charts/Legend.svelte +47 -0
- package/dist/charts/Legend.svelte.d.ts +12 -0
- package/dist/charts/LineChart.svelte +59 -0
- package/dist/charts/LineChart.svelte.d.ts +14 -0
- package/dist/charts/StackedBarChart.svelte +56 -0
- package/dist/charts/StackedBarChart.svelte.d.ts +18 -0
- package/dist/charts/chart-math.d.ts +57 -0
- package/dist/charts/chart-math.js +174 -0
- package/dist/charts/index.d.ts +8 -0
- package/dist/charts/index.js +11 -0
- package/dist/charts/palette.d.ts +4 -0
- package/dist/charts/palette.js +14 -0
- package/dist/format/format.d.ts +6 -0
- package/dist/format/format.js +41 -0
- package/dist/grid/Cell.svelte +249 -10
- package/dist/grid/Cell.svelte.d.ts +6 -0
- package/dist/grid/FilterMenu.svelte +7 -0
- package/dist/grid/Grid.svelte +338 -87
- package/dist/grid/Grid.svelte.d.ts +19 -0
- package/dist/grid/GroupRow.svelte +5 -2
- package/dist/grid/Pager.svelte +4 -0
- package/dist/grid/RowMenu.svelte +65 -2
- package/dist/grid/ToolPanel.svelte +5 -0
- package/dist/grid/column.d.ts +133 -0
- package/dist/grid/column.js +133 -4
- package/dist/grid/colvirt.d.ts +15 -0
- package/dist/grid/colvirt.js +43 -0
- package/dist/grid/export.js +5 -2
- package/dist/grid/filtering.d.ts +5 -2
- package/dist/grid/filtering.js +5 -4
- package/dist/grid/grouping.d.ts +30 -0
- package/dist/grid/grouping.js +33 -0
- package/dist/grid/theme.d.ts +25 -0
- package/dist/grid/theme.js +84 -0
- package/dist/grid/tree.d.ts +19 -7
- package/dist/grid/tree.js +16 -11
- package/dist/index.d.ts +5 -4
- package/dist/index.js +2 -2
- package/dist/sparkline/Sparkline.svelte +8 -2
- package/dist/sparkline/sparkline-render.d.ts +4 -1
- package/dist/sparkline/sparkline-render.js +5 -3
- package/package.json +12 -2
|
@@ -2,6 +2,7 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
import type { ColumnDef, GridRow, SortState, CellEditEvent } from './column';
|
|
3
3
|
import { type GridTheme } from './theme';
|
|
4
4
|
import { type AggKind } from './aggregate';
|
|
5
|
+
import { type LazyGroup } from './grouping';
|
|
5
6
|
import { type ColumnFilter } from './filtering';
|
|
6
7
|
import type { RowSource } from './source';
|
|
7
8
|
type $$ComponentProps = {
|
|
@@ -35,6 +36,10 @@ type $$ComponentProps = {
|
|
|
35
36
|
(the place to restore columns hidden via the menu). Lazy-loaded. Default
|
|
36
37
|
false. */
|
|
37
38
|
columnsPanel?: boolean;
|
|
39
|
+
/** Render only the columns within the horizontal scroll window (+ overscan)
|
|
40
|
+
for very wide grids (100+ columns). Forces fixed-width horizontal scroll;
|
|
41
|
+
pinned columns always render. Default false (fit-to-width). */
|
|
42
|
+
virtualizeColumns?: boolean;
|
|
38
43
|
/** Return extra CSS class(es) for a data row (e.g. to colour by value).
|
|
39
44
|
Style them via `:global(.your-class)` since rows live inside the grid. */
|
|
40
45
|
rowClass?: (row: GridRow) => string | undefined;
|
|
@@ -108,6 +113,20 @@ type $$ComponentProps = {
|
|
|
108
113
|
`rows` are the roots; the grid renders an indented, expandable tree.
|
|
109
114
|
In-memory mode; filter/sort/group/paginate are not applied to the tree. */
|
|
110
115
|
getChildren?: (row: GridRow) => GridRow[] | undefined;
|
|
116
|
+
/** Async tree data: load a row's children on expand (server-backed trees).
|
|
117
|
+
Returns a promise; the grid shows a loading row, then caches the result.
|
|
118
|
+
Pair with `hasChildren` (a cheap predicate) so chevrons show without
|
|
119
|
+
loading. Use instead of `getChildren`. In-memory roots. */
|
|
120
|
+
loadChildren?: (row: GridRow) => Promise<GridRow[]>;
|
|
121
|
+
/** Cheap predicate for whether a row has children (drives the expand chevron
|
|
122
|
+
without loading). Required with `loadChildren`; optional with `getChildren`. */
|
|
123
|
+
hasChildren?: (row: GridRow) => boolean;
|
|
124
|
+
/** Server-side grouping: top-level group summaries (header data, no leaf rows).
|
|
125
|
+
Each group's rows load on expand via `loadGroup`. In-memory mode. */
|
|
126
|
+
lazyGroups?: LazyGroup[];
|
|
127
|
+
/** Load a lazy group's leaf rows on expand (returns a promise; shows a loading
|
|
128
|
+
row, then caches). Required with `lazyGroups`. */
|
|
129
|
+
loadGroup?: (key: string) => Promise<GridRow[]>;
|
|
111
130
|
/** Enable drag-to-reorder rows via a handle in the first column. Called with
|
|
112
131
|
the from/to indices (into the visible rows) on drop — reorder your own
|
|
113
132
|
`rows` in here. Flat, unsorted, in-memory lists only. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { ColumnDef, GridRow } from './column';
|
|
3
|
-
import { colStyle, formatCell } from './column';
|
|
3
|
+
import { colStyle, formatCell, cellValue } from './column';
|
|
4
4
|
import { aggregate } from './aggregate';
|
|
5
5
|
import type { GroupNode } from './grouping';
|
|
6
6
|
|
|
@@ -19,10 +19,13 @@
|
|
|
19
19
|
// Aggregate over the group's leaf rows. Reads row $state values, so group
|
|
20
20
|
// subtotals stay live as the feed ticks (only on-screen groups are rendered).
|
|
21
21
|
function aggText(col: ColumnDef): string {
|
|
22
|
+
// Lazy/server groups carry preformatted aggregate strings (leaf rows aren't
|
|
23
|
+
// loaded), so use those directly when present.
|
|
24
|
+
if (group.aggText) return group.aggText[col.key] ?? '';
|
|
22
25
|
if (col.type === 'sparkline' || col.type === 'text' || !col.groupAgg) return '';
|
|
23
26
|
const vals: number[] = [];
|
|
24
27
|
for (const row of group.rows) {
|
|
25
|
-
const v = Number((row as GridRow)
|
|
28
|
+
const v = Number(cellValue(col, row as GridRow));
|
|
26
29
|
if (Number.isFinite(v)) vals.push(v);
|
|
27
30
|
}
|
|
28
31
|
const a = aggregate(vals);
|
package/dist/grid/Pager.svelte
CHANGED
package/dist/grid/RowMenu.svelte
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// Floating
|
|
3
|
-
//
|
|
2
|
+
// Floating action menu (row context menu + column header menu). Presentation +
|
|
3
|
+
// its own keyboard semantics (APG menu pattern): focus moves into the menu on
|
|
4
|
+
// open, Arrow/Home/End move between items, Enter/Space activate, Esc/Tab close.
|
|
5
|
+
// The parent owns open/close state and positions it via x/y.
|
|
4
6
|
let {
|
|
5
7
|
x,
|
|
6
8
|
y,
|
|
@@ -12,14 +14,69 @@
|
|
|
12
14
|
items: Array<{ label: string; onSelect: () => void }>;
|
|
13
15
|
onClose: () => void;
|
|
14
16
|
} = $props();
|
|
17
|
+
|
|
18
|
+
let menuEl: HTMLDivElement;
|
|
19
|
+
|
|
20
|
+
const buttons = (): HTMLButtonElement[] =>
|
|
21
|
+
menuEl ? [...menuEl.querySelectorAll<HTMLButtonElement>('.rowmenu-item')] : [];
|
|
22
|
+
|
|
23
|
+
function focusAt(i: number): void {
|
|
24
|
+
const b = buttons();
|
|
25
|
+
if (b.length === 0) return;
|
|
26
|
+
b[((i % b.length) + b.length) % b.length].focus();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Move focus into the menu on open; restore it to the opener on close.
|
|
30
|
+
$effect(() => {
|
|
31
|
+
const opener = document.activeElement as HTMLElement | null;
|
|
32
|
+
focusAt(0);
|
|
33
|
+
return () => opener?.focus?.();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
37
|
+
const b = buttons();
|
|
38
|
+
const i = b.indexOf(document.activeElement as HTMLButtonElement);
|
|
39
|
+
switch (e.key) {
|
|
40
|
+
case 'ArrowDown':
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
focusAt(i + 1);
|
|
44
|
+
break;
|
|
45
|
+
case 'ArrowUp':
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
focusAt(i - 1);
|
|
49
|
+
break;
|
|
50
|
+
case 'Home':
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
e.stopPropagation();
|
|
53
|
+
focusAt(0);
|
|
54
|
+
break;
|
|
55
|
+
case 'End':
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
focusAt(b.length - 1);
|
|
59
|
+
break;
|
|
60
|
+
case 'Escape':
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
onClose();
|
|
64
|
+
break;
|
|
65
|
+
case 'Tab':
|
|
66
|
+
onClose(); // close and let focus return to the opener
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
15
70
|
</script>
|
|
16
71
|
|
|
17
72
|
<div
|
|
18
73
|
class="rowmenu"
|
|
19
74
|
role="menu"
|
|
20
75
|
tabindex="-1"
|
|
76
|
+
bind:this={menuEl}
|
|
21
77
|
style="left:{x}px;top:{y}px;"
|
|
22
78
|
onpointerdown={(e) => e.stopPropagation()}
|
|
79
|
+
onkeydown={onKeydown}
|
|
23
80
|
>
|
|
24
81
|
{#each items as item (item.label)}
|
|
25
82
|
<button
|
|
@@ -63,4 +120,10 @@
|
|
|
63
120
|
.rowmenu-item:hover {
|
|
64
121
|
background: var(--bo-row-hover);
|
|
65
122
|
}
|
|
123
|
+
/* Visible keyboard focus (WCAG 2.4.7) — the menu is arrow-navigable. */
|
|
124
|
+
.rowmenu-item:focus-visible {
|
|
125
|
+
background: var(--bo-row-hover);
|
|
126
|
+
outline: 2px solid var(--bo-sel-border);
|
|
127
|
+
outline-offset: -2px;
|
|
128
|
+
}
|
|
66
129
|
</style>
|
package/dist/grid/column.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ interface ColBase {
|
|
|
7
7
|
/** Field on the row to read for this column's value. */
|
|
8
8
|
key: string;
|
|
9
9
|
header: string;
|
|
10
|
+
/** Computed value: derive this cell's value from the whole row instead of
|
|
11
|
+
reading `row[key]` (KPIs, ratios, deltas). Flows through display, sort,
|
|
12
|
+
filter, aggregation, export and conditional formatting. `key` still names
|
|
13
|
+
the column (and is the sort/filter key) but need not be a real row field.
|
|
14
|
+
Keep it cheap and pure — it's called during sort/filter. Computed columns
|
|
15
|
+
aren't editable. In-memory mode (a server source owns its own derivations). */
|
|
16
|
+
value?: (row: GridRow) => unknown;
|
|
10
17
|
/** Fixed width in px. Ignored when `flex` is set. */
|
|
11
18
|
width?: number;
|
|
12
19
|
/** Min/max width (px) enforced while drag-resizing this column. */
|
|
@@ -57,6 +64,58 @@ interface ColBase {
|
|
|
57
64
|
<Grid>). Defaults to the column's type; `'set'` shows a value checklist;
|
|
58
65
|
`false` disables filtering for this column. */
|
|
59
66
|
filter?: false | FilterKind;
|
|
67
|
+
/** Conditional formatting — a horizontal bar painted behind the cell value,
|
|
68
|
+
scaled across the column's value range. The range is auto-computed over the
|
|
69
|
+
current view (in-memory) unless `min`/`max` are given; pass `min: 0` for
|
|
70
|
+
absolute proportional bars. Bars cross a zero baseline when the range spans
|
|
71
|
+
negatives. Numeric columns only. */
|
|
72
|
+
dataBar?: DataBarConfig;
|
|
73
|
+
/** Conditional formatting — show an icon beside the value, chosen by the
|
|
74
|
+
highest threshold `at` that is ≤ the cell value (e.g. ▲ ● ▼ by sign or band).
|
|
75
|
+
Numeric columns only. */
|
|
76
|
+
icons?: IconRule[];
|
|
77
|
+
/** Conditional formatting — tint the cell background across the column's value
|
|
78
|
+
range (a soft heat ramp). Auto-ranged over the current view unless `min`/
|
|
79
|
+
`max` are given; pass `mid` for a 3-stop diverging scale (e.g. `mid: 0`).
|
|
80
|
+
Numeric columns only. */
|
|
81
|
+
colorScale?: ColorScaleConfig;
|
|
82
|
+
}
|
|
83
|
+
/** Conditional-formatting data-bar config (see `ColBase.dataBar`). */
|
|
84
|
+
export interface DataBarConfig {
|
|
85
|
+
min?: number;
|
|
86
|
+
max?: number;
|
|
87
|
+
/** Bar colour for values ≥ 0 (default `--bo-up`). Any CSS colour or var. */
|
|
88
|
+
color?: string;
|
|
89
|
+
/** Bar colour for values < 0 (default `--bo-down`). */
|
|
90
|
+
negative?: string;
|
|
91
|
+
}
|
|
92
|
+
/** Conditional-formatting colour-scale config (see `ColBase.colorScale`). */
|
|
93
|
+
export interface ColorScaleConfig {
|
|
94
|
+
min?: number;
|
|
95
|
+
/** Midpoint for a 3-stop diverging scale (e.g. `0`). Omit for a 2-stop scale. */
|
|
96
|
+
mid?: number;
|
|
97
|
+
max?: number;
|
|
98
|
+
/** Stop colours — `[low, high]` (2-stop) or `[low, mid, high]` (3-stop). Any
|
|
99
|
+
CSS colour or var. Defaults to a soft, translucent theme ramp (up tint for
|
|
100
|
+
high; down→up diverging when `mid` is set). */
|
|
101
|
+
colors?: [string, string] | [string, string, string];
|
|
102
|
+
}
|
|
103
|
+
/** A single icon-set threshold rule (see `ColBase.icons`). */
|
|
104
|
+
export interface IconRule {
|
|
105
|
+
/** Lower bound: this rule wins when `value >= at` and `at` is the greatest
|
|
106
|
+
such threshold. */
|
|
107
|
+
at: number;
|
|
108
|
+
icon: string;
|
|
109
|
+
tone?: BadgeTone;
|
|
110
|
+
}
|
|
111
|
+
/** Resolved data-bar geometry as fractions of the cell width (0..1). */
|
|
112
|
+
export interface DataBarGeom {
|
|
113
|
+
/** Distance of the bar's left edge from the cell's left edge. */
|
|
114
|
+
left: number;
|
|
115
|
+
/** Bar width. */
|
|
116
|
+
width: number;
|
|
117
|
+
/** The value is negative (use the bar's `negative` colour). */
|
|
118
|
+
negative: boolean;
|
|
60
119
|
}
|
|
61
120
|
export interface CellEditEvent {
|
|
62
121
|
row: GridRow;
|
|
@@ -83,6 +142,13 @@ export type ColumnDef = (ColBase & {
|
|
|
83
142
|
}) | (ColBase & {
|
|
84
143
|
type: 'date';
|
|
85
144
|
dateStyle?: DateStyle;
|
|
145
|
+
}) | (ColBase & {
|
|
146
|
+
type: 'currency';
|
|
147
|
+
currency?: string;
|
|
148
|
+
locale?: string;
|
|
149
|
+
decimals?: number;
|
|
150
|
+
}) | (ColBase & {
|
|
151
|
+
type: 'relative';
|
|
86
152
|
}) | (ColBase & {
|
|
87
153
|
type: 'heatmap';
|
|
88
154
|
min: number;
|
|
@@ -91,9 +157,34 @@ export type ColumnDef = (ColBase & {
|
|
|
91
157
|
}) | (ColBase & {
|
|
92
158
|
type: 'sparkline';
|
|
93
159
|
sparkKey: string;
|
|
160
|
+
}) | (ColBase & {
|
|
161
|
+
type: 'progress';
|
|
162
|
+
min?: number;
|
|
163
|
+
max?: number;
|
|
164
|
+
}) | (ColBase & {
|
|
165
|
+
type: 'rating';
|
|
166
|
+
max?: number;
|
|
167
|
+
}) | (ColBase & {
|
|
168
|
+
type: 'tags';
|
|
169
|
+
}) | (ColBase & {
|
|
170
|
+
type: 'badge';
|
|
171
|
+
tones?: Record<string, BadgeTone>;
|
|
172
|
+
}) | (ColBase & {
|
|
173
|
+
type: 'boolean';
|
|
174
|
+
trueLabel?: string;
|
|
175
|
+
falseLabel?: string;
|
|
176
|
+
}) | (ColBase & {
|
|
177
|
+
type: 'avatar';
|
|
178
|
+
sub?: string;
|
|
179
|
+
}) | (ColBase & {
|
|
180
|
+
type: 'link';
|
|
181
|
+
href?: (row: GridRow) => string;
|
|
182
|
+
newTab?: boolean;
|
|
94
183
|
}) | (ColBase & {
|
|
95
184
|
type: 'custom';
|
|
96
185
|
});
|
|
186
|
+
/** Semantic colour for a `badge` value (mapped via the column's `tones`). */
|
|
187
|
+
export type BadgeTone = 'up' | 'down' | 'amber' | 'info' | 'neutral';
|
|
97
188
|
export type SortDir = 'asc' | 'desc';
|
|
98
189
|
export interface SortState {
|
|
99
190
|
key: string;
|
|
@@ -106,6 +197,12 @@ export interface GridRow {
|
|
|
106
197
|
flashDir: 'up' | 'down';
|
|
107
198
|
[field: string]: unknown;
|
|
108
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The cell's value: a column's computed `value(row)` when defined, else the row
|
|
202
|
+
* field `row[key]`. Route ALL value reads through this so computed columns flow
|
|
203
|
+
* through display, sort, filter, aggregation and export.
|
|
204
|
+
*/
|
|
205
|
+
export declare function cellValue(col: ColumnDef, row: GridRow): unknown;
|
|
109
206
|
export declare function formatCell(col: ColumnDef, value: unknown, row?: GridRow): string;
|
|
110
207
|
export declare function isSortable(col: ColumnDef): boolean;
|
|
111
208
|
export declare function isEditable(col: ColumnDef): boolean;
|
|
@@ -120,4 +217,40 @@ export declare function colStyle(col: ColumnDef): string;
|
|
|
120
217
|
export declare function colWidth(col: ColumnDef): number;
|
|
121
218
|
export declare function isNumeric(col: ColumnDef): boolean;
|
|
122
219
|
export declare function candlesOf(row: GridRow, key: string): Candle[];
|
|
220
|
+
/**
|
|
221
|
+
* Conditional formatting — data-bar geometry. Maps `value` onto a 0..1 range,
|
|
222
|
+
* anchored at the zero baseline so signed columns diverge left/right around zero
|
|
223
|
+
* (left-anchored when the range doesn't cross zero). `range` is the data extent;
|
|
224
|
+
* `cfg.min`/`cfg.max` override it (e.g. `min: 0` for absolute bars). Returns null
|
|
225
|
+
* for non-numeric values (no bar). Pure; unit-tested.
|
|
226
|
+
*/
|
|
227
|
+
export declare function dataBarGeometry(value: unknown, range: {
|
|
228
|
+
min: number;
|
|
229
|
+
max: number;
|
|
230
|
+
}, cfg?: {
|
|
231
|
+
min?: number;
|
|
232
|
+
max?: number;
|
|
233
|
+
}): DataBarGeom | null;
|
|
234
|
+
/**
|
|
235
|
+
* Conditional formatting — colour-scale background for a value across the range.
|
|
236
|
+
* Two-stop (low→high) by default; pass `cfg.mid` (or 3 `colors`) for a diverging
|
|
237
|
+
* scale. `range` is the data extent; `cfg.min`/`cfg.max` override it. Returns null
|
|
238
|
+
* for non-numeric values (no tint). Pure; unit-tested.
|
|
239
|
+
*/
|
|
240
|
+
export declare function colorScaleBackground(value: unknown, range: {
|
|
241
|
+
min: number;
|
|
242
|
+
max: number;
|
|
243
|
+
}, cfg?: ColorScaleConfig): string | null;
|
|
244
|
+
/**
|
|
245
|
+
* Conditional formatting — pick the icon-set rule for a value: the rule with the
|
|
246
|
+
* greatest `at` threshold that is ≤ the value (order-independent). Returns null
|
|
247
|
+
* for non-numeric values or when no threshold matches. Pure; unit-tested.
|
|
248
|
+
*/
|
|
249
|
+
export declare function pickIcon(value: unknown, rules: readonly IconRule[]): IconRule | null;
|
|
250
|
+
/** Sanitize a `link` column's href: block the XSS-prone url schemes
|
|
251
|
+
(javascript:/data:/vbscript:), pass everything else (http(s)/mailto/tel/
|
|
252
|
+
relative). Returns undefined for an empty or unsafe href (renders as text). */
|
|
253
|
+
export declare function safeHref(url: string): string | undefined;
|
|
254
|
+
/** Semantic tone → grid theme colour var (shared by badges + icon sets). */
|
|
255
|
+
export declare function toneColor(tone?: BadgeTone): string;
|
|
123
256
|
export {};
|
package/dist/grid/column.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import { fmtPrice, fmtPercent, fmtVolume, fmtDate } from '../format/format';
|
|
1
|
+
import { fmtPrice, fmtPercent, fmtVolume, fmtDate, fmtCurrency, relativeTime } from '../format/format';
|
|
2
|
+
/**
|
|
3
|
+
* The cell's value: a column's computed `value(row)` when defined, else the row
|
|
4
|
+
* field `row[key]`. Route ALL value reads through this so computed columns flow
|
|
5
|
+
* through display, sort, filter, aggregation and export.
|
|
6
|
+
*/
|
|
7
|
+
export function cellValue(col, row) {
|
|
8
|
+
return col.value ? col.value(row) : row[col.key];
|
|
9
|
+
}
|
|
2
10
|
export function formatCell(col, value, row) {
|
|
3
11
|
if (col.format)
|
|
4
12
|
return col.format(value, row);
|
|
@@ -14,17 +22,31 @@ export function formatCell(col, value, row) {
|
|
|
14
22
|
return n.toFixed(col.decimals ?? 2);
|
|
15
23
|
case 'date':
|
|
16
24
|
return fmtDate(n, col.dateStyle);
|
|
25
|
+
case 'currency':
|
|
26
|
+
return fmtCurrency(n, col.currency, col.locale, col.decimals);
|
|
27
|
+
case 'relative':
|
|
28
|
+
return relativeTime(n);
|
|
17
29
|
case 'heatmap':
|
|
18
30
|
return n.toFixed(col.decimals ?? 2);
|
|
31
|
+
case 'rating':
|
|
32
|
+
return `${value ?? 0}`;
|
|
33
|
+
case 'tags':
|
|
34
|
+
return Array.isArray(value) ? value.join(', ') : String(value ?? '');
|
|
35
|
+
case 'boolean':
|
|
36
|
+
return value ? (col.trueLabel ?? 'Yes') : (col.falseLabel ?? 'No');
|
|
19
37
|
default:
|
|
38
|
+
// progress / badge / avatar / link / text and friends.
|
|
20
39
|
return value == null ? '' : String(value);
|
|
21
40
|
}
|
|
22
41
|
}
|
|
23
42
|
export function isSortable(col) {
|
|
24
43
|
return col.type !== 'sparkline' && col.sortable !== false;
|
|
25
44
|
}
|
|
45
|
+
// Rich display widgets aren't text-editable (they render structured/visual data).
|
|
46
|
+
const DISPLAY_ONLY = ['sparkline', 'custom', 'tags', 'badge', 'boolean', 'avatar', 'progress', 'rating', 'link', 'relative'];
|
|
26
47
|
export function isEditable(col) {
|
|
27
|
-
|
|
48
|
+
// Computed columns have no underlying field to write back to.
|
|
49
|
+
return !!col.editable && !col.value && !DISPLAY_ONLY.includes(col.type);
|
|
28
50
|
}
|
|
29
51
|
function rawCompare(a, b) {
|
|
30
52
|
if (typeof a === 'number' && typeof b === 'number')
|
|
@@ -32,7 +54,11 @@ function rawCompare(a, b) {
|
|
|
32
54
|
return String(a ?? '').localeCompare(String(b ?? ''));
|
|
33
55
|
}
|
|
34
56
|
export function compareRows(a, b, sort, col) {
|
|
35
|
-
|
|
57
|
+
// Computed columns sort by their derived value; everything else reads the
|
|
58
|
+
// authoritative sort key off the row.
|
|
59
|
+
const av = col?.value ? col.value(a) : a[sort.key];
|
|
60
|
+
const bv = col?.value ? col.value(b) : b[sort.key];
|
|
61
|
+
const d = col?.compare ? col.compare(av, bv) : rawCompare(av, bv);
|
|
36
62
|
return sort.dir === 'asc' ? d : -d;
|
|
37
63
|
}
|
|
38
64
|
/**
|
|
@@ -57,8 +83,111 @@ export function colWidth(col) {
|
|
|
57
83
|
return col.flex ? (col.width ?? 160) : (col.width ?? 96);
|
|
58
84
|
}
|
|
59
85
|
export function isNumeric(col) {
|
|
60
|
-
|
|
86
|
+
// Non-numeric (text-aligned / structured) types; everything else is a number.
|
|
87
|
+
return !['text', 'sparkline', 'custom', 'tags', 'badge', 'boolean', 'avatar', 'link'].includes(col.type);
|
|
61
88
|
}
|
|
62
89
|
export function candlesOf(row, key) {
|
|
63
90
|
return row[key] ?? [];
|
|
64
91
|
}
|
|
92
|
+
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
|
93
|
+
// Coerce to a number, but treat null/undefined/'' as non-numeric (NaN) — guards
|
|
94
|
+
// against `Number(null) === 0` slipping blanks through as a real zero value.
|
|
95
|
+
function numericOrNaN(value) {
|
|
96
|
+
if (value === null || value === undefined || value === '')
|
|
97
|
+
return NaN;
|
|
98
|
+
return typeof value === 'number' ? value : Number(value);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Conditional formatting — data-bar geometry. Maps `value` onto a 0..1 range,
|
|
102
|
+
* anchored at the zero baseline so signed columns diverge left/right around zero
|
|
103
|
+
* (left-anchored when the range doesn't cross zero). `range` is the data extent;
|
|
104
|
+
* `cfg.min`/`cfg.max` override it (e.g. `min: 0` for absolute bars). Returns null
|
|
105
|
+
* for non-numeric values (no bar). Pure; unit-tested.
|
|
106
|
+
*/
|
|
107
|
+
export function dataBarGeometry(value, range, cfg = {}) {
|
|
108
|
+
const n = numericOrNaN(value);
|
|
109
|
+
if (!Number.isFinite(n))
|
|
110
|
+
return null;
|
|
111
|
+
const min = cfg.min ?? range.min;
|
|
112
|
+
const max = cfg.max ?? range.max;
|
|
113
|
+
const span = max - min || 1;
|
|
114
|
+
const zero = clamp01((0 - min) / span);
|
|
115
|
+
const v = clamp01((n - min) / span);
|
|
116
|
+
return { left: Math.min(zero, v), width: Math.abs(v - zero), negative: n < 0 };
|
|
117
|
+
}
|
|
118
|
+
// Default colour-scale stops — soft, translucent theme tints so row striping
|
|
119
|
+
// shows through and text stays readable (mixed in sRGB to keep clean alpha).
|
|
120
|
+
const SCALE_SEQ = ['transparent', 'color-mix(in srgb, var(--bo-up) 32%, transparent)'];
|
|
121
|
+
const SCALE_DIV = [
|
|
122
|
+
'color-mix(in srgb, var(--bo-down) 34%, transparent)',
|
|
123
|
+
'transparent',
|
|
124
|
+
'color-mix(in srgb, var(--bo-up) 34%, transparent)',
|
|
125
|
+
];
|
|
126
|
+
const mixColor = (a, b, t) => `color-mix(in srgb, ${b} ${(clamp01(t) * 100).toFixed(1)}%, ${a})`;
|
|
127
|
+
/**
|
|
128
|
+
* Conditional formatting — colour-scale background for a value across the range.
|
|
129
|
+
* Two-stop (low→high) by default; pass `cfg.mid` (or 3 `colors`) for a diverging
|
|
130
|
+
* scale. `range` is the data extent; `cfg.min`/`cfg.max` override it. Returns null
|
|
131
|
+
* for non-numeric values (no tint). Pure; unit-tested.
|
|
132
|
+
*/
|
|
133
|
+
export function colorScaleBackground(value, range, cfg = {}) {
|
|
134
|
+
const n = numericOrNaN(value);
|
|
135
|
+
if (!Number.isFinite(n))
|
|
136
|
+
return null;
|
|
137
|
+
const min = cfg.min ?? range.min;
|
|
138
|
+
const max = cfg.max ?? range.max;
|
|
139
|
+
const span = max - min || 1;
|
|
140
|
+
const t = clamp01((n - min) / span);
|
|
141
|
+
if (cfg.mid != null || cfg.colors?.length === 3) {
|
|
142
|
+
const [lo, md, hi] = cfg.colors ?? SCALE_DIV;
|
|
143
|
+
const mt = clamp01(((cfg.mid ?? (min + max) / 2) - min) / span);
|
|
144
|
+
return t <= mt
|
|
145
|
+
? mixColor(lo, md, mt === 0 ? 1 : t / mt)
|
|
146
|
+
: mixColor(md, hi, mt === 1 ? 1 : (t - mt) / (1 - mt));
|
|
147
|
+
}
|
|
148
|
+
const [lo, hi] = cfg.colors ?? SCALE_SEQ;
|
|
149
|
+
return mixColor(lo, hi, t);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Conditional formatting — pick the icon-set rule for a value: the rule with the
|
|
153
|
+
* greatest `at` threshold that is ≤ the value (order-independent). Returns null
|
|
154
|
+
* for non-numeric values or when no threshold matches. Pure; unit-tested.
|
|
155
|
+
*/
|
|
156
|
+
export function pickIcon(value, rules) {
|
|
157
|
+
const n = numericOrNaN(value);
|
|
158
|
+
if (!Number.isFinite(n))
|
|
159
|
+
return null;
|
|
160
|
+
let pick = null;
|
|
161
|
+
for (const rule of rules) {
|
|
162
|
+
if (n >= rule.at && (pick === null || rule.at >= pick.at))
|
|
163
|
+
pick = rule;
|
|
164
|
+
}
|
|
165
|
+
return pick;
|
|
166
|
+
}
|
|
167
|
+
/** Sanitize a `link` column's href: block the XSS-prone url schemes
|
|
168
|
+
(javascript:/data:/vbscript:), pass everything else (http(s)/mailto/tel/
|
|
169
|
+
relative). Returns undefined for an empty or unsafe href (renders as text). */
|
|
170
|
+
export function safeHref(url) {
|
|
171
|
+
const u = url.trim();
|
|
172
|
+
if (!u)
|
|
173
|
+
return undefined;
|
|
174
|
+
// eslint-disable-next-line no-script-url
|
|
175
|
+
if (/^(javascript|data|vbscript):/i.test(u))
|
|
176
|
+
return undefined;
|
|
177
|
+
return u;
|
|
178
|
+
}
|
|
179
|
+
/** Semantic tone → grid theme colour var (shared by badges + icon sets). */
|
|
180
|
+
export function toneColor(tone) {
|
|
181
|
+
switch (tone) {
|
|
182
|
+
case 'up':
|
|
183
|
+
return 'var(--bo-up)';
|
|
184
|
+
case 'down':
|
|
185
|
+
return 'var(--bo-down)';
|
|
186
|
+
case 'amber':
|
|
187
|
+
return 'var(--bo-amber)';
|
|
188
|
+
case 'info':
|
|
189
|
+
return 'var(--bo-sel-border)';
|
|
190
|
+
default:
|
|
191
|
+
return 'var(--bo-text-dim)';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ColRenderItem = {
|
|
2
|
+
kind: 'cell';
|
|
3
|
+
ci: number;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'spacer';
|
|
6
|
+
w: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Given each column's x-offset (`x`) and `widths`, which columns are `pinned`
|
|
10
|
+
* (always rendered), and the horizontal viewport (`scrollLeft`..`+viewW`, grown
|
|
11
|
+
* by `overscan` px each side), return the render items in column order.
|
|
12
|
+
*/
|
|
13
|
+
export declare function columnWindow(x: readonly number[], widths: readonly number[], pinned: readonly boolean[], scrollLeft: number, viewW: number, overscan: number): ColRenderItem[];
|
|
14
|
+
/** Prefix-sum of column widths → each column's left x-offset. */
|
|
15
|
+
export declare function columnOffsets(widths: readonly number[]): number[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Column (horizontal) virtualization math. Pure + unit-tested; the Grid maps the
|
|
2
|
+
// result to <Cell> / spacer elements. Off-window runs of columns collapse into a
|
|
3
|
+
// single spacer of their summed width so positions/total width stay exact.
|
|
4
|
+
/**
|
|
5
|
+
* Given each column's x-offset (`x`) and `widths`, which columns are `pinned`
|
|
6
|
+
* (always rendered), and the horizontal viewport (`scrollLeft`..`+viewW`, grown
|
|
7
|
+
* by `overscan` px each side), return the render items in column order.
|
|
8
|
+
*/
|
|
9
|
+
export function columnWindow(x, widths, pinned, scrollLeft, viewW, overscan) {
|
|
10
|
+
const n = widths.length;
|
|
11
|
+
const lo = scrollLeft - overscan;
|
|
12
|
+
const hi = scrollLeft + viewW + overscan;
|
|
13
|
+
const out = [];
|
|
14
|
+
let pending = 0;
|
|
15
|
+
const flush = () => {
|
|
16
|
+
if (pending > 0) {
|
|
17
|
+
out.push({ kind: 'spacer', w: pending });
|
|
18
|
+
pending = 0;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
for (let ci = 0; ci < n; ci++) {
|
|
22
|
+
const visible = pinned[ci] || (x[ci] + widths[ci] > lo && x[ci] < hi);
|
|
23
|
+
if (visible) {
|
|
24
|
+
flush();
|
|
25
|
+
out.push({ kind: 'cell', ci });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
pending += widths[ci];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
flush();
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** Prefix-sum of column widths → each column's left x-offset. */
|
|
35
|
+
export function columnOffsets(widths) {
|
|
36
|
+
const xs = [];
|
|
37
|
+
let x = 0;
|
|
38
|
+
for (const w of widths) {
|
|
39
|
+
xs.push(x);
|
|
40
|
+
x += w;
|
|
41
|
+
}
|
|
42
|
+
return xs;
|
|
43
|
+
}
|
package/dist/grid/export.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatCell, isNumeric } from './column';
|
|
1
|
+
import { formatCell, isNumeric, cellValue } from './column';
|
|
2
2
|
function rawValue(col, v) {
|
|
3
3
|
if (col.type === 'date')
|
|
4
4
|
return formatCell(col, v); // epoch ms isn't useful raw
|
|
@@ -19,7 +19,10 @@ export function rowsToMatrix(rows, columns, opts = {}) {
|
|
|
19
19
|
if (opts.header !== false)
|
|
20
20
|
matrix.push(cols.map((c) => c.header));
|
|
21
21
|
for (const row of rows) {
|
|
22
|
-
matrix.push(cols.map((c) =>
|
|
22
|
+
matrix.push(cols.map((c) => {
|
|
23
|
+
const v = cellValue(c, row);
|
|
24
|
+
return opts.formatted ? formatCell(c, v, row) : rawValue(c, v);
|
|
25
|
+
}));
|
|
23
26
|
}
|
|
24
27
|
return matrix;
|
|
25
28
|
}
|
package/dist/grid/filtering.d.ts
CHANGED
|
@@ -35,7 +35,10 @@ export declare function emptyFilter(kind: FilterKind): ColumnFilter;
|
|
|
35
35
|
export declare function isFilterActive(f: ColumnFilter | undefined | null): boolean;
|
|
36
36
|
/** Does one cell value satisfy one filter? An inactive filter passes everything. */
|
|
37
37
|
export declare function matchesFilter(value: unknown, f: ColumnFilter): boolean;
|
|
38
|
+
/** Resolve a column's value for filtering — `row[key]` by default; Grid passes a
|
|
39
|
+
computed-aware resolver so computed columns filter on their derived value. */
|
|
40
|
+
export type ValueResolver = (row: GridRow, key: string) => unknown;
|
|
38
41
|
/** AND across every active per-column filter. */
|
|
39
|
-
export declare function passesFilters(row: GridRow, filters: Record<string, ColumnFilter
|
|
42
|
+
export declare function passesFilters(row: GridRow, filters: Record<string, ColumnFilter>, valueOf?: ValueResolver): boolean;
|
|
40
43
|
/** Sorted unique string values for a column — the set-filter checklist. */
|
|
41
|
-
export declare function distinctValues(rows: readonly GridRow[], key: string): string[];
|
|
44
|
+
export declare function distinctValues(rows: readonly GridRow[], key: string, valueOf?: ValueResolver): string[];
|
package/dist/grid/filtering.js
CHANGED
|
@@ -89,19 +89,20 @@ export function matchesFilter(value, f) {
|
|
|
89
89
|
}
|
|
90
90
|
return true; // unreachable fallback
|
|
91
91
|
}
|
|
92
|
+
const byKey = (row, key) => row[key];
|
|
92
93
|
/** AND across every active per-column filter. */
|
|
93
|
-
export function passesFilters(row, filters) {
|
|
94
|
+
export function passesFilters(row, filters, valueOf = byKey) {
|
|
94
95
|
for (const key in filters) {
|
|
95
96
|
const f = filters[key];
|
|
96
|
-
if (isFilterActive(f) && !matchesFilter(row
|
|
97
|
+
if (isFilterActive(f) && !matchesFilter(valueOf(row, key), f))
|
|
97
98
|
return false;
|
|
98
99
|
}
|
|
99
100
|
return true;
|
|
100
101
|
}
|
|
101
102
|
/** Sorted unique string values for a column — the set-filter checklist. */
|
|
102
|
-
export function distinctValues(rows, key) {
|
|
103
|
+
export function distinctValues(rows, key, valueOf = byKey) {
|
|
103
104
|
const seen = new Set();
|
|
104
105
|
for (const row of rows)
|
|
105
|
-
seen.add(String(row
|
|
106
|
+
seen.add(String(valueOf(row, key) ?? ''));
|
|
106
107
|
return [...seen].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
|
107
108
|
}
|