@toclocoinc/lattice-grid 1.4.3 → 1.5.5
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/LICENSE +1 -6
- package/README.md +38 -6
- package/docs/AI-SKILL.md +471 -0
- package/docs/API.html +2168 -0
- package/docs/api-detail.html +3886 -0
- package/lattice-grid.d.ts +9 -1
- package/lattice-grid.esm.min.js +519 -22
- package/lattice-grid.min.cjs +47322 -0
- package/lattice-grid.min.css +1 -1
- package/lattice-grid.min.js +518 -22
- package/modules/devtools.esm.min.js +66 -7
- package/modules/react.esm.min.js +2 -2
- package/modules/svelte.esm.min.js +2 -2
- package/modules/vue.esm.min.js +2 -2
- package/modules/webcomponent.esm.min.js +518 -22
- package/package.json +7 -9
|
@@ -0,0 +1,3886 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en-GB">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="description" content="Lattice Grid — a high-performance JavaScript data grid with no dependencies and no build step. What every part of the API does, and why.">
|
|
7
|
+
<!--
|
|
8
|
+
Lattice Grid — developer guide.
|
|
9
|
+
Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
|
|
10
|
+
|
|
11
|
+
Self-contained: no stylesheet, script or font is fetched, so this opens from
|
|
12
|
+
disk, from a file share or from behind a firewall with nothing else present.
|
|
13
|
+
API.html is the reference; this is the explanation.
|
|
14
|
+
-->
|
|
15
|
+
<title>Lattice Grid — Developer Guide</title>
|
|
16
|
+
<style>
|
|
17
|
+
/* ---------------------------------------------------------------
|
|
18
|
+
Palette lifted from the grid's own theme, so the reference reads as an
|
|
19
|
+
extension of the product it documents rather than a generic doc template.
|
|
20
|
+
--------------------------------------------------------------- */
|
|
21
|
+
:root {
|
|
22
|
+
--ground: #ffffff;
|
|
23
|
+
--surface: #f7f8f9;
|
|
24
|
+
--surface-2: #eef1f3;
|
|
25
|
+
--ink: #1c2126;
|
|
26
|
+
--muted: #5b6670;
|
|
27
|
+
--rule: #dfe3e6;
|
|
28
|
+
--rule-strong: #c4ccd2;
|
|
29
|
+
--accent: #1a6bc7;
|
|
30
|
+
--accent-soft: #e8f0fb;
|
|
31
|
+
--danger: #c22b2b;
|
|
32
|
+
--danger-soft: #fbeaea;
|
|
33
|
+
--warning: #a96a0b;
|
|
34
|
+
--success: #1b7f3b;
|
|
35
|
+
--code-bg: #f2f4f6;
|
|
36
|
+
|
|
37
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
38
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
39
|
+
|
|
40
|
+
--measure: 68ch;
|
|
41
|
+
--rail: 250px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media (prefers-color-scheme: dark) {
|
|
45
|
+
:root:not([data-theme="light"]) {
|
|
46
|
+
--ground: #14181c;
|
|
47
|
+
--surface: #1b2026;
|
|
48
|
+
--surface-2: #212831;
|
|
49
|
+
--ink: #e4e9ee;
|
|
50
|
+
--muted: #99a4ae;
|
|
51
|
+
--rule: #2a3138;
|
|
52
|
+
--rule-strong: #3a434c;
|
|
53
|
+
--accent: #4e9bea;
|
|
54
|
+
--accent-soft: #17293c;
|
|
55
|
+
--danger: #f08a8a;
|
|
56
|
+
--danger-soft: #33201f;
|
|
57
|
+
--warning: #e0a64a;
|
|
58
|
+
--success: #5fc27e;
|
|
59
|
+
--code-bg: #1e242b;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
:root[data-theme="dark"] {
|
|
64
|
+
--ground: #14181c;
|
|
65
|
+
--surface: #1b2026;
|
|
66
|
+
--surface-2: #212831;
|
|
67
|
+
--ink: #e4e9ee;
|
|
68
|
+
--muted: #99a4ae;
|
|
69
|
+
--rule: #2a3138;
|
|
70
|
+
--rule-strong: #3a434c;
|
|
71
|
+
--accent: #4e9bea;
|
|
72
|
+
--accent-soft: #17293c;
|
|
73
|
+
--danger: #f08a8a;
|
|
74
|
+
--danger-soft: #33201f;
|
|
75
|
+
--warning: #e0a64a;
|
|
76
|
+
--success: #5fc27e;
|
|
77
|
+
--code-bg: #1e242b;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
* { box-sizing: border-box; }
|
|
81
|
+
|
|
82
|
+
body {
|
|
83
|
+
margin: 0;
|
|
84
|
+
background: var(--ground);
|
|
85
|
+
color: var(--ink);
|
|
86
|
+
font-family: var(--sans);
|
|
87
|
+
font-size: 15px;
|
|
88
|
+
line-height: 1.6;
|
|
89
|
+
-webkit-font-smoothing: antialiased;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ---------------------------------------------------------------
|
|
93
|
+
Shell: an index rail that stays put, mirroring the grid's own
|
|
94
|
+
tool-panel dock, and a reading column beside it.
|
|
95
|
+
--------------------------------------------------------------- */
|
|
96
|
+
.shell {
|
|
97
|
+
display: grid;
|
|
98
|
+
grid-template-columns: var(--rail) minmax(0, 1fr);
|
|
99
|
+
gap: 0;
|
|
100
|
+
max-width: 1360px;
|
|
101
|
+
margin: 0 auto;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.rail {
|
|
105
|
+
position: sticky;
|
|
106
|
+
top: 0;
|
|
107
|
+
align-self: start;
|
|
108
|
+
max-height: 100vh;
|
|
109
|
+
overflow-y: auto;
|
|
110
|
+
padding: 40px 24px 40px 32px;
|
|
111
|
+
border-right: 1px solid var(--rule);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.rail__brand {
|
|
115
|
+
font-family: var(--mono);
|
|
116
|
+
font-size: 15px;
|
|
117
|
+
font-weight: 700;
|
|
118
|
+
letter-spacing: -0.02em;
|
|
119
|
+
margin: 0 0 2px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.rail__sub {
|
|
123
|
+
font-size: 12px;
|
|
124
|
+
color: var(--muted);
|
|
125
|
+
margin: 0 0 28px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.rail nav { display: flex; flex-direction: column; gap: 26px; }
|
|
129
|
+
.rail__group { display: flex; flex-direction: column; gap: 5px; }
|
|
130
|
+
|
|
131
|
+
.rail__label {
|
|
132
|
+
font-size: 10.5px;
|
|
133
|
+
font-weight: 700;
|
|
134
|
+
letter-spacing: 0.1em;
|
|
135
|
+
text-transform: uppercase;
|
|
136
|
+
color: var(--muted);
|
|
137
|
+
margin-bottom: 3px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.rail a {
|
|
141
|
+
font-family: var(--mono);
|
|
142
|
+
font-size: 12.5px;
|
|
143
|
+
color: var(--ink);
|
|
144
|
+
text-decoration: none;
|
|
145
|
+
padding: 2px 0;
|
|
146
|
+
border-left: 2px solid transparent;
|
|
147
|
+
padding-left: 10px;
|
|
148
|
+
margin-left: -12px;
|
|
149
|
+
transition: color 120ms, border-color 120ms;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.rail a:hover, .rail a:focus-visible {
|
|
153
|
+
color: var(--accent);
|
|
154
|
+
border-left-color: var(--accent);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.main { padding: 40px 40px 120px; min-width: 0; }
|
|
158
|
+
|
|
159
|
+
/* --------------------------------------------------------------- */
|
|
160
|
+
|
|
161
|
+
header.masthead {
|
|
162
|
+
padding-bottom: 28px;
|
|
163
|
+
margin-bottom: 40px;
|
|
164
|
+
border-bottom: 2px solid var(--ink);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.eyebrow {
|
|
168
|
+
font-size: 10.5px;
|
|
169
|
+
font-weight: 700;
|
|
170
|
+
letter-spacing: 0.14em;
|
|
171
|
+
text-transform: uppercase;
|
|
172
|
+
color: var(--accent);
|
|
173
|
+
margin: 0 0 12px;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
h1 {
|
|
177
|
+
font-family: var(--mono);
|
|
178
|
+
font-size: clamp(28px, 4vw, 40px);
|
|
179
|
+
font-weight: 700;
|
|
180
|
+
letter-spacing: -0.03em;
|
|
181
|
+
line-height: 1.1;
|
|
182
|
+
margin: 0 0 14px;
|
|
183
|
+
text-wrap: balance;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.lede { max-width: var(--measure); color: var(--muted); margin: 0; font-size: 16px; }
|
|
187
|
+
|
|
188
|
+
h2 {
|
|
189
|
+
font-family: var(--mono);
|
|
190
|
+
font-size: 21px;
|
|
191
|
+
font-weight: 700;
|
|
192
|
+
letter-spacing: -0.02em;
|
|
193
|
+
margin: 56px 0 6px;
|
|
194
|
+
padding-left: 14px;
|
|
195
|
+
border-left: 3px solid var(--accent);
|
|
196
|
+
scroll-margin-top: 24px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
h3 {
|
|
200
|
+
font-family: var(--mono);
|
|
201
|
+
font-size: 15.5px;
|
|
202
|
+
font-weight: 700;
|
|
203
|
+
margin: 34px 0 10px;
|
|
204
|
+
scroll-margin-top: 24px;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
h2 + .section-note { margin: 0 0 22px 17px; max-width: var(--measure); color: var(--muted); font-size: 14px; }
|
|
208
|
+
|
|
209
|
+
p { max-width: var(--measure); }
|
|
210
|
+
|
|
211
|
+
a { color: var(--accent); }
|
|
212
|
+
|
|
213
|
+
/* Inline identifiers — the primary content of the whole document. */
|
|
214
|
+
code {
|
|
215
|
+
font-family: var(--mono);
|
|
216
|
+
font-size: 0.885em;
|
|
217
|
+
background: var(--code-bg);
|
|
218
|
+
padding: 1px 5px;
|
|
219
|
+
border-radius: 3px;
|
|
220
|
+
white-space: nowrap;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
pre {
|
|
224
|
+
font-family: var(--mono);
|
|
225
|
+
font-size: 13px;
|
|
226
|
+
line-height: 1.65;
|
|
227
|
+
background: var(--surface);
|
|
228
|
+
border: 1px solid var(--rule);
|
|
229
|
+
border-radius: 5px;
|
|
230
|
+
padding: 16px 18px;
|
|
231
|
+
overflow-x: auto;
|
|
232
|
+
margin: 18px 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
pre code { background: none; padding: 0; font-size: inherit; white-space: pre; }
|
|
236
|
+
|
|
237
|
+
.cmt { color: var(--muted); }
|
|
238
|
+
.kw { color: var(--accent); }
|
|
239
|
+
|
|
240
|
+
/* ---------------------------------------------------------------
|
|
241
|
+
Reference tables. Dense, hairline, sticky-headed — the grid's own
|
|
242
|
+
visual language, and the right density for scanning an API.
|
|
243
|
+
--------------------------------------------------------------- */
|
|
244
|
+
.table-wrap {
|
|
245
|
+
overflow-x: auto;
|
|
246
|
+
border: 1px solid var(--rule);
|
|
247
|
+
border-radius: 5px;
|
|
248
|
+
margin: 18px 0 26px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
|
|
252
|
+
|
|
253
|
+
thead th {
|
|
254
|
+
position: sticky;
|
|
255
|
+
top: 0;
|
|
256
|
+
background: var(--surface);
|
|
257
|
+
text-align: left;
|
|
258
|
+
font-size: 10.5px;
|
|
259
|
+
font-weight: 700;
|
|
260
|
+
letter-spacing: 0.09em;
|
|
261
|
+
text-transform: uppercase;
|
|
262
|
+
color: var(--muted);
|
|
263
|
+
padding: 9px 14px;
|
|
264
|
+
border-bottom: 1px solid var(--rule-strong);
|
|
265
|
+
white-space: nowrap;
|
|
266
|
+
z-index: 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
tbody td { padding: 9px 14px; border-top: 1px solid var(--rule); vertical-align: top; }
|
|
270
|
+
tbody tr:first-child td { border-top: 0; }
|
|
271
|
+
tbody tr:hover { background: var(--surface); }
|
|
272
|
+
|
|
273
|
+
td.name, td.sig {
|
|
274
|
+
font-family: var(--mono);
|
|
275
|
+
font-size: 12.5px;
|
|
276
|
+
white-space: nowrap;
|
|
277
|
+
font-variant-numeric: tabular-nums;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
td.name { font-weight: 600; }
|
|
281
|
+
td.type { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
282
|
+
td.desc { min-width: 20em; }
|
|
283
|
+
td.dflt { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
284
|
+
|
|
285
|
+
/* --------------------------------------------------------------- */
|
|
286
|
+
|
|
287
|
+
.badge {
|
|
288
|
+
display: inline-block;
|
|
289
|
+
font-family: var(--mono);
|
|
290
|
+
font-size: 10px;
|
|
291
|
+
font-weight: 700;
|
|
292
|
+
letter-spacing: 0.05em;
|
|
293
|
+
text-transform: uppercase;
|
|
294
|
+
padding: 2px 6px;
|
|
295
|
+
border-radius: 3px;
|
|
296
|
+
white-space: nowrap;
|
|
297
|
+
vertical-align: 1px;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.badge--dom { background: var(--accent-soft); color: var(--accent); }
|
|
301
|
+
.badge--gap { background: var(--danger-soft); color: var(--danger); }
|
|
302
|
+
|
|
303
|
+
.note {
|
|
304
|
+
max-width: var(--measure);
|
|
305
|
+
background: var(--surface);
|
|
306
|
+
border-left: 3px solid var(--rule-strong);
|
|
307
|
+
padding: 14px 18px;
|
|
308
|
+
margin: 22px 0;
|
|
309
|
+
font-size: 14px;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.note--warn { border-left-color: var(--warning); }
|
|
313
|
+
.note strong { font-weight: 700; }
|
|
314
|
+
.note p { margin: 0; max-width: none; }
|
|
315
|
+
.note p + p { margin-top: 8px; }
|
|
316
|
+
|
|
317
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 26px; }
|
|
318
|
+
|
|
319
|
+
.chip {
|
|
320
|
+
font-family: var(--mono);
|
|
321
|
+
font-size: 12px;
|
|
322
|
+
background: var(--surface);
|
|
323
|
+
border: 1px solid var(--rule);
|
|
324
|
+
border-radius: 3px;
|
|
325
|
+
padding: 3px 8px;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.chip--new { border-color: var(--accent); color: var(--accent); }
|
|
329
|
+
|
|
330
|
+
footer {
|
|
331
|
+
margin-top: 72px;
|
|
332
|
+
padding-top: 22px;
|
|
333
|
+
border-top: 1px solid var(--rule);
|
|
334
|
+
font-size: 13px;
|
|
335
|
+
color: var(--muted);
|
|
336
|
+
max-width: var(--measure);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@media (max-width: 900px) {
|
|
340
|
+
.shell { grid-template-columns: 1fr; }
|
|
341
|
+
.rail {
|
|
342
|
+
position: static;
|
|
343
|
+
max-height: none;
|
|
344
|
+
border-right: 0;
|
|
345
|
+
border-bottom: 1px solid var(--rule);
|
|
346
|
+
padding: 28px 24px;
|
|
347
|
+
}
|
|
348
|
+
.rail nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; }
|
|
349
|
+
.main { padding: 28px 24px 80px; }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
@media (prefers-reduced-motion: reduce) {
|
|
353
|
+
* { transition: none !important; animation: none !important; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
|
|
357
|
+
</style>
|
|
358
|
+
<style>
|
|
359
|
+
/* ---------------------------------------------------------------
|
|
360
|
+
Additions for the long-form guide. The reference is a lookup
|
|
361
|
+
table; this document is read in order, so it needs devices the
|
|
362
|
+
reference does not: worked examples with captions, asides that
|
|
363
|
+
explain a decision rather than restate it, and comparisons.
|
|
364
|
+
--------------------------------------------------------------- */
|
|
365
|
+
|
|
366
|
+
.example { margin: 20px 0 24px; }
|
|
367
|
+
.example__label {
|
|
368
|
+
margin: 0 0 6px;
|
|
369
|
+
color: var(--muted);
|
|
370
|
+
font-size: 11.5px;
|
|
371
|
+
font-weight: 650;
|
|
372
|
+
letter-spacing: 0.07em;
|
|
373
|
+
text-transform: uppercase;
|
|
374
|
+
}
|
|
375
|
+
.example pre { margin-top: 0; }
|
|
376
|
+
.example__after {
|
|
377
|
+
margin: -10px 0 0;
|
|
378
|
+
padding: 8px 14px;
|
|
379
|
+
border: 1px solid var(--rule);
|
|
380
|
+
border-top: 0;
|
|
381
|
+
border-radius: 0 0 6px 6px;
|
|
382
|
+
background: var(--surface);
|
|
383
|
+
color: var(--muted);
|
|
384
|
+
font-family: var(--mono);
|
|
385
|
+
font-size: 12px;
|
|
386
|
+
white-space: pre-wrap;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* Why a thing is the way it is. Distinct from .note, which is a
|
|
390
|
+
caveat — this is the reasoning, and it is the part a developer
|
|
391
|
+
evaluating the product actually reads. */
|
|
392
|
+
.why {
|
|
393
|
+
/* Constrained to the reading measure plus its own padding. Left to fill
|
|
394
|
+
the column the tinted panel ran 400px past the text inside it, which
|
|
395
|
+
reads as a mistake rather than as emphasis. */
|
|
396
|
+
max-width: calc(var(--measure) + 36px);
|
|
397
|
+
margin: 18px 0;
|
|
398
|
+
padding: 14px 16px 14px 18px;
|
|
399
|
+
border-left: 3px solid var(--accent);
|
|
400
|
+
border-radius: 0 6px 6px 0;
|
|
401
|
+
background: var(--accent-soft);
|
|
402
|
+
}
|
|
403
|
+
.why p { margin: 0 0 8px; max-width: var(--measure); font-size: 14px; }
|
|
404
|
+
.why p:last-child { margin-bottom: 0; }
|
|
405
|
+
.why strong { font-weight: 650; }
|
|
406
|
+
|
|
407
|
+
.lead-in { max-width: var(--measure); font-size: 15.5px; line-height: 1.65; }
|
|
408
|
+
|
|
409
|
+
/* Two panels side by side, for a before/after or a wrong/right. */
|
|
410
|
+
.split { display: grid; gap: 16px; margin: 18px 0; }
|
|
411
|
+
@media (min-width: 900px) { .split { grid-template-columns: 1fr 1fr; } }
|
|
412
|
+
.split pre { height: 100%; margin: 0; }
|
|
413
|
+
.split__title { margin: 0 0 6px; font-size: 12px; font-weight: 650; }
|
|
414
|
+
.split__title--no { color: var(--danger); }
|
|
415
|
+
.split__title--yes { color: var(--success); }
|
|
416
|
+
|
|
417
|
+
.anchor-list { margin: 0 0 26px; padding: 0; list-style: none; }
|
|
418
|
+
.anchor-list li { margin: 0 0 6px; font-size: 14px; }
|
|
419
|
+
.anchor-list a { color: var(--accent); text-decoration: none; }
|
|
420
|
+
.anchor-list a:hover { text-decoration: underline; }
|
|
421
|
+
.anchor-list span { color: var(--muted); }
|
|
422
|
+
|
|
423
|
+
h3 + .lead-in { margin-top: 6px; }
|
|
424
|
+
.sig-line {
|
|
425
|
+
margin: 22px 0 4px;
|
|
426
|
+
padding: 8px 12px;
|
|
427
|
+
border-radius: 6px;
|
|
428
|
+
background: var(--code-bg);
|
|
429
|
+
font-family: var(--mono);
|
|
430
|
+
font-size: 13px;
|
|
431
|
+
overflow-x: auto;
|
|
432
|
+
}
|
|
433
|
+
</style>
|
|
434
|
+
</head>
|
|
435
|
+
<body>
|
|
436
|
+
|
|
437
|
+
<div class="shell">
|
|
438
|
+
<aside class="rail">
|
|
439
|
+
<p class="rail__brand">Lattice Grid</p>
|
|
440
|
+
<p class="rail__sub">Developer guide · v1.5.4</p>
|
|
441
|
+
<nav>
|
|
442
|
+
<div class="rail__group">
|
|
443
|
+
<span class="rail__label">Start here</span>
|
|
444
|
+
<a href="#what">What it is</a>
|
|
445
|
+
<a href="#install">Install</a>
|
|
446
|
+
<a href="#first-grid">Your first grid</a>
|
|
447
|
+
<a href="#adapters-guide">React, Vue, Svelte</a>
|
|
448
|
+
<a href="#concepts">How it works</a>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="rail__group">
|
|
451
|
+
<span class="rail__label">Columns</span>
|
|
452
|
+
<a href="#columns-guide">Defining columns</a>
|
|
453
|
+
<a href="#types">Types and formatting</a>
|
|
454
|
+
<a href="#renderers">Cells and renderers</a>
|
|
455
|
+
<a href="#styling">Styling</a>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="rail__group">
|
|
458
|
+
<span class="rail__label">Data</span>
|
|
459
|
+
<a href="#loading">Loading and updating</a>
|
|
460
|
+
<a href="#sorting">Sorting</a>
|
|
461
|
+
<a href="#filtering">Filtering</a>
|
|
462
|
+
<a href="#grouping">Grouping and totals</a>
|
|
463
|
+
<a href="#sources">Large data</a>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="rail__group">
|
|
466
|
+
<span class="rail__label">Interaction</span>
|
|
467
|
+
<a href="#editing">Editing</a>
|
|
468
|
+
<a href="#optimistic">Optimistic writes</a>
|
|
469
|
+
<a href="#tree-data">Tree data</a>
|
|
470
|
+
<a href="#master-detail">Master-detail</a>
|
|
471
|
+
<a href="#selection-guide">Selection and ranges</a>
|
|
472
|
+
<a href="#fill-series">Filling a series</a>
|
|
473
|
+
<a href="#updates-guide">Holding live updates</a>
|
|
474
|
+
<a href="#diagnostics-guide">Diagnostics and devtools</a>
|
|
475
|
+
<a href="#presence-guide">Collaborative presence</a>
|
|
476
|
+
<a href="#comments-guide">Cell comments</a>
|
|
477
|
+
<a href="#facets-guide">Header histograms</a>
|
|
478
|
+
<a href="#timeline-guide">Time scrubber</a>
|
|
479
|
+
<a href="#presentation-guide">Presentation mode</a>
|
|
480
|
+
<a href="#density-guide">Density</a>
|
|
481
|
+
<a href="#image-guide">Image columns</a>
|
|
482
|
+
<a href="#twoline-guide">Two-line cells</a>
|
|
483
|
+
<a href="#redaction-guide">Redacting a column</a>
|
|
484
|
+
<a href="#clipboard">Clipboard</a>
|
|
485
|
+
<a href="#keyboard">Keyboard</a>
|
|
486
|
+
<a href="#rules-guide">Conditional formatting</a>
|
|
487
|
+
<a href="#formatting-guide">Formatting a user can change</a>
|
|
488
|
+
<a href="#quickfilter-guide">Quick filter</a>
|
|
489
|
+
<a href="#charts-guide">In-cell charts</a>
|
|
490
|
+
<a href="#formulas-guide">Formulas</a>
|
|
491
|
+
<a href="#custom-menu">Custom items</a>
|
|
492
|
+
<a href="#rail">The left rail</a>
|
|
493
|
+
<a href="#maximise-guide">Maximise</a>
|
|
494
|
+
<a href="#highlighting">Highlighting</a>
|
|
495
|
+
</div>
|
|
496
|
+
<div class="rail__group">
|
|
497
|
+
<span class="rail__label">Product</span>
|
|
498
|
+
<a href="#views-guide">Saved views</a>
|
|
499
|
+
<a href="#history-guide">Undo</a>
|
|
500
|
+
<a href="#permissions-guide">Permissions</a>
|
|
501
|
+
<a href="#diff-guide">Audit mode</a>
|
|
502
|
+
<a href="#ai-guide">AI</a>
|
|
503
|
+
<a href="#export-guide">Export</a>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="rail__group">
|
|
506
|
+
<span class="rail__label">Reference</span>
|
|
507
|
+
<a href="#webcomponent-guide">Web component</a>
|
|
508
|
+
<a href="#events-guide">Events</a>
|
|
509
|
+
<a href="#licensing">Licensing</a>
|
|
510
|
+
<a href="#recipes">Recipes</a>
|
|
511
|
+
<a href="API.html">Full API tables →</a>
|
|
512
|
+
</div>
|
|
513
|
+
</nav>
|
|
514
|
+
</aside>
|
|
515
|
+
|
|
516
|
+
<main class="main">
|
|
517
|
+
<header>
|
|
518
|
+
<p class="eyebrow">TOCLOCO Inc</p>
|
|
519
|
+
<h1>Lattice Grid</h1>
|
|
520
|
+
<p class="lede">
|
|
521
|
+
A data grid for people who have been let down by data grids. A hundred thousand rows
|
|
522
|
+
that scroll like ten. Two files, no build step, no dependencies. This guide explains
|
|
523
|
+
what each part of the API does and why it works the way it does; the
|
|
524
|
+
<a href="API.html">reference tables</a> are the shorter version for when you already know.
|
|
525
|
+
</p>
|
|
526
|
+
<p class="chips">
|
|
527
|
+
<span class="chip">Version 1.5.4</span>
|
|
528
|
+
<span class="chip">Zero dependencies</span>
|
|
529
|
+
<span class="chip">No build step</span>
|
|
530
|
+
</p>
|
|
531
|
+
</header>
|
|
532
|
+
|
|
533
|
+
<h2 id="what">What it is</h2>
|
|
534
|
+
<p class="lead-in">
|
|
535
|
+
Lattice Grid renders tabular data in a browser. That sentence covers a great many products,
|
|
536
|
+
so here is what is actually different about this one.
|
|
537
|
+
</p>
|
|
538
|
+
|
|
539
|
+
<h3>It holds data in columns, not rows</h3>
|
|
540
|
+
<p class="lead-in">
|
|
541
|
+
A grid of a hundred thousand rows and thirty columns is three million values. Held as row
|
|
542
|
+
objects, that is three million property lookups per pass and a great deal of memory the
|
|
543
|
+
garbage collector has opinions about. Lattice stores each column as one typed array — numbers
|
|
544
|
+
in a <code>Float64Array</code>, repeated strings as integers into a dictionary, booleans as
|
|
545
|
+
bits in a bitset.
|
|
546
|
+
</p>
|
|
547
|
+
<div class="why">
|
|
548
|
+
<p><strong>Why it matters to you:</strong> sorting a column is a sort over one contiguous
|
|
549
|
+
array of numbers, not a walk over a hundred thousand objects. Filtering produces a bitmask
|
|
550
|
+
rather than a new array of rows. Both stay fast at sizes where the row-object approach has
|
|
551
|
+
already given up, and neither allocates much, so the browser is not collecting garbage while
|
|
552
|
+
the user is scrolling.</p>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
<h3>Work is memoised in stages</h3>
|
|
556
|
+
<p class="lead-in">
|
|
557
|
+
Everything between your data and the screen is six stages: filter, sort, group, total, pivot,
|
|
558
|
+
flatten. Each remembers its result and the inputs it was computed from.
|
|
559
|
+
</p>
|
|
560
|
+
<div class="why">
|
|
561
|
+
<p>Change a sort and the filter stage is not recomputed — its inputs did not change. Edit a
|
|
562
|
+
cell in a column nobody sorts, filters or groups on and <em>none</em> of the first five run;
|
|
563
|
+
only the totals move. This is why a grid that is heavily filtered and grouped still feels
|
|
564
|
+
immediate when you type in a cell.</p>
|
|
565
|
+
</div>
|
|
566
|
+
|
|
567
|
+
<h3>The renderer reuses everything</h3>
|
|
568
|
+
<p class="lead-in">
|
|
569
|
+
Rows and cells come from pools. Scrolling reassigns the twenty or so row elements that exist
|
|
570
|
+
rather than creating and destroying thousands, and the only vertical write is a
|
|
571
|
+
<code>transform</code>, which the compositor handles without a layout pass.
|
|
572
|
+
</p>
|
|
573
|
+
|
|
574
|
+
<h3>No dependencies, and no build step</h3>
|
|
575
|
+
<p class="lead-in">
|
|
576
|
+
Not "few dependencies" — none, at runtime and at build time. No lodash, no date library, no
|
|
577
|
+
virtualisation library, no icon font. The bundler and minifier that produce the distribution
|
|
578
|
+
are part of the repository. You can drop two files into a page and be finished.
|
|
579
|
+
</p>
|
|
580
|
+
<div class="why">
|
|
581
|
+
<p>A large share of enterprise line-of-business frontends are built without a bundler, and
|
|
582
|
+
they are usually treated as second-class by grid vendors. Here the script tag is the first
|
|
583
|
+
example in the documentation, not an appendix.</p>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
<h2 id="install">Install</h2>
|
|
587
|
+
<p class="lead-in">Two files. Nothing is fetched at runtime — no CDN, no font, no sprite sheet.</p>
|
|
588
|
+
|
|
589
|
+
<div class="example">
|
|
590
|
+
<p class="example__label">Script tag</p>
|
|
591
|
+
<pre><code><link rel="stylesheet" href="lattice-grid.min.css">
|
|
592
|
+
<script src="lattice-grid.min.js"></script>
|
|
593
|
+
|
|
594
|
+
<script>
|
|
595
|
+
<span class="kw">const</span> grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
|
|
596
|
+
</script></code></pre>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
<div class="example">
|
|
600
|
+
<p class="example__label">ES modules</p>
|
|
601
|
+
<pre><code><span class="kw">import</span> { createGrid } <span class="kw">from</span> './lattice-grid.esm.min.js';
|
|
602
|
+
<span class="kw">const</span> grid = createGrid(document.getElementById('grid'), config);</code></pre>
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
<div class="note">
|
|
606
|
+
<p><strong>There is no npm package.</strong> Lattice Grid is not published to any registry, so
|
|
607
|
+
<code>import … from '@latticegrid/dom'</code> will not resolve. Import the built file by path,
|
|
608
|
+
or use the script tag. For editor tooling, point your <code>tsconfig</code> at
|
|
609
|
+
<code>lattice-grid.d.ts</code>.</p>
|
|
610
|
+
</div>
|
|
611
|
+
|
|
612
|
+
<p class="lead-in">Everything else in the distribution is an alternative packaging or a
|
|
613
|
+
development aid:</p>
|
|
614
|
+
<div class="table-wrap">
|
|
615
|
+
<table>
|
|
616
|
+
<thead><tr><th>File</th><th>Gzipped</th><th>What it is</th></tr></thead>
|
|
617
|
+
<tbody>
|
|
618
|
+
<tr><td class="sig">lattice-grid.min.js</td><td class="desc">The whole product as a UMD build. Defines <code>window.LatticeGrid</code>, and works with AMD and CommonJS loaders.</td></tr>
|
|
619
|
+
<tr><td class="sig">lattice-grid.min.css</td><td class="desc">The single stylesheet. Without it the grid is in the DOM and unreadable — no widths, no scrolling, no theme.</td></tr>
|
|
620
|
+
<tr><td class="sig">lattice-grid.esm.min.js</td><td class="desc">The same, as an ES module.</td></tr>
|
|
621
|
+
<tr><td class="sig">lattice-core.esm.js</td><td class="desc">Headless core for Node — no renderer. See <a href="#export-guide">server-side export</a>.</td></tr>
|
|
622
|
+
<tr><td class="sig">lattice-grid.d.ts</td><td class="desc">Type declarations.</td></tr>
|
|
623
|
+
</tbody>
|
|
624
|
+
</table>
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
<h2 id="first-grid">Your first grid</h2>
|
|
628
|
+
<p class="lead-in">
|
|
629
|
+
Three things are required: an element to mount into, some columns, and some rows. Everything
|
|
630
|
+
else has a working default.
|
|
631
|
+
</p>
|
|
632
|
+
|
|
633
|
+
<div class="example">
|
|
634
|
+
<p class="example__label">The whole thing</p>
|
|
635
|
+
<pre><code><span class="kw">const</span> grid = LatticeGrid.createGrid(document.getElementById('grid'), {
|
|
636
|
+
rowKey: 'id',
|
|
637
|
+
columns: [
|
|
638
|
+
{ field: 'circuitId', title: 'Circuit' },
|
|
639
|
+
{ field: 'region' },
|
|
640
|
+
{ field: 'monthlyCharge', type: 'number', format: 'currency:GBP:2', total: 'sum' },
|
|
641
|
+
{ field: 'installedOn', type: 'date', format: 'date:dd MMM yyyy' },
|
|
642
|
+
],
|
|
643
|
+
rows: data,
|
|
644
|
+
});</code></pre>
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
<p class="lead-in">
|
|
648
|
+
A few things happened there without being asked for. <code>region</code> got a title of
|
|
649
|
+
"Region" — a field name is turned into a readable heading rather than left as-is. The number
|
|
650
|
+
column right-aligned itself, because numbers align right and a grid should not need telling.
|
|
651
|
+
The date column parsed <code>2024-03-11</code> and rendered <code>11 Mar 2024</code>. And
|
|
652
|
+
<code>total: 'sum'</code> put a figure in the totals row.
|
|
653
|
+
</p>
|
|
654
|
+
|
|
655
|
+
<div class="why">
|
|
656
|
+
<p><strong>On <code>rowKey</code>:</strong> it names the field that identifies a row. Set it
|
|
657
|
+
if you have one. Without it the grid assigns keys per row object, which is enough for
|
|
658
|
+
sorting, filtering, selection and copying within a session — but not across a reload, because
|
|
659
|
+
new objects are new rows. Change tracking, streaming dedupe, selection persistence and remote
|
|
660
|
+
reload all want a real key, and the grid warns once, naming them.</p>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
<h2 id="adapters-guide">React, Vue, Svelte</h2>
|
|
664
|
+
<p class="lead-in">
|
|
665
|
+
One optional bundle per framework. They are thin: the grid is
|
|
666
|
+
created once against a host element, prop changes are pushed into it through the same
|
|
667
|
+
public API you would call by hand, and it is destroyed on unmount.
|
|
668
|
+
</p>
|
|
669
|
+
<div class="why">
|
|
670
|
+
<p><strong>You pass the framework in.</strong> Every adapter is a factory taking the
|
|
671
|
+
framework and <code>createGrid</code>, rather than importing either. Lattice ships zero
|
|
672
|
+
dependencies and the bundler rejects bare specifiers outright, so an adapter could not
|
|
673
|
+
<code>import React from 'react'</code> even if it wanted to. The same choice keeps each
|
|
674
|
+
bundle small: the bundler inlines whatever it can resolve, so an adapter that imported the
|
|
675
|
+
grid would carry a second copy of it. Passing both in leaves each adapter a
|
|
676
|
+
few kilobytes of glue, and means the adapter cannot disagree with the grid version you
|
|
677
|
+
already loaded.</p>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
<div class="example">
|
|
681
|
+
<p class="example__label">React</p>
|
|
682
|
+
<pre><code><span class="kw">import</span> React <span class="kw">from</span> 'react';
|
|
683
|
+
<span class="kw">import</span> { createGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
|
|
684
|
+
<span class="kw">import</span> { createLatticeGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid/modules/react';
|
|
685
|
+
|
|
686
|
+
<span class="kw">const</span> LatticeGrid = createLatticeGrid({ React, createGrid });
|
|
687
|
+
|
|
688
|
+
<span class="kw">function</span> Circuits({ rows }) {
|
|
689
|
+
<span class="kw">const</span> ref = React.useRef(null);
|
|
690
|
+
<span class="kw">return</span> (
|
|
691
|
+
<LatticeGrid
|
|
692
|
+
ref={ref}
|
|
693
|
+
className="grid"
|
|
694
|
+
columns={columns}
|
|
695
|
+
rows={rows}
|
|
696
|
+
rowKey="id"
|
|
697
|
+
sort={[{ col: 'name', dir: 'asc' }]}
|
|
698
|
+
onCellChanged={(e) => save(e.key, e.colId, e.value)}
|
|
699
|
+
/>
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
<span class="cmt">// ref.current.grid is the live grid, for anything without a prop.</span></code></pre>
|
|
704
|
+
</div>
|
|
705
|
+
<div class="why">
|
|
706
|
+
<p><strong>Hold your props steady.</strong> Change detection is reference equality, because
|
|
707
|
+
deep-comparing a million-row array on every render would cost more than the reload it
|
|
708
|
+
avoids. Build <code>columns</code> once outside the component, or memoise it; a fresh array
|
|
709
|
+
literal on each render tells the grid the columns changed and it will rebuild them. Rows are
|
|
710
|
+
the same — hand back a new array when the data actually changes, not before.</p>
|
|
711
|
+
<p>StrictMode is handled. React 18 deliberately mounts, unmounts and mounts again in
|
|
712
|
+
development; the effect cleanup destroys the first grid, so the second starts clean and
|
|
713
|
+
nothing leaks.</p>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div class="example">
|
|
717
|
+
<p class="example__label">Vue 3</p>
|
|
718
|
+
<pre><code><span class="kw">import</span> * <span class="kw">as</span> vue <span class="kw">from</span> 'vue';
|
|
719
|
+
<span class="kw">import</span> { createGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
|
|
720
|
+
<span class="kw">import</span> { createLatticeGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid/modules/vue';
|
|
721
|
+
|
|
722
|
+
<span class="kw">const</span> LatticeGrid = createLatticeGrid({ vue, createGrid });</code></pre>
|
|
723
|
+
<pre><code><LatticeGrid
|
|
724
|
+
:columns="columns"
|
|
725
|
+
:rows="rows"
|
|
726
|
+
row-key="id"
|
|
727
|
+
:sort="[{ col: 'name', dir: 'asc' }]"
|
|
728
|
+
@cell-changed="onCellChanged"
|
|
729
|
+
@selection-changed="onSelectionChanged"
|
|
730
|
+
/></code></pre>
|
|
731
|
+
</div>
|
|
732
|
+
<div class="why">
|
|
733
|
+
<p>Events are re-emitted under dashed names — <code>cell:changed</code> becomes
|
|
734
|
+
<code>@cell-changed</code> — because a colon in a Vue template is directive syntax and
|
|
735
|
+
cannot be bound. Every event in the table below is declared in <code>emits</code>.</p>
|
|
736
|
+
</div>
|
|
737
|
+
|
|
738
|
+
<div class="example">
|
|
739
|
+
<p class="example__label">Svelte</p>
|
|
740
|
+
<pre><code><script>
|
|
741
|
+
<span class="kw">import</span> { createGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
|
|
742
|
+
<span class="kw">import</span> { createLatticeAction } <span class="kw">from</span> '@toclocoinc/lattice-grid/modules/svelte';
|
|
743
|
+
|
|
744
|
+
<span class="kw">const</span> lattice = createLatticeAction({ createGrid });
|
|
745
|
+
<span class="kw">let</span> rows = [];
|
|
746
|
+
</script>
|
|
747
|
+
|
|
748
|
+
<div
|
|
749
|
+
use:lattice={{ columns, rows, rowKey: 'id' }}
|
|
750
|
+
on:cell-changed={(e) => save(e.detail)}
|
|
751
|
+
></div></code></pre>
|
|
752
|
+
</div>
|
|
753
|
+
<div class="why">
|
|
754
|
+
<p><strong>An action, not a component.</strong> Svelte's action contract is
|
|
755
|
+
<code>{ update, destroy }</code> over a node the caller already owns, which is exactly the
|
|
756
|
+
shape of the work: create, push changes, tear down. A component wrapper would add an element
|
|
757
|
+
and a props layer to arrive back at the same three calls. It is also the only adapter here
|
|
758
|
+
that needs nothing but <code>createGrid</code>, since an action is a plain function with no
|
|
759
|
+
framework runtime behind it. Grid events arrive as <code>CustomEvent</code>s on the node,
|
|
760
|
+
with the grid event as <code>detail</code>. If you want a component, four lines around this
|
|
761
|
+
gets you one.</p>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
<div class="why">
|
|
765
|
+
<p><strong>What the adapters do not do.</strong> They add no features and wrap no API — the
|
|
766
|
+
grid instance is the same object the vanilla examples use, and anything without a prop is
|
|
767
|
+
reached through it directly (via the ref in React, <code>expose</code> in Vue, or a
|
|
768
|
+
reference you keep in Svelte). Nothing is proxied, so nothing can lag behind the grid.</p>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
<h3>The web component is self-contained — use it or the API, not both</h3>
|
|
772
|
+
<p class="lead-in">
|
|
773
|
+
The custom element carries the grid inside it, rather than being handed one. That is the point
|
|
774
|
+
of the format: you add the element and it works, with nothing to wire up.
|
|
775
|
+
</p>
|
|
776
|
+
<div class="why">
|
|
777
|
+
<p><strong>Do not load it alongside <code>createGrid</code> in the same page.</strong> You would
|
|
778
|
+
get two independent copies of the grid, and the cost is not the download — it is that
|
|
779
|
+
each copy keeps its own registries. A renderer, editor, data type or variant registered
|
|
780
|
+
through one is invisible to the other, and a licence key validated in one is not validated in
|
|
781
|
+
the other. Nothing errors; the custom renderer you registered simply never appears.</p>
|
|
782
|
+
<p>Pick one route per application. The other three adapters take <code>createGrid</code> as an
|
|
783
|
+
argument and so share whatever copy you already loaded, and can be mixed with direct API use
|
|
784
|
+
freely.</p>
|
|
785
|
+
</div>
|
|
786
|
+
|
|
787
|
+
<h2 id="concepts">How it works</h2>
|
|
788
|
+
<p class="lead-in">
|
|
789
|
+
Four ideas explain most of the API. If you read nothing else, read this section — the rest of
|
|
790
|
+
the guide assumes it.
|
|
791
|
+
</p>
|
|
792
|
+
|
|
793
|
+
<h3>The row you see is not the row you supplied</h3>
|
|
794
|
+
<p class="lead-in">
|
|
795
|
+
Your data objects are held by reference and never copied. What the grid hands back is a
|
|
796
|
+
<em>row wrapper</em>: your object under <code>data</code>, plus the identity and position the
|
|
797
|
+
grid needs — <code>key</code>, <code>index</code>, <code>level</code>, whether it is a group
|
|
798
|
+
row, whether it is expanded.
|
|
799
|
+
</p>
|
|
800
|
+
<div class="example">
|
|
801
|
+
<p class="example__label">Reading a value</p>
|
|
802
|
+
<pre><code>grid.rows.get(0).data <span class="cmt">// your object, untouched</span>
|
|
803
|
+
grid.rows.value('r1', 'cap') <span class="cmt">// the raw value</span>
|
|
804
|
+
grid.rows.text('r1', 'cap') <span class="cmt">// the formatted text, as rendered</span>
|
|
805
|
+
grid.rows.values('r1') <span class="cmt">// every readable column, as an object</span></code></pre>
|
|
806
|
+
</div>
|
|
807
|
+
<div class="why">
|
|
808
|
+
<p><code>value</code> and <code>text</code> are different questions and the difference bites
|
|
809
|
+
people. A currency column's value is <code>1234.5</code>; its text is
|
|
810
|
+
<code>£1,234.50</code>. Sorting and filtering use the value. Copying, exporting and searching
|
|
811
|
+
use the text, because that is what the user can see and what they typed against.</p>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
814
|
+
<h3>Display index and row key are different things</h3>
|
|
815
|
+
<p class="lead-in">
|
|
816
|
+
A display index is a position — row 0 is whatever is at the top right now, and it changes when
|
|
817
|
+
you sort. A key identifies a record for as long as it exists. Anything that has to survive a
|
|
818
|
+
sort, a filter or a page change is keyed.
|
|
819
|
+
</p>
|
|
820
|
+
<div class="split">
|
|
821
|
+
<div>
|
|
822
|
+
<p class="split__title split__title--no">Position — fragile</p>
|
|
823
|
+
<pre><code>grid.rows.get(4)
|
|
824
|
+
grid.selection.setRange({
|
|
825
|
+
startRow: 0, endRow: 9, columns: ['cap'],
|
|
826
|
+
})</code></pre>
|
|
827
|
+
</div>
|
|
828
|
+
<div>
|
|
829
|
+
<p class="split__title split__title--yes">Identity — durable</p>
|
|
830
|
+
<pre><code>grid.rows.byKey('CIR-100042')
|
|
831
|
+
grid.edit.setCells([
|
|
832
|
+
{ key: 'CIR-100042', colId: 'cap', value: 99 },
|
|
833
|
+
])</code></pre>
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
|
|
837
|
+
<h3>Everything is on one event bus</h3>
|
|
838
|
+
<p class="lead-in">
|
|
839
|
+
There are no <code>onSomething</code> configuration properties. One bus, one
|
|
840
|
+
<code>grid.on(type, handler)</code>, and every payload carries <code>type</code>,
|
|
841
|
+
<code>origin</code> and <code>grid</code> alongside its own fields.
|
|
842
|
+
</p>
|
|
843
|
+
<div class="example">
|
|
844
|
+
<p class="example__label">Subscribing</p>
|
|
845
|
+
<pre><code><span class="kw">const</span> off = grid.on('cell:changed', e => save(e.row.data));
|
|
846
|
+
grid.once('ready', init);
|
|
847
|
+
grid.on('*', e => console.log(e.type, e)); <span class="cmt">// wildcard, for working out what fires</span>
|
|
848
|
+
|
|
849
|
+
off(); <span class="cmt">// every subscription returns its own unsubscribe</span></code></pre>
|
|
850
|
+
</div>
|
|
851
|
+
<div class="why">
|
|
852
|
+
<p><code>origin</code> tells you where a change came from — <code>'user'</code>,
|
|
853
|
+
<code>'api'</code>, <code>'init'</code>, <code>'undo'</code>. It is what stops a feedback
|
|
854
|
+
loop when you persist changes: a handler that writes to a server on
|
|
855
|
+
<code>cell:changed</code> should usually ignore its own <code>'undo'</code> traffic, or at
|
|
856
|
+
least know that is what it is looking at.</p>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
859
|
+
<h3>Two rules hold across the whole API</h3>
|
|
860
|
+
<p class="lead-in">
|
|
861
|
+
<strong>Nothing is a double negative.</strong> There is no <code>suppressX</code>, no
|
|
862
|
+
<code>disableY</code>. Options are positive and say what they enable:
|
|
863
|
+
<code>edit</code>, <code>sortable</code>, <code>allowGroup</code>. Turning something off is
|
|
864
|
+
<code>false</code>.
|
|
865
|
+
</p>
|
|
866
|
+
<p class="lead-in">
|
|
867
|
+
<strong>Every configuration key is settable at runtime</strong> through
|
|
868
|
+
<code>grid.set(key, value)</code> and readable through <code>grid.get(key)</code>. There is no
|
|
869
|
+
separate "you can only pass this at construction" list to memorise.
|
|
870
|
+
</p>
|
|
871
|
+
<div class="example">
|
|
872
|
+
<p class="example__label">Which version am I running?</p>
|
|
873
|
+
<pre><code>grid.getVersion(); <span class="cmt">// '1.5.4'</span>
|
|
874
|
+
LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
|
|
875
|
+
</div>
|
|
876
|
+
<p class="lead-in">
|
|
877
|
+
On the instance as well as the module, because that is where it is wanted: a bug report says
|
|
878
|
+
"the grid on this page", and whoever reads it has a grid rather than the module it was built
|
|
879
|
+
from.
|
|
880
|
+
</p>
|
|
881
|
+
<h2 id="columns-guide">Defining columns</h2>
|
|
882
|
+
<p class="lead-in">
|
|
883
|
+
A column is an object with a <code>field</code> — the path to read from your data — and
|
|
884
|
+
whatever else it needs. Everything except <code>field</code> or <code>id</code> is optional.
|
|
885
|
+
</p>
|
|
886
|
+
|
|
887
|
+
<div class="sig-line">{ field: 'site.address.postcode', title: 'Postcode', type: 'text', width: 110 }</div>
|
|
888
|
+
<p class="lead-in">
|
|
889
|
+
<code>field</code> reads a dot path, so nested data needs no flattening. The column's
|
|
890
|
+
<code>id</code> defaults to the field, which is what you use everywhere else — in
|
|
891
|
+
<code>setCells</code>, in filters, in saved state.
|
|
892
|
+
</p>
|
|
893
|
+
|
|
894
|
+
<h3>Grouped headers</h3>
|
|
895
|
+
<p class="lead-in">Nest columns to get a spanning header row.</p>
|
|
896
|
+
<div class="example">
|
|
897
|
+
<p class="example__label">Two levels</p>
|
|
898
|
+
<pre><code>columns: [
|
|
899
|
+
{ field: 'circuitId', title: 'Circuit' },
|
|
900
|
+
{ title: 'Location', children: [
|
|
901
|
+
{ field: 'region' },
|
|
902
|
+
{ field: 'country' },
|
|
903
|
+
{ field: 'site.address.postcode', title: 'Postcode' },
|
|
904
|
+
]},
|
|
905
|
+
]</code></pre>
|
|
906
|
+
</div>
|
|
907
|
+
|
|
908
|
+
<h3>Computed columns</h3>
|
|
909
|
+
<p class="lead-in">
|
|
910
|
+
A column can compute its value instead of reading one. Declare what it depends on and the
|
|
911
|
+
grid builds a dependency graph, so editing <code>cost</code> invalidates <code>margin</code>
|
|
912
|
+
and nothing else.
|
|
913
|
+
</p>
|
|
914
|
+
<div class="example">
|
|
915
|
+
<p class="example__label">Derived values</p>
|
|
916
|
+
<pre><code>{
|
|
917
|
+
id: 'margin',
|
|
918
|
+
title: 'Margin',
|
|
919
|
+
type: 'number',
|
|
920
|
+
format: 'currency:GBP:2',
|
|
921
|
+
value: {
|
|
922
|
+
deps: ['monthlyCharge', 'cost'],
|
|
923
|
+
compute: (deps) => deps.monthlyCharge - deps.cost,
|
|
924
|
+
},
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
<span class="cmt">// compute(deps, ctx) — deps holds the resolved values of the columns you</span>
|
|
928
|
+
<span class="cmt">// named; ctx carries { data, row, column, grid, context } when you need more.</span></code></pre>
|
|
929
|
+
</div>
|
|
930
|
+
<div class="why">
|
|
931
|
+
<p><code>compute</code> is handed the values it declared rather than the whole row, which is
|
|
932
|
+
what lets the grid memoise it: the same inputs give the same answer, so the result is cached
|
|
933
|
+
until one of them changes. Reach for <code>ctx.data</code> when you genuinely need the rest of
|
|
934
|
+
the row, and set <code>pure: false</code> if the result depends on something the grid cannot
|
|
935
|
+
see.</p>
|
|
936
|
+
<p>Declaring <code>deps</code> is what makes this cheap. Without it the grid would have to
|
|
937
|
+
assume any change might affect any computed column and recompute all of them on every edit.
|
|
938
|
+
A cycle is caught at compile time with the full path named, rather than becoming a stack
|
|
939
|
+
overflow at render time.</p>
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
<h3>Sizing and pinning</h3>
|
|
943
|
+
<div class="example">
|
|
944
|
+
<p class="example__label">Layout</p>
|
|
945
|
+
<pre><code>{ field: 'circuitId', layout: { width: 130, pin: 'start' } }
|
|
946
|
+
{ field: 'notes', layout: { flex: 1, min: 200 } }
|
|
947
|
+
{ field: 'isActive', layout: { width: 90, pin: 'end' } }</code></pre>
|
|
948
|
+
</div>
|
|
949
|
+
<p class="lead-in">
|
|
950
|
+
Pinned columns are rendered in their own region and do not scroll horizontally.
|
|
951
|
+
<code>grid.columns.fit()</code> distributes the viewport width across visible columns, and
|
|
952
|
+
<code>autoSize</code> measures content.
|
|
953
|
+
</p>
|
|
954
|
+
<p>Both are also on the column menu — Move left, Move right, Move to start, Move to end, and
|
|
955
|
+
a Width submenu — and bound to the keyboard with a heading focused: <kbd>Alt</kbd> with a
|
|
956
|
+
left or right arrow resizes, <kbd>Shift</kbd> with one moves the column. Neither operation
|
|
957
|
+
depends on dragging.</p>
|
|
958
|
+
<p>A pinned region holds the edge of the viewport only while there is something to scroll. Where
|
|
959
|
+
the columns are narrower than the grid — fixed widths, or a <code>flex</code> column that
|
|
960
|
+
has reached its <code>max</code> — nothing scrolls, so the pinned columns sit directly
|
|
961
|
+
after the centre ones and the spare width falls beyond them all, at the right of the grid.</p>
|
|
962
|
+
<p>To take that space up rather than leave it, give a column <code>flex</code> and no
|
|
963
|
+
<code>max</code>, or call <code>grid.columns.fit()</code>. Note that removing a column’s
|
|
964
|
+
<code>width</code> does not do it: a column with neither <code>width</code> nor
|
|
965
|
+
<code>flex</code> takes the default 150 rather than a share of what is free. Absorbing space is
|
|
966
|
+
what <code>flex</code> is for, and <code>min</code> only ever sets a floor.</p>
|
|
967
|
+
|
|
968
|
+
<h2 id="types">Types and formatting</h2>
|
|
969
|
+
<p class="lead-in">
|
|
970
|
+
A type is not a label. It is a bundle of behaviour: how a value is formatted, parsed back from
|
|
971
|
+
text, compared when sorting, stored in the columnar backing, written to Excel, and put on the
|
|
972
|
+
clipboard. Setting <code>type</code> configures all of that at once.
|
|
973
|
+
</p>
|
|
974
|
+
|
|
975
|
+
<div class="table-wrap">
|
|
976
|
+
<table>
|
|
977
|
+
<thead><tr><th>Family</th><th>Types</th></tr></thead>
|
|
978
|
+
<tbody>
|
|
979
|
+
<tr><td class="name">Core</td><td class="desc"><code>text</code>, <code>number</code>, <code>boolean</code>, <code>date</code>, <code>dateString</code>, <code>object</code>, <code>lookup</code></td></tr>
|
|
980
|
+
<tr><td class="name">Temporal</td><td class="desc"><code>datetime</code>, <code>time</code>, <code>duration</code></td></tr>
|
|
981
|
+
<tr><td class="name">Network</td><td class="desc"><code>ipv4</code>, <code>ipv6</code>, <code>cidr</code>, <code>mac</code></td></tr>
|
|
982
|
+
<tr><td class="name">Numeric bases</td><td class="desc"><code>hex</code>, <code>hex8</code>, <code>hex16</code>, <code>hex32</code>, <code>binary</code>, <code>binary8</code>, <code>octal</code></td></tr>
|
|
983
|
+
<tr><td class="name">Units</td><td class="desc"><code>bytes</code>, <code>megabytes</code>, <code>gigabytes</code>, <code>bitrate</code></td></tr>
|
|
984
|
+
<tr><td class="name">Structured</td><td class="desc"><code>json</code>, <code>colour</code>, <code>rating</code>, <code>percent</code></td></tr>
|
|
985
|
+
</tbody>
|
|
986
|
+
</table>
|
|
987
|
+
</div>
|
|
988
|
+
|
|
989
|
+
<h3>Types read from the data</h3>
|
|
990
|
+
<p class="lead-in">
|
|
991
|
+
A column that declares no <code>type</code> takes one from the rows. The first hundred non-empty
|
|
992
|
+
values of that column are sampled, and a type is adopted only if every one of them matches it;
|
|
993
|
+
anything mixed or ambiguous stays <code>text</code> and says so once on the console.
|
|
994
|
+
</p>
|
|
995
|
+
<div class="example">
|
|
996
|
+
<p class="example__label">One key each, and the types arrive</p>
|
|
997
|
+
<pre><code>columns: [
|
|
998
|
+
{ field: 'sku' }, <span class="cmt">// text</span>
|
|
999
|
+
{ field: 'quantity' }, <span class="cmt">// number — aligned right, numeric filter</span>
|
|
1000
|
+
{ field: 'shipped' }, <span class="cmt">// date</span>
|
|
1001
|
+
{ field: 'expedited' } <span class="cmt">// boolean — checkbox editor</span>
|
|
1002
|
+
]</code></pre>
|
|
1003
|
+
</div>
|
|
1004
|
+
<p>Sampling happens once, when rows first arrive. A grid built empty and filled later infers on
|
|
1005
|
+
that first load, so fetching after construction is no reason to declare types you would otherwise
|
|
1006
|
+
leave out. Later loads keep the types already settled on — data that arrives tomorrow cannot
|
|
1007
|
+
change a column's type under a formatter or an editor that was configured around it.</p>
|
|
1008
|
+
<p>Only the built-in names are ever inferred. Candidates are tried in registration order, so every
|
|
1009
|
+
string reaches <code>text</code> and every number reaches <code>number</code> before an extended
|
|
1010
|
+
type is considered. A column of IP addresses or durations is <code>text</code> until you name the
|
|
1011
|
+
type you want.</p>
|
|
1012
|
+
<p>Inference never overrules a decision you made. An explicit <code>type</code>, a
|
|
1013
|
+
<code>preset</code> that carries one, a type in <code>columnDefaults</code>, and a
|
|
1014
|
+
<code>lookup</code> all win; <code>type: false</code> turns sampling off and keeps the column
|
|
1015
|
+
text whatever it holds. Set <code>sampleSize</code> to sample more or fewer than a hundred.</p>
|
|
1016
|
+
<div class="why">
|
|
1017
|
+
<p><strong>What declaring a type buys you, and what it does not.</strong> Sorting is not the
|
|
1018
|
+
reason. The default comparator is value-aware, so an undeclared column of numbers already sorts
|
|
1019
|
+
2.5 before 10 rather than lexicographically, and an undeclared column of IPv4 addresses already
|
|
1020
|
+
orders correctly across 128.0.0.1.</p>
|
|
1021
|
+
<p>What a type settles is everything around the sort: which editor opens, how typed text is
|
|
1022
|
+
parsed back into a value, how the value is formatted, which filter kind the header offers, how
|
|
1023
|
+
the column is stored, and what lands in Excel and on the clipboard. That is what inference
|
|
1024
|
+
supplies for free on a plain column, and what naming a type explicitly gives you where the data
|
|
1025
|
+
cannot say it — a duration, a byte count, a network address.</p>
|
|
1026
|
+
</div>
|
|
1027
|
+
|
|
1028
|
+
<h3>Dates are stored as strings, deliberately</h3>
|
|
1029
|
+
<p class="lead-in">
|
|
1030
|
+
A <code>date</code> column stores <code>'2024-03-11'</code>, not a <code>Date</code>. This is
|
|
1031
|
+
the single most consequential type decision in the product.
|
|
1032
|
+
</p>
|
|
1033
|
+
<div class="why">
|
|
1034
|
+
<p><strong>The bug it avoids:</strong> <code>new Date('2024-03-11')</code> is midnight
|
|
1035
|
+
<em>UTC</em>. Render that in New York and it is the 10th. A user in London sets a delivery
|
|
1036
|
+
date, a colleague in Mumbai opens the same grid and sees the day before. Storing the wall-clock
|
|
1037
|
+
string means the date a user typed is the date everyone sees, because there is no instant to
|
|
1038
|
+
convert.</p>
|
|
1039
|
+
<p>It is also faster and smaller: ISO 8601 sorts lexicographically in the same order it sorts
|
|
1040
|
+
chronologically, so a date column sorts as text, and repeated dates dictionary-encode well.</p>
|
|
1041
|
+
<p>When you genuinely mean an instant — a log timestamp — use <code>datetime</code> and set
|
|
1042
|
+
<code>format.timeZone</code>.</p>
|
|
1043
|
+
</div>
|
|
1044
|
+
|
|
1045
|
+
<h3>Formats</h3>
|
|
1046
|
+
<div class="example">
|
|
1047
|
+
<p class="example__label">Shorthand and full form</p>
|
|
1048
|
+
<pre><code>format: 'currency:GBP:2' <span class="cmt">// £1,234.50</span>
|
|
1049
|
+
format: 'percent:1' <span class="cmt">// 87.4%</span>
|
|
1050
|
+
format: 'date:dd MMM yyyy' <span class="cmt">// 11 Mar 2024</span>
|
|
1051
|
+
|
|
1052
|
+
format: { <span class="cmt">// when the shorthand runs out</span>
|
|
1053
|
+
type: 'number',
|
|
1054
|
+
style: 'currency', currency: 'GBP', decimals: 2,
|
|
1055
|
+
negative: 'parentheses', <span class="cmt">// (£1,234.50)</span>
|
|
1056
|
+
negativeClass: 'is-loss',
|
|
1057
|
+
nullDisplay: '—',
|
|
1058
|
+
}</code></pre>
|
|
1059
|
+
</div>
|
|
1060
|
+
|
|
1061
|
+
<h3>Lookups</h3>
|
|
1062
|
+
<p class="lead-in">
|
|
1063
|
+
A lookup column stores an id and shows a label. Sorting, filtering, grouping, copying and
|
|
1064
|
+
exporting all use the label, because that is the thing the user is reasoning about — but the
|
|
1065
|
+
data keeps the id.
|
|
1066
|
+
</p>
|
|
1067
|
+
<div class="example">
|
|
1068
|
+
<p class="example__label">A status column</p>
|
|
1069
|
+
<pre><code>{
|
|
1070
|
+
field: 'statusId',
|
|
1071
|
+
title: 'Status',
|
|
1072
|
+
type: 'lookup',
|
|
1073
|
+
lookup: { options: [
|
|
1074
|
+
{ id: 1, label: 'Open' },
|
|
1075
|
+
{ id: 2, label: 'Pending' },
|
|
1076
|
+
{ id: 3, label: 'Closed' },
|
|
1077
|
+
{ id: 4, label: 'Escalated', variant: 'danger' },
|
|
1078
|
+
]},
|
|
1079
|
+
cell: { decoration: 'pill' },
|
|
1080
|
+
}</code></pre>
|
|
1081
|
+
</div>
|
|
1082
|
+
<p class="lead-in">
|
|
1083
|
+
Options may be a function, may return a promise, and may be searched remotely with an
|
|
1084
|
+
<code>AbortSignal</code> so a superseded keystroke cancels its own request.
|
|
1085
|
+
</p>
|
|
1086
|
+
|
|
1087
|
+
<h3>Custom types</h3>
|
|
1088
|
+
<div class="example">
|
|
1089
|
+
<p class="example__label">A 32-bit register column</p>
|
|
1090
|
+
<pre><code>dataTypes: {
|
|
1091
|
+
reg32: LatticeGrid.createRadixType({
|
|
1092
|
+
radix: 2, bitWidth: 32, pad: true, signed: false, group: 8,
|
|
1093
|
+
}),
|
|
1094
|
+
},
|
|
1095
|
+
columns: [{ field: 'flags', type: 'reg32' }],
|
|
1096
|
+
|
|
1097
|
+
<span class="cmt">// 170 → 0b00000000 00000000 00000000 10101010</span></code></pre>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div class="why">
|
|
1100
|
+
<p><strong><code>radix</code> is 2, 8 or 16</strong>, or the names <code>binary</code>,
|
|
1101
|
+
<code>octal</code> and <code>hex</code>. Base 10 is not among them and is not an oversight:
|
|
1102
|
+
a decimal number is what the <code>number</code> type is for, with grouping, decimals,
|
|
1103
|
+
currency and notation that a radix formatter has no concept of. Passing
|
|
1104
|
+
<code>radix: 10</code> names the supported set in the console and falls back to hex rather
|
|
1105
|
+
than producing something that looks like a number column but is not one.</p>
|
|
1106
|
+
</div>
|
|
1107
|
+
|
|
1108
|
+
<h2 id="renderers">Cells and renderers</h2>
|
|
1109
|
+
<p class="lead-in">
|
|
1110
|
+
By default a cell writes text. When you want more, <code>cell</code> takes a decoration, a
|
|
1111
|
+
named renderer, a template or a component.
|
|
1112
|
+
</p>
|
|
1113
|
+
|
|
1114
|
+
<div class="example">
|
|
1115
|
+
<p class="example__label">Decorations — the common cases, without writing a renderer</p>
|
|
1116
|
+
<pre><code>cell: { decoration: 'pill' } <span class="cmt">// a status chip</span>
|
|
1117
|
+
cell: { decoration: 'bar', min: 0, max: 1 } <span class="cmt">// an inline bar</span>
|
|
1118
|
+
cell: { decoration: 'heat', ramp: 'redGreen' } <span class="cmt">// a heat fill</span>
|
|
1119
|
+
cell: { decoration: 'dot' } <span class="cmt">// a leading dot</span></code></pre>
|
|
1120
|
+
</div>
|
|
1121
|
+
|
|
1122
|
+
<div class="example">
|
|
1123
|
+
<p class="example__label">Variants — mapping a value to a semantic colour</p>
|
|
1124
|
+
<pre><code>cell: {
|
|
1125
|
+
decoration: 'pill',
|
|
1126
|
+
variant: { when: [
|
|
1127
|
+
{ op: 'eq', value: 'Escalated', use: 'danger' },
|
|
1128
|
+
{ op: 'eq', value: 'Pending', use: 'warning' },
|
|
1129
|
+
], default: 'success' },
|
|
1130
|
+
}</code></pre>
|
|
1131
|
+
</div>
|
|
1132
|
+
<div class="why">
|
|
1133
|
+
<p>Variants are semantic tokens rather than colours — <code>danger</code>, not
|
|
1134
|
+
<code>#c22b2b</code>. The theme decides what danger looks like, and it looks the same in the
|
|
1135
|
+
status pill, the filter chip and the validation message. Changing the palette is one custom
|
|
1136
|
+
property, not a search for hex codes.</p>
|
|
1137
|
+
</div>
|
|
1138
|
+
|
|
1139
|
+
<div class="example">
|
|
1140
|
+
<p class="example__label">Your own renderer</p>
|
|
1141
|
+
<pre><code>components: {
|
|
1142
|
+
sparkline: {
|
|
1143
|
+
render(el, p) { el.appendChild(draw(p.value)); },
|
|
1144
|
+
refresh(el, p) { update(el, p.value); <span class="kw">return</span> true; },
|
|
1145
|
+
release(el) { el.textContent = ''; },
|
|
1146
|
+
},
|
|
1147
|
+
},
|
|
1148
|
+
columns: [{ field: 'history', cell: { render: 'sparkline' } }],</code></pre>
|
|
1149
|
+
</div>
|
|
1150
|
+
<div class="why">
|
|
1151
|
+
<p><code>refresh</code> returning <code>true</code> is the contract that makes recycling work:
|
|
1152
|
+
it means "I updated in place, keep this element". Return <code>false</code> and the grid
|
|
1153
|
+
rebuilds the cell. A renderer that only implements <code>render</code> still works — it is
|
|
1154
|
+
just rebuilt on every reuse.</p>
|
|
1155
|
+
</div>
|
|
1156
|
+
|
|
1157
|
+
<div class="example">
|
|
1158
|
+
<p class="example__label">Templates, and what <code>allowUnsafeTemplates</code> permits</p>
|
|
1159
|
+
<pre><code>cell: { template: '<span class="sku">{{ value }}</span>' } <span class="cmt">// escaped</span>
|
|
1160
|
+
|
|
1161
|
+
<span class="cmt">// Raw interpolation needs the grid-level opt-in:</span>
|
|
1162
|
+
allowUnsafeTemplates: true,
|
|
1163
|
+
cell: { template: '<span>{{{ value }}}</span>' }</code></pre>
|
|
1164
|
+
</div>
|
|
1165
|
+
<div class="why">
|
|
1166
|
+
<p><strong>The flag permits markup, not code.</strong> Without it, <code>{{ }}</code> escapes
|
|
1167
|
+
and a <code>{{{ }}}</code> segment is refused outright. With it, an interpolated value may
|
|
1168
|
+
carry presentational markup — <code><b></code>, <code><a href></code>, a
|
|
1169
|
+
<code><span></code> — and everything executable is still stripped from it:
|
|
1170
|
+
<code><script></code>, <code><iframe></code>, <code><style></code> and the
|
|
1171
|
+
other code-bearing tags, every <code>on*</code> handler attribute, and
|
|
1172
|
+
<code>javascript:</code> or <code>data:</code> URLs including entity-encoded spellings of
|
|
1173
|
+
them. The same rules apply to a string returned from <code>cell.render</code>, which is the
|
|
1174
|
+
same gate.</p>
|
|
1175
|
+
<p><strong>Why the value is treated differently from the template.</strong> You wrote the
|
|
1176
|
+
template and can audit it; the value is row data and usually arrives from somewhere you
|
|
1177
|
+
cannot. A template is refused at compile time for a dangerous tag, but that refusal says
|
|
1178
|
+
nothing about what a value interpolated into it might contain.</p>
|
|
1179
|
+
<p><strong>It is a narrow allowance, not a sanitiser.</strong> It exists so a grid cell can
|
|
1180
|
+
show emphasis and a link. To render arbitrary third-party HTML, sanitise it yourself and
|
|
1181
|
+
return an element from <code>cell.render</code>.</p>
|
|
1182
|
+
</div>
|
|
1183
|
+
|
|
1184
|
+
<h2 id="styling">Styling</h2>
|
|
1185
|
+
<p class="lead-in">
|
|
1186
|
+
Cells, columns and rows can all carry classes and inline styles, static or computed.
|
|
1187
|
+
</p>
|
|
1188
|
+
<div class="example">
|
|
1189
|
+
<p class="example__label">By scope</p>
|
|
1190
|
+
<pre><code><span class="cmt">// Cells and columns — declared on the column</span>
|
|
1191
|
+
{ field: 'margin', cell: {
|
|
1192
|
+
class: 'tabular',
|
|
1193
|
+
classWhen: { 'is-loss': (p) => p.value < 0 },
|
|
1194
|
+
style: (p) => ({ fontWeight: p.value > 1e6 ? 650 : 400 }),
|
|
1195
|
+
}}
|
|
1196
|
+
|
|
1197
|
+
<span class="cmt">// Rows — on the grid</span>
|
|
1198
|
+
rowClass: (p) => p.data.slaBreached ? 'row-breach' : null,
|
|
1199
|
+
rowStyle: (p) => p.data.region === 'AMER' ? { borderLeft: '3px solid #7c3aed' } : null,</code></pre>
|
|
1200
|
+
</div>
|
|
1201
|
+
<div class="why">
|
|
1202
|
+
<p>All of these are re-evaluated on every repaint and remove what they added last time first.
|
|
1203
|
+
That is not caution. Rows and cells come from pools, so an element that carried a class for
|
|
1204
|
+
one row will later carry a different row — a class written once and left alone smears down
|
|
1205
|
+
the grid as the user scrolls.</p>
|
|
1206
|
+
</div>
|
|
1207
|
+
|
|
1208
|
+
<h3 id="theming">Theming</h3>
|
|
1209
|
+
<p class="lead-in">
|
|
1210
|
+
The stylesheet is custom properties throughout. Override the tokens, not the rules.
|
|
1211
|
+
</p>
|
|
1212
|
+
<div class="example">
|
|
1213
|
+
<p class="example__label">A house palette</p>
|
|
1214
|
+
<pre><code>.lattice {
|
|
1215
|
+
--lattice-accent: #7c3aed;
|
|
1216
|
+
--lattice-font-size: 13px;
|
|
1217
|
+
--lattice-space: 8px;
|
|
1218
|
+
--lattice-border-color: #e6e8eb;
|
|
1219
|
+
}</code></pre>
|
|
1220
|
+
</div>
|
|
1221
|
+
<p class="lead-in">
|
|
1222
|
+
Four themes ship. With no <code>theme</code> set, the grid follows the viewer's
|
|
1223
|
+
<code>prefers-color-scheme</code> between light and dark; naming one pins it.
|
|
1224
|
+
Density is separate — <code>compact</code>, <code>standard</code>, <code>comfortable</code>
|
|
1225
|
+
or <code>spacious</code> — and combines with any of them.
|
|
1226
|
+
</p>
|
|
1227
|
+
<div class="example">
|
|
1228
|
+
<p class="example__label">Pinning a theme, at build time or at runtime</p>
|
|
1229
|
+
<pre><code>createGrid(el, { theme: 'high-contrast' });
|
|
1230
|
+
grid.set('theme', 'terminal');
|
|
1231
|
+
grid.set('theme', <span class="kw">null</span>); <span class="cmt">// back to following the viewer</span></code></pre>
|
|
1232
|
+
</div>
|
|
1233
|
+
<div class="table-wrap">
|
|
1234
|
+
<table>
|
|
1235
|
+
<thead><tr><th>Theme</th><th>What it is for</th></tr></thead>
|
|
1236
|
+
<tbody>
|
|
1237
|
+
<tr><td class="name"><code>light</code></td><td class="desc">The default. Follows <code>prefers-color-scheme</code> when <code>theme</code> is unset.</td></tr>
|
|
1238
|
+
<tr><td class="name"><code>dark</code></td><td class="desc">The same palette inverted, with the accent and status hues re-picked for a dark ground rather than reused.</td></tr>
|
|
1239
|
+
<tr><td class="name"><code>high-contrast</code></td><td class="desc">Not "dark with more contrast". Text is 21:1 and borders 6.1:1 against the background, where the other themes sit near 1.3:1 on borders — WCAG 1.4.11 asks for 3:1 on the boundaries a user has to find. Cell borders are drawn rather than implied, selected rows carry an outline as well as a fill, and every status pill has a solid border so it does not depend on hue alone.</td></tr>
|
|
1240
|
+
<tr><td class="name"><code>terminal</code></td><td class="desc">A phosphor console: one hue on near-black, monospaced throughout. Status is carried by brightness rather than colour, so the palette stays a palette.</td></tr>
|
|
1241
|
+
</tbody>
|
|
1242
|
+
</table>
|
|
1243
|
+
</div>
|
|
1244
|
+
<h3>Forced colours</h3>
|
|
1245
|
+
<p class="lead-in">
|
|
1246
|
+
Windows High Contrast Mode replaces the palette outright — that is the point of it, and
|
|
1247
|
+
no stylesheet should fight it. What the grid does instead is translate every piece of meaning
|
|
1248
|
+
it normally carries in a background tint into something the mode preserves.
|
|
1249
|
+
</p>
|
|
1250
|
+
<p>A selected row takes the system's own selection colours. A pinned region loses its shadow,
|
|
1251
|
+
which forced colours do not render, and gains a rule in its place. Status pills, fill
|
|
1252
|
+
decorations, progress tracks and histogram bars each gain a border, because a fill with no
|
|
1253
|
+
edge is invisible once its colour is discarded. Diff states stop depending on hue altogether:
|
|
1254
|
+
added, removed and changed are told apart by border style — solid, dashed and doubled
|
|
1255
|
+
— since the mode offers no way to keep four distinct colours.</p>
|
|
1256
|
+
<p>Two things deliberately keep their colour, declared with
|
|
1257
|
+
<code>forced-color-adjust</code>: a colour swatch, where the colour <em>is</em> the value being
|
|
1258
|
+
shown, and a collaborator's presence colour, which is how one person is told from another.
|
|
1259
|
+
Replacing those would destroy the meaning rather than translate it. Both gain a border so they
|
|
1260
|
+
stay visible against either ground.</p>
|
|
1261
|
+
|
|
1262
|
+
<p class="lead-in">
|
|
1263
|
+
Every theme is the same token set with different values, so an override you write against
|
|
1264
|
+
<code>.lattice</code> applies to all of them, and one written against
|
|
1265
|
+
<code>.lattice[data-theme="terminal"]</code> applies to that one. The attribute is on the
|
|
1266
|
+
grid's own root element, not on <code><html></code>.
|
|
1267
|
+
</p>
|
|
1268
|
+
|
|
1269
|
+
<h3>The grid and your page's CSS</h3>
|
|
1270
|
+
<p class="lead-in">
|
|
1271
|
+
Every selector in the stylesheet is namespaced under <code>.lattice</code>, so the grid cannot
|
|
1272
|
+
restyle your page. From 1.4.0 the reverse is also true: the grid gives the elements it builds
|
|
1273
|
+
a floor for the properties a page is most likely to set on a bare tag — margin, padding,
|
|
1274
|
+
border, radius, background, shadow, text transform and letter spacing, plus type and colour on
|
|
1275
|
+
form controls, which inherit neither.
|
|
1276
|
+
</p>
|
|
1277
|
+
<div class="why">
|
|
1278
|
+
<p><strong>Why this is needed at all.</strong> A grid is mounted inside somebody else's
|
|
1279
|
+
stylesheet. A rule as ordinary as <code>section { padding: 5.5rem 0 }</code> — a marketing
|
|
1280
|
+
page, a CMS theme, a Tailwind preflight — used to reach inside and put 88px of padding on every
|
|
1281
|
+
filter row in the tool panel, because the grid builds those rows as <code><section></code>
|
|
1282
|
+
elements and its own rules said nothing about padding there.</p>
|
|
1283
|
+
<p>The reset uses no <code>!important</code>. It is specificity (0,1,1) and every rule that
|
|
1284
|
+
dresses a grid element is (0,2,0) or higher, so the grid's own styling always wins and the
|
|
1285
|
+
reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class —
|
|
1286
|
+
<code>.lattice .lat-cell { … }</code> — outranks it, so overriding the grid deliberately works
|
|
1287
|
+
exactly as before. Only bare-tag rules are shut out.</p>
|
|
1288
|
+
<p>It touches box model and decoration only. Nothing in it sets <code>display</code>,
|
|
1289
|
+
<code>position</code> or any dimension: those belong to the renderer, and a reset that reached
|
|
1290
|
+
them would break virtualisation rather than protect it.</p>
|
|
1291
|
+
</div>
|
|
1292
|
+
<h2 id="loading">Loading and updating data</h2>
|
|
1293
|
+
<p class="lead-in">
|
|
1294
|
+
Data usually arrives after the grid does. Build it empty, then load — the sort, filters,
|
|
1295
|
+
grouping and column layout you set up in the meantime all survive and apply to the new data.
|
|
1296
|
+
</p>
|
|
1297
|
+
<div class="example">
|
|
1298
|
+
<p class="example__label">The ordinary sequence</p>
|
|
1299
|
+
<pre><code><span class="kw">const</span> grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
|
|
1300
|
+
grid.overlay.show('loading');
|
|
1301
|
+
|
|
1302
|
+
<span class="kw">const</span> data = <span class="kw">await</span> fetch('/api/circuits').then(r => r.json());
|
|
1303
|
+
grid.rows.load(data);
|
|
1304
|
+
grid.overlay.hide();</code></pre>
|
|
1305
|
+
</div>
|
|
1306
|
+
|
|
1307
|
+
<h3>Incremental changes</h3>
|
|
1308
|
+
<p class="lead-in">
|
|
1309
|
+
<code>rows.load</code> replaces everything. When you have a delta — a websocket message, a
|
|
1310
|
+
save that returned the updated record — apply just that.
|
|
1311
|
+
</p>
|
|
1312
|
+
<div class="example">
|
|
1313
|
+
<p class="example__label">Adds, updates and removals in one call</p>
|
|
1314
|
+
<pre><code>grid.rows.apply({
|
|
1315
|
+
add: [{ id: 4, name: 'd' }, { id: 5, name: 'e' }],
|
|
1316
|
+
update: [{ id: 1, name: 'A' }],
|
|
1317
|
+
remove: ['3'], <span class="cmt">// row keys, or the row objects</span>
|
|
1318
|
+
at: 0, <span class="cmt">// optional insert position for `add`</span>
|
|
1319
|
+
});
|
|
1320
|
+
<span class="cmt">// → { added: [...], updated: [...], removed: [...] }</span></code></pre>
|
|
1321
|
+
</div>
|
|
1322
|
+
<div class="why">
|
|
1323
|
+
<p><strong>An update is a patch.</strong> Fields absent from it are untouched, so a delta
|
|
1324
|
+
arriving from a websocket or coming back from a save can be applied as-is without reading the
|
|
1325
|
+
row first. This has to be said explicitly because the opposite — assigning the patch
|
|
1326
|
+
over the row — looks identical for a caller who happens to send whole rows and silently
|
|
1327
|
+
destroys data for one who does not.</p>
|
|
1328
|
+
<p><strong>Coalescing merges fields rather than keeping the last message.</strong> A feed
|
|
1329
|
+
sending <code>{price}</code> and <code>{volume}</code> as separate messages inside one window
|
|
1330
|
+
keeps both. Coalescing may reorder work; it may not lose it.</p>
|
|
1331
|
+
<p><strong>Flushing happens on a frame, with a timer behind it.</strong> A queued batch lands
|
|
1332
|
+
on a paint boundary, which is what makes "ten thousand updates, one repaint" true rather than
|
|
1333
|
+
usually true — a timer can fire twice between two paints. But
|
|
1334
|
+
<code>requestAnimationFrame</code> is not guaranteed to fire at all: a backgrounded tab stops
|
|
1335
|
+
firing it entirely. So a frame and a timer are armed together and the first to arrive wins. In
|
|
1336
|
+
a foreground tab the frame always wins, at ~16ms against a 50ms fallback; in a hidden tab the
|
|
1337
|
+
timer keeps the feed applying instead of the grid silently stalling with every caller's
|
|
1338
|
+
promise unresolved.</p>
|
|
1339
|
+
<p><strong>A long flush defers rather than blocks.</strong> <code>updates.budgetMs</code> caps
|
|
1340
|
+
how long one flush spends applying; over budget, the remainder returns to the queue and lands
|
|
1341
|
+
next frame, and the promise a caller is holding resolves when their rows actually land rather
|
|
1342
|
+
than when the first slice does. Slicing is by row and only for updates — a partially
|
|
1343
|
+
applied row is not a state the store should be in, and splitting a structural change would
|
|
1344
|
+
re-run the pipeline twice for one batch. <code>stats().deferrals</code> rising steadily means
|
|
1345
|
+
the feed is arriving faster than the grid can apply it.</p>
|
|
1346
|
+
<p><strong>Rejections are reported, never thrown.</strong> Throwing would abandon the rows
|
|
1347
|
+
that were fine. An update or remove naming a row that is not here is <code>unknown-id</code>;
|
|
1348
|
+
an add whose key already exists is <code>duplicate-id</code> and is refused, because
|
|
1349
|
+
selection, expansion, comments and the key index all resolve one key to one row and admitting
|
|
1350
|
+
a second corrupts every one of them at once.</p>
|
|
1351
|
+
<p>This runs the minimum pipeline. An update touching no sorted, filtered or grouped column
|
|
1352
|
+
skips those stages entirely and only the totals and the affected cells refresh. Adds and
|
|
1353
|
+
removals are structural and re-run everything.</p>
|
|
1354
|
+
<p>Removals tombstone in place rather than compacting, so every existing index stays valid —
|
|
1355
|
+
which is what lets selection, expansion state and cached permutations survive a delete.</p>
|
|
1356
|
+
</div>
|
|
1357
|
+
|
|
1358
|
+
<h3>Patching a single cell</h3>
|
|
1359
|
+
<p class="lead-in">
|
|
1360
|
+
When you have a value rather than a row, <code>setCells</code> patches fields in place.
|
|
1361
|
+
</p>
|
|
1362
|
+
<div class="example">
|
|
1363
|
+
<p class="example__label">One cell, or many, without row objects</p>
|
|
1364
|
+
<pre><code>grid.edit.setCells([{ key: 'CIR-100042', colId: 'capacity', value: 990 }]);
|
|
1365
|
+
|
|
1366
|
+
grid.edit.setCells([
|
|
1367
|
+
{ key: 'CIR-100042', colId: 'notes', value: 'Chased' },
|
|
1368
|
+
{ key: 'CIR-100043', colId: 'capacity', value: 770 },
|
|
1369
|
+
]);
|
|
1370
|
+
<span class="cmt">// → the number of cells written</span></code></pre>
|
|
1371
|
+
</div>
|
|
1372
|
+
<p class="lead-in">
|
|
1373
|
+
This is the full path, not a shortcut: it validates, emits <code>cell:changed</code>,
|
|
1374
|
+
re-sorts if the column is sorted on, records one undo entry, and returns <code>0</code> for a
|
|
1375
|
+
column the user may not write.
|
|
1376
|
+
</p>
|
|
1377
|
+
|
|
1378
|
+
<h3>High-frequency updates</h3>
|
|
1379
|
+
<p class="lead-in">
|
|
1380
|
+
For a ticking feed, <code>queue</code> batches changes to the next animation frame so a
|
|
1381
|
+
thousand messages a second produce sixty repaints rather than a thousand.
|
|
1382
|
+
</p>
|
|
1383
|
+
<div class="example">
|
|
1384
|
+
<p class="example__label">A price feed</p>
|
|
1385
|
+
<pre><code>socket.on('tick', (row) => grid.rows.queue({ update: [row] }));</code></pre>
|
|
1386
|
+
</div>
|
|
1387
|
+
|
|
1388
|
+
<h3>Walking the data rather than the view</h3>
|
|
1389
|
+
<p class="lead-in">
|
|
1390
|
+
<code>rows.forEach</code> walks what is on screen: filtered, sorted, grouped, with collapsed
|
|
1391
|
+
rows left out. That is the right default, and the wrong answer for a caller totalling a column,
|
|
1392
|
+
exporting, or reconciling against another system.
|
|
1393
|
+
</p>
|
|
1394
|
+
<div class="example">
|
|
1395
|
+
<pre><code>let total = 0;
|
|
1396
|
+
grid.rows.forEach(r => { total += r.data.amount }); <span class="cmt">// what the user can see</span>
|
|
1397
|
+
grid.rows.forEachAll(r => { total += r.data.amount }); <span class="cmt">// what the grid holds</span></code></pre>
|
|
1398
|
+
</div>
|
|
1399
|
+
<p><code>forEachAll</code> visits leaf rows only, in the order they arrived. Group rows are a
|
|
1400
|
+
product of the current grouping and do not exist in the data, so they are not offered; the sort
|
|
1401
|
+
belongs to the filtered view, so the order here is physical rather than sorted.</p>
|
|
1402
|
+
<div class="why">
|
|
1403
|
+
<p>A remote or paged source holds the page it has fetched, not the whole set, so there is
|
|
1404
|
+
nothing there to walk past the filters. It warns and walks what it has rather than quietly
|
|
1405
|
+
returning the filtered rows — a caller who asked for everything and silently received a
|
|
1406
|
+
subset gets a number that looks entirely plausible and is wrong.</p>
|
|
1407
|
+
</div>
|
|
1408
|
+
|
|
1409
|
+
<h2 id="sorting">Sorting</h2>
|
|
1410
|
+
<div class="example">
|
|
1411
|
+
<p class="example__label">Setting and reading</p>
|
|
1412
|
+
<pre><code>grid.sort.set([{ col: 'capacity', dir: 'desc' }]);
|
|
1413
|
+
|
|
1414
|
+
<span class="cmt">// A multi-column sort is one call, in priority order.</span>
|
|
1415
|
+
grid.sort.set([
|
|
1416
|
+
{ col: 'region', dir: 'asc' },
|
|
1417
|
+
{ col: 'capacity', dir: 'desc' },
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
grid.sort.get(); <span class="cmt">// [{ col, dir }, …]</span>
|
|
1421
|
+
grid.sort.clear();</code></pre>
|
|
1422
|
+
</div>
|
|
1423
|
+
<p class="lead-in">
|
|
1424
|
+
Clicking a header cycles ascending, descending, none; shift-clicking a second header adds to
|
|
1425
|
+
the sort rather than replacing it. A column can supply its own <code>compare</code>, and a
|
|
1426
|
+
type already has one — dates compare chronologically, IP addresses numerically rather than as
|
|
1427
|
+
strings, durations by length.
|
|
1428
|
+
</p>
|
|
1429
|
+
|
|
1430
|
+
<h2 id="filtering">Filtering</h2>
|
|
1431
|
+
<p class="lead-in">
|
|
1432
|
+
There are two filters and they are separate on purpose. The <strong>quick filter</strong> is
|
|
1433
|
+
one string matched across every readable column. The <strong>filter set</strong> is a
|
|
1434
|
+
structured condition tree.
|
|
1435
|
+
</p>
|
|
1436
|
+
|
|
1437
|
+
<div class="example">
|
|
1438
|
+
<p class="example__label">Quick filter</p>
|
|
1439
|
+
<pre><code>grid.filters.quick('singapore');
|
|
1440
|
+
grid.filters.quick(''); <span class="cmt">// clear</span></code></pre>
|
|
1441
|
+
</div>
|
|
1442
|
+
|
|
1443
|
+
<div class="example">
|
|
1444
|
+
<p class="example__label">A condition tree</p>
|
|
1445
|
+
<pre><code>grid.filters.set({
|
|
1446
|
+
op: 'and',
|
|
1447
|
+
conditions: [
|
|
1448
|
+
{ col: 'region', op: 'in', value: ['EMEA', 'APAC'] },
|
|
1449
|
+
{ col: 'utilisation', op: 'gt', value: 0.9 },
|
|
1450
|
+
{ op: 'or', conditions: [
|
|
1451
|
+
{ col: 'slaBreached', op: 'eq', value: true },
|
|
1452
|
+
{ col: 'margin', op: 'lt', value: 0 },
|
|
1453
|
+
]},
|
|
1454
|
+
],
|
|
1455
|
+
});</code></pre>
|
|
1456
|
+
</div>
|
|
1457
|
+
<div class="why">
|
|
1458
|
+
<p>That structure is a published wire protocol, not an internal detail. It is what
|
|
1459
|
+
<code>grid.state.get()</code> serialises, what a saved view carries, and what you can send to
|
|
1460
|
+
a server to evaluate the same filter against the full dataset. A remote source hands it
|
|
1461
|
+
straight to your backend.</p>
|
|
1462
|
+
</div>
|
|
1463
|
+
|
|
1464
|
+
<p class="lead-in">
|
|
1465
|
+
Operators are per family: text has <code>contains</code>, <code>startsWith</code>,
|
|
1466
|
+
<code>matches</code>; numbers and dates have <code>between</code>, <code>gt</code>,
|
|
1467
|
+
<code>lte</code>; multi-value columns have <code>containsAny</code>,
|
|
1468
|
+
<code>containsAll</code>, <code>containsNone</code>. <code>blank</code> and
|
|
1469
|
+
<code>notBlank</code> work everywhere.
|
|
1470
|
+
</p>
|
|
1471
|
+
|
|
1472
|
+
<h2 id="grouping">Grouping, totals and pivot</h2>
|
|
1473
|
+
<div class="example">
|
|
1474
|
+
<p class="example__label">Group by one or more columns</p>
|
|
1475
|
+
<pre><code>grid.columns.group(['region', 'country']);
|
|
1476
|
+
grid.rows.expandAll();
|
|
1477
|
+
grid.rows.collapse('EMEA');</code></pre>
|
|
1478
|
+
</div>
|
|
1479
|
+
|
|
1480
|
+
<div class="example">
|
|
1481
|
+
<p class="example__label">Totals</p>
|
|
1482
|
+
<pre><code>{ field: 'capacity', type: 'number', total: 'sum' }
|
|
1483
|
+
{ field: 'margin', type: 'number', total: 'avg' }
|
|
1484
|
+
{ field: 'lastSeen', type: 'datetime', total: 'max' }
|
|
1485
|
+
|
|
1486
|
+
{ field: 'weighted', total: (values, rows) => weightedMean(values, rows) }</code></pre>
|
|
1487
|
+
</div>
|
|
1488
|
+
<p class="lead-in">
|
|
1489
|
+
Totals appear on every group row and on the grand total. <code>grandTotalRow: 'bottom'</code>
|
|
1490
|
+
pins the grand total below the rows instead of leaving it inline.
|
|
1491
|
+
</p>
|
|
1492
|
+
|
|
1493
|
+
<p class="lead-in">
|
|
1494
|
+
On a memory source the grand total is <strong>maintained incrementally</strong>: a cell
|
|
1495
|
+
update moves the running value by the difference rather than re-reducing the column, so a
|
|
1496
|
+
totals row costs the same on a million rows as on a thousand. This applies to
|
|
1497
|
+
<code>sum</code>, <code>avg</code>, <code>countValues</code>, <code>min</code> and
|
|
1498
|
+
<code>max</code> on numeric columns. Everything else re-reduces, and so does the
|
|
1499
|
+
incremental path itself whenever it cannot reach the right answer:
|
|
1500
|
+
</p>
|
|
1501
|
+
<div class="table-wrap">
|
|
1502
|
+
<table>
|
|
1503
|
+
<thead><tr><th>Case</th><th>What happens</th></tr></thead>
|
|
1504
|
+
<tbody>
|
|
1505
|
+
<tr><td class="name">sum, avg, countValues</td><td class="desc">Maintained by difference, with a compensated running sum so a long session does not accumulate floating-point error.</td></tr>
|
|
1506
|
+
<tr><td class="name">min, max</td><td class="desc">Maintained while values move <em>past</em> the extreme. A value moving off the current extreme re-reduces, because a running extreme cannot know what the next one is.</td></tr>
|
|
1507
|
+
<tr><td class="name">count</td><td class="desc">The number of rows in scope, already a single read.</td></tr>
|
|
1508
|
+
<tr><td class="name">A custom <code>total</code> function</td><td class="desc">Re-reduced on every change. A reduction supplied as a function has no inverse, so there is nothing to apply a difference to.</td></tr>
|
|
1509
|
+
<tr><td class="name">Adding or removing rows</td><td class="desc">Re-reduced once, then incremental again.</td></tr>
|
|
1510
|
+
<tr><td class="name">Filtering, sorting or grouping</td><td class="desc">Re-reduced once, because the rows contributing to the total have changed.</td></tr>
|
|
1511
|
+
<tr><td class="name">Totals above ~1e15</td><td class="desc">Re-reduced. Past that magnitude a small change no longer moves a 64-bit float, and a running total would silently stop tracking the data.</td></tr>
|
|
1512
|
+
<tr><td class="name">Group totals</td><td class="desc">Re-reduced per group on every change. Only the grand total is incremental.</td></tr>
|
|
1513
|
+
</tbody>
|
|
1514
|
+
</table>
|
|
1515
|
+
</div>
|
|
1516
|
+
<p class="lead-in">
|
|
1517
|
+
Nothing has to be configured for this, and the reported number is the same either way —
|
|
1518
|
+
where a running value cannot be trusted, the column falls back to a full pass rather than
|
|
1519
|
+
reporting a value it is unsure of.
|
|
1520
|
+
</p>
|
|
1521
|
+
|
|
1522
|
+
<h3 id="show-total-in-header">showTotalInHeader</h3>
|
|
1523
|
+
<p class="lead-in">
|
|
1524
|
+
Under grouping or pivot, a totalled column's cells hold an aggregate rather than a row's own
|
|
1525
|
+
value. On by default, the heading says which — a small <code>SUM</code> line above
|
|
1526
|
+
<code>Capacity</code>, <code>AVERAGE</code> above <code>Margin</code>. The heading returns to
|
|
1527
|
+
the column's own title when grouping and pivot are both off.
|
|
1528
|
+
</p>
|
|
1529
|
+
<div class="example">
|
|
1530
|
+
<p class="example__label">Leave the headings alone</p>
|
|
1531
|
+
<pre><code>showTotalInHeader: <span class="kw">false</span></code></pre>
|
|
1532
|
+
</div>
|
|
1533
|
+
<p class="lead-in">
|
|
1534
|
+
The reduction goes on its own line rather than reading <code>Sum of Capacity</code> across
|
|
1535
|
+
one. A header cell reserves width for its sort, filter and menu buttons whether or not they
|
|
1536
|
+
are showing, so the label gets well under half the column — on a default column, 54px of
|
|
1537
|
+
129px. One line truncated to <code>Sum o…</code>, trading the column's identity for its
|
|
1538
|
+
reduction. Stacked, it costs no width at all.
|
|
1539
|
+
</p>
|
|
1540
|
+
<p class="lead-in">
|
|
1541
|
+
Such a heading carries <code>data-total</code> on its header cell, naming the reduction, and
|
|
1542
|
+
its two lines are <code>.lat-header-total-fn</code> and
|
|
1543
|
+
<code>.lat-header-total-name</code>. <code>--lattice-header-total-size</code> and
|
|
1544
|
+
<code>--lattice-header-total-color</code> set the reduction line's size and colour; the full
|
|
1545
|
+
phrase is on the label's <code>title</code> for the pointer. Where a pivot has a single value
|
|
1546
|
+
column its leaf is titled with the pivot value rather than the column's name, and that
|
|
1547
|
+
heading is left alone: it is naming the category, not the measure.
|
|
1548
|
+
</p>
|
|
1549
|
+
|
|
1550
|
+
<h3 id="pivot-group-totals">A total beside the pivoted columns</h3>
|
|
1551
|
+
<div class="example">
|
|
1552
|
+
<p class="example__label">The grand total across every pivot value</p>
|
|
1553
|
+
<pre><code>pivot: { groupTotals: 'after' } <span class="cmt">// or 'before'; omitted, none</span>
|
|
1554
|
+
pivot: { groupTotals: 'after', totalsLabel: 'All regions' }</code></pre>
|
|
1555
|
+
</div>
|
|
1556
|
+
<div class="why">
|
|
1557
|
+
<p><strong>It answers the question the pivot took apart.</strong> Pivoting by Country turns
|
|
1558
|
+
one Sales column into one per country, and the number a reader most often wants next is the
|
|
1559
|
+
one that was there before: sales across all of them. <code>'before'</code> puts that group at
|
|
1560
|
+
the near edge, beside the row headings; <code>'after'</code> puts it at the far edge, which is
|
|
1561
|
+
where a spreadsheet puts a grand total.</p>
|
|
1562
|
+
<p><strong>It costs a column, not a pass.</strong> A pivoted group row still carries its
|
|
1563
|
+
reduction over every one of its leaves — which is exactly the total across all pivot values —
|
|
1564
|
+
so these columns read a number that has already been computed. They also count towards
|
|
1565
|
+
<code>maxColumns</code>, since they are columns like any other.</p>
|
|
1566
|
+
<p><strong>Opt in.</strong> Omitted, a pivot has the columns it has always had, so the option
|
|
1567
|
+
cannot quietly change what an existing grid exports or what a saved view restores.</p>
|
|
1568
|
+
</div>
|
|
1569
|
+
|
|
1570
|
+
<h3 id="total-filtered-only">totalFilteredOnly</h3>
|
|
1571
|
+
<div class="example">
|
|
1572
|
+
<p class="example__label">Total the dataset rather than the view</p>
|
|
1573
|
+
<pre><code>totalFilteredOnly: <span class="kw">false</span></code></pre>
|
|
1574
|
+
</div>
|
|
1575
|
+
<p class="lead-in">
|
|
1576
|
+
By default a total describes what is on screen: filter the grid and every total moves with
|
|
1577
|
+
it. Setting this to <code>false</code> makes the filter a lens instead — totals report the
|
|
1578
|
+
whole dataset no matter what is filtered out. Both the grand total and each group total
|
|
1579
|
+
follow the setting, so a group row shows the total for every row belonging to that group,
|
|
1580
|
+
not only the ones currently visible.
|
|
1581
|
+
</p>
|
|
1582
|
+
<p class="lead-in">
|
|
1583
|
+
A group whose every row the filter removed has no row to appear on, but its rows still count
|
|
1584
|
+
toward the totals above it. Editing a hidden row moves the totals, because it is part of the
|
|
1585
|
+
dataset they describe — under the default it does not, because it is not part of the view
|
|
1586
|
+
they describe.
|
|
1587
|
+
</p>
|
|
1588
|
+
<p class="lead-in">
|
|
1589
|
+
The count shown on the grand total row follows the total, so it never reports fewer rows
|
|
1590
|
+
than the total covers. Group row counts stay as the number of rows a user can expand to
|
|
1591
|
+
see, which is what that number is for.
|
|
1592
|
+
</p>
|
|
1593
|
+
<p class="lead-in">
|
|
1594
|
+
The unfiltered row set and its grouping are computed once and reused, so the cost lands when
|
|
1595
|
+
rows are added or removed rather than on every edit or filter change. Reducing over the full
|
|
1596
|
+
dataset is more work than reducing over a filtered subset: on 500,000 rows grouped and
|
|
1597
|
+
half-filtered, a single-cell update measured 2.1ms by default and 3.2ms with this off.
|
|
1598
|
+
</p>
|
|
1599
|
+
|
|
1600
|
+
<h3 id="total-only-changed">totalOnlyChangedColumns</h3>
|
|
1601
|
+
<div class="example">
|
|
1602
|
+
<p class="example__label">Skip the columns an edit did not touch</p>
|
|
1603
|
+
<pre><code>totalOnlyChangedColumns: <span class="kw">true</span></code></pre>
|
|
1604
|
+
</div>
|
|
1605
|
+
<p class="lead-in">
|
|
1606
|
+
Group totals re-reduce every totalled column on every change, including columns the change
|
|
1607
|
+
did not touch. Switching this on reduces only the columns whose values actually moved, and
|
|
1608
|
+
an update that rewrites a field with the value it already held reduces nothing at all —
|
|
1609
|
+
which is what a feed resending unchanged fields looks like.
|
|
1610
|
+
</p>
|
|
1611
|
+
<p class="lead-in">
|
|
1612
|
+
On a million rows in seven groups, a single-cell update with four totalled columns:
|
|
1613
|
+
</p>
|
|
1614
|
+
<div class="table-wrap">
|
|
1615
|
+
<table>
|
|
1616
|
+
<thead><tr><th>The update changes</th><th>Off</th><th>On</th></tr></thead>
|
|
1617
|
+
<tbody>
|
|
1618
|
+
<tr><td class="name">One of the four columns</td><td class="desc">15.4ms</td><td class="desc">7.3ms</td></tr>
|
|
1619
|
+
<tr><td class="name">All four columns</td><td class="desc">15.0ms</td><td class="desc">14.5ms</td></tr>
|
|
1620
|
+
<tr><td class="name">Nothing — same values rewritten</td><td class="desc">15.6ms</td><td class="desc">7.3ms</td></tr>
|
|
1621
|
+
</tbody>
|
|
1622
|
+
</table>
|
|
1623
|
+
</div>
|
|
1624
|
+
<p class="lead-in">
|
|
1625
|
+
The saving is proportional to the totalled columns an update leaves alone, so there is
|
|
1626
|
+
nothing to gain when every totalled column changes on every update.
|
|
1627
|
+
</p>
|
|
1628
|
+
<p class="lead-in">
|
|
1629
|
+
<strong>It is off by default because it is an assertion, not just an optimisation.</strong>
|
|
1630
|
+
Skipping a column assumes its total depends on nothing but that column's own values. That
|
|
1631
|
+
is true of every built-in reduction. It need not be true of a <code>total</code> supplied as
|
|
1632
|
+
a function, which also receives the row, the grid and <code>config.context</code> — such a
|
|
1633
|
+
total is only recomputed when its own column changes, so a function that reads application
|
|
1634
|
+
state outside the column will report the value from the last time that column moved. Leave
|
|
1635
|
+
the option off if any of your total functions work that way.
|
|
1636
|
+
</p>
|
|
1637
|
+
<p class="lead-in">
|
|
1638
|
+
Anything that changes which rows a total covers — adding or removing rows, filtering,
|
|
1639
|
+
sorting, grouping, or changing which columns are totalled — reduces everything again
|
|
1640
|
+
regardless of the option.
|
|
1641
|
+
</p>
|
|
1642
|
+
|
|
1643
|
+
<div class="example">
|
|
1644
|
+
<p class="example__label">Pivot</p>
|
|
1645
|
+
<pre><code>grid.columns.group(['region']);
|
|
1646
|
+
grid.columns.pivot(['statusId']); <span class="cmt">// a column per distinct status</span></code></pre>
|
|
1647
|
+
</div>
|
|
1648
|
+
|
|
1649
|
+
<h2 id="sources">Working with large data</h2>
|
|
1650
|
+
<p class="lead-in">
|
|
1651
|
+
A million rows in memory is fine — that is what the columnar store is for. Beyond that, or
|
|
1652
|
+
when the data lives behind an API, a source takes over.
|
|
1653
|
+
</p>
|
|
1654
|
+
<div class="table-wrap">
|
|
1655
|
+
<table>
|
|
1656
|
+
<thead><tr><th>Source</th><th>For</th></tr></thead>
|
|
1657
|
+
<tbody>
|
|
1658
|
+
<tr><td class="name">memory</td><td class="desc">The default. Everything is present; the grid does all the work.</td></tr>
|
|
1659
|
+
<tr><td class="name">paged</td><td class="desc">A page at a time from a server that paginates.</td></tr>
|
|
1660
|
+
<tr><td class="name">remote</td><td class="desc">Blocks fetched on demand as the user scrolls, with sort and filter pushed to the server.</td></tr>
|
|
1661
|
+
<tr><td class="name">stream</td><td class="desc">Rows arriving over time — a query that streams, a socket. Promotes to memory once complete.</td></tr>
|
|
1662
|
+
</tbody>
|
|
1663
|
+
</table>
|
|
1664
|
+
</div>
|
|
1665
|
+
<div class="example">
|
|
1666
|
+
<p class="example__label">A remote source</p>
|
|
1667
|
+
<pre><code>source: {
|
|
1668
|
+
mode: 'remote',
|
|
1669
|
+
pageSize: 100,
|
|
1670
|
+
<span class="kw">async</span> fetch(req) {
|
|
1671
|
+
<span class="kw">const</span> res = <span class="kw">await</span> api.rows({
|
|
1672
|
+
offset: req.range.start,
|
|
1673
|
+
limit: req.range.end - req.range.start,
|
|
1674
|
+
sort: req.sort, <span class="cmt">// [{ col, dir }, …]</span>
|
|
1675
|
+
filters: req.filters, <span class="cmt">// the condition tree, as documented above</span>
|
|
1676
|
+
quick: req.quick,
|
|
1677
|
+
}, { signal: req.signal }); <span class="cmt">// a superseded request aborts itself</span>
|
|
1678
|
+
|
|
1679
|
+
<span class="kw">return</span> { rows: res.rows, total: res.total };
|
|
1680
|
+
},
|
|
1681
|
+
}</code></pre>
|
|
1682
|
+
</div>
|
|
1683
|
+
<div class="why">
|
|
1684
|
+
<p>The request also carries <code>groupBy</code>, <code>groupPath</code>,
|
|
1685
|
+
<code>pivotBy</code>, <code>totals</code> and your own <code>context</code>, so a server that
|
|
1686
|
+
can group and aggregate does that work instead of the browser. Blocks are fetched as the
|
|
1687
|
+
viewport reaches them and cached; changing the sort or the filter invalidates the cache and
|
|
1688
|
+
re-queries.</p>
|
|
1689
|
+
<p>The <code>filters</code> your callback receives is the same condition tree documented
|
|
1690
|
+
above. You are not handed an opaque object to reverse-engineer — it is the published format,
|
|
1691
|
+
and the same shape you would have written by hand.</p>
|
|
1692
|
+
</div>
|
|
1693
|
+
|
|
1694
|
+
<h2 id="editing">Editing</h2>
|
|
1695
|
+
<div class="example">
|
|
1696
|
+
<p class="example__label">Turning it on</p>
|
|
1697
|
+
<pre><code>edit: { enabled: true }, <span class="cmt">// double-click, the default</span>
|
|
1698
|
+
edit: { enabled: true, start: 'single' },<span class="cmt">// single click</span>
|
|
1699
|
+
editBar: true, <span class="cmt">// a spreadsheet-style input above the grid</span>
|
|
1700
|
+
|
|
1701
|
+
columns: [
|
|
1702
|
+
{ field: 'notes', edit: true },
|
|
1703
|
+
{ field: 'statusId', edit: { editor: 'select' } },
|
|
1704
|
+
{ field: 'quality', edit: { editor: 'rating', props: { max: 5, allowHalf: true } } },
|
|
1705
|
+
{ field: 'circuitId', edit: false },
|
|
1706
|
+
]</code></pre>
|
|
1707
|
+
</div>
|
|
1708
|
+
<p class="lead-in">
|
|
1709
|
+
The editor is chosen from the type unless you name one. Enter commits and steps down, Tab
|
|
1710
|
+
commits and steps across, Escape cancels. A validator can refuse a value:
|
|
1711
|
+
</p>
|
|
1712
|
+
<div class="example">
|
|
1713
|
+
<p class="example__label">Validation</p>
|
|
1714
|
+
<pre><code>{ field: 'capacity', edit: {
|
|
1715
|
+
validate: (p) => p.value > 0 || 'Capacity must be positive',
|
|
1716
|
+
}}</code></pre>
|
|
1717
|
+
</div>
|
|
1718
|
+
|
|
1719
|
+
<h2 id="optimistic">Optimistic writes and rollback</h2>
|
|
1720
|
+
<p class="lead-in">
|
|
1721
|
+
The grid has always written optimistically without calling it that: an edit lands in the
|
|
1722
|
+
model and is painted before anything else happens. What <code>edit.commit</code> adds is
|
|
1723
|
+
<em>durability</em> — whether the write reached your server, and what to put back when it
|
|
1724
|
+
did not.
|
|
1725
|
+
</p>
|
|
1726
|
+
<div class="why">
|
|
1727
|
+
<p><strong>Nothing changes unless you ask for it.</strong> With no <code>commit</code> hook
|
|
1728
|
+
the grid behaves exactly as before: the value is written, history is recorded,
|
|
1729
|
+
<code>cell:changed</code> fires, and there is no pending state to think about. Subscribe to
|
|
1730
|
+
<code>cell:changed</code>, fire your request and ignore the result — that keeps working and
|
|
1731
|
+
costs nothing.</p>
|
|
1732
|
+
</div>
|
|
1733
|
+
<div class="example">
|
|
1734
|
+
<p class="example__label">The usual case — the promise is the answer</p>
|
|
1735
|
+
<pre><code>edit: {
|
|
1736
|
+
enabled: true,
|
|
1737
|
+
commit: async ({ key, colId, value }) => {
|
|
1738
|
+
const res = await fetch(`/api/rows/${key}`, {
|
|
1739
|
+
method: 'PATCH',
|
|
1740
|
+
body: JSON.stringify({ [colId]: value }),
|
|
1741
|
+
});
|
|
1742
|
+
if (!res.ok) throw new Error(await res.text()); <span class="cmt">// throw → rolled back</span>
|
|
1743
|
+
},
|
|
1744
|
+
}</code></pre>
|
|
1745
|
+
</div>
|
|
1746
|
+
<p class="lead-in">
|
|
1747
|
+
Resolving confirms the write; throwing rolls it back and fires <code>cell:reverted</code>
|
|
1748
|
+
with your error message as <code>reason</code>. A synchronous hook works too — returning
|
|
1749
|
+
normally confirms, throwing reverts.
|
|
1750
|
+
</p>
|
|
1751
|
+
<div class="example">
|
|
1752
|
+
<p class="example__label">When the answer arrives elsewhere</p>
|
|
1753
|
+
<pre><code>edit: {
|
|
1754
|
+
enabled: true,
|
|
1755
|
+
confirm: 'manual', <span class="cmt">// the return value is ignored</span>
|
|
1756
|
+
commit: ({ id, key, colId, value }) => {
|
|
1757
|
+
socket.send(JSON.stringify({ id, key, colId, value }));
|
|
1758
|
+
},
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
socket.onmessage = (m) => {
|
|
1762
|
+
const { id, ok, reason } = JSON.parse(m.data);
|
|
1763
|
+
grid.edit.settle(id, ok, reason);
|
|
1764
|
+
};</code></pre>
|
|
1765
|
+
</div>
|
|
1766
|
+
<div class="why">
|
|
1767
|
+
<p><strong>The mode is declared, never guessed.</strong> A websocket or event-sourced backend
|
|
1768
|
+
acknowledges on a different channel from the one the write went out on, so there is no
|
|
1769
|
+
promise to resolve. <code>confirm: 'manual'</code> says so explicitly. The grid does not infer
|
|
1770
|
+
it from what <code>commit</code> returns, because then a synchronous hook that happens to
|
|
1771
|
+
return nothing would leave every cell pending for ever with nothing in your code that looks
|
|
1772
|
+
wrong. If a write does stay pending, you get a console warning naming the cell — tune the
|
|
1773
|
+
threshold with <code>pendingTimeout</code>.</p>
|
|
1774
|
+
</div>
|
|
1775
|
+
|
|
1776
|
+
<h3>The states a write moves through</h3>
|
|
1777
|
+
<div class="table-wrap">
|
|
1778
|
+
<table>
|
|
1779
|
+
<thead><tr><th>State</th><th>Means</th><th>Event</th></tr></thead>
|
|
1780
|
+
<tbody>
|
|
1781
|
+
<tr><td class="sig">pending</td><td class="desc">Applied and painted, not yet acknowledged.</td><td class="desc"><code>cell:pending</code></td></tr>
|
|
1782
|
+
<tr><td class="sig">confirmed</td><td class="desc">The server accepted it. Nothing is written back.</td><td class="desc"><code>cell:confirmed</code></td></tr>
|
|
1783
|
+
<tr><td class="sig">reverted</td><td class="desc">The server refused it; the cell is rolled back.</td><td class="desc"><code>cell:reverted</code></td></tr>
|
|
1784
|
+
<tr><td class="sig">superseded</td><td class="desc">A newer edit replaced it while it was in flight.</td><td class="desc">either, with <code>superseded: true</code></td></tr>
|
|
1785
|
+
</tbody>
|
|
1786
|
+
</table>
|
|
1787
|
+
</div>
|
|
1788
|
+
|
|
1789
|
+
<div class="why">
|
|
1790
|
+
<p><strong>Rollback goes to the last confirmed value, not the previous one.</strong> This is
|
|
1791
|
+
the part that is easy to get wrong by hand. Suppose a cell holding <code>1</code> is edited to
|
|
1792
|
+
<code>2</code>, then to <code>3</code>, then to <code>4</code>, all before any answer comes
|
|
1793
|
+
back. If the second write fails, restoring “the value before it” would put back
|
|
1794
|
+
<code>2</code> — a value the server never held, and one the user has since replaced twice.
|
|
1795
|
+
So each cell remembers the newest value a confirmation has actually vouched for, and a write
|
|
1796
|
+
that a later edit has superseded reports its failure without writing anything back. You will
|
|
1797
|
+
see <code>cell:reverted</code> with <code>applied: false</code> for those.</p>
|
|
1798
|
+
<p>The rejected value travels on the event as <code>rejected</code>, so you can offer a retry
|
|
1799
|
+
rather than losing what the user typed.</p>
|
|
1800
|
+
</div>
|
|
1801
|
+
|
|
1802
|
+
<div class="why">
|
|
1803
|
+
<p><strong>Two behaviours worth knowing.</strong> An unconfirmed edit goes through the normal
|
|
1804
|
+
pipeline, so if it changes a sorted or filtered column the row moves immediately and moves
|
|
1805
|
+
back if the write fails. And undo of an in-flight edit issues a <em>compensating write</em> —
|
|
1806
|
+
a fresh write back to the previous value, itself tracked — rather than pretending to cancel a
|
|
1807
|
+
request that has already gone out.</p>
|
|
1808
|
+
</div>
|
|
1809
|
+
|
|
1810
|
+
<div class="example">
|
|
1811
|
+
<p class="example__label">Asking what is outstanding</p>
|
|
1812
|
+
<pre><code>grid.edit.pending(); <span class="cmt">// [{ id, key, colId, value, before, state, age }]</span>
|
|
1813
|
+
grid.edit.status('r1', 'cap'); <span class="cmt">// 'pending' | null</span></code></pre>
|
|
1814
|
+
</div>
|
|
1815
|
+
<p class="lead-in">
|
|
1816
|
+
Pending cells are marked with <code>--lattice-pending-background</code> and rolled-back ones
|
|
1817
|
+
flash <code>--lattice-reverted-background</code>; restyle either through the tokens. Both use
|
|
1818
|
+
the same highlight model as everything else, so the marks survive scrolling, sorting and row
|
|
1819
|
+
recycling.
|
|
1820
|
+
</p>
|
|
1821
|
+
|
|
1822
|
+
<h2 id="tree-data">Tree data</h2>
|
|
1823
|
+
<p class="lead-in">
|
|
1824
|
+
Rows that sit under one another rather than in a flat list. Two shapes, and they answer
|
|
1825
|
+
different questions about where the hierarchy lives.
|
|
1826
|
+
</p>
|
|
1827
|
+
<div class="example">
|
|
1828
|
+
<p class="example__label">The row names its parent</p>
|
|
1829
|
+
<pre><code>tree: {
|
|
1830
|
+
parentKey: 'parentId', <span class="cmt">// a field, or a function of the row</span>
|
|
1831
|
+
label: 'name', <span class="cmt">// what the tree column shows</span>
|
|
1832
|
+
orphans: 'Unassigned', <span class="cmt">// or 'root', the default</span>
|
|
1833
|
+
}</code></pre>
|
|
1834
|
+
</div>
|
|
1835
|
+
<div class="example">
|
|
1836
|
+
<p class="example__label">The row carries its own ancestry</p>
|
|
1837
|
+
<pre><code>tree: { path: (row) => row.hierarchy } <span class="cmt">// ['EMEA', 'UK', 'Colchester']</span></code></pre>
|
|
1838
|
+
</div>
|
|
1839
|
+
<p class="lead-in">
|
|
1840
|
+
<strong>Parent-reference</strong> is what a join or a document store produces. Every node is
|
|
1841
|
+
a real row. A row whose parent is not in the data — filtered away, not loaded, or simply
|
|
1842
|
+
wrong — is an orphan: it goes to the root by default, or into a named bucket. It is never
|
|
1843
|
+
dropped, because hiding a record over a bad reference loses data the user can see in a flat
|
|
1844
|
+
view.
|
|
1845
|
+
</p>
|
|
1846
|
+
<p class="lead-in">
|
|
1847
|
+
<strong>Path-based</strong> rows describe their own place, so intermediate levels may have no
|
|
1848
|
+
row at all — <code>EMEA/UK/Colchester</code> with no <code>EMEA/UK</code> row still needs a
|
|
1849
|
+
<code>UK</code> node to sit under. Those are synthesised, and render as group rows: a heading
|
|
1850
|
+
over the rows beneath it with no record of its own. A real row arriving later for a level
|
|
1851
|
+
already synthesised fills that node rather than appearing beside it.
|
|
1852
|
+
</p>
|
|
1853
|
+
<p class="lead-in">
|
|
1854
|
+
A parent cycle is not a hierarchy and cannot be walked. It is reported once and cut, with the
|
|
1855
|
+
rows shown at the root — wrong place beats vanished.
|
|
1856
|
+
</p>
|
|
1857
|
+
<p class="lead-in">
|
|
1858
|
+
The grid generates a <strong>tree column</strong> to carry the expander and the indent, on the
|
|
1859
|
+
same terms as the auto-group, selection and detail columns: pinned to the start, and absent
|
|
1860
|
+
from <code>columns.visible()</code>, saved views, exports and the tool panel. Its text comes
|
|
1861
|
+
from <code>tree.label</code>; without one it falls back to your first visible column, which
|
|
1862
|
+
then appears twice until you hide it — the grid does not remove a column you did not ask it
|
|
1863
|
+
to remove.
|
|
1864
|
+
</p>
|
|
1865
|
+
<h3 id="tree-lazy">Loading a branch on demand</h3>
|
|
1866
|
+
<div class="example">
|
|
1867
|
+
<p class="example__label">Children fetched when the node is opened</p>
|
|
1868
|
+
<pre><code>tree: {
|
|
1869
|
+
parentKey: 'parentId',
|
|
1870
|
+
hasChildren: (data) => data.childCount > 0,
|
|
1871
|
+
loadChildren: (row, signal) => api.children(row.data.id, { signal }),
|
|
1872
|
+
}</code></pre>
|
|
1873
|
+
</div>
|
|
1874
|
+
<p class="lead-in">
|
|
1875
|
+
<code>hasChildren</code> lets a row declare children it does not hold, so the expander is
|
|
1876
|
+
there before anything is fetched — without it there is nothing to click and the branch can
|
|
1877
|
+
never load. Such a node reads as <em>closed</em> even though tree nodes are otherwise expanded
|
|
1878
|
+
by default, because an open branch with nothing under it leaves no gesture to load it.
|
|
1879
|
+
</p>
|
|
1880
|
+
<p class="lead-in">
|
|
1881
|
+
The rows that arrive are added to the data set, so the hierarchy rebuilds through the same
|
|
1882
|
+
pipeline as everything else and the new rows sort, filter and export like any other. A branch
|
|
1883
|
+
is fetched once however often it is toggled; closing it before the rows arrive aborts the
|
|
1884
|
+
request through the <code>signal</code>. A rejection is reported and leaves the branch
|
|
1885
|
+
unloaded, so reopening tries again rather than showing an empty node for good.
|
|
1886
|
+
<code>tree:loading</code>, <code>tree:loaded</code> and <code>tree:loadFailed</code> are on the
|
|
1887
|
+
event bus.
|
|
1888
|
+
</p>
|
|
1889
|
+
|
|
1890
|
+
<p class="lead-in">
|
|
1891
|
+
Expansion is the same state group expansion uses, so <code>rows.expand</code>,
|
|
1892
|
+
<code>rows.collapse</code>, <code>expandAll</code>, <code>collapseAll</code> and saved views
|
|
1893
|
+
all work on it. A collapsed branch is skipped rather than hidden, so it costs nothing.
|
|
1894
|
+
Grouping and <code>tree</code> together is not a combination: the grouping wins and says so
|
|
1895
|
+
once, because two expanders in one row would be two hierarchies claiming the same rows.
|
|
1896
|
+
</p>
|
|
1897
|
+
|
|
1898
|
+
<h2 id="master-detail">Master-detail</h2>
|
|
1899
|
+
<p class="lead-in">
|
|
1900
|
+
A master row expands to reveal a detail region — by default a nested grid over whatever
|
|
1901
|
+
<code>detail.rows(row)</code> returns, which may be a promise. The grid adds an expander
|
|
1902
|
+
column while the feature is on, on the same terms as the auto-group and selection columns:
|
|
1903
|
+
pinned to the start, and absent from <code>columns.visible()</code>, saved views, exports and
|
|
1904
|
+
the tool panel.
|
|
1905
|
+
</p>
|
|
1906
|
+
<div class="example">
|
|
1907
|
+
<p class="example__label">Inline — a detail row beneath its master</p>
|
|
1908
|
+
<pre><code>detail: {
|
|
1909
|
+
rows: (row) => api.lines(row.data.id), <span class="cmt">// array or promise</span>
|
|
1910
|
+
config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
|
|
1911
|
+
height: 240,
|
|
1912
|
+
isMaster: (data) => data.lineCount > 0, <span class="cmt">// default: every data row</span>
|
|
1913
|
+
cacheLimit: 10,
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
grid.detail.toggle(key);
|
|
1917
|
+
grid.detail.keys(); <span class="cmt">// every open master</span>
|
|
1918
|
+
grid.detail.closeAll();</code></pre>
|
|
1919
|
+
</div>
|
|
1920
|
+
<p class="lead-in">
|
|
1921
|
+
The detail is a real display row: virtualised, height-managed, and pushing the rows below it
|
|
1922
|
+
down. Any number of masters can be open at once. <code>height</code> takes a number or a
|
|
1923
|
+
function of the row.
|
|
1924
|
+
</p>
|
|
1925
|
+
|
|
1926
|
+
<h3 id="detail-target">A detail pane instead of a detail row</h3>
|
|
1927
|
+
<div class="example">
|
|
1928
|
+
<p class="example__label">Targeted — the list-and-pane layout</p>
|
|
1929
|
+
<pre><code>detail: {
|
|
1930
|
+
target: '#detail-pane', <span class="cmt">// a selector or an element</span>
|
|
1931
|
+
rows: (row) => api.lines(row.data.id),
|
|
1932
|
+
config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
grid.detail.active(); <span class="cmt">// the open master, or null</span>
|
|
1936
|
+
grid.detail.placement(); <span class="cmt">// 'inline' | 'target' | null</span></code></pre>
|
|
1937
|
+
</div>
|
|
1938
|
+
<p class="lead-in">
|
|
1939
|
+
With <code>target</code> the detail renders into an element you own rather than into a row.
|
|
1940
|
+
No detail row is created, so the grid's row count does not change when a master opens, and
|
|
1941
|
+
nothing about the list's geometry moves.
|
|
1942
|
+
</p>
|
|
1943
|
+
<p class="lead-in">
|
|
1944
|
+
<strong>Exactly one master is open at a time in this placement.</strong> One element cannot
|
|
1945
|
+
show two details, and stacking them turns a fixed-height pane into a scrolling list of grids
|
|
1946
|
+
with no rule for how tall each should be. Expanding a second master closes the first;
|
|
1947
|
+
<code>height</code> is ignored, because the pane's height is yours.
|
|
1948
|
+
</p>
|
|
1949
|
+
<p class="lead-in">
|
|
1950
|
+
A <code>target</code> selector that matches no element is reported once and leaves the
|
|
1951
|
+
details unshown — silence there is indistinguishable from a detail that fails to open, and
|
|
1952
|
+
the cause is not visible from the grid.
|
|
1953
|
+
</p>
|
|
1954
|
+
<p class="lead-in">
|
|
1955
|
+
<strong>The control changes with the placement, because the gesture does.</strong> Inline it
|
|
1956
|
+
is a chevron that turns down when the row expands, carrying <code>aria-expanded</code> — the
|
|
1957
|
+
ordinary disclosure pattern. Targeted, nothing expands: the row is being chosen and its
|
|
1958
|
+
detail appears elsewhere, so the control becomes the “opens elsewhere” glyph and
|
|
1959
|
+
a toggle (<code>aria-pressed</code>) rather than a disclosure. A chevron there would promise
|
|
1960
|
+
an expansion that never comes. The chosen row is marked with
|
|
1961
|
+
<code>lat-row--detail-active</code> and <code>aria-current</code>, since with the detail off
|
|
1962
|
+
to the side nothing else in the grid says which record the pane belongs to.
|
|
1963
|
+
</p>
|
|
1964
|
+
|
|
1965
|
+
<h3 id="detail-editing">An editable detail</h3>
|
|
1966
|
+
<p class="lead-in">
|
|
1967
|
+
The detail is a whole grid, so it edits like one — put <code>edit</code> in
|
|
1968
|
+
<code>detail.config</code> and its cells are editable. The rows it shows are usually a
|
|
1969
|
+
sub-array of the master's own record, so an edit there changes the master's data directly;
|
|
1970
|
+
there is nothing to copy back.
|
|
1971
|
+
</p>
|
|
1972
|
+
<div class="example">
|
|
1973
|
+
<p class="example__label">One listener on the master covers every detail</p>
|
|
1974
|
+
<pre><code>detail: {
|
|
1975
|
+
rows: (row) => row.data.ports, <span class="cmt">// a sub-array of the record</span>
|
|
1976
|
+
config: {
|
|
1977
|
+
columns: [{ field: 'port' }, { field: 'vlan', type: 'number', edit: <span class="kw">true</span> }],
|
|
1978
|
+
edit: { enabled: <span class="kw">true</span> },
|
|
1979
|
+
},
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
grid.on('detail:cell:changed', (e) => {
|
|
1983
|
+
e.masterKey; <span class="cmt">// 'C1' — the row the detail belongs to</span>
|
|
1984
|
+
e.path; <span class="cmt">// 'ports.1.vlan' — where it lands on the master's record</span>
|
|
1985
|
+
e.value; <span class="cmt">// 999</span>
|
|
1986
|
+
e.oldValue; <span class="cmt">// 101</span>
|
|
1987
|
+
});</code></pre>
|
|
1988
|
+
</div>
|
|
1989
|
+
<p class="lead-in">
|
|
1990
|
+
A nested grid is created by the grid, not by you, so its own events would otherwise be out of
|
|
1991
|
+
reach. The edit lifecycle — <code>detail:edit:started</code>,
|
|
1992
|
+
<code>detail:edit:stopped</code>, <code>detail:cell:changed</code> — is re-emitted on the
|
|
1993
|
+
master, tagged with the master it came from. You never have to hold the nested grid to hear
|
|
1994
|
+
about an edit inside it.
|
|
1995
|
+
</p>
|
|
1996
|
+
<p class="lead-in">
|
|
1997
|
+
<code>path</code> is the dot notation from the master's record to the value that changed, so
|
|
1998
|
+
a host can persist a detail edit against the master and never think about the nested grid at
|
|
1999
|
+
all. It is worked out by identity: <code>rows(row)</code> usually returns an array that is
|
|
2000
|
+
already a property of the record, and that property is the prefix. A detail fetched from a
|
|
2001
|
+
server is not part of the master's record, so its <code>path</code> is <code>null</code> —
|
|
2002
|
+
set <code>detail.path</code> to name it yourself when you want one anyway.
|
|
2003
|
+
</p>
|
|
2004
|
+
<p class="lead-in">
|
|
2005
|
+
For anything the forwarded events do not cover, <code>detail.onCreate(grid, masterRow)</code>
|
|
2006
|
+
hands you the nested grid itself as it is built.
|
|
2007
|
+
</p>
|
|
2008
|
+
|
|
2009
|
+
<div class="table-wrap">
|
|
2010
|
+
<table>
|
|
2011
|
+
<thead><tr><th>Setting</th><th>What it does</th></tr></thead>
|
|
2012
|
+
<tbody>
|
|
2013
|
+
<tr><td class="name"><code>rows(row)</code></td><td class="desc">The nested grid's rows. May return a promise; the region is built empty and loaded when it settles, so an empty detail and a pending one do not look the same.</td></tr>
|
|
2014
|
+
<tr><td class="name"><code>config</code></td><td class="desc">The nested grid's configuration. It inherits the licence and module registry from the master by construction.</td></tr>
|
|
2015
|
+
<tr><td class="name"><code>render(container, row)</code></td><td class="desc">Draw the region yourself instead of a nested grid. Return anything with a <code>destroy()</code> method.</td></tr>
|
|
2016
|
+
<tr><td class="name"><code>isMaster(data, row)</code></td><td class="desc">Which rows can expand. Group rows and detail rows never can.</td></tr>
|
|
2017
|
+
<tr><td class="name"><code>target</code></td><td class="desc">A selector or element to render the detail into. Omit for inline. One master open at a time.</td></tr>
|
|
2018
|
+
<tr><td class="name"><code>onCreate(grid, row)</code></td><td class="desc">The nested grid, as it is created.</td></tr>
|
|
2019
|
+
<tr><td class="name"><code>path</code></td><td class="desc">The property of the master's record the detail rows live on, when it cannot be worked out by identity.</td></tr>
|
|
2020
|
+
<tr><td class="name"><code>cacheLimit</code></td><td class="desc">How many regions are retained <em>after</em> closing, so collapse and re-expand does not refetch. Open regions are never evicted, whatever the limit. <code>0</code> destroys on collapse. Default 10.</td></tr>
|
|
2021
|
+
</tbody>
|
|
2022
|
+
</table>
|
|
2023
|
+
</div>
|
|
2024
|
+
|
|
2025
|
+
<h2 id="selection-guide">Selection and ranges</h2>
|
|
2026
|
+
<p class="lead-in">
|
|
2027
|
+
Row selection and cell ranges are separate answers to separate questions —
|
|
2028
|
+
<em>which records</em> versus <em>which values</em>. Dragging across cells does not tick row
|
|
2029
|
+
checkboxes, and selecting rows does not build a range.
|
|
2030
|
+
</p>
|
|
2031
|
+
<div class="note">
|
|
2032
|
+
<p><strong>Cell ranges are on by default; row selection is not.</strong> Set
|
|
2033
|
+
<code>selection: 'single'</code> or <code>'multiple'</code> to turn rows on — until you do,
|
|
2034
|
+
<code>selection.set()</code> accepts the call and <code>keys()</code> comes back empty.</p>
|
|
2035
|
+
</div>
|
|
2036
|
+
<div class="example">
|
|
2037
|
+
<p class="example__label">Both</p>
|
|
2038
|
+
<pre><code>selection: 'multiple' <span class="cmt">// rows are off until you ask</span>
|
|
2039
|
+
|
|
2040
|
+
<span class="cmt">// Rows — which records</span>
|
|
2041
|
+
grid.selection.set(['r1', 'r2']);
|
|
2042
|
+
grid.selection.keys(); <span class="cmt">// the selected row keys</span>
|
|
2043
|
+
grid.selection.rows(); <span class="cmt">// the row wrappers</span>
|
|
2044
|
+
grid.selection.all(); <span class="cmt">// select everything that passes the filter</span>
|
|
2045
|
+
grid.selection.clear();
|
|
2046
|
+
|
|
2047
|
+
<span class="cmt">// Cells — which values</span>
|
|
2048
|
+
grid.selection.setRange({ startRow: 0, endRow: 9, columns: ['cap', 'margin'] });
|
|
2049
|
+
grid.selection.cells(); <span class="cmt">// [{ key, colId }, …]</span>
|
|
2050
|
+
grid.selection.summary(); <span class="cmt">// count, sum, min, max, avg over the range</span>
|
|
2051
|
+
grid.selection.clearRange();
|
|
2052
|
+
|
|
2053
|
+
<span class="cmt">// Several blocks at once — ctrl-click, Ctrl+Shift+Arrow, or from the API</span>
|
|
2054
|
+
grid.selection.addRange({ startRow: 20, endRow: 29, columns: ['cap'] });
|
|
2055
|
+
grid.selection.extendRange(34, 'cap'); <span class="cmt">// grows the block just added</span></code></pre>
|
|
2056
|
+
</div>
|
|
2057
|
+
<h3 id="selection-checkbox">The checkbox column</h3>
|
|
2058
|
+
<div class="example">
|
|
2059
|
+
<p class="example__label">A column of checkboxes, with select-all in the header</p>
|
|
2060
|
+
<pre><code>selection: { mode: 'multiple', checkbox: <span class="kw">true</span>, headerCheckbox: <span class="kw">true</span> }</code></pre>
|
|
2061
|
+
</div>
|
|
2062
|
+
<p class="lead-in">
|
|
2063
|
+
<code>checkbox: true</code> adds a narrow column of checkboxes at the start of every row,
|
|
2064
|
+
pinned so it does not scroll away. <code>headerCheckbox: true</code> puts a select-all box in
|
|
2065
|
+
its heading, which shows three states: unchecked when nothing is selected, checked when
|
|
2066
|
+
everything is, and the native indeterminate mark when some are. Clicking it selects
|
|
2067
|
+
everything when it is not already full, and clears when it is — including from the
|
|
2068
|
+
indeterminate state, where the intent is "select the rest".
|
|
2069
|
+
</p>
|
|
2070
|
+
<p class="lead-in">
|
|
2071
|
+
"Everything" means every row the filter currently shows, not every row loaded. The column is
|
|
2072
|
+
generated rather than declared: it does not appear in <code>columns.visible()</code>, in a
|
|
2073
|
+
saved view, in an export or in the tool panel's visibility list, and it disappears when the
|
|
2074
|
+
option is turned off. <code>grid.selection.headerState()</code> returns the same tri-state the
|
|
2075
|
+
header shows, for building your own control.
|
|
2076
|
+
</p>
|
|
2077
|
+
<p class="lead-in">
|
|
2078
|
+
Selected rows carry <code>aria-selected</code> and the class
|
|
2079
|
+
<code>lat-row--selected</code>, which the theme styles.
|
|
2080
|
+
</p>
|
|
2081
|
+
|
|
2082
|
+
<div class="why">
|
|
2083
|
+
<p><strong>Ctrl+Shift+Arrow is the keyboard form of ctrl-dragging.</strong> The first press
|
|
2084
|
+
opens a block at the focused cell without discarding what is already selected; the presses
|
|
2085
|
+
after it extend that block, so holding the chord draws one rectangle rather than a new one
|
|
2086
|
+
per key repeat. A plain Shift+Arrow goes back to extending a single block, and clicking
|
|
2087
|
+
anywhere ends the run.</p>
|
|
2088
|
+
</div>
|
|
2089
|
+
<div class="why">
|
|
2090
|
+
<p><strong>What multiple ranges do and do not support.</strong> Painting, <code>cells()</code>
|
|
2091
|
+
and the status-bar summary all work over the union of the selected blocks. Copy is narrower,
|
|
2092
|
+
because tab-separated text is a rectangle: blocks stacked over the same columns, or joined
|
|
2093
|
+
over the same rows, copy fine — a diagonal pair has no rectangular form, so
|
|
2094
|
+
<code>rangeText()</code> returns <code>''</code> and <code>clipboard:copy</code> reports
|
|
2095
|
+
<code>reason: 'discontiguous'</code> rather than emitting misaligned rows. Filling is
|
|
2096
|
+
narrower still: with more than one block selected there is no single source to extend, so the
|
|
2097
|
+
fill handle is hidden and <code>fillTo</code> and <code>fillDown</code> decline.</p>
|
|
2098
|
+
</div>
|
|
2099
|
+
<div class="example">
|
|
2100
|
+
<p class="example__label">Turning it off</p>
|
|
2101
|
+
<pre><code>selection: 'none' <span class="cmt">// neither</span>
|
|
2102
|
+
selection: { ranges: false } <span class="cmt">// rows only, no drag-select</span></code></pre>
|
|
2103
|
+
</div>
|
|
2104
|
+
|
|
2105
|
+
<h2 id="fill-series">Filling a series</h2>
|
|
2106
|
+
<p class="lead-in">
|
|
2107
|
+
Dragging the fill handle continues what it can recognise rather than repeating the block.
|
|
2108
|
+
Detection is per column, so dragging three columns down continues three independent series.
|
|
2109
|
+
</p>
|
|
2110
|
+
<div class="example">
|
|
2111
|
+
<p class="example__label">What is recognised</p>
|
|
2112
|
+
<pre><code>1, 2, 3 <span class="cmt">// → 4, 5, 6 constant difference, two or more values</span>
|
|
2113
|
+
5, 10, 15 <span class="cmt">// → 20, 25 any step, including negative and fractional</span>
|
|
2114
|
+
2026-01-01 … <span class="cmt">// → the next day, week or whatever the gap is</span>
|
|
2115
|
+
15 Jan, 15 Feb <span class="cmt">// → 15 Mar whole months, holding the day of month</span>
|
|
2116
|
+
31 Jan, 28 Feb <span class="cmt">// → 31 Mar, 30 Apr month ends stay on the month end</span>
|
|
2117
|
+
'x', 'y' <span class="cmt">// → x, y, x, y nothing recognised, so the block repeats</span>
|
|
2118
|
+
7 <span class="cmt">// → 7, 7, 7 one value is a copy, not a series</span></code></pre>
|
|
2119
|
+
</div>
|
|
2120
|
+
<div class="why">
|
|
2121
|
+
<p><strong>Months step as months.</strong> 15 January to 15 February is thirty-one days, and
|
|
2122
|
+
continuing in days would land on 18 March and drift further every step. A month step holds
|
|
2123
|
+
the date the user picked, and clamps where the month is short — 31 January plus a month is
|
|
2124
|
+
28 February, not 3 March.</p>
|
|
2125
|
+
<p><strong>Month ends are their own case.</strong> <code>31 Jan, 28 Feb</code> is a month
|
|
2126
|
+
series whose second value has already been clamped, so the two share no day of month and the
|
|
2127
|
+
day-preserving rule cannot see it. Where every source value is the last day of its own month,
|
|
2128
|
+
the fill stays on the month end: 31 March, 30 April, 31 May — and 29 February in a leap year.
|
|
2129
|
+
Values that share a day of month keep the day-preserving answer, so <code>30 Apr, 30 Jun</code>
|
|
2130
|
+
still gives 30 August rather than the 31st.</p>
|
|
2131
|
+
<p><strong>One limit worth knowing.</strong> Filling upwards is not supported; the handle
|
|
2132
|
+
extends downwards only.</p>
|
|
2133
|
+
</div>
|
|
2134
|
+
<div class="example">
|
|
2135
|
+
<p class="example__label">Supplying your own series</p>
|
|
2136
|
+
<pre><code>selection: {
|
|
2137
|
+
fill: ({ source, target, direction }) => target.map((t, i) => nextCode(source, i)),
|
|
2138
|
+
}</code></pre>
|
|
2139
|
+
</div>
|
|
2140
|
+
<div class="why">
|
|
2141
|
+
<p>A domain series — order codes, fiscal periods, seat numbers — is not something the grid
|
|
2142
|
+
can infer, so <code>selection.fill</code> takes precedence when you supply it. It must return
|
|
2143
|
+
one value per target row; anything else is ignored in favour of the built-in detection,
|
|
2144
|
+
rather than being partly applied.</p>
|
|
2145
|
+
</div>
|
|
2146
|
+
|
|
2147
|
+
<h2 id="updates-guide">Holding live updates</h2>
|
|
2148
|
+
<p class="lead-in">
|
|
2149
|
+
A pause button for incoming data. Changes are held and merged while paused, applied when
|
|
2150
|
+
play is pressed, and counted throughout — so the coalescing that makes a live grid fast is
|
|
2151
|
+
finally visible.
|
|
2152
|
+
</p>
|
|
2153
|
+
<div class="example">
|
|
2154
|
+
<p class="example__label">Pause, play, and the counters</p>
|
|
2155
|
+
<pre><code>grid.updates.pause();
|
|
2156
|
+
grid.updates.resume(); <span class="cmt">// apply everything held</span>
|
|
2157
|
+
grid.updates.flush(); <span class="cmt">// apply what is waiting, stay paused</span>
|
|
2158
|
+
|
|
2159
|
+
grid.updates.stats();
|
|
2160
|
+
<span class="cmt">// { paused, pending, queued, coalesced, coalescedTotal, rows, dropped, flushes, span }</span>
|
|
2161
|
+
|
|
2162
|
+
grid.updates.log({ since: Date.now() - 60000 }); <span class="cmt">// what arrived, in order</span></code></pre>
|
|
2163
|
+
</div>
|
|
2164
|
+
<div class="why">
|
|
2165
|
+
<p><strong>Pausing is a button, not a guess.</strong> Inferring it from whether the user
|
|
2166
|
+
looks busy sounds friendlier and is wrong in both directions: too broad and a mouse resting
|
|
2167
|
+
on the grid freezes the feed until someone reloads, too narrow and rows jump the instant
|
|
2168
|
+
somebody stops moving in order to read. An explicit control has no heuristic to get wrong and
|
|
2169
|
+
no invisible state to explain.</p>
|
|
2170
|
+
<p><strong>Merging continues while paused</strong>, so a long pause costs one entry per
|
|
2171
|
+
changed row rather than one per update. Forty updates to one row are one row of work when
|
|
2172
|
+
play is pressed — and <code>coalesced</code> is the thirty-nine, which is the number nobody
|
|
2173
|
+
could see before.</p>
|
|
2174
|
+
<p><strong>Bounding the rows themselves is separate.</strong> The log bounds change
|
|
2175
|
+
<em>history</em>; a streaming source also needs to bound row <em>retention</em>, or a grid
|
|
2176
|
+
left up overnight holds every row it was ever sent. Set <code>source.maxRows</code> and the
|
|
2177
|
+
stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how
|
|
2178
|
+
many it let go through <code>evicted</code> on the progress report.</p>
|
|
2179
|
+
<p><strong>The log keeps the raw sequence, not the merged one.</strong> Merging is right for
|
|
2180
|
+
applying a backlog quickly and wrong for looking at what happened, because the intermediate
|
|
2181
|
+
states are exactly what a time scrubber would move between. It survives the flush —
|
|
2182
|
+
<code>pending</code> is what is waiting, the log is what happened — and it is capped, so a
|
|
2183
|
+
grid paused over lunch holds the recent past and reports how much it dropped rather than
|
|
2184
|
+
taking the tab with it.</p>
|
|
2185
|
+
</div>
|
|
2186
|
+
|
|
2187
|
+
<h2 id="diagnostics-guide">Diagnostics and devtools</h2>
|
|
2188
|
+
<p class="lead-in">
|
|
2189
|
+
Data grids fail in ways that are hard to diagnose from outside. This is the grid saying
|
|
2190
|
+
what it is actually doing.
|
|
2191
|
+
</p>
|
|
2192
|
+
<div class="example">
|
|
2193
|
+
<p class="example__label">Asserting on DOM writes</p>
|
|
2194
|
+
<pre><code>const before = grid.diagnostics.renders().dom.cellWrites;
|
|
2195
|
+
await doTheThing();
|
|
2196
|
+
expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);</code></pre>
|
|
2197
|
+
</div>
|
|
2198
|
+
<div class="why">
|
|
2199
|
+
<p><strong>The API came first and the panel second, on purpose.</strong> Instrumentation
|
|
2200
|
+
written behind a UI gets shaped by the layout: it reports what is convenient to display
|
|
2201
|
+
rather than what is true, and it cannot be tested. An API that stands on its own can be
|
|
2202
|
+
asserted against, and the assertion above is not one most grids can support.</p>
|
|
2203
|
+
<p><strong>Most of it already existed and was unreachable.</strong> The renderer had counted
|
|
2204
|
+
cell writes, row updates and paints all along; the column store could already report a real
|
|
2205
|
+
byte footprint per column, summing backing array, presence bitset and dictionary. Neither was
|
|
2206
|
+
reachable from the public API. Exposing what a system already knows is usually a better first
|
|
2207
|
+
move than measuring something new.</p>
|
|
2208
|
+
<p><strong>Warnings are mostly collection, not detection.</strong> The grid has 160 places
|
|
2209
|
+
that warn once per cause, each already carrying a stable de-duplication key — which is
|
|
2210
|
+
exactly the stable identifier a support conversation needs. They went to the console and
|
|
2211
|
+
nowhere else. The console interleaves with your own logging, does not survive a reload, and
|
|
2212
|
+
cannot be asked what it has already complained about. They are now kept as records too.</p>
|
|
2213
|
+
<p><strong>Every warning names values, not just a condition.</strong> "Something is slow" is a
|
|
2214
|
+
warning nobody can act on. Each carries the specific numbers, a stable id, and is dismissible
|
|
2215
|
+
for the session but not permanently — a permanently dismissible warning is one nobody sees
|
|
2216
|
+
again after the person who dismissed it leaves.</p>
|
|
2217
|
+
<p><strong>The checks are tested for silence as much as for detection.</strong> A clean grid
|
|
2218
|
+
must raise nothing. A checker that cries wolf is one developers learn to ignore, and then it
|
|
2219
|
+
is worth less than no checker at all. The accessibility checks found two false positives in
|
|
2220
|
+
themselves during development — first comparing <code>aria-rowcount</code> against the row
|
|
2221
|
+
count when ARIA counts header rows too, then counting header row *elements*, of which a
|
|
2222
|
+
single-level header has three because the header is built once per pinned region.</p>
|
|
2223
|
+
<p><strong>Instrumentation must not change what it measures.</strong> Counters are integers
|
|
2224
|
+
incremented where the work already happened. Render phases are four <code>performance.now()</code>
|
|
2225
|
+
marks around existing sections. Timings are sampled — a bounded window of recent operations —
|
|
2226
|
+
and every report names which of its figures are sampled, because a number whose provenance is
|
|
2227
|
+
unclear is worse than no number. Paint wait is the browser's and is deliberately not claimed.</p>
|
|
2228
|
+
<p><strong>Render causes are captured, not inferred.</strong> By the time a paint runs,
|
|
2229
|
+
several distinct causes have collapsed into the same dirty flags, so working backwards gives a
|
|
2230
|
+
plausible answer rather than a true one. The renderer records the structural reason when the
|
|
2231
|
+
invalidation arrives; the semantic one — filter, sort, data — is only knowable from the event
|
|
2232
|
+
that preceded it, so the DOM layer supplies that and the structural reason is the fallback.</p>
|
|
2233
|
+
<p><strong>Providers are wrapped where they are installed</strong>, not at each call site, so
|
|
2234
|
+
one added later is instrumented by construction. The wrapper returns exactly what the original
|
|
2235
|
+
returned and re-throws exactly what it threw. Failures are kept, because a provider failure is
|
|
2236
|
+
usually swallowed by the host application's own error handling before a developer sees it, and
|
|
2237
|
+
"the grid is behaving strangely" is where that conversation otherwise starts.</p>
|
|
2238
|
+
<p><strong>The heat overlay uses two colours because there are two findings.</strong> A cell
|
|
2239
|
+
written with a value it did not hold is work the grid had to do. A cell rewritten with the
|
|
2240
|
+
value already in it is work it did not. Only the second is waste, and a counter alone will
|
|
2241
|
+
never tell you where it is. The overlay records a baseline when switched on, because
|
|
2242
|
+
otherwise the first paint has nothing to compare against and tints nothing, which reads as
|
|
2243
|
+
broken rather than as empty.</p>
|
|
2244
|
+
<p><strong>The module imports nothing.</strong> Not the grid, not a shared helper. The bundler
|
|
2245
|
+
inlines whatever a module imports: the framework adapters stay small because
|
|
2246
|
+
<code>createGrid</code> is handed to them, while the web component carries the grid with it
|
|
2247
|
+
because it imports it. A devtools module that imported anything from core would put the whole grid inside a
|
|
2248
|
+
bundle whose entire promise is that deployments not using it pay nothing.</p>
|
|
2249
|
+
<p><strong>The support bundle carries no row data.</strong> Configuration, query state, timing,
|
|
2250
|
+
warnings, provider statistics, version and environment — and nothing from your data. Stated as
|
|
2251
|
+
a guarantee because a bundle that had to be inspected for confidential values before sending
|
|
2252
|
+
is a bundle that never gets attached to the ticket.</p>
|
|
2253
|
+
<p><strong>It observes and never mutates.</strong> A configuration editor in a debug panel is
|
|
2254
|
+
tempting and would create a second path into grid state that has to be kept correct forever.
|
|
2255
|
+
No telemetry: nothing leaves the browser unless you export a bundle yourself.</p>
|
|
2256
|
+
</div>
|
|
2257
|
+
|
|
2258
|
+
<h2 id="presence-guide">Collaborative presence</h2>
|
|
2259
|
+
<p class="lead-in">
|
|
2260
|
+
Who else is looking at this grid, and what they are doing.
|
|
2261
|
+
</p>
|
|
2262
|
+
<div class="example">
|
|
2263
|
+
<p class="example__label">An in-memory provider, which is all the interface requires</p>
|
|
2264
|
+
<pre><code>const handlers = new Map();
|
|
2265
|
+
const providerFor = (id) => ({
|
|
2266
|
+
subscribe(fn) { handlers.set(id, fn); return () => handlers.delete(id); },
|
|
2267
|
+
publish(state) {
|
|
2268
|
+
for (const [peer, fn] of handlers) if (peer !== id) fn(state);
|
|
2269
|
+
},
|
|
2270
|
+
});</code></pre>
|
|
2271
|
+
</div>
|
|
2272
|
+
<div class="why">
|
|
2273
|
+
<p><strong>Presence carries intent, never values.</strong> This is the line that matters most.
|
|
2274
|
+
A peer's committed edit reaches the grid as data, through the channel you already use for
|
|
2275
|
+
data. Presence is throttled, lossy and ephemeral <em>by design</em>, so a value carried on it
|
|
2276
|
+
is a value that can be dropped — and that is the class of bug that appears once a month
|
|
2277
|
+
in production and cannot be reproduced on demand.</p>
|
|
2278
|
+
<p><strong>Positions are row keys, resolved against your view at render time.</strong> Peers
|
|
2279
|
+
sort and filter independently, so index 12 is a different record on every screen. Publishing
|
|
2280
|
+
an index would put a colleague's cursor on an unrelated row the moment either of you sorted.
|
|
2281
|
+
The cost of this is real — the grid resolves a key to a position rather than reading one
|
|
2282
|
+
— and it is the difference between the feature working and the feature lying.</p>
|
|
2283
|
+
<p><strong>Idle is measured from when a message arrived, not from what it says.</strong>
|
|
2284
|
+
Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle
|
|
2285
|
+
detection on the sender's timestamp means a peer with a fast clock never goes idle and one
|
|
2286
|
+
with a slow clock is idle immediately. The sender's timestamp is kept for inspection and
|
|
2287
|
+
decides nothing.</p>
|
|
2288
|
+
<p><strong>Publishing throttles rather than debounces.</strong> A debounce sends nothing until
|
|
2289
|
+
the user stops moving, so every peer sees a cursor that teleports on pause instead of moving.
|
|
2290
|
+
The leading edge goes out immediately and the trailing edge carries wherever it settled.</p>
|
|
2291
|
+
<p><strong>Publishing stops while the tab is hidden.</strong> Nobody is moving that cursor.
|
|
2292
|
+
Receiving continues, so coming back to the tab shows the current state rather than an empty
|
|
2293
|
+
roster filling in slowly.</p>
|
|
2294
|
+
<p><strong>A peer's cursor is dashed; your focus ring is solid.</strong> The distinction has to
|
|
2295
|
+
be in the kind of line, not only its colour. Colour alone fails for anyone who cannot separate
|
|
2296
|
+
two hues and fails for everyone at a glance — and the palette originally contained the
|
|
2297
|
+
exact value of <code>--lattice-focus-color</code>, so the first peer assigned drew a cursor
|
|
2298
|
+
identical to the local user's own selection. An active edit is solid and tinted, because an
|
|
2299
|
+
edit is not a cursor and those two must not be confused with each other either.</p>
|
|
2300
|
+
<p><strong>Nothing is inserted into the grid.</strong> Every treatment is an attribute and a
|
|
2301
|
+
custom property written onto a cell that already exists, drawn with an outline and a
|
|
2302
|
+
pseudo-element. That is what keeps presence from shifting layout, covering an in-cell chart or
|
|
2303
|
+
swallowing a click — none of which survives an implementation that appends overlay
|
|
2304
|
+
elements. The overlay layer is pointer-transparent and presence deliberately does not opt back
|
|
2305
|
+
in; only the roster does, because it is a control.</p>
|
|
2306
|
+
<p><strong>The roster is the part people use.</strong> More than the cursors, in practice. It
|
|
2307
|
+
carries the name as well as the colour, because colour alone is not a signal everyone can
|
|
2308
|
+
read, and it reports peers whose rows are not in your view rather than omitting them —
|
|
2309
|
+
an absent peer reads as a disconnection that has not happened.</p>
|
|
2310
|
+
<p><strong>A parked cursor does not fade.</strong> The label does, after a couple of seconds,
|
|
2311
|
+
because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and
|
|
2312
|
+
goes only on removal: a cursor that vanished while its owner was still connected would report
|
|
2313
|
+
exactly the thing this feature exists to prevent.</p>
|
|
2314
|
+
<p><strong>Locking is advisory, and the documentation says so because a developer who
|
|
2315
|
+
believes otherwise will skip the conditional write.</strong> Presence is throttled and can
|
|
2316
|
+
arrive out of order; two clients can enter an edit in the same instant. What actually resolves
|
|
2317
|
+
the conflict is the conditional write in <code>edit.commit</code>, which returns a conflict
|
|
2318
|
+
and rolls the optimistic edit back. Locking narrows the window. It does not close it. A stale
|
|
2319
|
+
claim is disregarded after a much shorter window than peer removal, because a lock held by
|
|
2320
|
+
someone who shut their laptop blocks a cell nobody is editing.</p>
|
|
2321
|
+
<p><strong>Not in this release:</strong> transport, reconnection or authentication; operational
|
|
2322
|
+
transform or CRDT merging; presence history; text-level cursors inside a cell editor; follow
|
|
2323
|
+
mode; chat.</p>
|
|
2324
|
+
</div>
|
|
2325
|
+
|
|
2326
|
+
<h2 id="comments-guide">Cell comments</h2>
|
|
2327
|
+
<p class="lead-in">
|
|
2328
|
+
Threaded discussion attached to individual cells, for reviewing data with other people
|
|
2329
|
+
without leaving the grid.
|
|
2330
|
+
</p>
|
|
2331
|
+
<div class="example">
|
|
2332
|
+
<p class="example__label">Wiring a provider</p>
|
|
2333
|
+
<pre><code>comments: {
|
|
2334
|
+
provider: {
|
|
2335
|
+
loadIndex: (rowIds, fields) => api.counts(rowIds, fields),
|
|
2336
|
+
loadThread: (cellKey) => api.thread(cellKey),
|
|
2337
|
+
addComment: (cellKey, body, parentId, ctx) => api.add(cellKey, body, parentId, ctx.value),
|
|
2338
|
+
editComment: api.edit, deleteComment: api.remove,
|
|
2339
|
+
resolveThread: api.resolve, unresolveThread: api.unresolve
|
|
2340
|
+
},
|
|
2341
|
+
rowLabel: (row) => row.data.name
|
|
2342
|
+
}</code></pre>
|
|
2343
|
+
</div>
|
|
2344
|
+
<div class="why">
|
|
2345
|
+
<p><strong>Comments are not row data, and never enter the column store.</strong> They have a
|
|
2346
|
+
different lifecycle: append-mostly, sparse against the row count, carrying their own identity
|
|
2347
|
+
and timestamps, subject to their own permissions, and outliving the values they annotate.
|
|
2348
|
+
Putting them in the store would mean columnarising a field that is empty for almost every row
|
|
2349
|
+
and rebuilding it on every write.</p>
|
|
2350
|
+
<p><strong>Two levels of data, and only one of them is cheap.</strong> The index maps a cell
|
|
2351
|
+
to <code>{ count, unresolved, updated }</code> and nothing else; it is consulted on every
|
|
2352
|
+
repaint, so it must never carry bodies. A thread is the bodies for one cell, loaded when the
|
|
2353
|
+
thread opens and dropped when it closes. Holding every thread a user opened would accumulate
|
|
2354
|
+
the whole comment dataset over a long session to render a few triangles.</p>
|
|
2355
|
+
<p><strong>Stable row identity is a hard requirement, enforced rather than documented.</strong>
|
|
2356
|
+
A comment is keyed on row identity plus field. Row index changes under sort, filter and
|
|
2357
|
+
grouping, so a comment keyed on it reattaches to whichever row later occupies that position
|
|
2358
|
+
— and a comment on the wrong row is worse than no comment at all. A grid with no
|
|
2359
|
+
<code>rowKey</code> disables comments and names them in the same single warning as the other
|
|
2360
|
+
identity-dependent features.</p>
|
|
2361
|
+
<p><strong>Identity has to survive more than the session.</strong> Comments outlive the page
|
|
2362
|
+
that wrote them, so a key that is stable only within one load — anything derived from
|
|
2363
|
+
arrival order, for instance — is not enough. Reload the data in a different order and
|
|
2364
|
+
every thread points somewhere else. If you have only used the grid client-side you may never
|
|
2365
|
+
have needed a durable identity before; you do now.</p>
|
|
2366
|
+
<p><strong>Writes are optimistic, and rejections are taken back.</strong> The author sees
|
|
2367
|
+
their own comment at once, faded until the provider confirms. A rejection removes it rather
|
|
2368
|
+
than leaving the grid displaying something the server refused. The same pattern as cell
|
|
2369
|
+
editing, for the same reason.</p>
|
|
2370
|
+
<p><strong>The grid authorises nothing.</strong> A comment may carry
|
|
2371
|
+
<code>can: { edit, delete, resolve }</code> and the affordances follow it, but that is a
|
|
2372
|
+
convenience for the person looking at the screen. It is not a control, and the documentation
|
|
2373
|
+
says so here rather than leaving it to be assumed: hiding a button stops nobody who opens the
|
|
2374
|
+
console. Reject in the provider.</p>
|
|
2375
|
+
<p><strong>A body is user input that has round-tripped through your storage.</strong> That is
|
|
2376
|
+
the exact shape of a stored cross-site script, so the default path sets text and nothing else.
|
|
2377
|
+
Turning on <code>markdown</code> buys emphasis, code and links — three constructs, built
|
|
2378
|
+
as elements rather than parsed as markup, with any scheme other than <code>http</code>,
|
|
2379
|
+
<code>https</code> and <code>mailto</code> refused. A refused link still shows its label, so
|
|
2380
|
+
nothing the author wrote vanishes without trace.</p>
|
|
2381
|
+
<p><strong>The marker cannot move the cell's contents.</strong> It is a corner triangle drawn
|
|
2382
|
+
with a border on a pseudo-element, so it occupies no space in the layout: no shifted text, no
|
|
2383
|
+
rewrapped number, no displaced sparkline. That constraint is why it is a corner rather than a
|
|
2384
|
+
badge — every other position in a cell is already spoken for. Only the corner opens a
|
|
2385
|
+
thread; a click elsewhere belongs to selection, and taking it would make commented cells
|
|
2386
|
+
behave unlike every other cell. The hit region is larger than the drawn mark, because at
|
|
2387
|
+
compact density the triangle is about seven pixels across.</p>
|
|
2388
|
+
<p><strong>A comment shows the value it was written against</strong> whenever that no longer
|
|
2389
|
+
matches the cell. Without it, a note reading "this looks too high" sits beside a number it
|
|
2390
|
+
never described and the reader concludes the comment is wrong. Changing a value never deletes
|
|
2391
|
+
or invalidates a comment.</p>
|
|
2392
|
+
<p><strong>Filtered-out comments are hidden, not lost — and the grid says so.</strong>
|
|
2393
|
+
<code>hiddenUnresolved()</code> reports what is still outstanding on rows the filter is
|
|
2394
|
+
hiding, because a user who filters and sees no markers should not conclude there is nothing
|
|
2395
|
+
left to deal with. The status bar carries this: its <code>comments</code> panel reads
|
|
2396
|
+
“3 unresolved comments on hidden rows” and is silent whenever the count is zero, so
|
|
2397
|
+
a grid with nothing outstanding gains no permanent furniture. The panel is in the default set,
|
|
2398
|
+
and can be placed explicitly like any other:</p>
|
|
2399
|
+
<div class="example">
|
|
2400
|
+
<pre><code>statusBar: { panels: ['rowCount', 'comments'] }</code></pre>
|
|
2401
|
+
</div>
|
|
2402
|
+
<p>The count returns zero rather than a number it cannot stand behind: it is only meaningful
|
|
2403
|
+
once the index covers every row, so it stays at zero — and the panel stays silent —
|
|
2404
|
+
until <code>loadAll()</code> has resolved.</p>
|
|
2405
|
+
<p><strong>The comments-only filter is refused rather than approximated.</strong> Restricting
|
|
2406
|
+
the grid to rows carrying comments needs the index to cover the whole row set, not just what
|
|
2407
|
+
has been scrolled past — a partial answer would hide precisely the rows the user opened
|
|
2408
|
+
it to find. Call <code>loadAll()</code> first; until <code>complete</code> is true,
|
|
2409
|
+
<code>filterToCommented()</code> returns false and does nothing.</p>
|
|
2410
|
+
<p><strong>Comments stay available while streaming</strong>, unlike header histograms: a
|
|
2411
|
+
comment does not move when new rows arrive. Index loads for new rows follow the same
|
|
2412
|
+
debounced viewport path. A thread whose row is evicted by a bounded window closes with a
|
|
2413
|
+
short explanation rather than hovering over a row that has gone.</p>
|
|
2414
|
+
<p><strong>Keyboard and screen reader.</strong> <kbd>Alt</kbd>+<kbd>M</kbd> opens the thread
|
|
2415
|
+
on the focused cell — Alt because the grid binds nearly every unmodified key to
|
|
2416
|
+
navigation and editing. The panel traps <kbd>Tab</kbd>, which it has to: the grid behind it is
|
|
2417
|
+
still there and still focusable, so without the trap a keyboard user would be moving through
|
|
2418
|
+
cells with a dialog open over them. Focus returns to the originating cell on close rather than
|
|
2419
|
+
being dropped at the top of the document. Cells announce their comment count and unresolved
|
|
2420
|
+
count through <code>aria-description</code> rather than their label, because the label is the
|
|
2421
|
+
cell's value and burying a count inside it would make every commented cell read as something
|
|
2422
|
+
other than what it holds.</p>
|
|
2423
|
+
<p><strong>Not in this release:</strong> mentions, notifications, rich text, attachments,
|
|
2424
|
+
reactions, row-level and column-level comments, and export of comments. The grid opens no
|
|
2425
|
+
transport of its own — if your application pushes updates, call <code>refresh()</code>
|
|
2426
|
+
and the index reloads.</p>
|
|
2427
|
+
</div>
|
|
2428
|
+
|
|
2429
|
+
<h2 id="facets-guide">Header histograms and facet filtering</h2>
|
|
2430
|
+
<p class="lead-in">
|
|
2431
|
+
The shape of a column, drawn in its heading, and clickable. Explore a dataset by
|
|
2432
|
+
clicking through headers instead of writing queries.
|
|
2433
|
+
</p>
|
|
2434
|
+
<div class="example">
|
|
2435
|
+
<p class="example__label">Turning them on</p>
|
|
2436
|
+
<pre><code>facets: { enabled: true }
|
|
2437
|
+
|
|
2438
|
+
<span class="cmt">// per column, layered over the grid's settings</span>
|
|
2439
|
+
{ field: 'price', type: 'number', facet: { strategy: 'quantile' } }
|
|
2440
|
+
{ field: 'notes', facet: false }</code></pre>
|
|
2441
|
+
</div>
|
|
2442
|
+
<div class="why">
|
|
2443
|
+
<p><strong>The column being filtered is not counted against its own filter.</strong> Every
|
|
2444
|
+
other active filter applies; that column's own conditions are pruned out of the tree before
|
|
2445
|
+
counting. This is the whole of faceted browsing and it is the part that is easy to get subtly
|
|
2446
|
+
wrong — a self-filtered chart collapses to a single bar the moment you click one, and
|
|
2447
|
+
there is then no way to see what you excluded or to widen the selection. Getting it wrong
|
|
2448
|
+
does not degrade the feature, it removes it.</p>
|
|
2449
|
+
<p><strong>Pruning is not symmetric across operators.</strong> An <code>and</code> group
|
|
2450
|
+
narrows with each condition, so dropping one widens the result — the direction faceting
|
|
2451
|
+
wants. An <code>or</code> group widens with each branch, so dropping one would show
|
|
2452
|
+
<em>fewer</em> rows than the user's actual filter. There is no partial answer that is correct,
|
|
2453
|
+
so a disjunction naming the column is dropped whole.</p>
|
|
2454
|
+
<p><strong>Bucket edges are placed once and kept.</strong> They are computed against the
|
|
2455
|
+
unfiltered column and survive every filter change until the data is replaced. Not only an
|
|
2456
|
+
optimisation: bars that resized on every click would make the chart unusable as a control,
|
|
2457
|
+
because the thing you are pointing at would move as you pointed at it.</p>
|
|
2458
|
+
<p><strong>Each bar carries two readings.</strong> Its full height is the bucket's share of
|
|
2459
|
+
the unfiltered column; the solid fill inside is how much survives the current filters. Either
|
|
2460
|
+
alone misleads — scaling to the filtered maximum draws a full-height chart out of three
|
|
2461
|
+
surviving rows, and scaling everything down together flattens the whole chart into a few
|
|
2462
|
+
pixels the moment anyone filters anything.</p>
|
|
2463
|
+
<p><strong>The filters are ordinary filters.</strong> They go through the same
|
|
2464
|
+
<code>filters.set</code> as everything else, so they undo, serialise into saved views, and
|
|
2465
|
+
appear in whatever filter UI already exists. Nothing downstream can tell a filter made by
|
|
2466
|
+
clicking a bar from one typed into the filter panel. A drag emits a <code>between</code>
|
|
2467
|
+
range rather than a set of bucket indices, so it still means the same thing after the data is
|
|
2468
|
+
replaced and the edges move.</p>
|
|
2469
|
+
<p><strong>Selection is derived, never stored.</strong> Which buckets look selected is read
|
|
2470
|
+
back out of the filter tree. Remove the filter through the filter panel, an undo or a saved
|
|
2471
|
+
view and the chart is correct without anything having to tell it.</p>
|
|
2472
|
+
<p><strong>High-cardinality columns are refused, and it costs nothing to know.</strong> Text
|
|
2473
|
+
columns are dictionary-encoded in the store, so the distinct count is a property read rather
|
|
2474
|
+
than a scan. The first column anyone points this at is a name or an id, and one hairline per
|
|
2475
|
+
customer looks like a rendering fault rather than a distribution. Above
|
|
2476
|
+
<code>cardinalityLimit</code> the chart is suppressed, or shows a top-N with an aggregated
|
|
2477
|
+
remainder if you ask for <code>aboveLimit: 'topN'</code> — aggregated rather than
|
|
2478
|
+
truncated, because silently dropping the tail would misrepresent the bars it did draw.</p>
|
|
2479
|
+
<p><strong>Nulls are never dropped.</strong> They land in a terminal bucket, always last, and
|
|
2480
|
+
the counts always sum to the row count. A column where nine thousand of ten thousand rows are
|
|
2481
|
+
empty is a fact about the data, and a chart that quietly showed the thousand would be lying
|
|
2482
|
+
about the shape. <code>NaN</code> joins them rather than forming its own bucket: it is the
|
|
2483
|
+
same answer to the same question.</p>
|
|
2484
|
+
<p><strong>Live streams suppress the charts.</strong> Constantly shifting distributions are
|
|
2485
|
+
unreadable, recounting on every batch is wasteful, and a filter control whose buckets move
|
|
2486
|
+
under the pointer is actively hostile. Filters already made stay applied, because they are
|
|
2487
|
+
ordinary filters. Pausing the stream brings the charts back — a paused stream is a still
|
|
2488
|
+
one — unless you set <code>whilePaused: false</code>.</p>
|
|
2489
|
+
<p><strong>Counting runs off the main thread above <code>workerThreshold</code>.</strong>
|
|
2490
|
+
Distributions are the only work the grid moves to a Worker. Sorting, filtering and grouping
|
|
2491
|
+
run on the main thread; nothing waits on a histogram, which is what makes this one
|
|
2492
|
+
offloadable. The column is copied, or shared where cross-origin isolation makes
|
|
2493
|
+
<code>SharedArrayBuffer</code> available; it is never transferred, because transferring would
|
|
2494
|
+
detach the buffer the grid is still rendering from.</p>
|
|
2495
|
+
<p><strong>The Worker settings.</strong> <code>useWorker</code> and
|
|
2496
|
+
<code>workerThreshold</code> decide whether and when a distribution is offloaded. Two more
|
|
2497
|
+
control how the Worker is built, and both are settled when it is constructed — changing
|
|
2498
|
+
either discards the running Worker so the next offload builds a new one.</p>
|
|
2499
|
+
<div class="table-wrap">
|
|
2500
|
+
<table>
|
|
2501
|
+
<thead><tr><th>Setting</th><th>What it does</th></tr></thead>
|
|
2502
|
+
<tbody>
|
|
2503
|
+
<tr><td class="name"><code>workerUrl</code></td><td class="desc">Loads the Worker from a URL you host instead of a <code>blob:</code>. Required under a Content-Security-Policy that forbids <code>blob:</code> workers — without it the Worker cannot be constructed at all on such a page, and compute stays on the main thread.</td></tr>
|
|
2504
|
+
<tr><td class="name"><code>sharedMemory</code></td><td class="desc">Off by default. Passes columns to the Worker in a <code>SharedArrayBuffer</code> rather than copying them on every message, at the cost of retaining a shared copy of each column that crosses. Needs the page to be cross-origin isolated; where it is not, it falls back to copying and says so once.</td></tr>
|
|
2505
|
+
</tbody>
|
|
2506
|
+
</table>
|
|
2507
|
+
</div>
|
|
2508
|
+
<p><code>grid.diagnostics.renders().worker</code> reports what the Worker host is actually
|
|
2509
|
+
doing — whether one was spawned, how many calls ran locally versus remotely, and the
|
|
2510
|
+
threshold, <code>sharedMemory</code> and <code>workerUrl</code> it was built with.</p>
|
|
2511
|
+
<p><strong>Server-side sources need a <code>provider</code>, and its absence is silent.</strong>
|
|
2512
|
+
A grid holding one page of data cannot compute a distribution over the whole set. Supply a
|
|
2513
|
+
function and it receives the column, the pruned filter state and the bucketing settings, and
|
|
2514
|
+
returns counts. Without one the charts are simply absent — no error, because most
|
|
2515
|
+
deployments will never supply one. Be clear-eyed about the load: results are cached against
|
|
2516
|
+
the filter state, but this is one query per column per filter change, and a grid with eight
|
|
2517
|
+
faceted columns asks eight questions every time a filter moves.</p>
|
|
2518
|
+
<p><strong>Keyboard and screen reader.</strong> Focus enters the chart from the header and
|
|
2519
|
+
arrows move between buckets, so a column costs one tab stop rather than twenty. <kbd>Enter</kbd>
|
|
2520
|
+
toggles, <kbd>Shift</kbd> with arrows extends a range on ordered columns, <kbd>Escape</kbd>
|
|
2521
|
+
clears. Selected buckets carry an outline as well as a colour. Beyond per-bucket labels the
|
|
2522
|
+
chart carries a sentence describing the distribution's shape, because twenty bucket readings
|
|
2523
|
+
do not add up to "most of the mass is at the low end" — and that shape is the entire
|
|
2524
|
+
value of the chart.</p>
|
|
2525
|
+
</div>
|
|
2526
|
+
|
|
2527
|
+
<h2 id="timeline-guide">Time scrubber</h2>
|
|
2528
|
+
<p class="lead-in">
|
|
2529
|
+
Move the grid back through recent data changes — what did this look like a minute ago,
|
|
2530
|
+
before that number moved.
|
|
2531
|
+
</p>
|
|
2532
|
+
<div class="example">
|
|
2533
|
+
<p class="example__label">Scrubbing</p>
|
|
2534
|
+
<pre><code>grid.timeline.attach(); <span class="cmt">// start recording; the window fills from here</span>
|
|
2535
|
+
|
|
2536
|
+
grid.timeline.seek(5); <span class="cmt">// stand five changes back</span>
|
|
2537
|
+
grid.timeline.step(-1); <span class="cmt">// one further back</span>
|
|
2538
|
+
grid.timeline.at(); <span class="cmt">// the moment being shown</span>
|
|
2539
|
+
grid.timeline.toLive(); <span class="cmt">// return, applying everything stepped over</span>
|
|
2540
|
+
|
|
2541
|
+
grid.timeline.detach();</code></pre>
|
|
2542
|
+
</div>
|
|
2543
|
+
<div class="why">
|
|
2544
|
+
<p><strong>Attaching puts a control on the grid.</strong> A slider along the bottom with two
|
|
2545
|
+
readings beside it — how long ago, and the clock time. Relative answers the question actually
|
|
2546
|
+
being asked; absolute is what someone reads out to the person next to them. It moves the grid
|
|
2547
|
+
while the handle is dragged rather than on release, and it turns accent-coloured the moment
|
|
2548
|
+
you are off live, because a grid quietly showing stale data is the failure this control can
|
|
2549
|
+
cause. It removes itself on <code>detach()</code>.</p>
|
|
2550
|
+
<p><strong>It reads the data, not your actions.</strong> Undo history records what the
|
|
2551
|
+
<em>user</em> did — sorts, filters, edits — which is rarely the question. This reads the
|
|
2552
|
+
change log: a bounded, timestamped, deliberately unmerged record of everything that arrived,
|
|
2553
|
+
so the intermediate states are all still there to move between.</p>
|
|
2554
|
+
<p><strong>Nothing is scrubbable before <code>attach()</code>.</strong> What a value used to
|
|
2555
|
+
be is not recoverable after the fact — no other part of the grid remembers it — so recording
|
|
2556
|
+
has to be switched on before there is a past to move through. It is off by default because
|
|
2557
|
+
reading a row per key on every change is real cost on a hot feed, and paying it for a
|
|
2558
|
+
scrubber nobody opened would be the wrong default.</p>
|
|
2559
|
+
<p><strong>Scrubbed back, the grid is not live.</strong> Changes keep being recorded but are
|
|
2560
|
+
not applied, because applying them would fight the position being held. Returning to the
|
|
2561
|
+
present applies everything missed.</p>
|
|
2562
|
+
<p><strong>Value changes reverse; row additions and removals do not.</strong> An add would
|
|
2563
|
+
need a removal and a remove would need re-insertion at its old position, and neither is
|
|
2564
|
+
recoverable from what the log holds. A window containing them scrubs over the value changes
|
|
2565
|
+
and leaves the row set alone — stated plainly because the alternative is a scrubber that
|
|
2566
|
+
silently half-works.</p>
|
|
2567
|
+
<p><strong>What moved is marked.</strong> Seeking compares each affected row before and after
|
|
2568
|
+
and marks the cells whose value changed, in <code>--lattice-timeline-changed</code>. Without
|
|
2569
|
+
it a scrub is nearly unreadable: the grid moves, and on a row twelve columns wide the one
|
|
2570
|
+
number you are hunting for goes past unseen.</p>
|
|
2571
|
+
<p><strong>The mark is held, not flashed.</strong> It stays until the next seek clears it.
|
|
2572
|
+
Every other transient signal in the grid fades on a timer, and this one deliberately does not
|
|
2573
|
+
— a scrub is someone hunting for what changed, and a highlight they can miss while reading the
|
|
2574
|
+
other end of the row helps nobody. <code>timeline:seeking</code> fires before any change is
|
|
2575
|
+
applied, so a five-step drag clears once and marks once rather than strobing per entry.</p>
|
|
2576
|
+
<p><strong>It compares column values, not raw fields.</strong> A computed column has no field
|
|
2577
|
+
of its own; diffing the source row would leave it silently unmarked while its number visibly
|
|
2578
|
+
moved. Reading through the column instead costs a little more and marks what the viewer can
|
|
2579
|
+
actually see change — which is the only definition of "changed" that matters here.</p>
|
|
2580
|
+
<p><strong>The window is bounded by rows, not only by changes.</strong> A cap on entries
|
|
2581
|
+
alone does not bound memory, because an entry is not a fixed size: one carrying a single
|
|
2582
|
+
changed cell and one carrying a fifty-thousand-row batch both count as one. So the log holds
|
|
2583
|
+
at most <code>updates.logLimit</code> changes (2000) <em>and</em> <code>updates.logRows</code>
|
|
2584
|
+
rows between them (100,000), dropping oldest-first on whichever it meets. It matters more
|
|
2585
|
+
here than it looks: the log is what keeps superseded row objects alive after the source has
|
|
2586
|
+
swapped in their replacements, so on a feed delivering five thousand rows a batch an
|
|
2587
|
+
entry-only cap retains twenty million of them. The one exception is a single change larger
|
|
2588
|
+
than the whole cap, which is kept — emptying the log would be worse than being briefly over,
|
|
2589
|
+
and it would drop the newest change rather than the oldest. Watch <code>held</code> against
|
|
2590
|
+
<code>heldLimit</code> in <code>grid.updates.stats()</code>; <code>rows</code> is a lifetime
|
|
2591
|
+
total and says nothing about memory.</p>
|
|
2592
|
+
<p><strong>Charts scrub like anything else.</strong> A sparkline column redraws to the series
|
|
2593
|
+
the row held at that point, and its cell is marked with the rest. The exception is the
|
|
2594
|
+
<code>delta</code> renderer: it samples on a wall-clock timer and compares against its own
|
|
2595
|
+
previous sample, so it reads a seek as a genuine movement and draws an arrow for it. Keep it
|
|
2596
|
+
off a grid you intend to scrub.</p>
|
|
2597
|
+
</div>
|
|
2598
|
+
|
|
2599
|
+
<h2 id="presentation-guide">Presentation mode</h2>
|
|
2600
|
+
<p class="lead-in">
|
|
2601
|
+
Renders the grid for a room: full-screen, application chrome hidden, everything enlarged.
|
|
2602
|
+
The data stays live and queryable throughout, so a question from the audience is answered by
|
|
2603
|
+
filtering in front of them rather than promised as a follow-up.
|
|
2604
|
+
</p>
|
|
2605
|
+
<div class="example">
|
|
2606
|
+
<p class="example__label">Starting and stopping</p>
|
|
2607
|
+
<pre><code>grid.presentation.start(); <span class="cmt">// 1.5x by default</span>
|
|
2608
|
+
grid.presentation.start({ scale: 2 });
|
|
2609
|
+
grid.presentation.start({ chrome: ['statusBar'] }); <span class="cmt">// keep some chrome</span>
|
|
2610
|
+
|
|
2611
|
+
grid.presentation.nudge(1); <span class="cmt">// live, or Ctrl/Cmd +</span>
|
|
2612
|
+
grid.presentation.setScale(1.8);
|
|
2613
|
+
grid.presentation.stop(); <span class="cmt">// or Escape</span></code></pre>
|
|
2614
|
+
</div>
|
|
2615
|
+
<div class="table-wrap">
|
|
2616
|
+
<table>
|
|
2617
|
+
<thead><tr><th>Keys</th><th>Does</th></tr></thead>
|
|
2618
|
+
<tbody>
|
|
2619
|
+
<tr><td class="sig">Escape</td><td class="desc">Leave, restoring the grid exactly as it was.</td></tr>
|
|
2620
|
+
<tr><td class="sig">Ctrl/Cmd + = / -</td><td class="desc">Enlarge or reduce live — a laptop on a call and a projector at the back of a room are different problems.</td></tr>
|
|
2621
|
+
<tr><td class="sig">Ctrl/Cmd + 0</td><td class="desc">Back to the default enlargement.</td></tr>
|
|
2622
|
+
</tbody>
|
|
2623
|
+
</table>
|
|
2624
|
+
</div>
|
|
2625
|
+
<div class="why">
|
|
2626
|
+
<p><strong>The scale multiplies your density, it does not replace it.</strong> A grid built at
|
|
2627
|
+
<code>spacious</code> presented at 1.5x is still recognisably that grid, half as big again —
|
|
2628
|
+
which is what makes a presentation look like the product rather than like a different one.
|
|
2629
|
+
Virtualisation follows the enlargement, so rows are positioned at the size they are drawn.</p>
|
|
2630
|
+
<p><strong>Full-screen is the maximiser, not a second implementation.</strong> A grid the user
|
|
2631
|
+
had already maximised stays maximised when the presentation ends: leaving it would be undoing
|
|
2632
|
+
something the presentation did not do. Chrome hidden on entry is recorded and put back, so an
|
|
2633
|
+
element the host had already hidden is not revealed on exit.</p>
|
|
2634
|
+
<p><strong>Events</strong> are <code>presentation:started</code>,
|
|
2635
|
+
<code>presentation:ended</code>, <code>presentation:scale</code> and
|
|
2636
|
+
<code>presentation:changed</code> — colon-separated like every other grid event rather than
|
|
2637
|
+
the camelCase the original brief used, so a host subscribing to them does not have to
|
|
2638
|
+
remember which family a name belongs to.</p>
|
|
2639
|
+
</div>
|
|
2640
|
+
<div class="example">
|
|
2641
|
+
<p class="example__label">Views are the slides</p>
|
|
2642
|
+
<pre><code>grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });
|
|
2643
|
+
grid.presentation.step(1); <span class="cmt">// or an arrow key, space, Page Down</span>
|
|
2644
|
+
grid.presentation.goTo(0); <span class="cmt">// or Home / End</span>
|
|
2645
|
+
grid.presentation.reset(); <span class="cmt">// or R — back to the view as saved</span></code></pre>
|
|
2646
|
+
</div>
|
|
2647
|
+
<div class="table-wrap">
|
|
2648
|
+
<table>
|
|
2649
|
+
<thead><tr><th>Keys</th><th>Does</th></tr></thead>
|
|
2650
|
+
<tbody>
|
|
2651
|
+
<tr><td class="sig">→ ↓ Space PageDown</td><td class="desc">Next view.</td></tr>
|
|
2652
|
+
<tr><td class="sig">← ↑ PageUp</td><td class="desc">Previous view.</td></tr>
|
|
2653
|
+
<tr><td class="sig">Home / End</td><td class="desc">First or last.</td></tr>
|
|
2654
|
+
<tr><td class="sig">R</td><td class="desc">Put the current view back as saved, discarding anything sorted or filtered since arriving at it.</td></tr>
|
|
2655
|
+
</tbody>
|
|
2656
|
+
</table>
|
|
2657
|
+
</div>
|
|
2658
|
+
<div class="why">
|
|
2659
|
+
<p><strong>They are the saved views you already have.</strong> A view captures the column
|
|
2660
|
+
set, order, widths, filters, sorts, grouping and density; stepping applies each through the
|
|
2661
|
+
ordinary <code>views.apply</code>, as a single undo entry. Nothing about presenting changes
|
|
2662
|
+
what a view means.</p>
|
|
2663
|
+
<p><strong>The stepping keys only bind when there is a sequence</strong>, and never while
|
|
2664
|
+
something is being typed into. Without a deck those keys belong to the grid — a presenter
|
|
2665
|
+
with no slides still expects Page Down to scroll — and a quick filter answering a question
|
|
2666
|
+
from the room must not advance the deck on the space bar.</p>
|
|
2667
|
+
<p><strong>Stepping past either end sits there.</strong> It does not wrap: a presenter who
|
|
2668
|
+
sees the first slide again thinks the deck has restarted.</p>
|
|
2669
|
+
<p><strong>Transitions are a cross-fade, not continuous row motion.</strong> Rows are pooled
|
|
2670
|
+
and virtualised, so an element holding a row before a view change may hold a different row
|
|
2671
|
+
after it — only rows visible in <em>both</em> states could be animated between positions, and
|
|
2672
|
+
half a movement draws the eye to whichever rows happened to survive rather than to the change
|
|
2673
|
+
itself. <code>prefers-reduced-motion</code> removes it; a projected fade is far larger than
|
|
2674
|
+
one on a laptop, so someone who asked for less motion meant it.</p>
|
|
2675
|
+
</div>
|
|
2676
|
+
<div class="example">
|
|
2677
|
+
<p class="example__label">Spotlight, redaction and unattended cycling</p>
|
|
2678
|
+
<pre><code><span class="cmt">// light one row across two columns; everything else recedes</span>
|
|
2679
|
+
grid.presentation.setSpotlight({ keys: ['R42'], colIds: ['margin', 'utilisation'] });
|
|
2680
|
+
grid.presentation.setSpotlight(null); <span class="cmt">// after the point is made</span>
|
|
2681
|
+
|
|
2682
|
+
<span class="cmt">// a wall display cycling saved views with nobody at the keyboard</span>
|
|
2683
|
+
grid.presentation.start({ views: [...], autoAdvance: 15000 });
|
|
2684
|
+
|
|
2685
|
+
<span class="cmt">// keep some chrome</span>
|
|
2686
|
+
grid.presentation.start({ chrome: ['statusBar'] });</code></pre>
|
|
2687
|
+
</div>
|
|
2688
|
+
<div class="why">
|
|
2689
|
+
<p><strong>Spotlight dims what it is not on, rather than lighting what it is.</strong> Rows
|
|
2690
|
+
and columns combine as an intersection, so naming both lights the cells where they meet. The
|
|
2691
|
+
dimming stops at 0.28 rather than going further: the audience has to see that there is more
|
|
2692
|
+
data and roughly what shape it is, or the spotlight reads as a filter and the room believes
|
|
2693
|
+
the other rows are gone. It is opacity alone, so a dimmed sparkline keeps its colours instead
|
|
2694
|
+
of flattening to grey.</p>
|
|
2695
|
+
<p><strong>A spotlight does not survive a view change.</strong> It belongs to the point being
|
|
2696
|
+
made, not to the deck — carried forward, it leaves the audience looking at a lit row that no
|
|
2697
|
+
longer means anything.</p>
|
|
2698
|
+
<p><strong>Redaction travels in views and undo.</strong> It is part of grid state, so a saved
|
|
2699
|
+
view carries its own masking and a view that redacts salary redacts it every time it is
|
|
2700
|
+
shown. Toggling is a tracked action, so it undoes like any other change.</p>
|
|
2701
|
+
<p><strong>Auto-advance wraps, unlike a keypress.</strong> An unattended display that stopped
|
|
2702
|
+
on the last view would show one screen for the rest of the day.</p>
|
|
2703
|
+
<p><strong>Escape ends the presentation, not just full screen.</strong> A presentation runs
|
|
2704
|
+
full screen with its chrome hidden, so leaving full screen without ending it would drop a
|
|
2705
|
+
chrome-less enlarged grid back into the page with no control left to turn it off. The
|
|
2706
|
+
maximiser stays the only listener on the key and the presentation follows it, which keeps one
|
|
2707
|
+
Escape doing one thing: an open editor or menu still closes first. A grid built with
|
|
2708
|
+
<code>maximise: false</code> binds the key directly instead.</p>
|
|
2709
|
+
</div>
|
|
2710
|
+
<div class="example">
|
|
2711
|
+
<p class="example__label">Capturing a still</p>
|
|
2712
|
+
<pre><code>const blob = await grid.capture({ scale: 2 });
|
|
2713
|
+
await grid.capture({ scale: 3, fileName: 'q3-margins.png' }); <span class="cmt">// and save it</span>
|
|
2714
|
+
|
|
2715
|
+
grid.on('presentation:captured', (e) => {
|
|
2716
|
+
console.log(e.width, e.height, e.bytes, e.mimeType); <span class="cmt">// 1800 600 41030 'image/png'</span>
|
|
2717
|
+
});</code></pre>
|
|
2718
|
+
</div>
|
|
2719
|
+
<div class="why">
|
|
2720
|
+
<p><strong>Mounting the bar elsewhere needs the grid's class.</strong> Every rule that styles
|
|
2721
|
+
the prompt bar is scoped under <code>.lattice</code>, and every colour token is declared
|
|
2722
|
+
there, so a bar mounted into your own chrome through <code>ai.element</code> arrives
|
|
2723
|
+
unstyled. Add <code>class="lattice"</code> to the container — and the same
|
|
2724
|
+
<code>data-theme</code> the grid carries, if you have set one — and it picks up the theme.</p>
|
|
2725
|
+
</div>
|
|
2726
|
+
|
|
2727
|
+
<div class="why">
|
|
2728
|
+
<p><strong>It photographs the browser's own rendering.</strong> The grid is cloned, every
|
|
2729
|
+
computed style is inlined onto the clone, and the result is wrapped in an SVG
|
|
2730
|
+
<code>foreignObject</code> and drawn to a canvas — so the picture is what the browser drew,
|
|
2731
|
+
not a second renderer's guess at it. That matters here more than usual: every decoration,
|
|
2732
|
+
sparkline and pill the cell layer produces comes out right without being reimplemented.</p>
|
|
2733
|
+
<p><strong>Virtualisation makes it cheap.</strong> Only the rows on screen exist in the DOM,
|
|
2734
|
+
so capturing a million-row grid clones the thirty rows a camera could have seen anyway.
|
|
2735
|
+
A full-screen capture at <code>scale: 2</code> takes around a second.</p>
|
|
2736
|
+
<p><strong>Cross-origin images are refused before the work starts.</strong> They taint the
|
|
2737
|
+
canvas, and a tainted canvas fails at the very last step with a <code>SecurityError</code>
|
|
2738
|
+
that names nothing — so the check runs first and the error names the offending URL. Serve the
|
|
2739
|
+
image same-origin, inline it as a <code>data:</code> URL, or hide the column.</p>
|
|
2740
|
+
<p><strong>Two further limits</strong>, both inherent to the technique: web fonts need
|
|
2741
|
+
embedding to appear (Lattice's default <code>system-ui</code> stack is unaffected), and CSS
|
|
2742
|
+
pseudo-elements are not captured.</p>
|
|
2743
|
+
</div>
|
|
2744
|
+
<div class="example">
|
|
2745
|
+
<p class="example__label">Drawing over the grid</p>
|
|
2746
|
+
<pre><code>grid.annotate.use('pen'); <span class="cmt">// pen · arrow · rect · highlight</span>
|
|
2747
|
+
grid.annotate.use('arrow', { colour: '#e0245e' });
|
|
2748
|
+
grid.annotate.undo();
|
|
2749
|
+
grid.annotate.clear();
|
|
2750
|
+
grid.annotate.use(null); <span class="cmt">// hand the grid back</span></code></pre>
|
|
2751
|
+
</div>
|
|
2752
|
+
<div class="why">
|
|
2753
|
+
<p><strong>It never touches data.</strong> Nothing in the layer reads a row or writes one. A
|
|
2754
|
+
grid with annotations sorts, filters and exports exactly as one without them.</p>
|
|
2755
|
+
<p><strong>It is inert unless a tool is chosen.</strong> The canvas is not even created until
|
|
2756
|
+
the first <code>use()</code>, and carries <code>pointer-events: none</code> whenever no tool
|
|
2757
|
+
is active, so scrolling, selection and editing pass straight through. A presenter who has
|
|
2758
|
+
finished drawing must not discover the grid has stopped responding.</p>
|
|
2759
|
+
<p><strong>Marks are anchored to the data, not the screen.</strong> They are stored in
|
|
2760
|
+
content coordinates and redrawn with the scroll offset subtracted, so a circle drawn round a
|
|
2761
|
+
cell travels with that cell rather than hanging over whatever scrolled underneath it.</p>
|
|
2762
|
+
<p><strong>They are transient.</strong> Marks annotate a moment, so they are cleared when the
|
|
2763
|
+
presentation ends. A capture taken while they are on screen includes them — the canvas bitmap
|
|
2764
|
+
is carried into the still deliberately, because <code>cloneNode</code> copies a canvas element
|
|
2765
|
+
and not one pixel of what was drawn on it.</p>
|
|
2766
|
+
<p><strong>No tool shortcuts are bound.</strong> The keys a presenter would want are already
|
|
2767
|
+
taken by stepping and by the grid itself, and a shortcut that silently shadows Page Down is
|
|
2768
|
+
worse than one the host chooses. Bind your own to <code>use()</code>.</p>
|
|
2769
|
+
</div>
|
|
2770
|
+
|
|
2771
|
+
<h2 id="accessibility-guide">Accessibility</h2>
|
|
2772
|
+
<p class="lead-in">
|
|
2773
|
+
The grid is built to WCAG 2.2 level AA. What follows is what it does, what it does not do yet,
|
|
2774
|
+
and the keyboard map in full — stated plainly, because a conformance claim that overstates
|
|
2775
|
+
is worth less than one that admits its edges.
|
|
2776
|
+
</p>
|
|
2777
|
+
|
|
2778
|
+
<h3>Keyboard</h3>
|
|
2779
|
+
<p>Every operation is reachable without a pointer. Resizing and reordering a column were once
|
|
2780
|
+
drag-only; both now have key bindings and menu items, so nothing depends on dragging.</p>
|
|
2781
|
+
<div class="table-wrap">
|
|
2782
|
+
<table>
|
|
2783
|
+
<thead><tr><th>Keys</th><th>Does</th></tr></thead>
|
|
2784
|
+
<!-- keyboard-map:start -->
|
|
2785
|
+
<tbody>
|
|
2786
|
+
<tr><th colspan="2">In the data</th></tr>
|
|
2787
|
+
<tr><td class="name"><kbd>ArrowUp/Down/Left/Right</kbd></td><td class="desc">Move the focused cell</td></tr>
|
|
2788
|
+
<tr><td class="name"><kbd>Home</kbd> <span class="sep">or</span> <kbd>End</kbd></td><td class="desc">First / last cell of the row</td></tr>
|
|
2789
|
+
<tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Home</kbd> <span class="sep">or</span> <kbd>Ctrl</kbd>+<kbd>End</kbd></td><td class="desc">First cell of the first row / last cell of the last row</td></tr>
|
|
2790
|
+
<tr><td class="name"><kbd>PageUp</kbd> <span class="sep">or</span> <kbd>PageDown</kbd></td><td class="desc">Move one viewport of rows</td></tr>
|
|
2791
|
+
<tr><td class="name"><kbd>Tab</kbd> <span class="sep">or</span> <kbd>Shift</kbd>+<kbd>Tab</kbd></td><td class="desc">Next / previous cell, wrapping across rows</td></tr>
|
|
2792
|
+
<tr><td class="name"><kbd>Enter</kbd></td><td class="desc">Start editing the focused cell</td></tr>
|
|
2793
|
+
<tr><td class="name"><kbd>Space</kbd></td><td class="desc">Toggle selection of the focused row</td></tr>
|
|
2794
|
+
<tr><td class="name"><kbd>Escape</kbd></td><td class="desc">Cancel the current edit or drag</td></tr>
|
|
2795
|
+
<tr><td class="name"><kbd>Alt</kbd>+<kbd>ArrowRight</kbd> <span class="sep">or</span> <kbd>Alt</kbd>+<kbd>ArrowLeft</kbd></td><td class="desc">Expand / collapse a group or tree row</td></tr>
|
|
2796
|
+
<tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd></td><td class="desc">Move focus to the column header</td></tr>
|
|
2797
|
+
<tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd></td><td class="desc">Move focus to the tool panel</td></tr>
|
|
2798
|
+
<tr><td class="name"><kbd>Shift</kbd>+<kbd>F10</kbd> <span class="sep">or</span> <kbd>ContextMenu</kbd></td><td class="desc">Open the context menu for the focused cell</td></tr>
|
|
2799
|
+
<tr><th colspan="2">On a column heading</th></tr>
|
|
2800
|
+
<tr><td class="name"><kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>ArrowRight</kbd></td><td class="desc">Move between headings</td></tr>
|
|
2801
|
+
<tr><td class="name"><kbd>Ctrl</kbd>+<kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>Ctrl</kbd>+<kbd>ArrowRight</kbd></td><td class="desc">First / last heading</td></tr>
|
|
2802
|
+
<tr><td class="name"><kbd>Enter or Space</kbd></td><td class="desc">Sort by the column, Shift to add to the sort</td></tr>
|
|
2803
|
+
<tr><td class="name"><kbd>Alt</kbd>+<kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>Alt</kbd>+<kbd>ArrowRight</kbd></td><td class="desc">Resize the column, Ctrl for a coarse step</td></tr>
|
|
2804
|
+
<tr><td class="name"><kbd>Shift</kbd>+<kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>Shift</kbd>+<kbd>ArrowRight</kbd></td><td class="desc">Move the column</td></tr>
|
|
2805
|
+
<tr><td class="name"><kbd>Alt</kbd>+<kbd>ArrowDown</kbd></td><td class="desc">Open the column menu</td></tr>
|
|
2806
|
+
<tr><td class="name"><kbd>ArrowDown or Escape</kbd></td><td class="desc">Return focus to the data</td></tr>
|
|
2807
|
+
<tr><th colspan="2">In the tool panel's column list</th></tr>
|
|
2808
|
+
<tr><td class="name"><kbd>G</kbd></td><td class="desc">Group by the column, or stop grouping by it</td></tr>
|
|
2809
|
+
<tr><td class="name"><kbd>V</kbd></td><td class="desc">Add the column to values, or take it out</td></tr>
|
|
2810
|
+
<tr><td class="name"><kbd>P</kbd></td><td class="desc">Pivot by the column, or stop pivoting by it</td></tr>
|
|
2811
|
+
<tr><td class="name"><kbd>Shift</kbd>+<kbd>ArrowUp</kbd> <span class="sep">or</span> <kbd>Shift</kbd>+<kbd>ArrowDown</kbd></td><td class="desc">Move the column</td></tr>
|
|
2812
|
+
</tbody>
|
|
2813
|
+
<!-- keyboard-map:end -->
|
|
2814
|
+
</table>
|
|
2815
|
+
</div>
|
|
2816
|
+
<p class="note">This table is generated from the bindings the build ships and checked on every
|
|
2817
|
+
build, so it cannot drift from what the grid actually does.</p>
|
|
2818
|
+
|
|
2819
|
+
<h3>What a screen reader is told</h3>
|
|
2820
|
+
<p>The grid reports itself as a <code>grid</code>, or a <code>treegrid</code> when it holds a
|
|
2821
|
+
hierarchy, and the role follows the configuration rather than being fixed when the grid is
|
|
2822
|
+
created. Rows and cells carry their position in the <em>dataset</em>, not in the rendered
|
|
2823
|
+
window: a reader on row 500,000 of a virtualised grid is told exactly that, which is the point
|
|
2824
|
+
most grids get wrong. Rows in a hierarchy also carry their position among their siblings, since
|
|
2825
|
+
a reader cannot count siblings that were never rendered.</p>
|
|
2826
|
+
<p>Focus is real focus, moved onto the cell, rather than <code>aria-activedescendant</code>. It
|
|
2827
|
+
is restored after a row is recycled or scrolled out and back, and the grid holds a single tab
|
|
2828
|
+
stop, so tabbing in and out crosses it once.</p>
|
|
2829
|
+
<p>State changes are announced: sorting, filtering, selection, grouping, expanding and
|
|
2830
|
+
collapsing, paging, undo and redo, pasting, and rows arriving or leaving on a live feed. A feed
|
|
2831
|
+
is summarised on an interval rather than narrated, because a reader queues what it is given and
|
|
2832
|
+
a fast feed would leave someone listening to counts that are no longer true. A rejected edit is
|
|
2833
|
+
announced with the value that was put back, which is the change users most need to hear about
|
|
2834
|
+
and the one a visual marker alone cannot convey.</p>
|
|
2835
|
+
|
|
2836
|
+
<h3>Colour and contrast</h3>
|
|
2837
|
+
<p>No information is carried by hue alone. The <code>high-contrast</code> theme runs text at 21:1
|
|
2838
|
+
and borders at 6.1:1. In Windows High Contrast Mode the grid translates its state into borders
|
|
2839
|
+
and system colours rather than fighting the palette — see
|
|
2840
|
+
<a href="#theming">theming</a> for what that means in detail.</p>
|
|
2841
|
+
|
|
2842
|
+
<h3>How this is checked</h3>
|
|
2843
|
+
<p>The grid carries its own accessibility rules, and they run on every build against each
|
|
2844
|
+
configuration that differs structurally — flat, grouped, tree, pinned, editing, paginated
|
|
2845
|
+
and with a tool panel — rather than against one sample grid. The same rules are available
|
|
2846
|
+
live from the devtools panel, where they can also read colour and measure targets.</p>
|
|
2847
|
+
<p>Be clear about what that proves. These are our own rules covering what a data grid gets wrong,
|
|
2848
|
+
not a general-purpose engine, and automated checking of any kind catches a minority of real
|
|
2849
|
+
problems. They are a regression net — they stop a fix being undone silently — and
|
|
2850
|
+
not evidence of conformance.</p>
|
|
2851
|
+
|
|
2852
|
+
<h3>Bigger targets for touch</h3>
|
|
2853
|
+
<p class="lead-in">
|
|
2854
|
+
The grid meets the minimum target size on its own. That minimum is a conformance floor, not a
|
|
2855
|
+
comfortable size for a finger — both mobile platforms recommend nearer 44 pixels.
|
|
2856
|
+
</p>
|
|
2857
|
+
<div class="example">
|
|
2858
|
+
<pre><code>targetSize: 'large'</code></pre>
|
|
2859
|
+
</div>
|
|
2860
|
+
<p>This raises the hit areas and leaves the type where it is, which is the distinction that
|
|
2861
|
+
matters: a touch user wants a larger target, and a low-vision user wants larger text. Density
|
|
2862
|
+
is the control for the second, and the two combine — a compact grid with large targets is
|
|
2863
|
+
a reasonable thing to want on a tablet.</p>
|
|
2864
|
+
<p>It applies by itself under a coarse pointer, since the person holding one is both who the
|
|
2865
|
+
criterion is for and the least likely to go looking for a setting. Pass
|
|
2866
|
+
<code>targetSize: 'default'</code> to opt out of that.</p>
|
|
2867
|
+
<div class="why">
|
|
2868
|
+
<p>Density alone does not do this. It scales the header, the rows and the type, and leaves the
|
|
2869
|
+
affordances inside them exactly as they were — measured at every preset, the menu button
|
|
2870
|
+
stays 24 pixels, the filter 16 and the resize grip 10. A spacious grid has the room going spare
|
|
2871
|
+
and controls no larger than a compact one, which is the gap this fills.</p>
|
|
2872
|
+
</div>
|
|
2873
|
+
|
|
2874
|
+
<h3>Known limits</h3>
|
|
2875
|
+
<p>Stated because a report that claims everything invites the one question it cannot answer.</p>
|
|
2876
|
+
<ul>
|
|
2877
|
+
<li><strong>Two-dimensional scrolling.</strong> A grid scrolls horizontally at narrow widths.
|
|
2878
|
+
WCAG 1.4.10 Reflow explicitly permits this for data tables, so it is conforming rather than a
|
|
2879
|
+
gap — but it is worth knowing before you meet it.</li>
|
|
2880
|
+
<li><strong>Pinned columns do not release at narrow widths.</strong> At around 320 pixels, two
|
|
2881
|
+
pinned columns of ordinary width can leave under 60 pixels for the scrolling middle. The grid
|
|
2882
|
+
stays operable and nothing is lost, but a layout that pins columns is worth reviewing if you
|
|
2883
|
+
expect it to be used at that size.</li>
|
|
2884
|
+
<li><strong>The filter icon is a small target, deliberately.</strong> It is 16 × 16,
|
|
2885
|
+
below the 24-pixel minimum of WCAG 2.5.8, and conforms under that criterion's
|
|
2886
|
+
<em>equivalent</em> allowance: filtering is also a column-menu item, and the menu button
|
|
2887
|
+
meets the size on its own. Worth knowing if you are pointing at it on a touch screen —
|
|
2888
|
+
the menu is the larger route to the same thing.</li>
|
|
2889
|
+
</ul>
|
|
2890
|
+
|
|
2891
|
+
<h2 id="density-guide">Density</h2>
|
|
2892
|
+
<p class="lead-in">
|
|
2893
|
+
One number drives the grid's geometry. A preset sets it; every token derives from it.
|
|
2894
|
+
</p>
|
|
2895
|
+
<div class="example">
|
|
2896
|
+
<p class="example__label">Four presets, or a number between them</p>
|
|
2897
|
+
<pre><code>createGrid(el, { density: 'spacious' });
|
|
2898
|
+
createGrid(el, { density: 1.4 }); <span class="cmt">// anything between the presets</span>
|
|
2899
|
+
grid.set('density', 'compact'); <span class="cmt">// live</span></code></pre>
|
|
2900
|
+
</div>
|
|
2901
|
+
<div class="table-wrap">
|
|
2902
|
+
<table>
|
|
2903
|
+
<thead><tr><th>Preset</th><th>Scale</th><th>Row</th><th>Font</th><th>For</th></tr></thead>
|
|
2904
|
+
<tbody>
|
|
2905
|
+
<tr><td class="sig">compact</td><td>0.85</td><td>23.8px</td><td>12.7px</td><td class="desc">The default. Dense operational and financial grids; most rows on screen.</td></tr>
|
|
2906
|
+
<tr><td class="sig">standard</td><td>1</td><td>28px</td><td>13px</td><td class="desc">Roomier than the default, and what the grid rendered before density was connected.</td></tr>
|
|
2907
|
+
<tr><td class="sig">comfortable</td><td>1.5</td><td>42px</td><td>14px</td><td class="desc">Roomier application tables.</td></tr>
|
|
2908
|
+
<tr><td class="sig">spacious</td><td>2</td><td>56px</td><td>15px</td><td class="desc">Modern app listings with avatars and thumbnails.</td></tr>
|
|
2909
|
+
</tbody>
|
|
2910
|
+
</table>
|
|
2911
|
+
</div>
|
|
2912
|
+
<div class="why">
|
|
2913
|
+
<p><strong>Type does not scale with the rows.</strong> The row doubles from
|
|
2914
|
+
<code>compact</code> to <code>spacious</code> while the font moves about 18%. Scaling text
|
|
2915
|
+
against row height reads as a children's book long before the rows look generous, so height
|
|
2916
|
+
and spacing follow the scale directly and type is damped against it.</p>
|
|
2917
|
+
<p><strong>Virtualisation follows the token, not a separate number.</strong> Row positions
|
|
2918
|
+
are computed from the resolved <code>--lattice-row-height</code>, so a host that overrides
|
|
2919
|
+
that token by hand gets the virtualisation to agree. An explicit <code>rowHeight</code> in
|
|
2920
|
+
config outranks both — a host that names a number means it.</p>
|
|
2921
|
+
<p><strong>Height alone will not reproduce a modern app listing.</strong> Those designs pair
|
|
2922
|
+
generous rows with two-line cells — a bold title over a grey sub-label — and an avatar or
|
|
2923
|
+
thumbnail. <code>spacious</code> gives the room, an <a href="#image-guide">image column</a>
|
|
2924
|
+
gives the picture, and <a href="#twoline-guide">twoline</a> gives the second line.</p>
|
|
2925
|
+
</div>
|
|
2926
|
+
|
|
2927
|
+
<h2 id="image-guide">Image columns</h2>
|
|
2928
|
+
<p class="lead-in">
|
|
2929
|
+
A column whose value is a URL, drawn as a picture. Avatars beside a name, product
|
|
2930
|
+
thumbnails, company logos.
|
|
2931
|
+
</p>
|
|
2932
|
+
<div class="example">
|
|
2933
|
+
<p class="example__label">A circular avatar</p>
|
|
2934
|
+
<pre><code>{ field: 'avatar', type: 'image', layout: { width: 72 },
|
|
2935
|
+
cell: { props: { shape: 'circle' } } }</code></pre>
|
|
2936
|
+
</div>
|
|
2937
|
+
<div class="table-wrap">
|
|
2938
|
+
<table>
|
|
2939
|
+
<thead><tr><th>Prop</th><th>Type</th><th>Description</th></tr></thead>
|
|
2940
|
+
<tbody>
|
|
2941
|
+
<tr><td class="sig">shape</td><td class="type">'rounded' | 'circle' | 'square'</td><td class="desc">Defaults to <code>rounded</code>. <code>circle</code> is the avatar case.</td></tr>
|
|
2942
|
+
<tr><td class="sig">size</td><td class="type">number</td><td class="desc">Pixels. Omit and the box follows the density scale at 0.68 of the row height.</td></tr>
|
|
2943
|
+
<tr><td class="sig">fit</td><td class="type">'cover' | 'contain'</td><td class="desc"><code>cover</code> by default, so a mixed set of aspect ratios still forms a tidy column.</td></tr>
|
|
2944
|
+
<tr><td class="sig">alt</td><td class="type">string | function</td><td class="desc">Alternative text. Defaults to the cell's formatted text.</td></tr>
|
|
2945
|
+
<tr><td class="sig">loading</td><td class="type">'lazy' | 'eager'</td><td class="desc"><code>lazy</code> by default.</td></tr>
|
|
2946
|
+
</tbody>
|
|
2947
|
+
</table>
|
|
2948
|
+
</div>
|
|
2949
|
+
<div class="why">
|
|
2950
|
+
<p><strong>Only image URLs load.</strong> <code>http</code>, <code>https</code>,
|
|
2951
|
+
<code>blob:</code> and <code>data:image/</code> are permitted; everything else is refused,
|
|
2952
|
+
including a <code>data:</code> URL claiming to be anything other than an image. Relative URLs
|
|
2953
|
+
pass, since they cannot name a scheme. A grid drawing URLs that arrived in a data feed is
|
|
2954
|
+
exactly where a bad one gets through. Note this differs from the <code>link</code> renderer,
|
|
2955
|
+
which refuses <code>data:</code> outright — correct for an anchor, wrong for a picture.</p>
|
|
2956
|
+
<p><strong>A missing or broken image never moves the column.</strong> The box is sized from
|
|
2957
|
+
the row height whether or not the picture loads, and a failed load leaves it empty rather
|
|
2958
|
+
than showing the browser's broken-image glyph, which is a different size in every engine.</p>
|
|
2959
|
+
<p><strong>Exports carry the URL.</strong> CSV, Excel and the clipboard all get the text, not
|
|
2960
|
+
markup. Values are dictionary-encoded, which pays unusually well here: one URL per user or
|
|
2961
|
+
per company repeats down the whole column.</p>
|
|
2962
|
+
</div>
|
|
2963
|
+
|
|
2964
|
+
<h2 id="twoline-guide">Two-line cells</h2>
|
|
2965
|
+
<p class="lead-in">
|
|
2966
|
+
A bold primary line over a quieter secondary one, taken from a second property of the same
|
|
2967
|
+
row. A name over an email, a title over a category, a company over its sector.
|
|
2968
|
+
</p>
|
|
2969
|
+
<div class="example">
|
|
2970
|
+
<p class="example__label">Naming the second line</p>
|
|
2971
|
+
<pre><code>{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }
|
|
2972
|
+
|
|
2973
|
+
<span class="cmt">// a dot path reaches a nested field without a callback</span>
|
|
2974
|
+
{ field: 'name', cell: { render: 'twoline', props: { secondary: 'contact.email' } } }
|
|
2975
|
+
|
|
2976
|
+
<span class="cmt">// a function for anything the row does not already hold</span>
|
|
2977
|
+
{ field: 'name', cell: { render: 'twoline', props: {
|
|
2978
|
+
secondary: (p) => `${p.data.city}, ${p.data.country}`,
|
|
2979
|
+
} } }
|
|
2980
|
+
|
|
2981
|
+
<span class="cmt">// `format` decorates whatever `secondary` produced</span>
|
|
2982
|
+
props: { secondary: 'user', format: (v) => v ? `User: ${v}` : '' }</code></pre>
|
|
2983
|
+
</div>
|
|
2984
|
+
<div class="why">
|
|
2985
|
+
<p><strong>It needs the room.</strong> Two lines do not fit in a 28px row. Pair it with
|
|
2986
|
+
<code>density: 'comfortable'</code> or <code>'spacious'</code>, or a <code>rowHeight</code>
|
|
2987
|
+
of 40 or more. Nothing stops you using it in a shorter row; the second line is simply
|
|
2988
|
+
clipped.</p>
|
|
2989
|
+
<p><strong>The second line reads the row's data, not another column.</strong> It is usually a
|
|
2990
|
+
field nobody wants a column of, and requiring one would mean declaring a column purely to
|
|
2991
|
+
hide it. A path that does not resolve leaves the line empty rather than throwing, and a cell
|
|
2992
|
+
with no second line collapses to one centred line rather than leaving a gap.</p>
|
|
2993
|
+
<p><strong>Both lines truncate; neither wraps.</strong> A wrapped second line would change the
|
|
2994
|
+
row height, and in a fixed-height grid that means being cut off mid-descender instead. The
|
|
2995
|
+
accessible name carries both lines as one string, so a screen reader gets the half of the
|
|
2996
|
+
cell that disambiguates the first.</p>
|
|
2997
|
+
</div>
|
|
2998
|
+
|
|
2999
|
+
<h2 id="redaction-guide">Redacting a column</h2>
|
|
3000
|
+
<p class="lead-in">
|
|
3001
|
+
For presenting and screen sharing: obscure the values in a column while everything that
|
|
3002
|
+
makes the grid readable — the row count, the sort, the filters, the column layout — stays
|
|
3003
|
+
exactly as it was. Right-click a column heading and choose <strong>Redact column</strong>.
|
|
3004
|
+
</p>
|
|
3005
|
+
<div class="example">
|
|
3006
|
+
<p class="example__label">From the API</p>
|
|
3007
|
+
<pre><code>grid.redaction.toggle('salary');
|
|
3008
|
+
grid.redaction.set(['salary', 'bonus']);
|
|
3009
|
+
grid.redaction.clear(); <span class="cmt">// when the call ends</span></code></pre>
|
|
3010
|
+
</div>
|
|
3011
|
+
<div class="why">
|
|
3012
|
+
<p><strong>This is not a security control, and the difference matters.</strong> The values
|
|
3013
|
+
are still in the model, still in the DOM, still on the clipboard and still in every export.
|
|
3014
|
+
Anyone looking at the page can read them from devtools, or by turning off a single CSS rule.
|
|
3015
|
+
What redaction defeats is a camera and a screen recorder — which is the threat a presenter
|
|
3016
|
+
actually has, and the only one it claims to answer. For a value that must never reach the
|
|
3017
|
+
browser, use <code>permissions</code> with <code>writeOnly</code>: there the value is not
|
|
3018
|
+
sent, so there is nothing to reveal.</p>
|
|
3019
|
+
<p><strong>Headings stay readable, totals do not.</strong> The column heading is left alone
|
|
3020
|
+
deliberately — a redacted column still has to be identifiable, by the presenter who wants to
|
|
3021
|
+
turn it back on and by the audience who need to know what they are not being shown. The
|
|
3022
|
+
pinned totals row <em>is</em> redacted, because the sum of a column is not a hint at its
|
|
3023
|
+
values: filter to one row and the total is the value.</p>
|
|
3024
|
+
<p><strong>Swapping the treatment.</strong> <code>--lattice-redaction-filter</code> defaults
|
|
3025
|
+
to <code>blur(5px) contrast(0.85)</code> and takes anything the CSS <code>filter</code>
|
|
3026
|
+
property does. A blur is the default because it is GPU-composited and stays cheap across a
|
|
3027
|
+
scrolling viewport; point the token at an SVG mosaic filter if you prefer classic
|
|
3028
|
+
pixellation and can afford it per cell.</p>
|
|
3029
|
+
</div>
|
|
3030
|
+
|
|
3031
|
+
<h2 id="clipboard">Clipboard</h2>
|
|
3032
|
+
<p class="lead-in">
|
|
3033
|
+
Copy produces the tab-separated form Excel, Numbers and Sheets all read, so a range pastes as
|
|
3034
|
+
cells rather than as one lump of text. Values go through each column's clipboard hook — a
|
|
3035
|
+
lookup copies its label, a date copies an unambiguous form.
|
|
3036
|
+
</p>
|
|
3037
|
+
<div class="example">
|
|
3038
|
+
<p class="example__label">Copy and paste</p>
|
|
3039
|
+
<pre><code>grid.export.rangeText(); <span class="cmt">// the text, without writing it</span>
|
|
3040
|
+
grid.export.clipboard({ rows: 'range' }); <span class="cmt">// copy the range</span>
|
|
3041
|
+
grid.export.clipboard({ headers: true, rows: 'selected' });
|
|
3042
|
+
|
|
3043
|
+
grid.edit.pasteInto(text); <span class="cmt">// Excel's tiling rules</span></code></pre>
|
|
3044
|
+
</div>
|
|
3045
|
+
<div class="why">
|
|
3046
|
+
<p>Pasting follows the spreadsheet convention: one cell into a range fills the range, one row
|
|
3047
|
+
into several rows repeats down, and a block larger than the target extends past it. People
|
|
3048
|
+
have twenty years of muscle memory for this and a grid that invents its own rules is a grid
|
|
3049
|
+
people fight.</p>
|
|
3050
|
+
</div>
|
|
3051
|
+
|
|
3052
|
+
<h2 id="keyboard">Keyboard</h2>
|
|
3053
|
+
<div class="table-wrap">
|
|
3054
|
+
<table>
|
|
3055
|
+
<thead><tr><th>Keys</th><th>Does</th></tr></thead>
|
|
3056
|
+
<tbody>
|
|
3057
|
+
<tr><td class="sig">Arrows</td><td class="desc">Move the focused cell.</td></tr>
|
|
3058
|
+
<tr><td class="sig">Shift + arrows</td><td class="desc">Extend the range.</td></tr>
|
|
3059
|
+
<tr><td class="sig">Ctrl/Cmd + Shift + arrows</td><td class="desc">Open a second range at the focused cell, keeping the first; further presses extend it.</td></tr>
|
|
3060
|
+
<tr><td class="sig">Ctrl/Cmd + C, V, D</td><td class="desc">Copy, paste, fill down.</td></tr>
|
|
3061
|
+
<tr><td class="sig">Delete / Backspace</td><td class="desc">Clear the selected cells.</td></tr>
|
|
3062
|
+
<tr><td class="sig">Enter</td><td class="desc">Start editing; commit and step down.</td></tr>
|
|
3063
|
+
<tr><td class="sig">Tab</td><td class="desc">Commit and step across.</td></tr>
|
|
3064
|
+
<tr><td class="sig">Escape</td><td class="desc">Cancel the edit; restore a maximised grid once nothing else wants it.</td></tr>
|
|
3065
|
+
<tr><td class="sig">Space</td><td class="desc">Toggle the row's selection.</td></tr>
|
|
3066
|
+
<tr><td class="sig">Home / End, Page Up / Down</td><td class="desc">Jump; with Ctrl, to the ends of the grid.</td></tr>
|
|
3067
|
+
</tbody>
|
|
3068
|
+
</table>
|
|
3069
|
+
</div>
|
|
3070
|
+
<div class="why">
|
|
3071
|
+
<p>None of these fire while you are typing into an input — a filter box, an open editor, the
|
|
3072
|
+
view-name field. The grid checks where the keystroke came from before claiming it, which
|
|
3073
|
+
sounds obvious and is the sort of thing that is usually wrong.</p>
|
|
3074
|
+
</div>
|
|
3075
|
+
|
|
3076
|
+
<h2 id="rules-guide">Conditional formatting</h2>
|
|
3077
|
+
<p class="lead-in">
|
|
3078
|
+
A rule is a condition and the styling it produces. <code>compileRules()</code> turns a list
|
|
3079
|
+
into the function <code>cell.style</code> already accepts.
|
|
3080
|
+
</p>
|
|
3081
|
+
<div class="example">
|
|
3082
|
+
<pre><code><span class="kw">import</span> { compileRules } <span class="kw">from</span> '@toclocoinc/lattice-grid';
|
|
3083
|
+
|
|
3084
|
+
{ field: 'margin', cell: { style: compileRules([
|
|
3085
|
+
{ when: { op: 'lt', value: 0 }, style: { background: '#fdecea', colour: '#b91c1c' } },
|
|
3086
|
+
{ when: { op: 'gt', value: 20 }, style: { weight: 600 }, stopIfTrue: <span class="kw">false</span> },
|
|
3087
|
+
{ scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
|
|
3088
|
+
]) } }</code></pre>
|
|
3089
|
+
</div>
|
|
3090
|
+
<div class="why">
|
|
3091
|
+
<p><strong>The operators are the filter's operators.</strong> <code>gt</code>,
|
|
3092
|
+
<code>between</code> and <code>contains</code> mean here exactly what they mean in the grid's
|
|
3093
|
+
filters. Anyone
|
|
3094
|
+
who has built a filter has already learned this, and two vocabularies for one idea is how a
|
|
3095
|
+
product ends up explaining itself twice.</p>
|
|
3096
|
+
<p><strong>First match wins, by default.</strong> "Red if overdue, amber if due this week"
|
|
3097
|
+
reads top to bottom and stops — the spreadsheet convention, and the one people expect.
|
|
3098
|
+
<code>stopIfTrue: false</code> lets rules combine: weight from one, colour from another.</p>
|
|
3099
|
+
<p><strong>A blank cell satisfies no comparison.</strong> <code>Number(null)</code> is zero, so
|
|
3100
|
+
a naive implementation sweeps every empty cell into "less than 100" and formats half a column
|
|
3101
|
+
that has no data in it.</p>
|
|
3102
|
+
<p><strong>Text still compares numerically.</strong> A rules panel produces strings — the box
|
|
3103
|
+
the user typed into yields <code>"100"</code>, not <code>100</code> — and comparing those as
|
|
3104
|
+
text puts <code>"9"</code> above <code>"100"</code>.</p>
|
|
3105
|
+
<p><strong>A scale's bounds are given, not derived.</strong> Deriving them means scanning the
|
|
3106
|
+
column per cell, and a scale that rescaled as rows were filtered would change a cell's colour
|
|
3107
|
+
without its value changing — the opposite of what the colour is for.</p>
|
|
3108
|
+
</div>
|
|
3109
|
+
|
|
3110
|
+
<h2 id="formatting-guide">Formatting an end user can change</h2>
|
|
3111
|
+
<p class="lead-in">
|
|
3112
|
+
<code>compileRules()</code> above compiles at configuration time into <code>cell.style</code>,
|
|
3113
|
+
which you write and a user cannot reach. <code>grid.formatting</code> holds the same rules as
|
|
3114
|
+
runtime state instead: the cell layer asks it on every paint, so a rule added while the grid
|
|
3115
|
+
is running takes effect on the next frame.
|
|
3116
|
+
</p>
|
|
3117
|
+
<div class="example">
|
|
3118
|
+
<p class="example__label">Rules as state</p>
|
|
3119
|
+
<pre><code>grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } });
|
|
3120
|
+
grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } });
|
|
3121
|
+
|
|
3122
|
+
grid.formatting.list('margin'); <span class="cmt">// [{ id, when, style }, …] in evaluation order</span>
|
|
3123
|
+
grid.formatting.move('margin', id, 0); <span class="cmt">// order decides which rule wins</span>
|
|
3124
|
+
grid.formatting.update('margin', id, { enabled: false });
|
|
3125
|
+
grid.formatting.clear('margin');</code></pre>
|
|
3126
|
+
</div>
|
|
3127
|
+
<div class="why">
|
|
3128
|
+
<p><strong>Two scopes, one ordered list.</strong> A rule sits on a column id or on
|
|
3129
|
+
<code>'*'</code> for every column. Evaluation joins them — grid-wide first, then the column's
|
|
3130
|
+
own — so a column rule can override a grid-wide one, and <code>stopIfTrue</code> means the
|
|
3131
|
+
same thing across the join as it does within either half.</p>
|
|
3132
|
+
<p><strong>Saved views and undo came free.</strong> The rules are a section of
|
|
3133
|
+
<code>GridState</code>, and both saved views and the undo timeline are built on that. Nothing
|
|
3134
|
+
in the formatting model knows either exists.</p>
|
|
3135
|
+
<p><strong>Rules must be JSON.</strong> <code>style</code> cannot be a function here, because
|
|
3136
|
+
the rules are serialised into views and undo slices. Config-time <code>cell.style</code> still
|
|
3137
|
+
takes one, which is the right home for a rule a user should not be able to change.</p>
|
|
3138
|
+
<p><strong>Both paths coexist.</strong> Where a column has a <code>cell.style</code> and a
|
|
3139
|
+
runtime rule matches, the two are merged and written once; the runtime rule wins for the
|
|
3140
|
+
properties it names and leaves the rest of your styling alone.</p>
|
|
3141
|
+
<p><strong>Group rows are not formatted.</strong> A group row summarises many values rather
|
|
3142
|
+
than being an instance of one, which is the same reason decoration is dropped for it.</p>
|
|
3143
|
+
</div>
|
|
3144
|
+
<div class="example">
|
|
3145
|
+
<p class="example__label">The panel</p>
|
|
3146
|
+
<pre><code>createGrid(el, { toolPanel: { side: 'right', panels: ['columns', 'filters', 'formatting'] } });</code></pre>
|
|
3147
|
+
</div>
|
|
3148
|
+
<div class="why">
|
|
3149
|
+
<p>The panel is a form over that array and nothing more — every control is one call into
|
|
3150
|
+
<code>grid.formatting</code>, which is what makes each gesture undoable without the panel
|
|
3151
|
+
knowing undo exists. It exposes ordering because ordering is meaning: dragging a rule up can
|
|
3152
|
+
change which of two colours a cell takes.</p>
|
|
3153
|
+
<p><strong>Not yet in the panel:</strong> icon sets and data bars. Both are column decorations
|
|
3154
|
+
rather than cell styles — the <code>bar</code> and <code>icon</code> decorations already
|
|
3155
|
+
render them, and driving those from the panel needs a runtime column-decoration API that
|
|
3156
|
+
persists and undoes alongside the rules. Building it as a second bar implementation inside
|
|
3157
|
+
the rule engine was the alternative, and the wrong one.</p>
|
|
3158
|
+
</div>
|
|
3159
|
+
|
|
3160
|
+
<h2 id="quickfilter-guide">Quick filter modes</h2>
|
|
3161
|
+
<p class="lead-in">
|
|
3162
|
+
One box, four ways to match: <code>contains</code> (the default), <code>words</code>,
|
|
3163
|
+
<code>fuzzy</code> and <code>regex</code>.
|
|
3164
|
+
</p>
|
|
3165
|
+
<div class="example">
|
|
3166
|
+
<pre><code>grid.filters.quick('acme london', { mode: 'words' }); <span class="cmt">// every term, any column</span>
|
|
3167
|
+
grid.filters.quick('crc', { mode: 'fuzzy' }); <span class="cmt">// characters in order</span>
|
|
3168
|
+
grid.filters.quick('^CIR-[12]', { mode: 'regex' });
|
|
3169
|
+
|
|
3170
|
+
grid.filters.quick('acme'); <span class="cmt">// mode persists — still 'regex' here</span>
|
|
3171
|
+
grid.filters.quickState(); <span class="cmt">// { text, mode }</span></code></pre>
|
|
3172
|
+
</div>
|
|
3173
|
+
<div class="why">
|
|
3174
|
+
<p><strong>Compiled once per query, not per row.</strong> A regular expression rebuilt for each
|
|
3175
|
+
of a hundred thousand rows is a hundred thousand compiles for one keystroke. The predicate is
|
|
3176
|
+
built in the filter stage and applied to a cached text blob per row, which is why typing stays
|
|
3177
|
+
responsive at scale.</p>
|
|
3178
|
+
<p><strong>An unfinished pattern does not blank the grid.</strong> <code>foo(</code> is what
|
|
3179
|
+
<code>foo(bar)</code> looks like halfway through typing. An invalid expression falls back to a
|
|
3180
|
+
literal search, so the list stays sensible until the pattern is valid again.</p>
|
|
3181
|
+
<p><strong>Fuzzy does not rank.</strong> Subsequence matching decides what stays; it never
|
|
3182
|
+
reorders. Sorting results by match quality would fight the sort the user chose, and a filter
|
|
3183
|
+
that quietly re-sorts is worse than one that matches too much.</p>
|
|
3184
|
+
<p><strong>Permissions still apply.</strong> The text blob is built only from columns the
|
|
3185
|
+
viewer may see. A hidden column is not searchable, or the row count becomes a way to probe the
|
|
3186
|
+
value behind it.</p>
|
|
3187
|
+
</div>
|
|
3188
|
+
|
|
3189
|
+
<h2 id="charts-guide">In-cell charts</h2>
|
|
3190
|
+
<p class="lead-in">
|
|
3191
|
+
Seven chart types for a cell: <code>line</code>, <code>area</code>, <code>column</code>,
|
|
3192
|
+
<code>winloss</code>, <code>pie</code>, <code>donut</code>, <code>bullet</code>,
|
|
3193
|
+
<code>stacked</code>, <code>range</code>, <code>gauge</code> and <code>delta</code>. Most read an array;
|
|
3194
|
+
<code>bullet</code> and <code>gauge</code> read a number.
|
|
3195
|
+
</p>
|
|
3196
|
+
<div class="example">
|
|
3197
|
+
<p class="example__label">A trend, a mix and a target</p>
|
|
3198
|
+
<pre><code>columns: [
|
|
3199
|
+
{ id: 'trend', field: 'readings', cell: 'line' },
|
|
3200
|
+
{ id: 'mix', field: 'split', cell: { render: 'donut', props: { hole: 0.55 } } },
|
|
3201
|
+
{ id: 'sla', field: 'uptime',
|
|
3202
|
+
cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } },
|
|
3203
|
+
|
|
3204
|
+
<span class="cmt">// Two views of one field: give each an explicit id.</span>
|
|
3205
|
+
{ id: 'shape', field: 'readings', cell: 'line' },
|
|
3206
|
+
{ id: 'detail', field: 'readings', cell: 'column' },
|
|
3207
|
+
]</code></pre>
|
|
3208
|
+
</div>
|
|
3209
|
+
<div class="why">
|
|
3210
|
+
<p><strong>A cell is a few hundred pixels seen for a second</strong>, so there are no axes, no
|
|
3211
|
+
gridlines and no legend. The chart carries one idea — a shape, a share, a comparison — and the
|
|
3212
|
+
number beside it carries the precision. Hide the number with <code>label: false</code> when the
|
|
3213
|
+
column next to it already says the same thing.</p>
|
|
3214
|
+
<p><strong>Pin <code>min</code> and <code>max</code> when columns are meant to compare.</strong>
|
|
3215
|
+
A sparkline scaled to its own data fills its cell whatever the magnitude, so two rows differing
|
|
3216
|
+
by an order of magnitude draw identically. Pinning the scale is what makes the column readable
|
|
3217
|
+
down its length rather than only across it.</p>
|
|
3218
|
+
<p><strong>A gap is not a zero.</strong> Entries that are not numbers break the line and omit
|
|
3219
|
+
the bar, rather than being drawn at the baseline — joining across a missing reading would draw
|
|
3220
|
+
a trend nobody measured, and drawing it at zero invents a dip.</p>
|
|
3221
|
+
<p><strong>Cost.</strong> Each chart is one SVG built once, with the paths' <code>d</code>
|
|
3222
|
+
attributes the only thing a repaint writes, and a bar chart is two paths rather than one
|
|
3223
|
+
element per bar. Rows recycle as you scroll, so this is what keeps a chart column the same
|
|
3224
|
+
price as a text one. Nothing measures the DOM — the drawing happens in a fixed coordinate
|
|
3225
|
+
space that CSS scales.</p>
|
|
3226
|
+
<p><strong>Accessibility.</strong> The chart is <code>aria-hidden</code> and the cell carries a
|
|
3227
|
+
summary: "12 points, 9 to 20, ending 18". A path cannot be read aloud, and a series announced
|
|
3228
|
+
value by value tells a listener less than the sentence does.</p>
|
|
3229
|
+
</div>
|
|
3230
|
+
|
|
3231
|
+
<h2 id="formulas-guide">Formulas</h2>
|
|
3232
|
+
<p class="lead-in">
|
|
3233
|
+
A leading <code>=</code> in a numeric cell is a formula. Type
|
|
3234
|
+
<code>=quantity * unitPrice</code> and the grid stores 119.88.
|
|
3235
|
+
</p>
|
|
3236
|
+
<div class="example">
|
|
3237
|
+
<p class="example__label">What a user can type</p>
|
|
3238
|
+
<pre><code>=5 + 5
|
|
3239
|
+
=quantity * unitPrice <span class="cmt">// by field name</span>
|
|
3240
|
+
=[Unit Price] * 1.2 <span class="cmt">// by title, when it has spaces</span>
|
|
3241
|
+
=ROUND(quantity * unitPrice, 2)
|
|
3242
|
+
=IF(quantity > 10, "bulk", "single")
|
|
3243
|
+
=SUM(readings) <span class="cmt">// an array property on the row</span>
|
|
3244
|
+
=MAX(readings) - MIN(readings)</code></pre>
|
|
3245
|
+
</div>
|
|
3246
|
+
<div class="why">
|
|
3247
|
+
<p><strong>References name columns, not cells.</strong> A spreadsheet can say <code>A1</code>
|
|
3248
|
+
because its rows do not move. A grid sorts, filters, groups, pages and virtualises, so the row
|
|
3249
|
+
at position 1 is a different row a moment later and a formula written against it would silently
|
|
3250
|
+
change meaning. <code>quantity * price</code> means the same thing wherever the row goes.</p>
|
|
3251
|
+
<p><strong>No <code>eval</code>, no <code>new Function</code>.</strong> This is text a
|
|
3252
|
+
<em>user</em> typed into a cell. Handing it to the JavaScript engine would let anyone who can
|
|
3253
|
+
edit a cell read your cookies, call your API with your credentials, or post the grid's contents
|
|
3254
|
+
anywhere. It is a hand-written tokeniser and recursive-descent parser, and the only callable
|
|
3255
|
+
things are the built-in functions — <code>constructor</code>, <code>globalThis</code> and
|
|
3256
|
+
<code>constructor.constructor("return 1")()</code> all simply fail to resolve.</p>
|
|
3257
|
+
<p><strong>The result is stored, not the expression.</strong> A formula is a way of
|
|
3258
|
+
<em>entering</em> a value, exactly like <code>1,200</code> or <code>(50)</code> or
|
|
3259
|
+
<code>12%</code>. It commits as one undo step, fires one <code>cell:changed</code>, and passes
|
|
3260
|
+
through the column's own validation.</p>
|
|
3261
|
+
<p><strong>The result is stored, not the formula.</strong> The expression is evaluated once, at
|
|
3262
|
+
the moment you commit it, and what lands in the cell is a value like any other — so it does not
|
|
3263
|
+
recalculate when a cell it referred to changes later. For a value that must stay in step with
|
|
3264
|
+
its inputs, use a computed column, which is re-evaluated whenever its dependencies move.</p>
|
|
3265
|
+
</div>
|
|
3266
|
+
<div class="example">
|
|
3267
|
+
<p class="example__label">Adding your own functions</p>
|
|
3268
|
+
<pre><code>createGrid(el, {
|
|
3269
|
+
formulaFunctions: {
|
|
3270
|
+
MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
|
|
3271
|
+
BAND: ([value]) => (value > 1000 ? 'A' : 'B'),
|
|
3272
|
+
},
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
<span class="cmt">// Or evaluate one yourself, anywhere.</span>
|
|
3276
|
+
<span class="kw">import</span> { evaluateFormula } <span class="kw">from</span> '@toclocoinc/lattice-grid';
|
|
3277
|
+
<span class="kw">const</span> r = evaluateFormula('=a * b', { data: { a: 6, b: 7 } });
|
|
3278
|
+
r.ok ? r.value : r.error; <span class="cmt">// 42</span></code></pre>
|
|
3279
|
+
</div>
|
|
3280
|
+
<p class="lead-in">
|
|
3281
|
+
A formula that cannot be read rejects the edit and the cell keeps its old value, the same as any
|
|
3282
|
+
other unparseable text. Failures are returned rather than thrown — this runs on the commit path,
|
|
3283
|
+
where an exception would abandon the commit half-done.
|
|
3284
|
+
</p>
|
|
3285
|
+
<div class="why">
|
|
3286
|
+
<p><strong>Bare arithmetic is deliberately not a formula.</strong> <code>2-1</code> is a
|
|
3287
|
+
plausible product code and <code>1/2</code> a plausible date. Evaluating either on a guess would
|
|
3288
|
+
repeat the bug this replaced, where the number reader stripped the operator and stored
|
|
3289
|
+
<code>2*3</code> as <strong>23</strong>. Arithmetic without a leading <code>=</code> is now
|
|
3290
|
+
refused outright, so the cell keeps what it had rather than taking a plausible wrong number.</p>
|
|
3291
|
+
</div>
|
|
3292
|
+
|
|
3293
|
+
<h2 id="custom-menu">Your own menu items and buttons</h2>
|
|
3294
|
+
<p class="lead-in">
|
|
3295
|
+
The cell menu's function form is handed the cell that was clicked <strong>and</strong> the
|
|
3296
|
+
built-in items. Adding one entry does not mean reproducing the other thirteen.
|
|
3297
|
+
</p>
|
|
3298
|
+
<div class="example">
|
|
3299
|
+
<p class="example__label">An item that acts on the cell it was opened on</p>
|
|
3300
|
+
<pre><code>createGrid(el, {
|
|
3301
|
+
contextMenu: (params, defaults) => [
|
|
3302
|
+
...defaults,
|
|
3303
|
+
{ separator: <span class="kw">true</span> },
|
|
3304
|
+
{
|
|
3305
|
+
name: `Open ${params.value} in CRM`,
|
|
3306
|
+
action: (ctx) => window.open(`/crm/${ctx.data.accountId}`),
|
|
3307
|
+
},
|
|
3308
|
+
],
|
|
3309
|
+
});</code></pre>
|
|
3310
|
+
</div>
|
|
3311
|
+
<p class="lead-in">
|
|
3312
|
+
<code>params</code> and the <code>action</code>'s argument are the same shape:
|
|
3313
|
+
<code>{ key, colId, value, row, data, column, index, grid }</code>. <code>data</code> is your
|
|
3314
|
+
original row object, so an item can reach fields the grid never displayed.
|
|
3315
|
+
</p>
|
|
3316
|
+
<div class="why">
|
|
3317
|
+
<p><strong>Handed the defaults, rather than replacing them.</strong> A builder that had to
|
|
3318
|
+
return every item in order to append one would be written once as a copy of the built-ins and
|
|
3319
|
+
would then drift from them — the copy keeps the menu it was forked from, and stops gaining
|
|
3320
|
+
whatever the grid adds later. Spreading <code>defaults</code> costs one line and never goes
|
|
3321
|
+
stale.</p>
|
|
3322
|
+
<p>Return the array you want shown: add, remove, reorder, or replace outright. An empty array
|
|
3323
|
+
suppresses the menu deliberately. Returning <em>nothing</em> leaves the defaults alone, because
|
|
3324
|
+
a missing <code>return</code> is a typo and deleting the whole menu is a harsh reading of
|
|
3325
|
+
one.</p>
|
|
3326
|
+
</div>
|
|
3327
|
+
<div class="example">
|
|
3328
|
+
<p class="example__label">A button of your own on the rail</p>
|
|
3329
|
+
<pre><code>createGrid(el, {
|
|
3330
|
+
toolPanel: {
|
|
3331
|
+
side: 'left',
|
|
3332
|
+
<span class="cmt">// A string names a built-in; an object is yours. Order is respected, so</span>
|
|
3333
|
+
<span class="cmt">// yours can sit between built-ins rather than only after them.</span>
|
|
3334
|
+
actions: ['undo', 'redo', {
|
|
3335
|
+
name: 'sync',
|
|
3336
|
+
title: 'Sync to the server',
|
|
3337
|
+
icon: 'restore',
|
|
3338
|
+
run: ({ grid, keys, cells }) => api.sync(keys),
|
|
3339
|
+
enabled: () => grid.state.modified(),
|
|
3340
|
+
}],
|
|
3341
|
+
},
|
|
3342
|
+
});</code></pre>
|
|
3343
|
+
</div>
|
|
3344
|
+
<p class="lead-in">
|
|
3345
|
+
<code>title</code> and <code>icon</code> may each be a function, re-read on every repaint, for
|
|
3346
|
+
a control whose meaning changes — that is how maximise becomes restore. <code>enabled</code> is
|
|
3347
|
+
a predicate rather than a flag, so a button that cannot do anything greys itself out instead of
|
|
3348
|
+
doing nothing when clicked.
|
|
3349
|
+
</p>
|
|
3350
|
+
|
|
3351
|
+
<h2 id="rail">The left rail</h2>
|
|
3352
|
+
<p class="lead-in">
|
|
3353
|
+
<code>toolPanel: { side: 'left' }</code> docks the panels as an icon rail and turns on the
|
|
3354
|
+
action buttons: undo, redo, export to CSV, export to Excel, copy to the clipboard, print,
|
|
3355
|
+
restore the default view, and maximise. Each is a single click on the thing you came for, and
|
|
3356
|
+
all four export destinations are in the cell context menu as well.
|
|
3357
|
+
</p>
|
|
3358
|
+
<div class="example">
|
|
3359
|
+
<p class="example__label">Picking a subset</p>
|
|
3360
|
+
<pre><code>createGrid(el, {
|
|
3361
|
+
toolPanel: {
|
|
3362
|
+
side: 'left',
|
|
3363
|
+
panels: ['columns', 'filters', 'views', 'quick'],
|
|
3364
|
+
<span class="cmt">// Omit `actions` entirely to take all seven, including any added later.</span>
|
|
3365
|
+
actions: ['undo', 'redo', 'export', 'excel', 'clipboard', 'print', 'restore', 'maximise'],
|
|
3366
|
+
exportName: 'circuits',
|
|
3367
|
+
},
|
|
3368
|
+
});</code></pre>
|
|
3369
|
+
</div>
|
|
3370
|
+
<div class="why">
|
|
3371
|
+
<p><strong>An explicit <code>actions</code> array replaces the default rather than extending
|
|
3372
|
+
it.</strong> That is what makes it useful — you choose the set and the order — but it also
|
|
3373
|
+
means a config written against an earlier version keeps exactly the buttons it named and
|
|
3374
|
+
silently misses anything added since. If you want whatever the current version offers, leave
|
|
3375
|
+
the key out.</p>
|
|
3376
|
+
<p>Every action is gated on a predicate rather than always offered, so undo greys out when
|
|
3377
|
+
there is nothing to undo and "restore the default view" greys out when nothing has changed. A
|
|
3378
|
+
rail of seven icons where three do nothing is worse than a rail of four.</p>
|
|
3379
|
+
</div>
|
|
3380
|
+
|
|
3381
|
+
<h2 id="maximise-guide">Maximise</h2>
|
|
3382
|
+
<p class="lead-in">
|
|
3383
|
+
A grid usually lives in whatever box the page layout gave it, and that box is usually too
|
|
3384
|
+
small for the job. The left rail's last button fills the browser window with the grid, and
|
|
3385
|
+
clicking it again puts the grid back exactly where it was. <kbd>Esc</kbd> also restores.
|
|
3386
|
+
</p>
|
|
3387
|
+
<div class="example">
|
|
3388
|
+
<p class="example__label">Your own control, or a keyboard shortcut</p>
|
|
3389
|
+
<pre><code><span class="cmt">// The rail button is on by default. This is the same thing.</span>
|
|
3390
|
+
grid.maximise.toggle();
|
|
3391
|
+
grid.maximise.active(); <span class="cmt">// true while it fills the window</span>
|
|
3392
|
+
|
|
3393
|
+
document.addEventListener('keydown', (e) => {
|
|
3394
|
+
<span class="kw">if</span> (e.key === 'F11' && !e.ctrlKey) { e.preventDefault(); grid.maximise.toggle(); }
|
|
3395
|
+
});
|
|
3396
|
+
|
|
3397
|
+
<span class="cmt">// Or take the button away, if the application has its own full-screen mode.</span>
|
|
3398
|
+
createGrid(el, { maximise: <span class="kw">false</span> });</code></pre>
|
|
3399
|
+
</div>
|
|
3400
|
+
<p class="lead-in">
|
|
3401
|
+
One button in two states rather than two buttons: the icon and its label turn round when the
|
|
3402
|
+
grid is maximised, so the control always describes what it is about to do.
|
|
3403
|
+
</p>
|
|
3404
|
+
<div class="why">
|
|
3405
|
+
<p><strong>The element is moved, not just restyled.</strong> A <code>position: fixed</code>
|
|
3406
|
+
element is positioned against the nearest ancestor carrying a <code>transform</code>,
|
|
3407
|
+
<code>filter</code>, <code>contain</code> or <code>will-change</code> — which is to say any
|
|
3408
|
+
card, any animated panel, any sticky app shell — and against the viewport only when there is
|
|
3409
|
+
no such ancestor. A class alone would therefore fill the window on one page and land in a
|
|
3410
|
+
300px box on the next, and would still be clipped by an <code>overflow: hidden</code> or
|
|
3411
|
+
buried by a stacking context. Reparenting to <code><body></code> removes every ancestor
|
|
3412
|
+
that could do any of that.</p>
|
|
3413
|
+
<p>Coming back is a hidden placeholder left in the element's place, not a remembered parent
|
|
3414
|
+
and index — an index goes stale the moment your application inserts a sibling while the grid
|
|
3415
|
+
is away, and then silently reinserts in the wrong slot. The placeholder also holds the vacated
|
|
3416
|
+
space open at the size the grid had, so the page behind neither reflows nor loses its scroll
|
|
3417
|
+
position while you are looking at the grid.</p>
|
|
3418
|
+
<p>The geometry is written as inline styles and every displaced property is handed back
|
|
3419
|
+
exactly as it was found, because the element being restyled is <em>yours</em> — most often one
|
|
3420
|
+
with an inline <code>height</code> on it, which is the ordinary way a grid gets sized and
|
|
3421
|
+
which nothing but an inline style can beat. While maximised, the element carries
|
|
3422
|
+
<code>.lat-maximised</code> and <code><body></code> carries
|
|
3423
|
+
<code>.lat-maximised-host</code>, as hooks for your own CSS.</p>
|
|
3424
|
+
</div>
|
|
3425
|
+
|
|
3426
|
+
<h2 id="highlighting">Highlighting</h2>
|
|
3427
|
+
<p class="lead-in">
|
|
3428
|
+
One mechanism for two jobs: the flash a changed cell makes, and a marker you paint
|
|
3429
|
+
deliberately.
|
|
3430
|
+
</p>
|
|
3431
|
+
<div class="example">
|
|
3432
|
+
<p class="example__label">On change, and on demand</p>
|
|
3433
|
+
<pre><code>highlightOnChange: { colour: '#ffe08a', duration: 1200 },
|
|
3434
|
+
|
|
3435
|
+
grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
|
|
3436
|
+
grid.highlight({ key: 'r3' }, { colour: '#fdeaea', duration: 0 }); <span class="cmt">// until cleared</span>
|
|
3437
|
+
grid.highlight({ colId: 'margin' },{ colour: '#e7f1fd', duration: 0 });
|
|
3438
|
+
grid.highlight.clear({ key: 'r3' });
|
|
3439
|
+
grid.highlight.clear();</code></pre>
|
|
3440
|
+
</div>
|
|
3441
|
+
<p class="lead-in">
|
|
3442
|
+
Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over
|
|
3443
|
+
it. A highlight belongs to the row rather than the element, so it survives scrolling, sorting
|
|
3444
|
+
and paging.
|
|
3445
|
+
</p>
|
|
3446
|
+
<h2 id="views-guide">Saved views</h2>
|
|
3447
|
+
<p class="lead-in">
|
|
3448
|
+
A view is a named grid state — sort, filters, grouping, column order, widths, visibility.
|
|
3449
|
+
There are two kinds and the picker keeps them apart.
|
|
3450
|
+
</p>
|
|
3451
|
+
<div class="example">
|
|
3452
|
+
<p class="example__label">Views you ship, and views the user saves</p>
|
|
3453
|
+
<pre><code>views: {
|
|
3454
|
+
saved: [
|
|
3455
|
+
{ id: 'escalations', name: 'Escalations',
|
|
3456
|
+
description: 'Escalated circuits, worst SLA first',
|
|
3457
|
+
state: {
|
|
3458
|
+
filters: { col: 'statusId', op: 'eq', value: 4 },
|
|
3459
|
+
sort: [{ col: 'utilisation', dir: 'desc' }],
|
|
3460
|
+
}},
|
|
3461
|
+
{ id: 'commercial', name: 'Commercial', isDefault: true,
|
|
3462
|
+
state: { columns: [{ id: 'notes', hidden: true }] }},
|
|
3463
|
+
],
|
|
3464
|
+
allowSave: true,
|
|
3465
|
+
}</code></pre>
|
|
3466
|
+
</div>
|
|
3467
|
+
<p class="lead-in">
|
|
3468
|
+
Views in <code>saved</code> are <strong>defined views</strong>: part of the application,
|
|
3469
|
+
listed under their own heading, and neither renamable nor deletable — refused by the model as
|
|
3470
|
+
well as hidden in the interface. A view flagged <code>isDefault</code> is applied on load.
|
|
3471
|
+
Everything the user saves sits below, with rename, share, make-default and delete.
|
|
3472
|
+
</p>
|
|
3473
|
+
<div class="why">
|
|
3474
|
+
<p><strong>Applying a view is a destination, not a patch.</strong> A view's state names only
|
|
3475
|
+
the sections it cares about, so applying one resets to the grid's starting state first. Without
|
|
3476
|
+
that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns —
|
|
3477
|
+
the same view giving a different grid depending on what preceded it, which is the one thing a
|
|
3478
|
+
named view must not do.</p>
|
|
3479
|
+
</div>
|
|
3480
|
+
|
|
3481
|
+
<h3>Persisting them</h3>
|
|
3482
|
+
<p class="lead-in">
|
|
3483
|
+
<strong>The grid makes no network calls.</strong> It tells you what happened and you decide
|
|
3484
|
+
what that means.
|
|
3485
|
+
</p>
|
|
3486
|
+
<div class="example">
|
|
3487
|
+
<p class="example__label">To a server</p>
|
|
3488
|
+
<pre><code>grid.on('view:saved', e => api.post('/views', e.view));
|
|
3489
|
+
grid.on('view:renamed', e => api.patch(`/views/${e.view.id}`, { name: e.view.name }));
|
|
3490
|
+
grid.on('view:removed', e => api.delete(`/views/${e.view.id}`));
|
|
3491
|
+
grid.on('view:default', e => api.patch(`/views/${e.view.id}`, { isDefault: true }));</code></pre>
|
|
3492
|
+
</div>
|
|
3493
|
+
<p class="lead-in">
|
|
3494
|
+
Each event carries the one view that moved, so you send a single record rather than diffing
|
|
3495
|
+
two lists. For a synchronous mirror — <code>localStorage</code> — use
|
|
3496
|
+
<code>views.storage</code> instead. Since the grid does not track whether your write landed,
|
|
3497
|
+
catch the failure and call <code>grid.views.reload()</code>.
|
|
3498
|
+
</p>
|
|
3499
|
+
|
|
3500
|
+
<h2 id="history-guide">Undo</h2>
|
|
3501
|
+
<p class="lead-in">
|
|
3502
|
+
Undo covers the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied
|
|
3503
|
+
view and a restore all record an entry — and each carries a label written for a button.
|
|
3504
|
+
</p>
|
|
3505
|
+
<div class="example">
|
|
3506
|
+
<p class="example__label">Naming the action</p>
|
|
3507
|
+
<pre><code>grid.history.peek('undo'); <span class="cmt">// { type: 'sort', label: 'sort by Region' }</span>
|
|
3508
|
+
grid.history.undo();
|
|
3509
|
+
grid.history.list(); <span class="cmt">// the timeline, newest first</span></code></pre>
|
|
3510
|
+
</div>
|
|
3511
|
+
<div class="why">
|
|
3512
|
+
<p>A button that says only "Undo" makes the user press it to find out what it does — and
|
|
3513
|
+
pressing it is the thing they were unsure about. "Undo sort by Region" is decided before the
|
|
3514
|
+
click rather than after.</p>
|
|
3515
|
+
</div>
|
|
3516
|
+
<p class="lead-in">
|
|
3517
|
+
Grouping is by user action, not by internal operation. A multi-cell paste is one entry, not
|
|
3518
|
+
one per cell. An AI plan is one entry however many actions it contains.
|
|
3519
|
+
</p>
|
|
3520
|
+
<div class="example">
|
|
3521
|
+
<p class="example__label">Grouping your own changes</p>
|
|
3522
|
+
<pre><code>grid.history.transaction('apply the quarterly template', () => {
|
|
3523
|
+
grid.sort.set([{ col: 'margin', dir: 'desc' }]);
|
|
3524
|
+
grid.filters.set({ col: 'region', op: 'eq', value: 'EMEA' });
|
|
3525
|
+
grid.columns.hide(['notes', 'mgmtIp']);
|
|
3526
|
+
});
|
|
3527
|
+
<span class="cmt">// one press of undo reverses all three</span></code></pre>
|
|
3528
|
+
</div>
|
|
3529
|
+
|
|
3530
|
+
<h2 id="permissions-guide">Column permissions</h2>
|
|
3531
|
+
<p class="lead-in">
|
|
3532
|
+
Four levels, resolved per column from configuration or a callback. They are not a ladder —
|
|
3533
|
+
reading and writing are independent, so they are the four corners of a 2×2.
|
|
3534
|
+
</p>
|
|
3535
|
+
<div class="table-wrap">
|
|
3536
|
+
<table>
|
|
3537
|
+
<thead><tr><th>Level</th><th>Visible</th><th>Readable</th><th>Editable</th><th>For</th></tr></thead>
|
|
3538
|
+
<tbody>
|
|
3539
|
+
<tr><td class="sig">hidden</td><td>—</td><td>—</td><td>—</td><td class="desc">Absent from the grid, the tool panel, exports, the clipboard, saved state and the filter model.</td></tr>
|
|
3540
|
+
<tr><td class="sig">read</td><td>yes</td><td>yes</td><td>—</td><td class="desc">No editor opens; paste, fill and clear skip it.</td></tr>
|
|
3541
|
+
<tr><td class="sig">writeOnly</td><td>yes</td><td>—</td><td>yes</td><td class="desc">A secret. The cell shows a mask, the editor opens empty.</td></tr>
|
|
3542
|
+
<tr><td class="sig">write</td><td>yes</td><td>yes</td><td>yes</td><td class="desc">The default, so the feature is opt-in.</td></tr>
|
|
3543
|
+
</tbody>
|
|
3544
|
+
</table>
|
|
3545
|
+
</div>
|
|
3546
|
+
<div class="example">
|
|
3547
|
+
<p class="example__label">Every accepted form</p>
|
|
3548
|
+
<pre><code>permissions: 'read' <span class="cmt">// blanket</span>
|
|
3549
|
+
permissions: { salary: 'read', ssn: 'hidden' } <span class="cmt">// '*' sets the default</span>
|
|
3550
|
+
permissions: (column, ctx) =>
|
|
3551
|
+
ctx.context.role === 'admin' ? 'write' : 'read'
|
|
3552
|
+
|
|
3553
|
+
permissions: {
|
|
3554
|
+
default: 'read',
|
|
3555
|
+
columns: { name: 'write' },
|
|
3556
|
+
resolve: (column, ctx) => ctx.context.role === 'admin' ? 'write' : undefined,
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
grid.permissions.setContext({ role: 'clerk' }); <span class="cmt">// re-resolves everything</span></code></pre>
|
|
3560
|
+
</div>
|
|
3561
|
+
<div class="note note--warn">
|
|
3562
|
+
<p><strong>For three of the four this is a usability control, not a security boundary.</strong>
|
|
3563
|
+
Anything the grid can render it has already loaded, and devtools reaches it. Hiding a column
|
|
3564
|
+
removes it from the interface, not from the process — which is worth a great deal for the way
|
|
3565
|
+
data actually leaks, which is an export mailed onward or a shared view carrying a column a
|
|
3566
|
+
colleague should not see. It is worth nothing against someone determined.</p>
|
|
3567
|
+
<p><code>writeOnly</code> is the exception, and the reason it exists. Nothing in the grid needs
|
|
3568
|
+
the value, so your server can send <code>null</code> for that field and the column still
|
|
3569
|
+
works — at which point the secret is genuinely not on the page. Enforce everything else
|
|
3570
|
+
server-side; <code>permittedColumns</code> and <code>permittedExport</code> are pure and
|
|
3571
|
+
dependency-free so the same policy object runs in Node against a request that arrived over
|
|
3572
|
+
the wire.</p>
|
|
3573
|
+
</div>
|
|
3574
|
+
|
|
3575
|
+
<h2 id="diff-guide">Audit mode</h2>
|
|
3576
|
+
<p class="lead-in">
|
|
3577
|
+
Give the grid a prior snapshot and every row reports whether it was added, removed or
|
|
3578
|
+
changed, and which cells moved.
|
|
3579
|
+
</p>
|
|
3580
|
+
<div class="example">
|
|
3581
|
+
<p class="example__label">Before and after</p>
|
|
3582
|
+
<pre><code>diff: { snapshot: lastApprovedVersion },
|
|
3583
|
+
|
|
3584
|
+
grid.diff.summary(); <span class="cmt">// { added, removed, changed, unchanged }</span>
|
|
3585
|
+
grid.diff.statusOf('CIR-100042'); <span class="cmt">// 'changed'</span>
|
|
3586
|
+
grid.diff.changedColumns('CIR-100042');
|
|
3587
|
+
grid.diff.before('CIR-100042', 'capacity');</code></pre>
|
|
3588
|
+
</div>
|
|
3589
|
+
<p class="lead-in">
|
|
3590
|
+
Changed rows get a band down the leading edge and changed cells a tint with the prior value on
|
|
3591
|
+
the cell as <code>data-before</code>. The row band and the cell tint are deliberately different
|
|
3592
|
+
devices: "which rows moved" and "what changed in this row" are different questions and one
|
|
3593
|
+
highlight cannot serve both.
|
|
3594
|
+
</p>
|
|
3595
|
+
|
|
3596
|
+
<h3 id="removed-rows">Showing what was deleted</h3>
|
|
3597
|
+
<div class="example">
|
|
3598
|
+
<p class="example__label">A deletion is a change too</p>
|
|
3599
|
+
<pre><code>diff: { snapshot: lastApprovedVersion, removedRows: 'pinned' } <span class="cmt">// or 'data'</span></code></pre>
|
|
3600
|
+
</div>
|
|
3601
|
+
<div class="why">
|
|
3602
|
+
<p><strong>The row is gone from the data and still in the snapshot.</strong> That is the only
|
|
3603
|
+
place it exists, and it is what these rows are built from — so they carry the values they had
|
|
3604
|
+
when the snapshot was taken, not any current ones.</p>
|
|
3605
|
+
<p><strong>One option answers both questions:</strong> whether to show it, and whether it
|
|
3606
|
+
counts. <code>'pinned'</code> puts it beneath the rows, struck through and dimmed, outside the
|
|
3607
|
+
row set — so <code>rows.count()</code>, exports and selection all pass over it.
|
|
3608
|
+
<code>'data'</code> appends it to the set instead, and all three include it. Omitted, nothing
|
|
3609
|
+
is shown and the grid is the one you already had.</p>
|
|
3610
|
+
<p><strong>Neither sorts or filters it among the live rows.</strong> A removed row's values
|
|
3611
|
+
are yesterday's; ordering them among today's presents two data sets as one, and lets a filter
|
|
3612
|
+
written for current values decide the fate of historical ones. Neither permits an edit either
|
|
3613
|
+
— a write aimed at a removed row is refused and returns <code>0</code>, rather than being
|
|
3614
|
+
counted as applied against a record that is not there.</p>
|
|
3615
|
+
</div>
|
|
3616
|
+
|
|
3617
|
+
<h2 id="ai-guide">Driving the grid with a model</h2>
|
|
3618
|
+
<p class="lead-in">
|
|
3619
|
+
The grid describes its own columns and operators, you send that to whichever model you like,
|
|
3620
|
+
and it validates the reply before anything is applied. It makes no network call and has no
|
|
3621
|
+
default provider.
|
|
3622
|
+
</p>
|
|
3623
|
+
<div class="example">
|
|
3624
|
+
<p class="example__label">The loop</p>
|
|
3625
|
+
<pre><code>ai: {
|
|
3626
|
+
<span class="kw">async</span> ask({ message }) {
|
|
3627
|
+
<span class="kw">const</span> res = <span class="kw">await</span> yourModelClient.complete({
|
|
3628
|
+
model: 'your-model-of-choice', messages: [{ role: 'user', content: message }],
|
|
3629
|
+
});
|
|
3630
|
+
<span class="kw">return</span> res.text;
|
|
3631
|
+
},
|
|
3632
|
+
}</code></pre>
|
|
3633
|
+
</div>
|
|
3634
|
+
<p class="lead-in">
|
|
3635
|
+
That mounts a prompt bar. The user types "EMEA circuits over 500 gigs, biggest first"; the
|
|
3636
|
+
grid composes a message including its schema; your callback returns the model's reply; the
|
|
3637
|
+
grid validates it and shows what it would do in plain English —
|
|
3638
|
+
<em>"Filter Region is EMEA and Capacity is more than 500, sort Capacity descending"</em> —
|
|
3639
|
+
with Apply and Discard.
|
|
3640
|
+
</p>
|
|
3641
|
+
<div class="why">
|
|
3642
|
+
<p><strong>Nothing is executed on trust.</strong> The vocabulary is seven actions —
|
|
3643
|
+
<code>setFilters</code>, <code>setSort</code>, <code>groupBy</code>,
|
|
3644
|
+
<code>showColumns</code>, <code>hideColumns</code>, <code>setQuick</code>,
|
|
3645
|
+
<code>clear</code> — and a reply naming a column that does not exist is rejected with a
|
|
3646
|
+
reason while the valid actions in the same reply are kept. A model cannot be talked into an
|
|
3647
|
+
operation the vocabulary does not contain, because there is nothing else to call.</p>
|
|
3648
|
+
</div>
|
|
3649
|
+
<p class="lead-in">
|
|
3650
|
+
Applying is one undo entry, labelled with what it did. <code>docs/AI-SKILL.md</code> is the
|
|
3651
|
+
reference to hand your model.
|
|
3652
|
+
</p>
|
|
3653
|
+
|
|
3654
|
+
<h2 id="export-guide">Export</h2>
|
|
3655
|
+
<div class="example">
|
|
3656
|
+
<p class="example__label">CSV, Excel, clipboard, print</p>
|
|
3657
|
+
<pre><code>grid.export.csv({ download: true, fileName: 'circuits' });
|
|
3658
|
+
grid.export.excel({ download: true, sheetName: 'Circuits' });
|
|
3659
|
+
grid.export.clipboard({ headers: true, rows: 'range' });
|
|
3660
|
+
grid.export.print();</code></pre>
|
|
3661
|
+
</div>
|
|
3662
|
+
<p class="lead-in">
|
|
3663
|
+
Exports follow what the user is looking at — the current filter, sort and column order,
|
|
3664
|
+
formatted values, and only the columns they may read.
|
|
3665
|
+
</p>
|
|
3666
|
+
|
|
3667
|
+
<h3>On the server</h3>
|
|
3668
|
+
<p class="lead-in">
|
|
3669
|
+
A user asks to export half a million rows. You can ship them all to the browser to be
|
|
3670
|
+
formatted, or write the export server-side and watch it drift from what the grid shows. The
|
|
3671
|
+
headless core is the third option.
|
|
3672
|
+
</p>
|
|
3673
|
+
<div class="example">
|
|
3674
|
+
<p class="example__label">The same code, in Node</p>
|
|
3675
|
+
<pre><code><span class="kw">import</span> { createHeadlessGrid } <span class="kw">from</span> './lattice-core.esm.js';
|
|
3676
|
+
|
|
3677
|
+
<span class="kw">const</span> grid = createHeadlessGrid({ columns, rows: fromDatabase });
|
|
3678
|
+
grid.state.apply(savedView.state);
|
|
3679
|
+
<span class="kw">return</span> grid.export.csv();</code></pre>
|
|
3680
|
+
</div>
|
|
3681
|
+
<div class="why">
|
|
3682
|
+
<p>A saved view is a serialisable state object, so the server applies exactly what the user
|
|
3683
|
+
set up, using exactly the code the browser uses. The £1,234.50 in the file is the £1,234.50 on
|
|
3684
|
+
the screen because it came out of the same formatter. 50,000 rows filtered, sorted and written
|
|
3685
|
+
to CSV takes about 100ms.</p>
|
|
3686
|
+
</div>
|
|
3687
|
+
|
|
3688
|
+
<h2 id="webcomponent-guide">Web component</h2>
|
|
3689
|
+
<p class="lead-in">
|
|
3690
|
+
<code><lattice-grid></code> is the grid as a custom element, shipped as a self-contained
|
|
3691
|
+
module bundle. It exists for pages without a bundler — a Rails, Django or Laravel template
|
|
3692
|
+
that wants a grid without adopting a front-end build.
|
|
3693
|
+
</p>
|
|
3694
|
+
<div class="example">
|
|
3695
|
+
<p class="example__label">The whole integration</p>
|
|
3696
|
+
<pre><code><link rel="stylesheet" href="dist/lattice-grid.min.css">
|
|
3697
|
+
<script type="module" src="dist/modules/webcomponent.esm.min.js"></script>
|
|
3698
|
+
|
|
3699
|
+
<lattice-grid row-key="id"
|
|
3700
|
+
columns='[{"field":"id"},{"field":"city"}]'
|
|
3701
|
+
rows='[{"id":"A","city":"Leeds"},{"id":"B","city":"Cardiff"}]'></lattice-grid></code></pre>
|
|
3702
|
+
</div>
|
|
3703
|
+
<div class="why">
|
|
3704
|
+
<p><strong>Load this or the main bundle, not both.</strong> The module is self-contained — it
|
|
3705
|
+
carries the grid with it, so a page that also loads <code>lattice-grid.esm.js</code>
|
|
3706
|
+
downloads and evaluates the grid twice.</p>
|
|
3707
|
+
</div>
|
|
3708
|
+
|
|
3709
|
+
<div class="example">
|
|
3710
|
+
<p class="example__label">Driven from script</p>
|
|
3711
|
+
<pre><code><span class="cmt">// Structures are properties; scalars are attributes.</span>
|
|
3712
|
+
const el = document.createElement('@toclocoinc/lattice-grid');
|
|
3713
|
+
el.setAttribute('row-key', 'id');
|
|
3714
|
+
el.columns = [{ field: 'id' }, { field: 'charge', type: 'number' }];
|
|
3715
|
+
el.rows = data;
|
|
3716
|
+
document.body.appendChild(el);
|
|
3717
|
+
|
|
3718
|
+
<span class="cmt">// The full imperative API is on `.grid`.</span>
|
|
3719
|
+
el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
|
|
3720
|
+
</div>
|
|
3721
|
+
<div class="why">
|
|
3722
|
+
<p><strong>Assign before appending where you can.</strong> The element builds its grid once,
|
|
3723
|
+
at the end of the task in which it connects, so everything set in that task arrives as one
|
|
3724
|
+
configuration rather than as a series of updates. Setting properties later still works — they
|
|
3725
|
+
go through the live configuration path — but the grid is built empty first and repainted
|
|
3726
|
+
after, which is a visible flash on a large set.</p>
|
|
3727
|
+
<p>Reading <code>.grid</code> forces the build immediately, so the property is never briefly
|
|
3728
|
+
null.</p>
|
|
3729
|
+
</div>
|
|
3730
|
+
|
|
3731
|
+
<div class="example">
|
|
3732
|
+
<p class="example__label">Events</p>
|
|
3733
|
+
<pre><code>el.addEventListener('lattice-cell-changed', (e) => {
|
|
3734
|
+
console.log(e.detail.key, e.detail.colId, e.detail.value);
|
|
3735
|
+
});</code></pre>
|
|
3736
|
+
</div>
|
|
3737
|
+
<div class="why">
|
|
3738
|
+
<p>Every grid event is re-dispatched as a <code>CustomEvent</code> named <code>lattice-</code>
|
|
3739
|
+
plus the grid name with colons hyphenated, so <code>cell:edit:start</code> becomes
|
|
3740
|
+
<code>lattice-cell-edit-start</code>. The payload is <code>event.detail</code>. The prefix is
|
|
3741
|
+
not decoration: the grid emits an event called <code>scroll</code>, and an unprefixed
|
|
3742
|
+
<code>CustomEvent</code> of that name would be indistinguishable from the platform's own.
|
|
3743
|
+
Forwarding is a wildcard subscription, so events added to the grid later appear here with no
|
|
3744
|
+
change to the component.</p>
|
|
3745
|
+
<p><strong>Light DOM, deliberately.</strong> The element renders into itself rather than a
|
|
3746
|
+
shadow root, because the grid's generated decoration rules are injected into
|
|
3747
|
+
<code>document.head</code> and the theme stylesheet is a <code><link></code> the page
|
|
3748
|
+
owns — neither crosses a shadow boundary. A shadowed grid would be structurally correct and
|
|
3749
|
+
completely unstyled, and subtly so: the <code>--lattice-*</code> tokens <em>do</em> inherit
|
|
3750
|
+
through a shadow root, so the colours would arrive while every pill, bar and heat cell stayed
|
|
3751
|
+
bare. Light DOM keeps every documented theming route working unchanged.</p>
|
|
3752
|
+
</div>
|
|
3753
|
+
|
|
3754
|
+
<h2 id="events-guide">Events</h2>
|
|
3755
|
+
<p class="lead-in">
|
|
3756
|
+
One bus, forty-odd events. These are the ones most applications actually use; the
|
|
3757
|
+
<a href="API.html#events">reference</a> lists them all.
|
|
3758
|
+
</p>
|
|
3759
|
+
<div class="table-wrap">
|
|
3760
|
+
<table>
|
|
3761
|
+
<thead><tr><th>Event</th><th>Carries</th><th>Use it for</th></tr></thead>
|
|
3762
|
+
<tbody>
|
|
3763
|
+
<tr><td class="name">ready</td><td class="type">{}</td><td class="desc">First render is done. Fires on a future turn, so you can subscribe on the line after <code>createGrid</code>.</td></tr>
|
|
3764
|
+
<tr><td class="name">cell:changed</td><td class="type">{ row, key, colId, value, oldValue, undo }</td><td class="desc">Persisting an edit. <code>undo</code> distinguishes a rollback from a fresh change.</td></tr>
|
|
3765
|
+
<tr><td class="name">selection:changed</td><td class="type">{ keys }</td><td class="desc">Enabling a bulk action.</td></tr>
|
|
3766
|
+
<tr><td class="name">range:changed</td><td class="type">{ ranges }</td><td class="desc">A status bar showing the sum of what is selected — see <code>selection.summary()</code>.</td></tr>
|
|
3767
|
+
<tr><td class="name">sort:changed / filter:changed</td><td class="type">{ sort } / { filters }</td><td class="desc">Reflecting the view in the URL.</td></tr>
|
|
3768
|
+
<tr><td class="name">history:changed</td><td class="type">{ canUndo, canRedo, undo, redo }</td><td class="desc">Driving your own undo button. Emitted <em>after</em> the entry is pushed, so the label is right.</td></tr>
|
|
3769
|
+
<tr><td class="name">history:applied</td><td class="type">{ direction, step }</td><td class="desc">An action was undone or redone, with which and what. <code>history:changed</code> also fires when a new action is pushed, so it cannot distinguish the two.</td></tr>
|
|
3770
|
+
<tr><td class="name">view:saved / :removed</td><td class="type">{ view, views }</td><td class="desc">Persisting saved views to a server.</td></tr>
|
|
3771
|
+
<tr><td class="name">render:done</td><td class="type">{ first, last }</td><td class="desc">Decorating cells from outside. The cell layer rewrites class names on every paint, so anything added before this is erased.</td></tr>
|
|
3772
|
+
</tbody>
|
|
3773
|
+
</table>
|
|
3774
|
+
</div>
|
|
3775
|
+
|
|
3776
|
+
<h2 id="licensing">Licensing</h2>
|
|
3777
|
+
<p class="lead-in">
|
|
3778
|
+
<strong>There is one Lattice Grid and every copy is feature-identical.</strong> No community
|
|
3779
|
+
edition, no pro tier, no feature held back behind a key. A licence removes the trial
|
|
3780
|
+
watermark; that is the whole of what it does.
|
|
3781
|
+
</p>
|
|
3782
|
+
<p class="lead-in">
|
|
3783
|
+
Free to develop against, licensed to deploy. A grid on <strong>localhost</strong> — or any
|
|
3784
|
+
loopback host — needs no key at all. On any other domain an unlicensed grid still renders
|
|
3785
|
+
everything and carries a small trial watermark linking to latticegrid.dev.
|
|
3786
|
+
</p>
|
|
3787
|
+
<div class="table-wrap">
|
|
3788
|
+
<table>
|
|
3789
|
+
<thead><tr><th>Where it runs</th><th>No key</th><th>Valid key</th></tr></thead>
|
|
3790
|
+
<tbody>
|
|
3791
|
+
<tr><td class="sig">localhost, *.localhost, 127.0.0.0/8, ::1</td><td>everything, no mark</td><td>everything, no mark</td></tr>
|
|
3792
|
+
<tr><td class="sig">any other domain</td><td>everything, trial watermark</td><td>everything, no mark</td></tr>
|
|
3793
|
+
</tbody>
|
|
3794
|
+
</table>
|
|
3795
|
+
</div>
|
|
3796
|
+
<div class="note"><p><code>.local</code>, <code>.internal</code> and private IP ranges are <strong>not</strong> exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.</p></div>
|
|
3797
|
+
<div class="example">
|
|
3798
|
+
<p class="example__label">Installing a key</p>
|
|
3799
|
+
<pre><code>LatticeGrid.setLicence('LG1.…'); <span class="cmt">// your key; setLicense also works</span>
|
|
3800
|
+
grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'</span></code></pre>
|
|
3801
|
+
</div>
|
|
3802
|
+
<p class="lead-in">
|
|
3803
|
+
Call it once, before creating a grid. Setting a key later still works — the watermark comes
|
|
3804
|
+
off and <code>licence:changed</code> fires — but the first frames of the grid will carry it.
|
|
3805
|
+
</p>
|
|
3806
|
+
<p class="lead-in">
|
|
3807
|
+
Keys come from <a href="https://www.latticegrid.dev">latticegrid.dev</a> and are issued per
|
|
3808
|
+
deployment rather than per developer or per seat: name the domains the grid will run on and
|
|
3809
|
+
one key covers every developer, every build and every user on them. A key names the domains
|
|
3810
|
+
it covers as you would expect — <code>*.acme.com</code> matches <code>app.acme.com</code>,
|
|
3811
|
+
<code>a.b.acme.com</code> and <code>acme.com</code> itself.
|
|
3812
|
+
</p>
|
|
3813
|
+
<p class="lead-in">
|
|
3814
|
+
Checking a key needs no network. There is no licence server, no call home, and nothing that
|
|
3815
|
+
can fail at three in the morning — a key carries its own answer and the grid reads it
|
|
3816
|
+
locally, so a grid on an air-gapped network behaves exactly like one on the open internet.
|
|
3817
|
+
</p>
|
|
3818
|
+
<div class="why">
|
|
3819
|
+
<p><strong>Nothing ever refuses to render, and nothing is ever withheld.</strong> An expired
|
|
3820
|
+
key, a wrong domain, a key that will not read — all of them log one warning and show the
|
|
3821
|
+
watermark. Every feature keeps working. The failure to avoid is a customer's production
|
|
3822
|
+
screen going blank because a licence lapsed over a weekend, and a grid that quietly drops a
|
|
3823
|
+
feature is the same failure wearing a disguise.</p>
|
|
3824
|
+
</div>
|
|
3825
|
+
|
|
3826
|
+
<h2 id="recipes">Recipes</h2>
|
|
3827
|
+
|
|
3828
|
+
<h3>Put the view in the URL</h3>
|
|
3829
|
+
<div class="example">
|
|
3830
|
+
<pre><code>grid.on('state:changed', () => {
|
|
3831
|
+
<span class="kw">const</span> encoded = btoa(JSON.stringify(grid.state.get()));
|
|
3832
|
+
history.replaceState(null, '', `?view=${encoded}`);
|
|
3833
|
+
});
|
|
3834
|
+
|
|
3835
|
+
<span class="kw">const</span> saved = new URLSearchParams(location.search).get('view');
|
|
3836
|
+
<span class="kw">if</span> (saved) grid.state.apply(JSON.parse(atob(saved)));</code></pre>
|
|
3837
|
+
</div>
|
|
3838
|
+
|
|
3839
|
+
<h3>Save edits as they happen</h3>
|
|
3840
|
+
<div class="example">
|
|
3841
|
+
<pre><code>grid.on('cell:changed', <span class="kw">async</span> (e) => {
|
|
3842
|
+
<span class="kw">if</span> (e.undo) <span class="kw">return</span>; <span class="cmt">// a rollback, not a new change</span>
|
|
3843
|
+
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#fff3cd', duration: 0 });
|
|
3844
|
+
<span class="kw">try</span> {
|
|
3845
|
+
<span class="kw">await</span> api.patch(`/rows/${e.key}`, { [e.colId]: e.value });
|
|
3846
|
+
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#d4edda', duration: 900 });
|
|
3847
|
+
} <span class="kw">catch</span> {
|
|
3848
|
+
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#f8d7da', duration: 0 });
|
|
3849
|
+
grid.history.undo();
|
|
3850
|
+
}
|
|
3851
|
+
});</code></pre>
|
|
3852
|
+
</div>
|
|
3853
|
+
|
|
3854
|
+
<h3>A read-only grid</h3>
|
|
3855
|
+
<div class="example">
|
|
3856
|
+
<pre><code>createGrid(el, {
|
|
3857
|
+
columns, rows, rowKey: 'id',
|
|
3858
|
+
edit: false,
|
|
3859
|
+
contextMenu: false, <span class="cmt">// the default menu offers Paste, Clear and Fill down</span>
|
|
3860
|
+
columnMenu: false, <span class="cmt">// optional: the header's 3-dot menu</span>
|
|
3861
|
+
selection: { ranges: false },
|
|
3862
|
+
});</code></pre>
|
|
3863
|
+
</div>
|
|
3864
|
+
|
|
3865
|
+
<h3>A dashboard grid, no chrome</h3>
|
|
3866
|
+
<div class="example">
|
|
3867
|
+
<pre><code>createGrid(el, {
|
|
3868
|
+
columns, rows, rowKey: 'id',
|
|
3869
|
+
edit: false, contextMenu: false, selection: 'none',
|
|
3870
|
+
rowHeight: 24, density: 'compact',
|
|
3871
|
+
grandTotalRow: 'bottom',
|
|
3872
|
+
});</code></pre>
|
|
3873
|
+
</div>
|
|
3874
|
+
|
|
3875
|
+
<footer>
|
|
3876
|
+
<p>
|
|
3877
|
+
Lattice Grid 1.5.4 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
|
|
3878
|
+
Written against the shipped source. Where this guide and the code disagree, the code wins —
|
|
3879
|
+
please <a href="https://www.latticegrid.dev">tell us</a>.
|
|
3880
|
+
</p>
|
|
3881
|
+
</footer>
|
|
3882
|
+
</main>
|
|
3883
|
+
</div>
|
|
3884
|
+
|
|
3885
|
+
</body>
|
|
3886
|
+
</html>
|