tosijs-ui 1.9.5 → 1.9.7

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,171 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.7
4
+
5
+ **`<tosi-table>` can group rows.** The motivating shape is invoice lines: rows that belong
6
+ together, striped as a unit, with the values they all repeat shown once.
7
+
8
+ ```js
9
+ table.rowGroupId = (row) => `${row.invoice}/${row.buyer}`
10
+ table.nonRepeatingGroupedRowCells = ['invoice', 'buyer'] // shown once per group
11
+ table.visibleGroupedRowIds = ['INV-1001/Acme'] // stays visible past the filter
12
+ ```
13
+
14
+ Set `nonRepeatingGroupedRowCells` on its own and the grouping is **inferred** from exactly
15
+ those columns, so the common case is one line. Grouped rows get `table-cluster-even` /
16
+ `table-cluster-odd`, alternating **per group** — a five-line invoice is one stripe, not
17
+ five.
18
+
19
+ `visibleGroupedRowIds` keeps whole groups visible regardless of the filter, so a search
20
+ matching one line of an invoice can open the whole invoice without the filter needing to
21
+ know anything about grouping. It is additive to the filter's own result, so a filter that
22
+ ranks as well as selects keeps its ranking.
23
+
24
+ Two details worth knowing. **Clusters keep your sort**: groups appear in first-appearance
25
+ order, so a group lands wherever its best-sorted row landed. Sorting the clusters by their
26
+ id instead would have quietly thrown away the sort the user just clicked. And repeated
27
+ cells are **hidden, not emptied** — `.tr:not(.table-cluster-first) .cluster-repeat
28
+ { visibility: hidden }`. If you write your own rule for a custom `dataCell`, use
29
+ `visibility`, never `display: none`: every cell is an item of the row's grid, so removing
30
+ one pulls each later cell a column left and the row renders under the wrong headers.
31
+ `table.isFirstInGroup(row)` answers the same question in JavaScript.
32
+
33
+ Ungrouped tables are unaffected — all three properties default to off.
34
+
35
+ **The inline doc-test lane was under-reporting, and staying green about it.** If you use
36
+ `tosijs-ui/site` and have doc pages whose examples do async work, **your test counts may
37
+ have been wrong.** A test iframe reported "page done" as soon as its _first_ example
38
+ settled, so any example still awaiting a slow `js` block — a `fetch`, typically — never ran
39
+ its tests, and they were omitted rather than failed. On this repo's own table page that was
40
+ 8 tests silently becoming 1, with the suite reporting "passed" either way.
41
+
42
+ Completion is now decided by which examples have `test` **source**, which is known before
43
+ anything executes, so an example that has not started yet is waited on instead of
44
+ overlooked. An example that never finishes now fails by name with an excerpt of its source.
45
+ The rule itself is exported as `unsettledExamples()` / `isSettled()` from
46
+ `src/doc-system/test-completion.ts`, pure and unit-tested — a bug whose symptom is a
47
+ _missing_ test cannot be caught by the browser lane it breaks.
48
+
49
+ Check your own counts against what your pages actually contain; a number that goes **up**
50
+ after upgrading is this bug, not a new one.
51
+
52
+ **The dev server no longer 404s while it rebuilds** — the actual cause behind the LAN
53
+ stalls in #63, traced by tosijs-3d.
54
+
55
+ `buildSite` moves the output directory aside (`mv docs docs.last-good`) and repopulates it
56
+ from empty, so for the length of every build **every served path is simply absent**. Two
57
+ failures follow:
58
+
59
+ - a request that _starts_ in that window gets a plain 404 — the page loads and never
60
+ hydrates
61
+ - a request already _in flight_ has its file vanish underneath it, stops producing data,
62
+ and idles until something closes it — the "loads then stalls" symptom
63
+
64
+ It only ever bit over the LAN because a loopback transfer finishes in ~22 ms and almost
65
+ never overlaps the hole, while a multi-MB transfer to a phone or a second machine is
66
+ easily still running two seconds later. It also explains why reloading never helped while
67
+ you were actively editing: every save reopened the window.
68
+
69
+ The dev server now falls back to the last-good tree for any path missing from the live
70
+ one. That copy exists for exactly the window's duration — created by the stash at the
71
+ start of a build, removed on success — so the fallback is self-scoping, and no stale
72
+ content can be served outside a rebuild.
73
+
74
+ Measured across real rebuilds: **2 failed requests before, 0 after**.
75
+
76
+ **Formatting is gated.** `bun format` was named in no gate, so drift only accumulated — 24
77
+ unformatted files at 1.9.0, 40 by now — and the cost always landed on whoever next ran it,
78
+ as three dozen unrelated files in their feature diff. New `bun run format-check`
79
+ (`prettier --check .`), run in CI. Adopters get the script; nothing about the published
80
+ package changes.
81
+
82
+ > **On versioning.** This is a patch that adds public API, which the project's own rule
83
+ > previously called a minor. The rule has changed: minors are for **breaking changes and
84
+ > feature rollouts**, while additive, non-breaking extensions ship as patches. The contract
85
+ > you rely on is unchanged and is the whole point — **a patch never breaks you.**
86
+
87
+ > The `idleTimeout` raise and asset compression in 1.9.6 both help, but neither closes this
88
+ > window — compression shortens the exposure, and `idleTimeout` only decides how long a
89
+ > stalled request waits before failing. If you are on 1.9.6 and still seeing LAN stalls,
90
+ > this is the release that fixes them.
91
+
92
+ ## 1.9.6
93
+
94
+ **`<tosi-table>` sorts correctly** (#62, reported by snowfox). Two independent defects, one
95
+ in each half of the column comparator.
96
+
97
+ **Numeric strings sorted by first digit.** The comparator used a raw `>`, which compares
98
+ strings lexically — so `'9'` sorted after `'399'`, and `'1200'` before `'3.5'`. Real data
99
+ is full of numeric strings: CSV and TSV imports, BigQuery exports, JSON where numbers
100
+ arrived as text, anything id-shaped. Sorting now compares naturally:
101
+
102
+ - numeric values (including numeric strings) compare as numbers, decimals and negatives
103
+ included
104
+ - very long integers stay exact — two 20-digit ids differing in the last digit order
105
+ correctly, where a plain `parseFloat` comparison calls them equal
106
+ - text compares with a locale collator, so accented letters order the way the reader
107
+ expects rather than the way ASCII does
108
+ - blank cells sort **last in both directions** — a descending sort that opens on a
109
+ screenful of empty cells is never what was clicked for
110
+
111
+ The old comparator also never returned `0`, so two _equal_ values each claimed to be
112
+ greater than the other. `Array.sort` is entitled to turn an inconsistent comparator into
113
+ arbitrary output, not merely wrong output.
114
+
115
+ **New `ColumnOptions.sortValue`** — sort by what the cell _shows_, when that differs from
116
+ what the row stores. A column with a custom `dataCell` renders whatever it likes while the
117
+ sort keyed on `prop`, so the rows reordered by an invisible value:
118
+
119
+ ```typescript
120
+ {
121
+ name: 'Invoice #',
122
+ prop: 'Customer invoice ID', // what the row stores — CSV export, lookups
123
+ dataCell: invoiceCell, // what the reader sees
124
+ sortValue: (row) => row['Invoice number'] || row['Customer invoice ID'],
125
+ }
126
+ ```
127
+
128
+ Purely additive; `table.sort` still overrides everything for cases a per-column value
129
+ cannot express.
130
+
131
+ **New `naturalCompare` / `naturalSorter` / `isBlank`** exported from `tosijs-ui`, since the
132
+ same comparison is wanted outside tables.
133
+
134
+ ### Build system
135
+
136
+ - **Code-split chunks land in `_chunks/` instead of the web root** (#64). `--splitting`
137
+ emits one hashed chunk per dynamic import, flat beside `index.html` — for a corpus that
138
+ pulls something large, that is thousands of files: tosijs-3d reported **2,473 hashed
139
+ chunks among 2,604 files**, all tracked in git because `docs/` is the Pages source.
140
+
141
+ The cost that hurts is not legibility. Any dependency bump rewrites every hash, so
142
+ upgrading one package became a multi-thousand-file commit — which makes the standing
143
+ "never commit `docs/` from a feature push" hazard far worse, since a stray `git add -A`
144
+ moves thousands of files and the diff is unreviewable. Assets move to `_assets/` for the
145
+ same reason.
146
+
147
+ - **The dev server no longer times out mid-transfer on LAN loads** (#63). `Bun.serve`
148
+ defaults to a 10s `idleTimeout`, sized for small API responses — but a doc site's bundle
149
+ can be multiple MB, and over wifi to a phone or a second laptop that legitimately takes
150
+ longer, so the connection closed part-way. It never reproduces on loopback, which makes
151
+ it look like a client-side stall. Now 120s, overridable with
152
+ `DEV_REQUEST_TIMEOUT_SECONDS`. Testing on real devices over the LAN is exactly what the
153
+ dev cert covers `<host>.local` for.
154
+
155
+ ### Also
156
+
157
+ - **The release lane now checks, against the packed manifest, that each peer dependency we
158
+ also develop against is pinned within the range we publish** (#57). A floor the library
159
+ is not itself built against is a contract nobody tests, and the only signal was an
160
+ adopter's install warning. `tosijs`'s `^1.7.8` floor is not arbitrary — it encodes
161
+ tosijs#20 (the `parts` proxy crossing into nested components) and #21 — and that reason
162
+ is now recorded in `CLAUDE.md` rather than only in an old changelog entry.
163
+
164
+ `makeSorter` deliberately keeps its existing comparison: switching it would change _case_
165
+ ordering (`'Zed'` before `'alice'` today) in a general-purpose utility where that has not
166
+ been reported as a problem. Coerce numeric strings in the valuator, or pass values through
167
+ `naturalCompare` yourself.
168
+
3
169
  ## 1.9.5
4
170
 
5
171
  **`localize()` no longer destroys literal `#`** (#55, reported by snowfox).
@@ -13,18 +179,18 @@ An annotation is a **suffix identifier**, so only a `#` that looks like one is t
13
179
  one — immediately preceded by a non-space, followed by letters/digits/`_`/`-`, at the end
14
180
  of the string:
15
181
 
16
- | string | read as |
17
- | --- | --- |
18
- | `Okay#confirm` | annotation |
19
- | `C# Tutorial` | literal — space after |
20
- | `Issue #42` | literal — space before |
21
- | `#hashtag` | literal — nothing before |
22
- | `C#` | literal — no annotation after |
182
+ | string | read as |
183
+ | -------------- | ----------------------------- |
184
+ | `Okay#confirm` | annotation |
185
+ | `C# Tutorial` | literal — space after |
186
+ | `Issue #42` | literal — space before |
187
+ | `#hashtag` | literal — nothing before |
188
+ | `C#` | literal — no annotation after |
23
189
 
24
190
  No author action and no data migration; every documented annotation form is unchanged.
25
191
  Escape with `\#` for a literal that genuinely looks like an annotation (`tag\#42`).
26
192
 
27
- **The worse half:** annotations were stripped from the *translated value* too, so a
193
+ **The worse half:** annotations were stripped from the _translated value_ too, so a
28
194
  translation containing `#` was truncated **even when the source string had none** —
29
195
  `'Sharp'` → `'Dièse #1'` came back as `'Dièse '`. A translator writing an ordinary string
30
196
  had it silently cut, with nothing in the source to hint why. Translations are no longer
@@ -44,9 +210,9 @@ it resolves to.
44
210
 
45
211
  It spawned its own channel via `bunx haltija@^1.6.1` and ignored the project's dependency
46
212
  entirely — and because **bunx caches the resolution**, a range that resolves forward never
47
- *re*-resolves once its cache key exists. So an adopter who bumped `haltija` to `^1.11.2`
213
+ _re_-resolves once its cache key exists. So an adopter who bumped `haltija` to `^1.11.2`
48
214
  for a fix, restarted, and still didn't have the fix was running a cached 1.11.0: new
49
- enough to look current, old enough to lack it. `hj where` reports the *spawned* server, so
215
+ enough to look current, old enough to lack it. `hj where` reports the _spawned_ server, so
50
216
  the version indicator agreed with them.
51
217
 
52
218
  - Your `node_modules/.bin/haltija` is preferred when present. `HALTIJA_VERSION` still
@@ -79,7 +245,7 @@ upstream as [oven-sh/bun#36788](https://github.com/oven-sh/bun/issues/36788).
79
245
  ### ePub volumes are now linkable (#46)
80
246
 
81
247
  The build wrote an ePub per volume and linked to none of them, so a reader had no route
82
- to a book that existed. The filename is *derived* (`<project>-<volume>.epub`), so
248
+ to a book that existed. The filename is _derived_ (`<project>-<volume>.epub`), so
83
249
  hand-written links rot silently when a volume is renamed.
84
250
 
85
251
  Three ways to surface them, and the ePub build now names its output through the same
@@ -101,7 +267,7 @@ at.
101
267
  walled you with "that invite link has been used". The stale token is simply irrelevant
102
268
  to someone who already holds a session.
103
269
  - **`tosijs-tunnel --close` confirms identity before signalling.** It SIGTERMed every
104
- `pgrep -f` match, and `pgrep -f` matches an argv *substring* — a shell echoing the
270
+ `pgrep -f` match, and `pgrep -f` matches an argv _substring_ — a shell echoing the
105
271
  command or an editor holding a log path both matched. It now checks the process really
106
272
  is `ssh`, says what it signalled, and names what it skipped. It also interpolated `host`
107
273
  into a REGEX unescaped, so `me@a.b` matched `me@axb` and a host containing `+`, `(` or
@@ -122,15 +288,15 @@ at.
122
288
  `hj map` (haltija 1.11) over the doc site, which reports a measured contrast ratio per
123
289
  node — none of these were visible as wrong to the eye.
124
290
 
125
- | pair | was | now |
126
- | --- | --- | --- |
127
- | test widget, running | white on `#fa0` — **1.9:1** | `#2b1a00` on `#fa0` — 8.8:1 |
128
- | test widget, passed | white on `#0a0` — **3.1:1** | white on `#008a00` — 4.5:1 |
129
- | `--tosi-accent` as text | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1 |
130
- | `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1** | white on `#d92270` — 4.8:1 |
291
+ | pair | was | now |
292
+ | ------------------------- | -------------------------------------------------- | --------------------------- |
293
+ | test widget, running | white on `#fa0` — **1.9:1** | `#2b1a00` on `#fa0` — 8.8:1 |
294
+ | test widget, passed | white on `#0a0` — **3.1:1** | white on `#008a00` — 4.5:1 |
295
+ | `--tosi-accent` as text | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1 |
296
+ | `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1** | white on `#d92270` — 4.8:1 |
131
297
 
132
298
  **The brand pink is 9% darker** (`#EE257B` → `#d92270`). It is imperceptible side by side
133
- and it was the only value that clears AA in *both* directions — as text on the page, and
299
+ and it was the only value that clears AA in _both_ directions — as text on the page, and
134
300
  as a fill carrying white text. Override `--tosi-accent` if you want the original.
135
301
 
136
302
  Two more, found by sweeping wider — both in **example code we publish**, which matters
@@ -150,8 +316,8 @@ Verified across 8 pages in both themes on haltija 1.11.2: **0 failures, 0 uncert
150
316
 
151
317
  **The browser-test lane now runs its own private haltija.** It used to reuse any running
152
318
  instance and otherwise spawn with `-f`, which reclaims haltija's shared default port and
153
- kills whatever held it. Both halves were wrong: adopting meant inheriting the *desktop
154
- app's* window, whose visibility depends on whatever else is on screen — and `hj` rightly
319
+ kills whatever held it. Both halves were wrong: adopting meant inheriting the _desktop
320
+ app's_ window, whose visibility depends on whatever else is on screen — and `hj` rightly
155
321
  refuses to drive a hidden tab, since a backgrounded tab throttles `requestAnimationFrame`
156
322
  and would produce plausible-but-wrong results. The lane failed roughly two runs in three
157
323
  with no programmatic way out. It now takes an isolated instance on an ephemeral port:
@@ -183,11 +349,11 @@ Two runtime requirements are unchanged, and are now documented rather than impli
183
349
  are honest constraints, not bugs, but the failure messages named symptoms rather than
184
350
  causes:
185
351
 
186
- | entry point | runtime |
187
- | --- | --- |
188
- | `tosijs-ui/site` | **bun** — it shells out, builds and spawns |
189
- | `tosijs-ui`, `tosijs-ui/<component>` | a **browser** or a bundler targeting one |
190
- | `tosijs-ui/icon-svg` | anything — deliberately DOM-free |
352
+ | entry point | runtime |
353
+ | ------------------------------------ | ------------------------------------------ |
354
+ | `tosijs-ui/site` | **bun** — it shells out, builds and spawns |
355
+ | `tosijs-ui`, `tosijs-ui/<component>` | a **browser** or a bundler targeting one |
356
+ | `tosijs-ui/icon-svg` | anything — deliberately DOM-free |
191
357
 
192
358
  `engines` now declares bun, and `doc-site-system.md` has a "Runtimes — what runs where"
193
359
  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 } from './row-grouping.js';
3
4
  import { ValueRendererType } from './value-renderer.js';
4
5
  export interface ColumnOptions {
5
6
  name?: string;
@@ -10,6 +11,23 @@ export interface ColumnOptions {
10
11
  type?: ValueRendererType;
11
12
  pinned?: 'left' | 'right';
12
13
  sort?: false | 'ascending' | 'descending';
14
+ /**
15
+ * What this column SORTS by, when that differs from what it stores.
16
+ *
17
+ * Defaults to `row[prop]`. A column with a custom `dataCell` renders whatever it likes
18
+ * while the sort keys on `prop` — so when those differ, clicking "Sort Ascending"
19
+ * reorders rows by a value the reader cannot see, which reads as "sorting is broken"
20
+ * rather than "sorting a different field" (tosijs-ui#62).
21
+ *
22
+ * { name: 'Invoice #', prop: 'Customer invoice ID', dataCell: invoiceCell,
23
+ * sortValue: (row) => row['Invoice number'] || row['Customer invoice ID'] }
24
+ *
25
+ * Every other escape hatch costs more: `table.sort` is table-wide (one derived column
26
+ * means reimplementing sorting for all of them), replacing `headerCell` means
27
+ * reimplementing the header menu, and changing `prop` breaks CSV export and anything
28
+ * else keyed on it.
29
+ */
30
+ sortValue?: (row: any) => unknown;
13
31
  headerCell?: (options: ColumnOptions) => HTMLElement;
14
32
  dataCell?: (options: ColumnOptions) => HTMLElement;
15
33
  }
@@ -77,6 +95,12 @@ export declare class TosiTable extends WebComponent {
77
95
  zIndex: string;
78
96
  background: string;
79
97
  };
98
+ ':host .tr:not(.table-cluster-first) .cluster-repeat': {
99
+ visibility: string;
100
+ };
101
+ ':host .tr.table-cluster-odd': {
102
+ _tosiTableBg: string;
103
+ };
80
104
  ':host .tr[aria-selected] .td': {
81
105
  background: string;
82
106
  };
@@ -152,6 +176,9 @@ export declare class TosiTable extends WebComponent {
152
176
  private _columns;
153
177
  private _filter;
154
178
  private _sort?;
179
+ private _rowGroupId;
180
+ private _visibleGroupedRowIds;
181
+ private _nonRepeatingGroupedRowCells;
155
182
  private _pinnedTopRows?;
156
183
  private _pinnedBottomRows?;
157
184
  get pinnedTopRows(): any[] | undefined;
@@ -166,6 +193,15 @@ export declare class TosiTable extends WebComponent {
166
193
  set array(newArray: any[]);
167
194
  get filter(): ArrayFilter;
168
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
+ private _grouping;
169
205
  get sort(): SortCallback | undefined;
170
206
  set sort(sortFunc: SortCallback | undefined);
171
207
  get columns(): ColumnOptions[];
@@ -188,6 +224,15 @@ export declare class TosiTable extends WebComponent {
188
224
  private cellStyle;
189
225
  private applyGridCellAttrs;
190
226
  private buildCell;
227
+ private tagClusterParity;
228
+ /**
229
+ * Is this row the first of its group? Always true when the table is ungrouped.
230
+ *
231
+ * Exposed for custom `dataCell` columns, which render themselves and so have to decide
232
+ * for themselves whether to show a value the rest of the group repeats.
233
+ */
234
+ isFirstInGroup(row: any): boolean;
235
+ private isRepeatedGroupRow;
191
236
  private buildRow;
192
237
  private buildHeaderCell;
193
238
  private buildHeader;