@toclocoinc/lattice-grid 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/API.html +21 -17
- package/docs/api-detail.html +863 -17
- package/lattice-grid.d.ts +22 -11
- package/lattice-grid.esm.min.js +614 -49
- package/lattice-grid.min.cjs +614 -49
- package/lattice-grid.min.css +1 -1
- package/lattice-grid.min.js +614 -49
- package/modules/ai.d.ts +11 -2
- package/modules/ai.esm.min.js +24 -4
- package/modules/ai.min.cjs +24 -4
- package/modules/ai.min.js +24 -4
- package/modules/angular.d.ts +1 -1
- package/modules/angular.esm.min.js +2 -2
- package/modules/angular.min.cjs +2 -2
- package/modules/angular.min.js +2 -2
- package/modules/chart-alluvial.d.ts +1 -1
- package/modules/chart-alluvial.esm.min.js +1 -1
- package/modules/chart-arc.d.ts +1 -1
- package/modules/chart-arc.esm.min.js +1 -1
- package/modules/chart-bubblemap.d.ts +1 -1
- package/modules/chart-bubblemap.esm.min.js +1 -1
- package/modules/chart-bump.d.ts +1 -1
- package/modules/chart-bump.esm.min.js +1 -1
- package/modules/chart-calendar.d.ts +1 -1
- package/modules/chart-calendar.esm.min.js +1 -1
- package/modules/chart-decomposition.d.ts +1 -1
- package/modules/chart-decomposition.esm.min.js +1 -1
- package/modules/chart-diverging.d.ts +1 -1
- package/modules/chart-diverging.esm.min.js +1 -1
- package/modules/chart-dumbbell.d.ts +1 -1
- package/modules/chart-dumbbell.esm.min.js +1 -1
- package/modules/chart-fan.d.ts +1 -1
- package/modules/chart-fan.esm.min.js +1 -1
- package/modules/chart-hexbin.d.ts +1 -1
- package/modules/chart-hexbin.esm.min.js +1 -1
- package/modules/chart-hexmap.d.ts +1 -1
- package/modules/chart-hexmap.esm.min.js +1 -1
- package/modules/chart-icicle.d.ts +1 -1
- package/modules/chart-icicle.esm.min.js +1 -1
- package/modules/chart-parallel.d.ts +1 -1
- package/modules/chart-parallel.esm.min.js +1 -1
- package/modules/chart-ridgeline.d.ts +1 -1
- package/modules/chart-ridgeline.esm.min.js +1 -1
- package/modules/chart-roc.d.ts +1 -1
- package/modules/chart-roc.esm.min.js +1 -1
- package/modules/chart-slope.d.ts +1 -1
- package/modules/chart-slope.esm.min.js +1 -1
- package/modules/chart-splom.d.ts +1 -1
- package/modules/chart-splom.esm.min.js +1 -1
- package/modules/chart-waffle.d.ts +1 -1
- package/modules/chart-waffle.esm.min.js +1 -1
- package/modules/charts.d.ts +1 -1
- package/modules/charts.esm.min.js +48 -15
- package/modules/charts.min.cjs +48 -15
- package/modules/charts.min.js +48 -15
- package/modules/data-router.d.ts +1 -1
- package/modules/data-router.esm.min.js +4 -4
- package/modules/data-router.min.cjs +4 -4
- package/modules/data-router.min.js +4 -4
- package/modules/devtools.d.ts +1 -1
- package/modules/devtools.esm.min.js +2 -2
- package/modules/devtools.min.cjs +2 -2
- package/modules/devtools.min.js +2 -2
- package/modules/dhtmlx-compat.d.ts +1 -1
- package/modules/dhtmlx-compat.esm.min.js +4 -4
- package/modules/dhtmlx-compat.min.cjs +4 -4
- package/modules/dhtmlx-compat.min.js +4 -4
- package/modules/gantt.d.ts +144 -12
- package/modules/gantt.esm.min.js +1070 -201
- package/modules/gantt.min.cjs +1070 -201
- package/modules/gantt.min.js +1070 -201
- package/modules/htmx.d.ts +1 -1
- package/modules/htmx.esm.min.js +614 -49
- package/modules/htmx.min.cjs +614 -49
- package/modules/htmx.min.js +614 -49
- package/modules/kanban.d.ts +1 -1
- package/modules/kanban.esm.min.js +4 -4
- package/modules/kanban.min.cjs +4 -4
- package/modules/kanban.min.js +4 -4
- package/modules/kpi.d.ts +1 -1
- package/modules/kpi.esm.min.js +19 -6
- package/modules/kpi.min.cjs +19 -6
- package/modules/kpi.min.js +19 -6
- package/modules/layout.d.ts +1 -1
- package/modules/layout.esm.min.js +4 -4
- package/modules/layout.min.cjs +4 -4
- package/modules/layout.min.js +4 -4
- package/modules/mock-socket.d.ts +1 -1
- package/modules/mock-socket.esm.min.js +2 -2
- package/modules/mock-socket.min.cjs +2 -2
- package/modules/mock-socket.min.js +2 -2
- package/modules/react.d.ts +1 -1
- package/modules/react.esm.min.js +2 -2
- package/modules/react.min.cjs +2 -2
- package/modules/react.min.js +2 -2
- package/modules/svelte.d.ts +1 -1
- package/modules/svelte.esm.min.js +2 -2
- package/modules/svelte.min.cjs +2 -2
- package/modules/svelte.min.js +2 -2
- package/modules/tabs.d.ts +1 -1
- package/modules/tabs.esm.min.js +11 -4
- package/modules/tabs.min.cjs +11 -4
- package/modules/tabs.min.js +11 -4
- package/modules/vue.d.ts +1 -1
- package/modules/vue.esm.min.js +2 -2
- package/modules/vue.min.cjs +2 -2
- package/modules/vue.min.js +2 -2
- package/modules/webcomponent.d.ts +1 -1
- package/modules/webcomponent.esm.min.js +614 -49
- package/modules/webcomponent.min.cjs +614 -49
- package/modules/webcomponent.min.js +614 -49
- package/package.json +1 -1
package/docs/api-detail.html
CHANGED
|
@@ -437,7 +437,7 @@
|
|
|
437
437
|
<div class="shell">
|
|
438
438
|
<aside class="rail">
|
|
439
439
|
<p class="rail__brand">Lattice Grid</p>
|
|
440
|
-
<p class="rail__sub">Developer guide · v1.
|
|
440
|
+
<p class="rail__sub">Developer guide · v1.62.0</p>
|
|
441
441
|
<nav>
|
|
442
442
|
<div class="rail__group">
|
|
443
443
|
<span class="rail__label">Start here</span>
|
|
@@ -554,7 +554,7 @@
|
|
|
554
554
|
<a href="API.html">reference tables</a> are the shorter version for when you already know.
|
|
555
555
|
</p>
|
|
556
556
|
<p class="chips">
|
|
557
|
-
<span class="chip">Version 1.
|
|
557
|
+
<span class="chip">Version 1.62.0</span>
|
|
558
558
|
<span class="chip">Zero dependencies</span>
|
|
559
559
|
<span class="chip">No build step</span>
|
|
560
560
|
</p>
|
|
@@ -1279,7 +1279,7 @@ off(); <span class="cmt">// every subscrip
|
|
|
1279
1279
|
</p>
|
|
1280
1280
|
<div class="example">
|
|
1281
1281
|
<p class="example__label">Which version am I running?</p>
|
|
1282
|
-
<pre><code>grid.getVersion(); <span class="cmt">// '1.
|
|
1282
|
+
<pre><code>grid.getVersion(); <span class="cmt">// '1.62.0'</span>
|
|
1283
1283
|
LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
|
|
1284
1284
|
</div>
|
|
1285
1285
|
<p class="lead-in">
|
|
@@ -4308,32 +4308,110 @@ grid.destroy();
|
|
|
4308
4308
|
content over another's.
|
|
4309
4309
|
</p>
|
|
4310
4310
|
|
|
4311
|
-
<h2 id="scrollbars">Always-visible scrollbars</h2>
|
|
4311
|
+
<h2 id="scrollbars">Always-visible and grid-drawn scrollbars</h2>
|
|
4312
4312
|
<p class="lead-in">
|
|
4313
4313
|
Native scrollbars are overlay bars on most platforms now: they fade away when the pointer is
|
|
4314
4314
|
idle, which reads as a cleaner surface but hides the affordance — a touchpad user has no
|
|
4315
|
-
standing sign that a grid scrolls at all. <code>scrollbars
|
|
4316
|
-
|
|
4317
|
-
|
|
4315
|
+
standing sign that a grid scrolls at all. <code>scrollbars</code> takes three modes.
|
|
4316
|
+
<code>'auto'</code> (the default) leaves the platform's own behaviour alone, so an existing
|
|
4317
|
+
grid is unchanged on upgrade. <code>'always'</code> keeps the platform's bar shown whether or
|
|
4318
|
+
not the pointer is over the grid. <code>'custom'</code> replaces it with a bar the grid draws
|
|
4319
|
+
itself.
|
|
4318
4320
|
</p>
|
|
4319
4321
|
<p class="lead-in">
|
|
4320
4322
|
The two axes are separate decisions, so the object form controls each on its own:
|
|
4321
4323
|
<code>{ y: 'always' }</code> pins the vertical bar while the horizontal one stays native, and
|
|
4322
4324
|
<code>{ x: 'always', y: 'always' }</code> is the same as the bare <code>'always'</code>. Under
|
|
4323
|
-
|
|
4324
|
-
when the content fits, and the scrollbar is styled as a classic, always-drawn bar
|
|
4325
|
-
fading overlay (BACKLOG-0000990). The same viewport also suppresses the
|
|
4326
|
-
bounce, so a synchronised grid does not spring at its scroll boundary
|
|
4327
|
-
|
|
4325
|
+
<code>'always'</code> the pinned axis switches to <code>overflow: scroll</code> so its track is
|
|
4326
|
+
present even when the content fits, and the scrollbar is styled as a classic, always-drawn bar
|
|
4327
|
+
rather than a fading overlay (BACKLOG-0000990). The same viewport also suppresses the
|
|
4328
|
+
overscroll rubber-band bounce, so a synchronised grid does not spring at its scroll boundary
|
|
4329
|
+
(BACKLOG-0000991).
|
|
4330
|
+
</p>
|
|
4331
|
+
|
|
4332
|
+
<h3>Why <code>'custom'</code> exists, and when to reach for it</h3>
|
|
4333
|
+
<p class="lead-in">
|
|
4334
|
+
<code>'always'</code> pins the <em>native</em> bar, which leaves two things it cannot fix. Its
|
|
4335
|
+
size is still the platform's — on Chrome/macOS a 7 pixel overlay ribbon, small to
|
|
4336
|
+
see and fiddly to grab. And the rules that style it are a WebKit/Blink extension, so Firefox
|
|
4337
|
+
ignores them: <code>'always'</code> is not the same feature there. <code>'custom'</code> makes
|
|
4338
|
+
the grid draw the bar, so its thickness, colour, minimum thumb length and hit area are the
|
|
4339
|
+
same in every browser on every platform, and all of them are theme tokens a host can raise.
|
|
4340
|
+
The default is a 12 pixel track with a 32 pixel minimum thumb, which is a comfortably
|
|
4341
|
+
larger target than the platform's own (BACKLOG-0001288).
|
|
4342
|
+
</p>
|
|
4343
|
+
<p class="lead-in">
|
|
4344
|
+
<strong>Scrolling is unchanged.</strong> The drawn bars are display and input only: the grid's
|
|
4345
|
+
body still scrolls itself, so the wheel, the trackpad, a finger, the keyboard and
|
|
4346
|
+
<code>scrollToRow</code> behave exactly as they do in the other two modes, with the browser's
|
|
4347
|
+
own momentum and acceleration. The thumb is a readout of the scroll position that happens to
|
|
4348
|
+
be draggable, and it is placed in the same painted frame as the content, so it never trails
|
|
4349
|
+
what is on screen.
|
|
4350
|
+
</p>
|
|
4351
|
+
<p class="lead-in">
|
|
4352
|
+
<strong>Everything a scrollbar does.</strong> The thumb's length is the fraction of the content
|
|
4353
|
+
on screen, never shorter than <code>--lattice-scrollbar-thumb-min</code>; dragging it scrolls;
|
|
4354
|
+
pressing the track above or below it pages by one viewport; and with the bar focused the
|
|
4355
|
+
arrows, <kbd>Page Up</kbd>/<kbd>Page Down</kbd>, <kbd>Home</kbd> and <kbd>End</kbd>
|
|
4356
|
+
all work. Each bar carries <code>role="scrollbar"</code> with
|
|
4357
|
+
<code>aria-orientation</code>, <code>aria-controls</code> and
|
|
4358
|
+
<code>aria-valuenow</code>, and its accessible name comes from the message catalogue, so it is
|
|
4359
|
+
announced in the grid's own language. It is deliberately <em>not</em> in the page's tab order:
|
|
4360
|
+
the grid is a single tab stop and its own arrow keys already scroll.
|
|
4361
|
+
</p>
|
|
4362
|
+
<p class="lead-in">
|
|
4363
|
+
<strong>Two things to know.</strong> The gutter the drawn bar occupies is reserved permanently
|
|
4364
|
+
while the mode is on — the same 12 pixels <code>'always'</code> reserves, so column
|
|
4365
|
+
widths and <code>columns.fit()</code> are unaffected by the change and nothing moves under the
|
|
4366
|
+
pointer as rows come and go. And no browser lets one axis' native scrollbar be hidden on its
|
|
4367
|
+
own, so setting <code>'custom'</code> on one axis hides the native bar on <em>both</em>; the
|
|
4368
|
+
grid warns once if the two axes disagree. Set <code>'custom'</code> on both axes, or on
|
|
4369
|
+
neither.
|
|
4370
|
+
</p>
|
|
4371
|
+
|
|
4372
|
+
<h3>Theming the drawn bar</h3>
|
|
4373
|
+
<div class="table-wrap">
|
|
4374
|
+
<table>
|
|
4375
|
+
<thead><tr><th>Token</th><th>Default</th><th>What it sets</th></tr></thead>
|
|
4376
|
+
<tbody>
|
|
4377
|
+
<tr><td class="name"><code>--lattice-scrollbar-size</code></td><td class="desc"><code>12px</code></td><td class="desc">The thickness of each bar, and the gutter reserved for it. Raise it for a larger grab target.</td></tr>
|
|
4378
|
+
<tr><td class="name"><code>--lattice-scrollbar-thumb-min</code></td><td class="desc"><code>32px</code></td><td class="desc">The shortest the thumb may ever be. Sized strictly in proportion, a very long list gives a thumb of a pixel or two.</td></tr>
|
|
4379
|
+
<tr><td class="name"><code>--lattice-scrollbar-track</code></td><td class="desc"><code>transparent</code></td><td class="desc">The track behind the thumb.</td></tr>
|
|
4380
|
+
<tr><td class="name"><code>--lattice-scrollbar-thumb</code></td><td class="desc"><code>var(--lattice-border-strong)</code></td><td class="desc">The thumb at rest. Derived from the theme's line colour, so dark, high-contrast and terminal themes follow without declaring anything.</td></tr>
|
|
4381
|
+
<tr><td class="name"><code>--lattice-scrollbar-thumb-hover</code></td><td class="desc"><code>var(--lattice-foreground-muted)</code></td><td class="desc">The thumb while the pointer is over its bar.</td></tr>
|
|
4382
|
+
<tr><td class="name"><code>--lattice-scrollbar-thumb-active</code></td><td class="desc"><code>var(--lattice-accent)</code></td><td class="desc">The thumb while it is being dragged, or while the bar has keyboard focus.</td></tr>
|
|
4383
|
+
<tr><td class="name"><code>--lattice-scrollbar-radius</code></td><td class="desc"><code>var(--lattice-radius-pill)</code></td><td class="desc">The thumb's corner radius. Set it to <code>0</code> for a square thumb.</td></tr>
|
|
4384
|
+
</tbody>
|
|
4385
|
+
</table>
|
|
4386
|
+
</div>
|
|
4328
4387
|
|
|
4329
4388
|
<div class="example">
|
|
4330
|
-
<p class="example__label">
|
|
4331
|
-
<pre data-run="js" data-expect="always: always/always; y-only: auto/always" data-covers="config:scrollbars"><code><span class="cmt">// The renderer resolves `scrollbars` to a per-axis mode it stamps on the root.</span>
|
|
4389
|
+
<p class="example__label">The three modes, and one axis at a time</p>
|
|
4390
|
+
<pre data-run="js" data-expect="always: always/always; custom: custom/custom; y-only: auto/always; mixed: custom/auto" data-covers="config:scrollbars"><code><span class="cmt">// The renderer resolves `scrollbars` to a per-axis mode it stamps on the root.</span>
|
|
4332
4391
|
<span class="kw">const</span> { resolveScrollbars } = <span class="kw">await</span> import('../packages/dom/src/renderer/renderer.js');
|
|
4333
4392
|
|
|
4334
|
-
<span class="kw">const</span> both = resolveScrollbars(<span class="str">'always'</span>); <span class="cmt">// pins both
|
|
4393
|
+
<span class="kw">const</span> both = resolveScrollbars(<span class="str">'always'</span>); <span class="cmt">// pins both native bars</span>
|
|
4394
|
+
<span class="kw">const</span> drawn = resolveScrollbars(<span class="str">'custom'</span>); <span class="cmt">// the grid draws both</span>
|
|
4335
4395
|
<span class="kw">const</span> onlyY = resolveScrollbars({ y: <span class="str">'always'</span> }); <span class="cmt">// pins the vertical bar only</span>
|
|
4336
|
-
<span class="kw">
|
|
4396
|
+
<span class="kw">const</span> mixed = resolveScrollbars({ x: <span class="str">'custom'</span> }); <span class="cmt">// draws x, leaves y native (and warns)</span>
|
|
4397
|
+
<span class="kw">return</span> `always: ${both.x}/${both.y}; custom: ${drawn.x}/${drawn.y}; `
|
|
4398
|
+
+ `y-only: ${onlyY.x}/${onlyY.y}; mixed: ${mixed.x}/${mixed.y}`;</code></pre>
|
|
4399
|
+
</div>
|
|
4400
|
+
|
|
4401
|
+
<div class="example">
|
|
4402
|
+
<p class="example__label">A bigger grab target than the platform's</p>
|
|
4403
|
+
<pre><code>createGrid(host, {
|
|
4404
|
+
columns, rows,
|
|
4405
|
+
<span class="cmt">// Always visible, the same in every browser, and drawn by the grid.</span>
|
|
4406
|
+
scrollbars: <span class="str">'custom'</span>,
|
|
4407
|
+
});
|
|
4408
|
+
|
|
4409
|
+
<span class="cmt">/* Wider and darker than the default, in your own stylesheet: */</span>
|
|
4410
|
+
.lattice {
|
|
4411
|
+
--lattice-scrollbar-size: 16px;
|
|
4412
|
+
--lattice-scrollbar-thumb-min: 48px;
|
|
4413
|
+
--lattice-scrollbar-thumb: #8a9199;
|
|
4414
|
+
}</code></pre>
|
|
4337
4415
|
</div>
|
|
4338
4416
|
|
|
4339
4417
|
<h2 id="cards">Cards, lists and feeds</h2>
|
|
@@ -7867,6 +7945,774 @@ grid.import.apply(preview);</code></pre>
|
|
|
7867
7945
|
</table>
|
|
7868
7946
|
</div>
|
|
7869
7947
|
|
|
7948
|
+
<h3 id="kanban-config-examples">Kanban config keys, executed</h3>
|
|
7949
|
+
<p class="lead-in">
|
|
7950
|
+
The table above names each Kanban config key on its own line; these six examples run the
|
|
7951
|
+
board (headless, or over the test DOM where a key's effect is visual) and show what each key
|
|
7952
|
+
actually does, grouped by what they configure together.
|
|
7953
|
+
</p>
|
|
7954
|
+
|
|
7955
|
+
<div class="example">
|
|
7956
|
+
<p class="example__label">Board structure: grouping, order and a done set, executed</p>
|
|
7957
|
+
<pre data-run="js" data-expect="done,todo | 2,1 | points 5 | done 5" data-covers="config:columnProperty config:columnOrder config:pointsProperty config:orderProperty config:doneColumns"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
7958
|
+
const rows = [
|
|
7959
|
+
{ id: 1, status: 'todo', epic: 'E1', points: 3, order: 20 },
|
|
7960
|
+
{ id: 2, status: 'todo', epic: 'E1', points: 2, order: 10 },
|
|
7961
|
+
{ id: 3, status: 'done', epic: 'E1', points: 5, order: 5 },
|
|
7962
|
+
];
|
|
7963
|
+
const b = createKanban(null, {
|
|
7964
|
+
rows, rowKey: 'id',
|
|
7965
|
+
columnProperty: 'status',
|
|
7966
|
+
columns: [{ id: 'todo' }, { id: 'done' }],
|
|
7967
|
+
columnOrder: ['done', 'todo'],
|
|
7968
|
+
pointsProperty: 'points', showPoints: true,
|
|
7969
|
+
orderProperty: 'order',
|
|
7970
|
+
doneColumns: ['done'],
|
|
7971
|
+
});
|
|
7972
|
+
const ids = b.columns().map((c) => c.id);
|
|
7973
|
+
const todoOrder = b.column('todo').cards.map((c) => c.row.id);
|
|
7974
|
+
const donePoints = b.rollup('epic')[0].donePoints;
|
|
7975
|
+
return (`${ids.join(',')} | ${todoOrder.join(',')} | points ${b.points('todo')} | done ${donePoints}`);
|
|
7976
|
+
</code></pre>
|
|
7977
|
+
<p>
|
|
7978
|
+
<code>columnOrder</code> pins <code>done</code> before <code>todo</code> though it was configured
|
|
7979
|
+
second; <code>orderProperty</code> sorts card 2 (order 10) before card 1 (order 20) inside
|
|
7980
|
+
<code>todo</code>; <code>pointsProperty</code> is what makes <code>b.points('todo')</code> a real
|
|
7981
|
+
sum rather than 0 (<code>showPoints</code> only decides whether that sum is drawn into the DOM
|
|
7982
|
+
column header — see the accessible-name example below for that half); and
|
|
7983
|
+
<code>doneColumns: ['done']</code> — set directly, with no column def carrying
|
|
7984
|
+
<code>done: true</code> — is what lets <code>rollup('epic')</code> count card 3's points as done.
|
|
7985
|
+
</p>
|
|
7986
|
+
</div>
|
|
7987
|
+
|
|
7988
|
+
<div class="example">
|
|
7989
|
+
<p class="example__label">Sprint, epic and swimlane selection, executed</p>
|
|
7990
|
+
<pre data-run="js" data-expect="1 | lanes Zed,Ann | empty 0 | sprints S1,S2" data-covers="config:sprintProperty config:sprint config:sprints config:epicProperty config:epic config:swimlaneProperty config:swimlanes config:lanes config:laneOrder"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
7991
|
+
const rows = [
|
|
7992
|
+
{ id: 1, status: 'todo', sprint: 'S1', epic: 'E1', assignee: 'Ann' },
|
|
7993
|
+
{ id: 2, status: 'todo', sprint: 'S2', epic: 'E1', assignee: 'Bob' },
|
|
7994
|
+
{ id: 3, status: 'todo', sprint: 'S1', epic: 'E2', assignee: 'Cy' },
|
|
7995
|
+
];
|
|
7996
|
+
const b = createKanban(null, {
|
|
7997
|
+
rows, rowKey: 'id',
|
|
7998
|
+
columnProperty: 'status',
|
|
7999
|
+
columns: [{ id: 'todo' }],
|
|
8000
|
+
sprintProperty: 'sprint', sprint: 'S1',
|
|
8001
|
+
epicProperty: 'epic', epic: 'E1',
|
|
8002
|
+
swimlaneProperty: 'assignee', swimlanes: true,
|
|
8003
|
+
lanes: [{ id: 'Ann', title: 'Ann T' }, { id: 'Zed', title: 'Empty lane' }],
|
|
8004
|
+
laneOrder: ['Zed', 'Ann'],
|
|
8005
|
+
sprints: [{ id: 'S1', title: 'Sprint 1' }, { id: 'S2', title: 'Sprint 2' }],
|
|
8006
|
+
});
|
|
8007
|
+
const visible = [...b.model.cardsByKey.keys()];
|
|
8008
|
+
const laneIds = b.model.lanes.map((l) => l.id);
|
|
8009
|
+
const emptyLane = b.model.lanes.find((l) => l.id === 'Zed');
|
|
8010
|
+
return (`${visible.join(',')} | lanes ${laneIds.join(',')} | empty ${emptyLane ? emptyLane.count : 'MISSING'} | sprints ${b.sprints().join(',')}`);
|
|
8011
|
+
</code></pre>
|
|
8012
|
+
<p>
|
|
8013
|
+
The board is constructed already narrowed to sprint S1 and epic E1 — no <code>setSprint</code>/
|
|
8014
|
+
<code>setEpic</code> call — so only card 1 is visible. <code>lanes</code> configures an
|
|
8015
|
+
<code>Ann</code> lane and a <code>Zed</code> lane nobody's <code>assignee</code> matches, kept
|
|
8016
|
+
(count 0) because it is configured, exactly as an empty column is; <code>laneOrder</code> pins
|
|
8017
|
+
it first. <code>sprints</code> lists both configured sprints even though only S1 has a visible
|
|
8018
|
+
card.
|
|
8019
|
+
</p>
|
|
8020
|
+
</div>
|
|
8021
|
+
|
|
8022
|
+
<div class="example">
|
|
8023
|
+
<p class="example__label">Card mapping, quick filter and readonly, executed</p>
|
|
8024
|
+
<pre data-run="js" data-expect="OAuth flow | readonly true" data-covers="config:card config:quickFilter config:readonly"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
8025
|
+
const rows = [
|
|
8026
|
+
{ id: 1, status: 'todo', title: 'Login form' },
|
|
8027
|
+
{ id: 2, status: 'todo', title: 'OAuth flow' },
|
|
8028
|
+
];
|
|
8029
|
+
const b = createKanban(null, {
|
|
8030
|
+
rows, rowKey: 'id',
|
|
8031
|
+
columnProperty: 'status',
|
|
8032
|
+
columns: [{ id: 'todo' }],
|
|
8033
|
+
card: { title: 'title' },
|
|
8034
|
+
quickFilter: 'oauth',
|
|
8035
|
+
readonly: { columns: { todo: true } },
|
|
8036
|
+
});
|
|
8037
|
+
const visibleTitles = [...b.model.cardsByKey.values()].map((c) => c.fields.title);
|
|
8038
|
+
const isReadonly = b.readonly({ column: 'todo' });
|
|
8039
|
+
return (`${visibleTitles.join(',')} | readonly ${isReadonly}`);
|
|
8040
|
+
</code></pre>
|
|
8041
|
+
<p>
|
|
8042
|
+
<code>card: { title: 'title' }</code> is what makes <code>c.fields.title</code> readable at all;
|
|
8043
|
+
the initial <code>quickFilter</code> narrows the board to the one matching card before anything
|
|
8044
|
+
renders; and the per-column <code>readonly</code> map reports <code>todo</code> as locked.
|
|
8045
|
+
</p>
|
|
8046
|
+
</div>
|
|
8047
|
+
|
|
8048
|
+
<div class="example">
|
|
8049
|
+
<p class="example__label">Accessible name, localised labels and selection, executed</p>
|
|
8050
|
+
<pre data-run="js" data-expect="Sprint board | selectable-click 0 | grab 'Grabbed, A, Todo, 1/1' | empty 'No cards' | points true" data-covers="config:ariaLabel config:selectable config:labels config:emptyText config:showPoints"><code>const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8051
|
+
const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
8052
|
+
const dom = createTestDom({});
|
|
8053
|
+
const el = dom.document.createElement('div');
|
|
8054
|
+
dom.root.appendChild(el);
|
|
8055
|
+
const b = createKanban(el, {
|
|
8056
|
+
rows: [{ id: 1, status: 'todo', title: 'A', points: 5 }],
|
|
8057
|
+
rowKey: 'id', columnProperty: 'status',
|
|
8058
|
+
columns: [{ id: 'todo' }, { id: 'blocked' }],
|
|
8059
|
+
card: { title: 'title' },
|
|
8060
|
+
ariaLabel: 'Sprint board',
|
|
8061
|
+
emptyText: 'No cards',
|
|
8062
|
+
selectable: false,
|
|
8063
|
+
labels: { grabbed: 'Grabbed' },
|
|
8064
|
+
pointsProperty: 'points', showPoints: true,
|
|
8065
|
+
});
|
|
8066
|
+
const cardEl = el.querySelector('.lat-kanban__card');
|
|
8067
|
+
cardEl.dispatchEvent(new TestEvent('click', {}));
|
|
8068
|
+
const selectedAfterClick = b.selection().length;
|
|
8069
|
+
cardEl.setAttribute('tabindex', '0');
|
|
8070
|
+
cardEl.dispatchEvent(new TestEvent('keydown', { key: ' ' }));
|
|
8071
|
+
const liveText = b.live.textContent;
|
|
8072
|
+
const blockedEmpty = [...el.querySelectorAll('.lat-kanban__column')].find(c => c.dataset.column === 'blocked').querySelector('.lat-kanban__empty').textContent;
|
|
8073
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
8074
|
+
const todoContent = el.querySelector('.lat-kanban__headcontent');
|
|
8075
|
+
const hasPoints = /lat-kanban__points/.test(todoContent.innerHTML);
|
|
8076
|
+
return (`${ariaLabel} | selectable-click ${selectedAfterClick} | grab '${liveText}' | empty '${blockedEmpty}' | points ${hasPoints}`);
|
|
8077
|
+
</code></pre>
|
|
8078
|
+
<p>
|
|
8079
|
+
<code>ariaLabel</code> replaces the default "Board" name; <code>selectable: false</code> means a
|
|
8080
|
+
plain click leaves the selection empty (the keyboard grab is a separate mechanism, unaffected);
|
|
8081
|
+
<code>labels.grabbed</code> supplies the word the live region announces; <code>emptyText</code>
|
|
8082
|
+
is the placeholder shown in the empty <code>blocked</code> column; and <code>showPoints</code>
|
|
8083
|
+
(with <code>pointsProperty</code>) is what draws the points span into the column header — its
|
|
8084
|
+
real job, distinct from <code>pointsProperty</code> alone computing the sum (see the board
|
|
8085
|
+
structure example above, where <code>showPoints</code> is absent and <code>b.points()</code>
|
|
8086
|
+
still works).
|
|
8087
|
+
</p>
|
|
8088
|
+
</div>
|
|
8089
|
+
|
|
8090
|
+
<div class="example">
|
|
8091
|
+
<p class="example__label">Add-card, edit and move lifecycle, plus WIP enforcement, executed</p>
|
|
8092
|
+
<pre data-run="js" data-expect="created Created by onAddCard | edited Renamed | wip-moved 0 | veto-moved 0 | reverted true | events click:a,dbl:a,ctx:a" data-covers="config:addCard config:onAddCard config:onCardEdit config:onBeforeMove config:onCardMove config:onCardClick config:onCardDblClick config:onCardContextMenu config:enforceWip"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
8093
|
+
const seen = [];
|
|
8094
|
+
const b = createKanban(null, {
|
|
8095
|
+
rows: [{ id: 'a', status: 'todo' }, { id: 'b', status: 'doing' }],
|
|
8096
|
+
rowKey: 'id', columnProperty: 'status',
|
|
8097
|
+
columns: [{ id: 'todo' }, { id: 'doing', wipLimit: 1 }, { id: 'blocked' }, { id: 'review' }],
|
|
8098
|
+
card: { title: { field: 'title', edit: true } },
|
|
8099
|
+
addCard: true,
|
|
8100
|
+
onAddCard: (columnId) => ({ id: 'new', status: columnId, title: 'Created by onAddCard' }),
|
|
8101
|
+
onCardEdit: () => true,
|
|
8102
|
+
onBeforeMove: (card, from, to) => to !== 'blocked',
|
|
8103
|
+
onCardMove: (e) => e.keys[0] !== 'a' || e.to !== 'review',
|
|
8104
|
+
onCardClick: (e) => seen.push(`click:${e.card.key}`),
|
|
8105
|
+
onCardDblClick: (e) => seen.push(`dbl:${e.card.key}`),
|
|
8106
|
+
onCardContextMenu: (e) => seen.push(`ctx:${e.card.key}`),
|
|
8107
|
+
enforceWip: true,
|
|
8108
|
+
});
|
|
8109
|
+
|
|
8110
|
+
const newKey = b.addCard('todo');
|
|
8111
|
+
const createdTitle = b.card(newKey).row.title;
|
|
8112
|
+
await b.applyEdit('a', 'title', 'Renamed');
|
|
8113
|
+
const editedTitle = b.card('a').row.title;
|
|
8114
|
+
|
|
8115
|
+
const wipRefused = await b.move('a', 'doing');
|
|
8116
|
+
const vetoRefused = await b.move('a', 'blocked');
|
|
8117
|
+
const revertedMove = await b.move('a', 'review');
|
|
8118
|
+
|
|
8119
|
+
b.fire('card:click', { card: b.card('a') });
|
|
8120
|
+
b.fire('card:dblclick', { card: b.card('a') });
|
|
8121
|
+
b.fire('card:contextmenu', { card: b.card('a') });
|
|
8122
|
+
|
|
8123
|
+
return ([
|
|
8124
|
+
`created ${createdTitle}`,
|
|
8125
|
+
`edited ${editedTitle}`,
|
|
8126
|
+
`wip-moved ${wipRefused.moved.length}`,
|
|
8127
|
+
`veto-moved ${vetoRefused.moved.length}`,
|
|
8128
|
+
`reverted ${revertedMove.reverted}`,
|
|
8129
|
+
`events ${seen.join(',')}`,
|
|
8130
|
+
].join(' | '));
|
|
8131
|
+
</code></pre>
|
|
8132
|
+
<p>
|
|
8133
|
+
<code>addCard</code> opts the column into add-card at all; <code>onAddCard</code> supplies the
|
|
8134
|
+
new row rather than an auto-generated one; <code>onCardEdit</code> confirms the inline edit.
|
|
8135
|
+
<code>enforceWip</code> refuses the move into <code>doing</code> (already at its limit of 1) before
|
|
8136
|
+
<code>onBeforeMove</code> is ever asked; a separate move into <code>blocked</code> is vetoed by
|
|
8137
|
+
<code>onBeforeMove</code> instead; and a move into <code>review</code> — allowed by both — is
|
|
8138
|
+
still reverted because <code>onCardMove</code> rejects it. <code>onCardClick</code>/
|
|
8139
|
+
<code>onCardDblClick</code>/<code>onCardContextMenu</code> each fire from the matching
|
|
8140
|
+
<code>card:*</code> event.
|
|
8141
|
+
</p>
|
|
8142
|
+
</div>
|
|
8143
|
+
|
|
8144
|
+
<div class="example">
|
|
8145
|
+
<p class="example__label">Card aging / SLA: basis, thresholds, callbacks and the tick, executed</p>
|
|
8146
|
+
<pre data-run="js" data-expect="basis-column ok | basis-board breach | ignore-default null | ignore-false breach | hits warn:warn:w,breach:breach:br | age 3d | tick-fired true" data-covers="config:sla config:basis config:createdProperty config:enteredProperty config:ignoreDone config:now config:warn config:breach config:onWarn config:onBreach config:showAge config:useTransitionLog config:tick"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
8147
|
+
const E = 1_700_000_000_000;
|
|
8148
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
8149
|
+
|
|
8150
|
+
// basis comparison: same row data, two boards differing only in `basis`.
|
|
8151
|
+
const rowData = { id: 'x', status: 'doing', enteredAt: E - 1 * DAY, createdAt: E - 10 * DAY };
|
|
8152
|
+
function makeBoard(basis) {
|
|
8153
|
+
return createKanban(null, {
|
|
8154
|
+
rows: [rowData],
|
|
8155
|
+
rowKey: 'id', columnProperty: 'status',
|
|
8156
|
+
columns: [{ id: 'doing' }],
|
|
8157
|
+
flow: false,
|
|
8158
|
+
sla: {
|
|
8159
|
+
basis, enteredProperty: 'enteredAt', createdProperty: 'createdAt',
|
|
8160
|
+
useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 },
|
|
8161
|
+
now: () => E,
|
|
8162
|
+
},
|
|
8163
|
+
});
|
|
8164
|
+
}
|
|
8165
|
+
const byColumn = makeBoard('column').sla.stateFor('x').level; // uses enteredAt (1d) -> ok
|
|
8166
|
+
const byBoard = makeBoard('board').sla.stateFor('x').level; // uses createdAt (10d) -> breach
|
|
8167
|
+
|
|
8168
|
+
// ignoreDone
|
|
8169
|
+
const doneRow = { id: 'd', status: 'done', enteredAt: E - 30 * DAY };
|
|
8170
|
+
const withIgnore = createKanban(null, {
|
|
8171
|
+
rows: [doneRow], rowKey: 'id', columnProperty: 'status',
|
|
8172
|
+
columns: [{ id: 'done', done: true }], flow: false,
|
|
8173
|
+
sla: { enteredProperty: 'enteredAt', useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
|
|
8174
|
+
});
|
|
8175
|
+
const ignoredByDefault = withIgnore.sla.stateFor('d').level;
|
|
8176
|
+
const notIgnored = createKanban(null, {
|
|
8177
|
+
rows: [doneRow], rowKey: 'id', columnProperty: 'status',
|
|
8178
|
+
columns: [{ id: 'done', done: true }], flow: false,
|
|
8179
|
+
sla: { enteredProperty: 'enteredAt', useTransitionLog: false, ignoreDone: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
|
|
8180
|
+
}).sla.stateFor('d').level;
|
|
8181
|
+
|
|
8182
|
+
// onWarn/onBreach + showAge
|
|
8183
|
+
const hits = [];
|
|
8184
|
+
const warnBoard = createKanban(null, {
|
|
8185
|
+
rows: [
|
|
8186
|
+
{ id: 'w', status: 'doing', enteredAt: E - 3 * DAY },
|
|
8187
|
+
{ id: 'br', status: 'doing', enteredAt: E - 6 * DAY },
|
|
8188
|
+
],
|
|
8189
|
+
rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
|
|
8190
|
+
sla: {
|
|
8191
|
+
enteredProperty: 'enteredAt', useTransitionLog: false,
|
|
8192
|
+
warn: { days: 2 }, breach: { days: 5 }, now: () => E, showAge: 'always',
|
|
8193
|
+
onWarn: (level, rows) => hits.push(`warn:${level}:${rows[0].id}`),
|
|
8194
|
+
onBreach: (level, rows) => hits.push(`breach:${level}:${rows[0].id}`),
|
|
8195
|
+
},
|
|
8196
|
+
});
|
|
8197
|
+
const showAgeState = warnBoard.sla.stateFor('w').ageText;
|
|
8198
|
+
|
|
8199
|
+
// tick: a real re-check interval that fires without a manual evaluate()/move.
|
|
8200
|
+
const clock = { t: E };
|
|
8201
|
+
const ticks = [];
|
|
8202
|
+
const tickBoard = createKanban(null, {
|
|
8203
|
+
rows: [{ id: 't', status: 'doing', enteredAt: E }],
|
|
8204
|
+
rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
|
|
8205
|
+
sla: {
|
|
8206
|
+
enteredProperty: 'enteredAt', useTransitionLog: false,
|
|
8207
|
+
breach: { days: 1 }, tick: 15, now: () => clock.t,
|
|
8208
|
+
},
|
|
8209
|
+
});
|
|
8210
|
+
tickBoard.on('card:sla', (e) => ticks.push(e.level));
|
|
8211
|
+
clock.t = E + 2 * DAY; // now past breach, but nobody called evaluate()
|
|
8212
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
8213
|
+
tickBoard.sla.destroy();
|
|
8214
|
+
|
|
8215
|
+
return ([
|
|
8216
|
+
`basis-column ${byColumn}`,
|
|
8217
|
+
`basis-board ${byBoard}`,
|
|
8218
|
+
`ignore-default ${ignoredByDefault}`,
|
|
8219
|
+
`ignore-false ${notIgnored}`,
|
|
8220
|
+
`hits ${hits.join(',')}`,
|
|
8221
|
+
`age ${showAgeState}`,
|
|
8222
|
+
`tick-fired ${ticks.length > 0}`,
|
|
8223
|
+
].join(' | '));
|
|
8224
|
+
</code></pre>
|
|
8225
|
+
<p>
|
|
8226
|
+
The same card (an <code>enteredAt</code> of 1 day and a <code>createdAt</code> of 10 days) is
|
|
8227
|
+
<code>ok</code> under <code>basis: 'column'</code> (which prefers <code>enteredProperty</code>) and
|
|
8228
|
+
<code>breach</code> under <code>basis: 'board'</code> (which prefers <code>createdProperty</code>)
|
|
8229
|
+
— the same <code>warn</code>/<code>breach</code> thresholds, only the basis changed.
|
|
8230
|
+
<code>ignoreDone</code> defaults a done card to no ageing at all (<code>null</code>) and
|
|
8231
|
+
<code>ignoreDone: false</code> ages it anyway. <code>onWarn</code> and <code>onBreach</code> each
|
|
8232
|
+
fire, shaped <code>(level, rows)</code>, for the card that reaches that level; <code>showAge:
|
|
8233
|
+
'always'</code> gives an age chip on a card that is not yet warned. <code>tick</code> re-evaluates
|
|
8234
|
+
on its own timer — the card crosses into breach with no <code>move</code> or manual
|
|
8235
|
+
<code>evaluate()</code> call — and <code>useTransitionLog: false</code> throughout is what keeps
|
|
8236
|
+
every reading on the row timestamps rather than the flow log.
|
|
8237
|
+
</p>
|
|
8238
|
+
</div>
|
|
8239
|
+
|
|
8240
|
+
<div class="example">
|
|
8241
|
+
<p class="example__label">Custom card body, virtualization and a child pop-out, executed</p>
|
|
8242
|
+
<pre data-run="js" data-expect="virtual true | rendered true | custom true | expand-epic true | expand-leaf false" data-covers="config:cardRenderer config:virtualize config:children"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8243
|
+
const { createKanban } = await import('../packages/modules/kanban/index.js');
|
|
8244
|
+
|
|
8245
|
+
const dom = createTestDom({});
|
|
8246
|
+
const el = dom.document.createElement('div');
|
|
8247
|
+
dom.root.appendChild(el);
|
|
8248
|
+
|
|
8249
|
+
const many = [];
|
|
8250
|
+
for (let i = 0; i < 200; i++) many.push({ id: i, status: 'todo', title: `C${i}`, parent: null });
|
|
8251
|
+
many.push({ id: 'e1', status: 'todo', title: 'Epic', parent: null });
|
|
8252
|
+
many.push({ id: 's1', status: 'todo', title: 'Story', parent: 'e1' });
|
|
8253
|
+
|
|
8254
|
+
const b = createKanban(el, {
|
|
8255
|
+
rows: many, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }],
|
|
8256
|
+
card: { title: 'title' },
|
|
8257
|
+
cardRenderer: (card) => `<div class="custom">${card.fields.title}!</div>`,
|
|
8258
|
+
virtualize: { rowHeight: 100, viewport: 500, threshold: 50, overscan: 2 },
|
|
8259
|
+
children: { property: 'parent' },
|
|
8260
|
+
});
|
|
8261
|
+
|
|
8262
|
+
const list = el.querySelector('.lat-kanban__cards');
|
|
8263
|
+
const rendered = list.querySelectorAll('.lat-kanban__card').length;
|
|
8264
|
+
const isVirtual = list.dataset.virtual;
|
|
8265
|
+
const first = list.querySelector('.lat-kanban__card');
|
|
8266
|
+
const customRendered = /class="custom"/.test(first.innerHTML);
|
|
8267
|
+
const canExpandEpic = b.canExpand(b.card('e1'));
|
|
8268
|
+
const canExpandLeaf = b.canExpand(b.card('s1'));
|
|
8269
|
+
|
|
8270
|
+
return ([
|
|
8271
|
+
`virtual ${isVirtual}`,
|
|
8272
|
+
`rendered ${rendered > 0 && rendered < 202}`,
|
|
8273
|
+
`custom ${customRendered}`,
|
|
8274
|
+
`expand-epic ${canExpandEpic}`,
|
|
8275
|
+
`expand-leaf ${canExpandLeaf}`,
|
|
8276
|
+
].join(' | '));
|
|
8277
|
+
</code></pre>
|
|
8278
|
+
<p>
|
|
8279
|
+
202 cards in one column, but <code>virtualize</code> renders only a scroll window of them;
|
|
8280
|
+
<code>cardRenderer</code> owns the whole card body, replacing the default template; and
|
|
8281
|
+
<code>children: { property: 'parent' }</code> is what makes the epic card expandable
|
|
8282
|
+
(<code>canExpand</code> true) while a plain story is not.
|
|
8283
|
+
</p>
|
|
8284
|
+
</div>
|
|
8285
|
+
|
|
8286
|
+
<h3 id="layout-config-examples">Layout config keys, executed</h3>
|
|
8287
|
+
<p class="lead-in">
|
|
8288
|
+
The reference table names each Layout config key on its own line; these five examples run a
|
|
8289
|
+
real dashboard over the test DOM and show what each key actually does, grouped by what they
|
|
8290
|
+
configure together.
|
|
8291
|
+
</p>
|
|
8292
|
+
|
|
8293
|
+
<div class="example">
|
|
8294
|
+
<p class="example__label">Geometry: overflow axes, track size, gap and padding, executed</p>
|
|
8295
|
+
<pre data-run="js" data-expect="auto hidden | repeat(6, 200px) | gap 20px | padding 15px" data-covers="config:overflowX config:overflowY config:columnWidth config:gap config:padding config:compact"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8296
|
+
const { createLayout } = await import('../packages/modules/layout/index.js');
|
|
8297
|
+
const { document, root } = createTestDom({ width: 600, height: 400 });
|
|
8298
|
+
const host = document.createElement('div');
|
|
8299
|
+
root.appendChild(host);
|
|
8300
|
+
const layout = createLayout(host, {
|
|
8301
|
+
columns: 6, rows: 4, overflowX: 'scroll', overflowY: 'static', columnWidth: '200px',
|
|
8302
|
+
gap: '20px', padding: '15px', compact: 'none',
|
|
8303
|
+
windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 2, ySize: 1 }],
|
|
8304
|
+
});
|
|
8305
|
+
const canvas = host.querySelector('.lat-layout__canvas');
|
|
8306
|
+
const viewport = host.querySelector('.lat-layout__viewport');
|
|
8307
|
+
return (`${viewport.style.overflowX} ${viewport.style.overflowY} | ${canvas.style.gridTemplateColumns} | gap ${canvas.style.gap} | padding ${layout.payload('a').style.padding}`);
|
|
8308
|
+
</code></pre>
|
|
8309
|
+
<p>
|
|
8310
|
+
<code>overflowX: 'scroll'</code> is the axis that scrolls; <code>overflowY</code> stays
|
|
8311
|
+
<code>'static'</code> independently; <code>columnWidth</code> is the fixed track a scrolling
|
|
8312
|
+
axis repeats; and <code>gap</code>/<code>padding</code> reach the DOM as the real CSS lengths
|
|
8313
|
+
configured, not their 8px/5px built-in fallbacks. <code>compact: 'none'</code> is what a
|
|
8314
|
+
single-window layout cannot show on its own — see the before-events example below for
|
|
8315
|
+
<code>compact</code>'s effect on a move.
|
|
8316
|
+
</p>
|
|
8317
|
+
</div>
|
|
8318
|
+
|
|
8319
|
+
<div class="example">
|
|
8320
|
+
<p class="example__label">Layout-level interactivity defaults, executed</p>
|
|
8321
|
+
<pre data-run="js" data-expect="close true | grip true | opted-out-grip false | maximise true | minimise true" data-covers="config:closable config:movable config:resizable config:maximisable config:minimisable"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8322
|
+
const { createLayout } = await import('../packages/modules/layout/index.js');
|
|
8323
|
+
const { document, root } = createTestDom({ width: 600, height: 400 });
|
|
8324
|
+
const host = document.createElement('div');
|
|
8325
|
+
root.appendChild(host);
|
|
8326
|
+
const layout = createLayout(host, {
|
|
8327
|
+
columns: 3, rows: 1,
|
|
8328
|
+
closable: true, movable: true, resizable: true, maximisable: true, minimisable: true,
|
|
8329
|
+
windows: [
|
|
8330
|
+
{ id: 'bare', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
|
|
8331
|
+
{ id: 'opted-out', xPos: 2, yPos: 1, xSize: 1, ySize: 1, movable: false },
|
|
8332
|
+
],
|
|
8333
|
+
});
|
|
8334
|
+
const frame = (id) => host.querySelector(`[data-window-id="${id}"]`);
|
|
8335
|
+
const bareHasClose = !!frame('bare').querySelector('.lat-layout__close');
|
|
8336
|
+
const bareHasGrip = !!frame('bare').querySelector('.lat-layout__grip');
|
|
8337
|
+
const optedOutHasGrip = !!frame('opted-out').querySelector('.lat-layout__grip');
|
|
8338
|
+
const canMaximise = layout.maximise('bare');
|
|
8339
|
+
const canMinimise = layout.minimise('bare');
|
|
8340
|
+
return (`close ${bareHasClose} | grip ${bareHasGrip} | opted-out-grip ${optedOutHasGrip} | maximise ${canMaximise} | minimise ${canMinimise}`);
|
|
8341
|
+
</code></pre>
|
|
8342
|
+
<p>
|
|
8343
|
+
Every layout-level default (<code>closable</code>, <code>movable</code>, <code>resizable</code>,
|
|
8344
|
+
<code>maximisable</code>, <code>minimisable</code>) applies to <code>bare</code>, which declares
|
|
8345
|
+
none of its own; <code>opted-out</code>'s own <code>movable: false</code> beats the layout-level
|
|
8346
|
+
<code>true</code>, which is why it renders no grip. <code>maximisable</code>/<code>minimisable</code>
|
|
8347
|
+
are what let <code>maximise()</code>/<code>minimise()</code> succeed on a window that never
|
|
8348
|
+
declared either itself.
|
|
8349
|
+
</p>
|
|
8350
|
+
</div>
|
|
8351
|
+
|
|
8352
|
+
<div class="example">
|
|
8353
|
+
<p class="example__label">The window list and a saved arrangement applied at mount, executed</p>
|
|
8354
|
+
<pre data-run="js" data-expect="a,b | a@3,2 | b@2,1" data-covers="config:windows config:layout"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8355
|
+
const { createLayout } = await import('../packages/modules/layout/index.js');
|
|
8356
|
+
const { document, root } = createTestDom({ width: 600, height: 400 });
|
|
8357
|
+
const host = document.createElement('div');
|
|
8358
|
+
root.appendChild(host);
|
|
8359
|
+
const layout = createLayout(host, {
|
|
8360
|
+
columns: 4, rows: 4, compact: 'none',
|
|
8361
|
+
windows: [
|
|
8362
|
+
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
|
|
8363
|
+
{ id: 'b', xPos: 2, yPos: 1, xSize: 1, ySize: 1 },
|
|
8364
|
+
],
|
|
8365
|
+
layout: { columns: 4, rows: 4, windows: [{ id: 'a', xPos: 3, yPos: 2, xSize: 1, ySize: 1 }] },
|
|
8366
|
+
});
|
|
8367
|
+
const ids = layout.windows();
|
|
8368
|
+
const snap = layout.getLayout();
|
|
8369
|
+
const aPlaced = snap.windows.find((w) => w.id === 'a');
|
|
8370
|
+
const bPlaced = snap.windows.find((w) => w.id === 'b');
|
|
8371
|
+
return (`${ids.join(',')} | a@${aPlaced.xPos},${aPlaced.yPos} | b@${bPlaced.xPos},${bPlaced.yPos}`);
|
|
8372
|
+
</code></pre>
|
|
8373
|
+
<p>
|
|
8374
|
+
<code>windows</code> declares two windows, <code>a</code> at column 1 and <code>b</code> at
|
|
8375
|
+
column 2; the mount-time <code>layout</code> arrangement then moves <code>a</code> to (3, 2),
|
|
8376
|
+
overriding its own declared placement, while <code>b</code> — not named in <code>layout</code> —
|
|
8377
|
+
stays exactly where <code>windows</code> put it.
|
|
8378
|
+
</p>
|
|
8379
|
+
</div>
|
|
8380
|
+
|
|
8381
|
+
<div class="example">
|
|
8382
|
+
<p class="example__label">Move and resize before-events, independently gated and paired with their cancellations, executed</p>
|
|
8383
|
+
<pre data-run="js" data-expect="moved true/false to xPos 2 | resized true/false to xSize 2 | cancelled-move pinned | cancelled-resize too-wide | moved-events 1 | resized-events 1" data-covers="config:onWindowMoved config:onWindowResized config:onBeforeWindowMove config:onWindowMoveCancelled config:onBeforeWindowResize config:onWindowResizeCancelled"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8384
|
+
const { createLayout } = await import('../packages/modules/layout/index.js');
|
|
8385
|
+
const { document, root } = createTestDom({ width: 600, height: 400 });
|
|
8386
|
+
const host = document.createElement('div');
|
|
8387
|
+
root.appendChild(host);
|
|
8388
|
+
const moved = [];
|
|
8389
|
+
const resized = [];
|
|
8390
|
+
const cancelledMove = [];
|
|
8391
|
+
const cancelledResize = [];
|
|
8392
|
+
const layout = createLayout(host, {
|
|
8393
|
+
columns: 4, rows: 4, compact: 'none',
|
|
8394
|
+
windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 }],
|
|
8395
|
+
onWindowMoved: (e) => moved.push(e),
|
|
8396
|
+
onWindowResized: (e) => resized.push([e.id, e.xSize, e.ySize]),
|
|
8397
|
+
onBeforeWindowMove: (e) => (e.to.xPos === 4 ? e.preventDefault('pinned') : true),
|
|
8398
|
+
onWindowMoveCancelled: (e) => cancelledMove.push(e.reason),
|
|
8399
|
+
onBeforeWindowResize: (e) => (e.to.xSize === 3 ? e.preventDefault('too-wide') : true),
|
|
8400
|
+
onWindowResizeCancelled: (e) => cancelledResize.push(e.reason),
|
|
8401
|
+
});
|
|
8402
|
+
const okMove = await layout.move('a', { xPos: 2 });
|
|
8403
|
+
const vetoedMove = await layout.move('a', { xPos: 4 });
|
|
8404
|
+
const okResize = await layout.move('a', { xSize: 2 });
|
|
8405
|
+
const vetoedResize = await layout.move('a', { xSize: 3 });
|
|
8406
|
+
return (`moved ${okMove}/${vetoedMove} to xPos ${layout.getLayout().windows[0].xPos} | resized ${okResize}/${vetoedResize} to xSize ${layout.getLayout().windows[0].xSize} | cancelled-move ${cancelledMove.join(',')} | cancelled-resize ${cancelledResize.join(',')} | moved-events ${moved.length} | resized-events ${resized.length}`);
|
|
8407
|
+
</code></pre>
|
|
8408
|
+
<p>
|
|
8409
|
+
<code>move(id, to)</code> resolves to a resize or a move from what changed. The first move and
|
|
8410
|
+
the first resize both succeed and fire <code>onWindowMoved</code>/<code>onWindowResized</code>
|
|
8411
|
+
once each; the second of each is vetoed by its own before-hook — <code>onBeforeWindowMove</code>
|
|
8412
|
+
never sees the resize and <code>onBeforeWindowResize</code> never sees the move — firing
|
|
8413
|
+
<code>onWindowMoveCancelled</code>/<code>onWindowResizeCancelled</code> with the veto's reason
|
|
8414
|
+
and leaving the window exactly where it was.
|
|
8415
|
+
</p>
|
|
8416
|
+
</div>
|
|
8417
|
+
|
|
8418
|
+
<div class="example">
|
|
8419
|
+
<p class="example__label">Close before-event and layout:changed, executed</p>
|
|
8420
|
+
<pre data-run="js" data-expect="closed true/false | ids a | cancelled unsaved | changed-causes close | remaining locked" data-covers="config:onBeforeWindowClose config:onWindowCloseCancelled config:onWindowClosed config:onLayoutChanged"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8421
|
+
const { createLayout } = await import('../packages/modules/layout/index.js');
|
|
8422
|
+
const { document, root } = createTestDom({ width: 600, height: 400 });
|
|
8423
|
+
const host = document.createElement('div');
|
|
8424
|
+
root.appendChild(host);
|
|
8425
|
+
const closed = [];
|
|
8426
|
+
const cancelledClose = [];
|
|
8427
|
+
const changed = [];
|
|
8428
|
+
const layout = createLayout(host, {
|
|
8429
|
+
columns: 4, rows: 4, compact: 'none',
|
|
8430
|
+
windows: [
|
|
8431
|
+
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1, closable: true },
|
|
8432
|
+
{ id: 'locked', xPos: 2, yPos: 1, xSize: 1, ySize: 1, closable: true },
|
|
8433
|
+
],
|
|
8434
|
+
onWindowClosed: (e) => closed.push(e.id),
|
|
8435
|
+
onBeforeWindowClose: (e) => (e.id === 'locked' ? e.preventDefault('unsaved') : true),
|
|
8436
|
+
onWindowCloseCancelled: (e) => cancelledClose.push(e.reason),
|
|
8437
|
+
onLayoutChanged: (e) => changed.push(e.cause),
|
|
8438
|
+
});
|
|
8439
|
+
const okClose = await layout.close('a');
|
|
8440
|
+
const vetoedClose = await layout.close('locked');
|
|
8441
|
+
return (`closed ${okClose}/${vetoedClose} | ids ${closed.join(',')} | cancelled ${cancelledClose.join(',')} | changed-causes ${changed.join(',')} | remaining ${layout.windows().join(',')}`);
|
|
8442
|
+
</code></pre>
|
|
8443
|
+
<p>
|
|
8444
|
+
Closing <code>a</code> succeeds, firing <code>onWindowClosed</code> and <code>onLayoutChanged</code>
|
|
8445
|
+
(cause <code>'close'</code>); closing <code>locked</code> is vetoed by <code>onBeforeWindowClose</code>,
|
|
8446
|
+
firing <code>onWindowCloseCancelled</code> with the reason instead, and it survives in
|
|
8447
|
+
<code>windows()</code>.
|
|
8448
|
+
</p>
|
|
8449
|
+
</div>
|
|
8450
|
+
|
|
8451
|
+
<h3 id="ai-config-examples">AI config keys, executed</h3>
|
|
8452
|
+
<p class="lead-in">
|
|
8453
|
+
The reference table names each AI config key on its own line; these three examples run a
|
|
8454
|
+
real controller (a MOCK <code>ask()</code> throughout — never a real provider) and show
|
|
8455
|
+
what each key actually does.
|
|
8456
|
+
</p>
|
|
8457
|
+
|
|
8458
|
+
<div class="example">
|
|
8459
|
+
<p class="example__label">The insights panel: element, tools, enable, maxColumns, reconcile, onNarrative, executed</p>
|
|
8460
|
+
<pre data-run="js" data-expect="There are 2 rows. Confidence 900%. | narrated 1 | flagged 900% | panel true | facts 12/34" data-covers="config:ask config:element config:tools config:enable config:maxColumns config:reconcile config:onNarrative"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8461
|
+
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
|
|
8462
|
+
const { createAI } = await import('../packages/modules/ai/index.js');
|
|
8463
|
+
const dom = createTestDom({});
|
|
8464
|
+
const el = dom.document.createElement('div');
|
|
8465
|
+
const grid = createHeadlessGrid({
|
|
8466
|
+
rowKey: 'id',
|
|
8467
|
+
columns: [{ field: 'id' }, { field: 'a' }, { field: 'b' }],
|
|
8468
|
+
rows: [{ id: 1, a: 1, b: 2 }, { id: 2, a: 3, b: 4 }],
|
|
8469
|
+
});
|
|
8470
|
+
const narrated = [];
|
|
8471
|
+
const ai = createAI(grid, {
|
|
8472
|
+
ask: async () => ({ text: 'There are 2 rows. Confidence 900%.' }),
|
|
8473
|
+
element: el,
|
|
8474
|
+
tools: false,
|
|
8475
|
+
maxColumns: 1,
|
|
8476
|
+
reconcile: 'flag',
|
|
8477
|
+
enable: ['narrative'],
|
|
8478
|
+
onNarrative: (r) => narrated.push(r.text),
|
|
8479
|
+
});
|
|
8480
|
+
ai.insights();
|
|
8481
|
+
const result = await ai.explain({ kind: 'view' });
|
|
8482
|
+
const cappedFacts = ai.facts({ kind: 'view' }).facts.length;
|
|
8483
|
+
const uncapped = createAI(grid, { ask: async () => ({ text: '' }) }).facts({ kind: 'view' }).facts.length;
|
|
8484
|
+
return (`${result.text} | narrated ${narrated.length} | flagged ${result.flagged.join(',')} | panel ${!!el.querySelector('.lat-ai__btn')} | facts ${cappedFacts}/${uncapped}`);
|
|
8485
|
+
</code></pre>
|
|
8486
|
+
<p>
|
|
8487
|
+
<code>element</code> + <code>tools: false</code> (packet mode, no function-calling) are what
|
|
8488
|
+
make <code>ai.insights()</code> mount a real panel button; <code>enable: ['narrative']</code>
|
|
8489
|
+
is what lets that mounting succeed at all — leaving it out of <code>enable</code> would
|
|
8490
|
+
return with no panel. <code>enable</code> gates only the three DOM-mounting convenience
|
|
8491
|
+
methods (<code>insights()</code>, <code>askBar()</code>, <code>actorBar()</code>): the
|
|
8492
|
+
<code>ai.explain()</code> call right below still runs and returns a result even though
|
|
8493
|
+
<code>'insights'</code>/<code>'query'</code>/<code>'ask'</code>/<code>'actor'</code> are absent
|
|
8494
|
+
from this <code>enable</code> list — the programmatic API is never gated, so a host that
|
|
8495
|
+
wants no AI surface at all simply never calls these methods. <code>reconcile: 'flag'</code>
|
|
8496
|
+
keeps the ungrounded "900%" in the text
|
|
8497
|
+
(the default strips it instead) while still reporting it in <code>flagged</code>;
|
|
8498
|
+
<code>onNarrative</code> fires with the same result <code>explain()</code> returns;
|
|
8499
|
+
<code>maxColumns: 1</code> is why the capped run gathers 12 facts against 34 uncapped.
|
|
8500
|
+
</p>
|
|
8501
|
+
</div>
|
|
8502
|
+
|
|
8503
|
+
<div class="example">
|
|
8504
|
+
<p class="example__label">Ask-your-data: autoApply, schemaOptions, redact, router, onQuery, onError, executed</p>
|
|
8505
|
+
<pre data-run="js" data-expect="queried true | schema-cols id,region | chart 1,3 | errors 0" data-covers="config:autoApply config:schemaOptions config:redact config:router config:onQuery config:onError"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
|
|
8506
|
+
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
|
|
8507
|
+
const { createAI } = await import('../packages/modules/ai/index.js');
|
|
8508
|
+
|
|
8509
|
+
const grid = createHeadlessGrid({
|
|
8510
|
+
rowKey: 'id',
|
|
8511
|
+
columns: [{ field: 'id' }, { field: 'region' }, { field: 'ssn' }],
|
|
8512
|
+
rows: [
|
|
8513
|
+
{ id: 1, region: 'EMEA', ssn: 'a' },
|
|
8514
|
+
{ id: 2, region: 'AMER', ssn: 'b' },
|
|
8515
|
+
{ id: 3, region: 'EMEA', ssn: 'c' },
|
|
8516
|
+
],
|
|
8517
|
+
});
|
|
8518
|
+
const chart = createHeadlessGrid({ rowKey: 'id', columns: [{ field: 'id' }, { field: 'region' }] });
|
|
8519
|
+
const router = createDataRouter({ rowKey: 'id', overlap: true });
|
|
8520
|
+
router.attach(chart, () => true);
|
|
8521
|
+
|
|
8522
|
+
const errors = [];
|
|
8523
|
+
const queried = [];
|
|
8524
|
+
let capturedSchema = null;
|
|
8525
|
+
const ask = async (req) => {
|
|
8526
|
+
capturedSchema = req.schema;
|
|
8527
|
+
return { actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] };
|
|
8528
|
+
};
|
|
8529
|
+
const ai = createAI(grid, {
|
|
8530
|
+
ask, router, autoApply: true, schemaOptions: { maxColumns: 2 }, redact: ['ssn'],
|
|
8531
|
+
onQuery: (r) => queried.push(r.ok), onError: (e) => errors.push(e),
|
|
8532
|
+
});
|
|
8533
|
+
await ai.query('EMEA only');
|
|
8534
|
+
const chartIds = [];
|
|
8535
|
+
for (let i = 0; i < chart.rows.count(); i++) chartIds.push(chart.rows.get(i).key);
|
|
8536
|
+
return (`queried ${queried.join(',')} | schema-cols ${capturedSchema.columns.map((c) => c.id).join(',')} | chart ${chartIds.sort().join(',')} | errors ${errors.length}`);
|
|
8537
|
+
</code></pre>
|
|
8538
|
+
<p>
|
|
8539
|
+
<code>schemaOptions: { maxColumns: 2 }</code> is why the model is shown only
|
|
8540
|
+
<code>id,region</code> — <code>ssn</code> is also stripped by <code>redact</code>, so it
|
|
8541
|
+
never reaches the schema either way; <code>autoApply</code> runs the safe read the moment it
|
|
8542
|
+
resolves, with no confirm step; <code>router</code>, configured once here rather than passed
|
|
8543
|
+
to every call, is what fans the answer to the chart with no <code>opts.router</code> anywhere
|
|
8544
|
+
in this example; <code>onQuery</code> fires with the result, and <code>onError</code> is wired
|
|
8545
|
+
but silent because nothing failed.
|
|
8546
|
+
</p>
|
|
8547
|
+
</div>
|
|
8548
|
+
|
|
8549
|
+
<div class="example">
|
|
8550
|
+
<p class="example__label">The governed actor: onProposal, executed</p>
|
|
8551
|
+
<pre data-run="js" data-expect="proposals true | diff old 10 new 50 | applied 1 | score-now 50" data-covers="config:onProposal"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
|
|
8552
|
+
const { createAI } = await import('../packages/modules/ai/index.js');
|
|
8553
|
+
|
|
8554
|
+
const grid = createHeadlessGrid({
|
|
8555
|
+
rowKey: 'id',
|
|
8556
|
+
columns: [{ field: 'id' }, { field: 'name' }, { field: 'score', type: 'number', edit: { enabled: true } }],
|
|
8557
|
+
rows: [{ id: 1, name: 'Alpha', score: 10 }],
|
|
8558
|
+
});
|
|
8559
|
+
const proposals = [];
|
|
8560
|
+
const ask = async () => ({ structured: { edits: [{ match: 'Alpha', column: 'score', value: 50 }] } });
|
|
8561
|
+
const ai = createAI(grid, { ask, onProposal: (r) => proposals.push(r.ok) });
|
|
8562
|
+
const result = await ai.propose('set Alpha score to 50');
|
|
8563
|
+
const report = await ai.applyProposal(result);
|
|
8564
|
+
return (`proposals ${proposals.join(',')} | diff ${result.diff.map((d) => `old ${d.oldValue} new ${d.newValue}`).join(',')} | applied ${report.applied} | score-now ${grid.rows.byKey('1').data.score}`);
|
|
8565
|
+
</code></pre>
|
|
8566
|
+
<p>
|
|
8567
|
+
<code>onProposal</code> fires with the built diff before any approval; <code>applyProposal</code>
|
|
8568
|
+
is the separate human-approval step that actually writes, through the grid's own gated edit
|
|
8569
|
+
path.
|
|
8570
|
+
</p>
|
|
8571
|
+
</div>
|
|
8572
|
+
|
|
8573
|
+
<h3 id="kpi-config-examples">KPI config keys, executed</h3>
|
|
8574
|
+
<p class="lead-in">
|
|
8575
|
+
Three examples covering the eleven KPI config keys not already demonstrated elsewhere in this
|
|
8576
|
+
guide.
|
|
8577
|
+
</p>
|
|
8578
|
+
|
|
8579
|
+
<div class="example">
|
|
8580
|
+
<p class="example__label">The tile hierarchy: tiles, an explicit separator, expanded, onNodeToggle, executed</p>
|
|
8581
|
+
<pre data-run="js" data-expect="compute,network | tiles 3 | toggled /compute:false" data-covers="config:tiles config:separator config:expanded config:onNodeToggle"><code>const { createKPI } = await import('../packages/modules/kpi/index.js');
|
|
8582
|
+
|
|
8583
|
+
const HOSTS = [{ id: 1, cpu: 91, mem: 40, lat: 12 }, { id: 2, cpu: 30, mem: 55, lat: 40 }];
|
|
8584
|
+
const toggled = [];
|
|
8585
|
+
const kpi = createKPI(null, {
|
|
8586
|
+
rows: HOSTS, rowKey: 'id',
|
|
8587
|
+
tree: { separator: '/', expanded: true },
|
|
8588
|
+
tiles: [
|
|
8589
|
+
{ id: 'compute/cpu', label: 'cpu', aggregation: 'max', field: 'cpu' },
|
|
8590
|
+
{ id: 'compute/memory', label: 'memory', aggregation: 'avg', field: 'mem' },
|
|
8591
|
+
{ id: 'network/latency', label: 'latency', aggregation: 'max', field: 'lat' },
|
|
8592
|
+
],
|
|
8593
|
+
onNodeToggle: (e) => toggled.push([e.key, e.expanded]),
|
|
8594
|
+
});
|
|
8595
|
+
const topLevel = kpi.nodes().map((n) => n.label).sort();
|
|
8596
|
+
const key = kpi.nodes()[0].key;
|
|
8597
|
+
kpi.toggle(key);
|
|
8598
|
+
return (`${topLevel.join(',')} | tiles ${kpi.model.tiles.length} | toggled ${toggled.map((t) => t.join(':')).join(',')}`);
|
|
8599
|
+
</code></pre>
|
|
8600
|
+
<p>
|
|
8601
|
+
<code>tiles</code> declares the three measured leaves; <code>separator: '/'</code> is what
|
|
8602
|
+
derives <code>compute</code>/<code>network</code> from ids that only contain <code>/</code>,
|
|
8603
|
+
not the default <code>.</code>; <code>expanded: true</code> opens every branch at construction,
|
|
8604
|
+
so the very first <code>toggle()</code> call closes one rather than opening it;
|
|
8605
|
+
<code>onNodeToggle</code> reports exactly that.
|
|
8606
|
+
</p>
|
|
8607
|
+
</div>
|
|
8608
|
+
|
|
8609
|
+
<div class="example">
|
|
8610
|
+
<p class="example__label">Grid-bound events: fields, onTileClick, onTileDblClick, onTileContextMenu, onChange, executed</p>
|
|
8611
|
+
<pre data-run="js" data-expect="p1-jobs 1 | click p1 | dbl p1 | ctx p1 | changes 1" data-covers="config:fields config:onTileClick config:onTileDblClick config:onTileContextMenu config:onChange"><code>const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8612
|
+
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
|
|
8613
|
+
const { createKPI } = await import('../packages/modules/kpi/index.js');
|
|
8614
|
+
|
|
8615
|
+
const dom = createTestDom({});
|
|
8616
|
+
const el = dom.document.createElement('div');
|
|
8617
|
+
const grid = createHeadlessGrid({
|
|
8618
|
+
rowKey: 'id',
|
|
8619
|
+
columns: [{ id: 'id', field: 'id' }, { id: 'priority', field: 'priority' }],
|
|
8620
|
+
rows: [{ id: 1, priority: 'P1' }, { id: 2, priority: 'P2' }],
|
|
8621
|
+
});
|
|
8622
|
+
const clicks = [];
|
|
8623
|
+
const dbl = [];
|
|
8624
|
+
const ctx = [];
|
|
8625
|
+
const changes = [];
|
|
8626
|
+
const kpi = createKPI(el, {
|
|
8627
|
+
grid,
|
|
8628
|
+
fields: ['priority'],
|
|
8629
|
+
tiles: [{ id: 'p1', label: 'P1 jobs', aggregation: 'count', filter: (r) => r.priority === 'P1' }],
|
|
8630
|
+
onTileClick: ({ id }) => clicks.push(id),
|
|
8631
|
+
onTileDblClick: ({ id }) => dbl.push(id),
|
|
8632
|
+
onTileContextMenu: ({ id }) => ctx.push(id),
|
|
8633
|
+
onChange: (e) => changes.push(e.model.tiles[0].value),
|
|
8634
|
+
});
|
|
8635
|
+
const fig = el.querySelector('.lat-kpi__tile');
|
|
8636
|
+
fig.dispatchEvent(new TestEvent('click', {}));
|
|
8637
|
+
fig.dispatchEvent(new TestEvent('dblclick', {}));
|
|
8638
|
+
fig.dispatchEvent(new TestEvent('contextmenu', {}));
|
|
8639
|
+
return (`p1-jobs ${kpi.value('p1')} | click ${clicks.join(',')} | dbl ${dbl.join(',')} | ctx ${ctx.join(',')} | changes ${changes.length}`);
|
|
8640
|
+
</code></pre>
|
|
8641
|
+
<p>
|
|
8642
|
+
<code>fields: ['priority']</code> is what lets the tile's <code>filter</code> read
|
|
8643
|
+
<code>priority</code> at all on a grid-bound panel — a field a tile does not itself declare is
|
|
8644
|
+
otherwise not projected; the three pointer events each route to their matching config
|
|
8645
|
+
callback, and <code>onChange</code> fires once, at construction.
|
|
8646
|
+
</p>
|
|
8647
|
+
</div>
|
|
8648
|
+
|
|
8649
|
+
<div class="example">
|
|
8650
|
+
<p class="example__label">A host catalogue and an unmeasured tile: messages, nullText, executed</p>
|
|
8651
|
+
<pre data-run="js" data-expect="compute, 2 items, schlimmster Status kritisch | disk-formatted n/a | disk-status null" data-covers="config:messages config:nullText"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
|
|
8652
|
+
const { createKPI } = await import('../packages/modules/kpi/index.js');
|
|
8653
|
+
|
|
8654
|
+
const dom = createTestDom({});
|
|
8655
|
+
const el = dom.document.createElement('div');
|
|
8656
|
+
const HOSTS = [{ id: 1, cpu: 91, mem: 40 }];
|
|
8657
|
+
const kpi = createKPI(el, {
|
|
8658
|
+
rows: HOSTS, rowKey: 'id',
|
|
8659
|
+
tree: { expanded: true },
|
|
8660
|
+
nullText: 'n/a',
|
|
8661
|
+
messages: {
|
|
8662
|
+
t: (key, params) => {
|
|
8663
|
+
if (key === 'kpi.status.critical') return 'kritisch';
|
|
8664
|
+
if (key === 'kpi.node.worst') return `schlimmster Status ${params.status}`;
|
|
8665
|
+
return key;
|
|
8666
|
+
},
|
|
8667
|
+
},
|
|
8668
|
+
tiles: [
|
|
8669
|
+
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
|
|
8670
|
+
// A tile whose field names no column any row carries: measures nothing.
|
|
8671
|
+
{ id: 'compute.disk', label: 'disk', aggregation: 'max', field: 'disk' },
|
|
8672
|
+
],
|
|
8673
|
+
});
|
|
8674
|
+
const compute = kpi.nodes()[0];
|
|
8675
|
+
const diskTile = kpi.tile('compute.disk');
|
|
8676
|
+
return (`${el.querySelectorAll('.lat-kpi__node')[0].getAttribute('aria-label')} | disk-formatted ${diskTile.formatted} | disk-status ${diskTile.status}`);
|
|
8677
|
+
</code></pre>
|
|
8678
|
+
<p>
|
|
8679
|
+
<code>messages</code> translates the node's accessible name (falling back to English for a key
|
|
8680
|
+
the host catalogue omits); <code>nullText</code> is what the unmeasured <code>disk</code> tile
|
|
8681
|
+
renders instead of a fabricated zero.
|
|
8682
|
+
</p>
|
|
8683
|
+
</div>
|
|
8684
|
+
|
|
8685
|
+
<h3 id="route-options-examples">Data Router route options, executed</h3>
|
|
8686
|
+
<div class="example">
|
|
8687
|
+
<p class="example__label">A per-route filter and transform, executed</p>
|
|
8688
|
+
<pre data-run="js" data-expect="o2,o3 | label #o2:30" data-covers="config:filter config:transform"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
|
|
8689
|
+
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
|
|
8690
|
+
|
|
8691
|
+
const grid = createHeadlessGrid({
|
|
8692
|
+
rowKey: 'id',
|
|
8693
|
+
columns: [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }],
|
|
8694
|
+
});
|
|
8695
|
+
const router = createDataRouter({ key: 'type', rowKey: 'id' });
|
|
8696
|
+
router.attach(grid, 'order', {
|
|
8697
|
+
filter: (row) => row.amt >= 20,
|
|
8698
|
+
transform: (row) => ({ id: row.id, type: row.type, amt: row.amt, label: `#${row.id}:${row.amt}` }),
|
|
8699
|
+
});
|
|
8700
|
+
router.load([
|
|
8701
|
+
{ id: 'o1', type: 'order', amt: 10 },
|
|
8702
|
+
{ id: 'o2', type: 'order', amt: 30 },
|
|
8703
|
+
{ id: 'o3', type: 'order', amt: 20 },
|
|
8704
|
+
]);
|
|
8705
|
+
const ids = [];
|
|
8706
|
+
for (let i = 0; i < grid.rows.count(); i++) ids.push(grid.rows.get(i).key);
|
|
8707
|
+
return (`${ids.sort().join(',')} | label ${grid.rows.value('o2', 'label')}`);
|
|
8708
|
+
</code></pre>
|
|
8709
|
+
<p>
|
|
8710
|
+
<code>filter</code> gives the grid only the rows at or above 20 — the route decides what the
|
|
8711
|
+
grid ever sees, before any grid-level filter runs; <code>transform</code> derives the
|
|
8712
|
+
<code>label</code> field the columns declare, reshaping each row on the way in.
|
|
8713
|
+
</p>
|
|
8714
|
+
</div>
|
|
8715
|
+
|
|
7870
8716
|
<h2 id="webcomponent-guide">Web component</h2>
|
|
7871
8717
|
<p class="lead-in">
|
|
7872
8718
|
<code><lattice-grid></code> is the grid as a custom element, shipped as a self-contained
|
|
@@ -8342,7 +9188,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'<
|
|
|
8342
9188
|
|
|
8343
9189
|
<footer>
|
|
8344
9190
|
<p>
|
|
8345
|
-
Lattice Grid 1.
|
|
9191
|
+
Lattice Grid 1.62.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
|
|
8346
9192
|
Written against the shipped source. Where this guide and the code disagree, the code wins,
|
|
8347
9193
|
please <a href="https://www.latticegrid.dev">tell us</a>.
|
|
8348
9194
|
</p>
|