tosijs-ui 1.9.6 → 1.9.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,126 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.8
4
+
5
+ **Grouped tables can now say how much of a group is showing.** The piece missing from 1.9.7's
6
+ row grouping: a cell that reports on its group ("showing 2 of 7") or offers a **show-all**
7
+ toggle needs to compare what is rendered against what exists — and that is the one comparison
8
+ a cell renderer cannot make for itself. The filter has already run by then, so a consumer
9
+ sees the survivors with nothing to measure them against. The table sees both sides.
10
+
11
+ ```js
12
+ const id = table.groupIdFor(row)
13
+ const { visible, total } = table.rowGroupCounts.get(id) ?? { visible: 0, total: 0 }
14
+ cell.textContent = visible < total ? `showing ${visible} of ${total}` : `${total} lines`
15
+
16
+ // the toggle is just a set of ids handed back to the table
17
+ expanded.has(id) ? expanded.delete(id) : expanded.add(id)
18
+ table.visibleGroupedRowIds = [...expanded]
19
+ ```
20
+
21
+ - **`table.rowGroupCounts`** — a `Map` from group id to `{ visible, total }`, rendered rows
22
+ against rows before filtering. Recomputed each render and readable while cells render.
23
+ - **`table.groupIdFor(row)`** — a row's group id, or `null` when ungrouped. This matters when
24
+ the grouping was **inferred** from `nonRepeatingGroupedRowCells`: you never wrote that
25
+ function, so its ids are otherwise unreproducible and the map would be keyed by strings you
26
+ could not construct.
27
+
28
+ Groups the filter removed **entirely** stay in the map with `visible: 0`, so "nothing here
29
+ matched" stays distinguishable from "no such group" — the first being exactly when a show-all
30
+ toggle is worth offering. `rowGroupCounts` is always a `Map` (empty when ungrouped), so
31
+ `.get()` needs no null check. Pinned rows sit outside grouping and are not counted.
32
+
33
+ Additive only; ungrouped tables are unaffected.
34
+
35
+ ## 1.9.7
36
+
37
+ **`<tosi-table>` can group rows.** The motivating shape is invoice lines: rows that belong
38
+ together, striped as a unit, with the values they all repeat shown once.
39
+
40
+ ```js
41
+ table.rowGroupId = (row) => `${row.invoice}/${row.buyer}`
42
+ table.nonRepeatingGroupedRowCells = ['invoice', 'buyer'] // shown once per group
43
+ table.visibleGroupedRowIds = ['INV-1001/Acme'] // stays visible past the filter
44
+ ```
45
+
46
+ Set `nonRepeatingGroupedRowCells` on its own and the grouping is **inferred** from exactly
47
+ those columns, so the common case is one line. Grouped rows get `table-cluster-even` /
48
+ `table-cluster-odd`, alternating **per group** — a five-line invoice is one stripe, not
49
+ five.
50
+
51
+ `visibleGroupedRowIds` keeps whole groups visible regardless of the filter, so a search
52
+ matching one line of an invoice can open the whole invoice without the filter needing to
53
+ know anything about grouping. It is additive to the filter's own result, so a filter that
54
+ ranks as well as selects keeps its ranking.
55
+
56
+ Two details worth knowing. **Clusters keep your sort**: groups appear in first-appearance
57
+ order, so a group lands wherever its best-sorted row landed. Sorting the clusters by their
58
+ id instead would have quietly thrown away the sort the user just clicked. And repeated
59
+ cells are **hidden, not emptied** — `.tr:not(.table-cluster-first) .cluster-repeat
60
+ { visibility: hidden }`. If you write your own rule for a custom `dataCell`, use
61
+ `visibility`, never `display: none`: every cell is an item of the row's grid, so removing
62
+ one pulls each later cell a column left and the row renders under the wrong headers.
63
+ `table.isFirstInGroup(row)` answers the same question in JavaScript.
64
+
65
+ Ungrouped tables are unaffected — all three properties default to off.
66
+
67
+ **The inline doc-test lane was under-reporting, and staying green about it.** If you use
68
+ `tosijs-ui/site` and have doc pages whose examples do async work, **your test counts may
69
+ have been wrong.** A test iframe reported "page done" as soon as its _first_ example
70
+ settled, so any example still awaiting a slow `js` block — a `fetch`, typically — never ran
71
+ its tests, and they were omitted rather than failed. On this repo's own table page that was
72
+ 8 tests silently becoming 1, with the suite reporting "passed" either way.
73
+
74
+ Completion is now decided by which examples have `test` **source**, which is known before
75
+ anything executes, so an example that has not started yet is waited on instead of
76
+ overlooked. An example that never finishes now fails by name with an excerpt of its source.
77
+ The rule itself is exported as `unsettledExamples()` / `isSettled()` from
78
+ `src/doc-system/test-completion.ts`, pure and unit-tested — a bug whose symptom is a
79
+ _missing_ test cannot be caught by the browser lane it breaks.
80
+
81
+ Check your own counts against what your pages actually contain; a number that goes **up**
82
+ after upgrading is this bug, not a new one.
83
+
84
+ **The dev server no longer 404s while it rebuilds** — the actual cause behind the LAN
85
+ stalls in #63, traced by tosijs-3d.
86
+
87
+ `buildSite` moves the output directory aside (`mv docs docs.last-good`) and repopulates it
88
+ from empty, so for the length of every build **every served path is simply absent**. Two
89
+ failures follow:
90
+
91
+ - a request that _starts_ in that window gets a plain 404 — the page loads and never
92
+ hydrates
93
+ - a request already _in flight_ has its file vanish underneath it, stops producing data,
94
+ and idles until something closes it — the "loads then stalls" symptom
95
+
96
+ It only ever bit over the LAN because a loopback transfer finishes in ~22 ms and almost
97
+ never overlaps the hole, while a multi-MB transfer to a phone or a second machine is
98
+ easily still running two seconds later. It also explains why reloading never helped while
99
+ you were actively editing: every save reopened the window.
100
+
101
+ The dev server now falls back to the last-good tree for any path missing from the live
102
+ one. That copy exists for exactly the window's duration — created by the stash at the
103
+ start of a build, removed on success — so the fallback is self-scoping, and no stale
104
+ content can be served outside a rebuild.
105
+
106
+ Measured across real rebuilds: **2 failed requests before, 0 after**.
107
+
108
+ **Formatting is gated.** `bun format` was named in no gate, so drift only accumulated — 24
109
+ unformatted files at 1.9.0, 40 by now — and the cost always landed on whoever next ran it,
110
+ as three dozen unrelated files in their feature diff. New `bun run format-check`
111
+ (`prettier --check .`), run in CI. Adopters get the script; nothing about the published
112
+ package changes.
113
+
114
+ > **On versioning.** This is a patch that adds public API, which the project's own rule
115
+ > previously called a minor. The rule has changed: minors are for **breaking changes and
116
+ > feature rollouts**, while additive, non-breaking extensions ship as patches. The contract
117
+ > you rely on is unchanged and is the whole point — **a patch never breaks you.**
118
+
119
+ > The `idleTimeout` raise and asset compression in 1.9.6 both help, but neither closes this
120
+ > window — compression shortens the exposure, and `idleTimeout` only decides how long a
121
+ > stalled request waits before failing. If you are on 1.9.6 and still seeing LAN stalls,
122
+ > this is the release that fixes them.
123
+
3
124
  ## 1.9.6
4
125
 
5
126
  **`<tosi-table>` sorts correctly** (#62, reported by snowfox). Two independent defects, one
@@ -19,11 +140,11 @@ arrived as text, anything id-shaped. Sorting now compares naturally:
19
140
  - blank cells sort **last in both directions** — a descending sort that opens on a
20
141
  screenful of empty cells is never what was clicked for
21
142
 
22
- The old comparator also never returned `0`, so two *equal* values each claimed to be
143
+ The old comparator also never returned `0`, so two _equal_ values each claimed to be
23
144
  greater than the other. `Array.sort` is entitled to turn an inconsistent comparator into
24
145
  arbitrary output, not merely wrong output.
25
146
 
26
- **New `ColumnOptions.sortValue`** — sort by what the cell *shows*, when that differs from
147
+ **New `ColumnOptions.sortValue`** — sort by what the cell _shows_, when that differs from
27
148
  what the row stores. A column with a custom `dataCell` renders whatever it likes while the
28
149
  sort keyed on `prop`, so the rows reordered by an invisible value:
29
150
 
@@ -72,7 +193,7 @@ same comparison is wanted outside tables.
72
193
  tosijs#20 (the `parts` proxy crossing into nested components) and #21 — and that reason
73
194
  is now recorded in `CLAUDE.md` rather than only in an old changelog entry.
74
195
 
75
- `makeSorter` deliberately keeps its existing comparison: switching it would change *case*
196
+ `makeSorter` deliberately keeps its existing comparison: switching it would change _case_
76
197
  ordering (`'Zed'` before `'alice'` today) in a general-purpose utility where that has not
77
198
  been reported as a problem. Coerce numeric strings in the valuator, or pass values through
78
199
  `naturalCompare` yourself.
@@ -90,18 +211,18 @@ An annotation is a **suffix identifier**, so only a `#` that looks like one is t
90
211
  one — immediately preceded by a non-space, followed by letters/digits/`_`/`-`, at the end
91
212
  of the string:
92
213
 
93
- | string | read as |
94
- | --- | --- |
95
- | `Okay#confirm` | annotation |
96
- | `C# Tutorial` | literal — space after |
97
- | `Issue #42` | literal — space before |
98
- | `#hashtag` | literal — nothing before |
99
- | `C#` | literal — no annotation after |
214
+ | string | read as |
215
+ | -------------- | ----------------------------- |
216
+ | `Okay#confirm` | annotation |
217
+ | `C# Tutorial` | literal — space after |
218
+ | `Issue #42` | literal — space before |
219
+ | `#hashtag` | literal — nothing before |
220
+ | `C#` | literal — no annotation after |
100
221
 
101
222
  No author action and no data migration; every documented annotation form is unchanged.
102
223
  Escape with `\#` for a literal that genuinely looks like an annotation (`tag\#42`).
103
224
 
104
- **The worse half:** annotations were stripped from the *translated value* too, so a
225
+ **The worse half:** annotations were stripped from the _translated value_ too, so a
105
226
  translation containing `#` was truncated **even when the source string had none** —
106
227
  `'Sharp'` → `'Dièse #1'` came back as `'Dièse '`. A translator writing an ordinary string
107
228
  had it silently cut, with nothing in the source to hint why. Translations are no longer
@@ -121,9 +242,9 @@ it resolves to.
121
242
 
122
243
  It spawned its own channel via `bunx haltija@^1.6.1` and ignored the project's dependency
123
244
  entirely — and because **bunx caches the resolution**, a range that resolves forward never
124
- *re*-resolves once its cache key exists. So an adopter who bumped `haltija` to `^1.11.2`
245
+ _re_-resolves once its cache key exists. So an adopter who bumped `haltija` to `^1.11.2`
125
246
  for a fix, restarted, and still didn't have the fix was running a cached 1.11.0: new
126
- enough to look current, old enough to lack it. `hj where` reports the *spawned* server, so
247
+ enough to look current, old enough to lack it. `hj where` reports the _spawned_ server, so
127
248
  the version indicator agreed with them.
128
249
 
129
250
  - Your `node_modules/.bin/haltija` is preferred when present. `HALTIJA_VERSION` still
@@ -156,7 +277,7 @@ upstream as [oven-sh/bun#36788](https://github.com/oven-sh/bun/issues/36788).
156
277
  ### ePub volumes are now linkable (#46)
157
278
 
158
279
  The build wrote an ePub per volume and linked to none of them, so a reader had no route
159
- to a book that existed. The filename is *derived* (`<project>-<volume>.epub`), so
280
+ to a book that existed. The filename is _derived_ (`<project>-<volume>.epub`), so
160
281
  hand-written links rot silently when a volume is renamed.
161
282
 
162
283
  Three ways to surface them, and the ePub build now names its output through the same
@@ -178,7 +299,7 @@ at.
178
299
  walled you with "that invite link has been used". The stale token is simply irrelevant
179
300
  to someone who already holds a session.
180
301
  - **`tosijs-tunnel --close` confirms identity before signalling.** It SIGTERMed every
181
- `pgrep -f` match, and `pgrep -f` matches an argv *substring* — a shell echoing the
302
+ `pgrep -f` match, and `pgrep -f` matches an argv _substring_ — a shell echoing the
182
303
  command or an editor holding a log path both matched. It now checks the process really
183
304
  is `ssh`, says what it signalled, and names what it skipped. It also interpolated `host`
184
305
  into a REGEX unescaped, so `me@a.b` matched `me@axb` and a host containing `+`, `(` or
@@ -199,15 +320,15 @@ at.
199
320
  `hj map` (haltija 1.11) over the doc site, which reports a measured contrast ratio per
200
321
  node — none of these were visible as wrong to the eye.
201
322
 
202
- | pair | was | now |
203
- | --- | --- | --- |
204
- | test widget, running | white on `#fa0` — **1.9:1** | `#2b1a00` on `#fa0` — 8.8:1 |
205
- | test widget, passed | white on `#0a0` — **3.1:1** | white on `#008a00` — 4.5:1 |
206
- | `--tosi-accent` as text | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1 |
207
- | `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1** | white on `#d92270` — 4.8:1 |
323
+ | pair | was | now |
324
+ | ------------------------- | -------------------------------------------------- | --------------------------- |
325
+ | test widget, running | white on `#fa0` — **1.9:1** | `#2b1a00` on `#fa0` — 8.8:1 |
326
+ | test widget, passed | white on `#0a0` — **3.1:1** | white on `#008a00` — 4.5:1 |
327
+ | `--tosi-accent` as text | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1 |
328
+ | `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1** | white on `#d92270` — 4.8:1 |
208
329
 
209
330
  **The brand pink is 9% darker** (`#EE257B` → `#d92270`). It is imperceptible side by side
210
- and it was the only value that clears AA in *both* directions — as text on the page, and
331
+ and it was the only value that clears AA in _both_ directions — as text on the page, and
211
332
  as a fill carrying white text. Override `--tosi-accent` if you want the original.
212
333
 
213
334
  Two more, found by sweeping wider — both in **example code we publish**, which matters
@@ -227,8 +348,8 @@ Verified across 8 pages in both themes on haltija 1.11.2: **0 failures, 0 uncert
227
348
 
228
349
  **The browser-test lane now runs its own private haltija.** It used to reuse any running
229
350
  instance and otherwise spawn with `-f`, which reclaims haltija's shared default port and
230
- kills whatever held it. Both halves were wrong: adopting meant inheriting the *desktop
231
- app's* window, whose visibility depends on whatever else is on screen — and `hj` rightly
351
+ kills whatever held it. Both halves were wrong: adopting meant inheriting the _desktop
352
+ app's_ window, whose visibility depends on whatever else is on screen — and `hj` rightly
232
353
  refuses to drive a hidden tab, since a backgrounded tab throttles `requestAnimationFrame`
233
354
  and would produce plausible-but-wrong results. The lane failed roughly two runs in three
234
355
  with no programmatic way out. It now takes an isolated instance on an ephemeral port:
@@ -260,11 +381,11 @@ Two runtime requirements are unchanged, and are now documented rather than impli
260
381
  are honest constraints, not bugs, but the failure messages named symptoms rather than
261
382
  causes:
262
383
 
263
- | entry point | runtime |
264
- | --- | --- |
265
- | `tosijs-ui/site` | **bun** — it shells out, builds and spawns |
266
- | `tosijs-ui`, `tosijs-ui/<component>` | a **browser** or a bundler targeting one |
267
- | `tosijs-ui/icon-svg` | anything — deliberately DOM-free |
384
+ | entry point | runtime |
385
+ | ------------------------------------ | ------------------------------------------ |
386
+ | `tosijs-ui/site` | **bun** — it shells out, builds and spawns |
387
+ | `tosijs-ui`, `tosijs-ui/<component>` | a **browser** or a bundler targeting one |
388
+ | `tosijs-ui/icon-svg` | anything — deliberately DOM-free |
268
389
 
269
390
  `engines` now declares bun, and `doc-site-system.md` has a "Runtimes — what runs where"
270
391
  section.
@@ -1,5 +1,6 @@
1
1
  import { Component as WebComponent, ElementCreator } from 'tosijs';
2
2
  import { SortCallback } from './make-sorter.js';
3
+ import { RowGroupIdFn, GroupCount } from './row-grouping.js';
3
4
  import { ValueRendererType } from './value-renderer.js';
4
5
  export interface ColumnOptions {
5
6
  name?: string;
@@ -94,6 +95,12 @@ export declare class TosiTable extends WebComponent {
94
95
  zIndex: string;
95
96
  background: string;
96
97
  };
98
+ ':host .tr:not(.table-cluster-first) .cluster-repeat': {
99
+ visibility: string;
100
+ };
101
+ ':host .tr.table-cluster-odd': {
102
+ _tosiTableBg: string;
103
+ };
97
104
  ':host .tr[aria-selected] .td': {
98
105
  background: string;
99
106
  };
@@ -169,6 +176,9 @@ export declare class TosiTable extends WebComponent {
169
176
  private _columns;
170
177
  private _filter;
171
178
  private _sort?;
179
+ private _rowGroupId;
180
+ private _visibleGroupedRowIds;
181
+ private _nonRepeatingGroupedRowCells;
172
182
  private _pinnedTopRows?;
173
183
  private _pinnedBottomRows?;
174
184
  get pinnedTopRows(): any[] | undefined;
@@ -183,6 +193,36 @@ export declare class TosiTable extends WebComponent {
183
193
  set array(newArray: any[]);
184
194
  get filter(): ArrayFilter;
185
195
  set filter(filterFunc: ArrayFilter);
196
+ get rowGroupId(): RowGroupIdFn | null;
197
+ set rowGroupId(fn: RowGroupIdFn | null);
198
+ get visibleGroupedRowIds(): string[] | null;
199
+ set visibleGroupedRowIds(ids: string[] | null);
200
+ get nonRepeatingGroupedRowCells(): string[] | null;
201
+ set nonRepeatingGroupedRowCells(props: string[] | null);
202
+ /** The grouping function in force, or null when the table is ungrouped. */
203
+ private get groupIdFn();
204
+ /**
205
+ * This row's group id, or null when the table is ungrouped.
206
+ *
207
+ * Public because the grouping may be INFERRED from `nonRepeatingGroupedRowCells`, in which
208
+ * case the consumer never wrote the function and cannot reproduce its ids — which would
209
+ * leave `rowGroupCounts` keyed by strings they have no way to construct.
210
+ */
211
+ groupIdFor(row: any): string | null;
212
+ /**
213
+ * Per-group `{ visible, total }` counts — rendered rows against rows before filtering.
214
+ *
215
+ * For cells that report or control their own group: "showing 2 of 7", or a toggle that
216
+ * adds the group to `visibleGroupedRowIds`. The table is the only thing that sees both
217
+ * sides of the filter, so this is the piece a custom cell renderer cannot derive itself.
218
+ *
219
+ * Recomputed each render, and always a Map — empty when ungrouped — so callers can `.get()`
220
+ * without a null check. Groups filtered away entirely are present with `visible: 0`;
221
+ * pinned rows sit outside grouping and are not counted.
222
+ */
223
+ get rowGroupCounts(): Map<string, GroupCount>;
224
+ private _grouping;
225
+ private _rowGroupCounts;
186
226
  get sort(): SortCallback | undefined;
187
227
  set sort(sortFunc: SortCallback | undefined);
188
228
  get columns(): ColumnOptions[];
@@ -205,6 +245,15 @@ export declare class TosiTable extends WebComponent {
205
245
  private cellStyle;
206
246
  private applyGridCellAttrs;
207
247
  private buildCell;
248
+ private tagClusterParity;
249
+ /**
250
+ * Is this row the first of its group? Always true when the table is ungrouped.
251
+ *
252
+ * Exposed for custom `dataCell` columns, which render themselves and so have to decide
253
+ * for themselves whether to show a value the rest of the group repeats.
254
+ */
255
+ isFirstInGroup(row: any): boolean;
256
+ private isRepeatedGroupRow;
208
257
  private buildRow;
209
258
  private buildHeaderCell;
210
259
  private buildHeader;