@quillmark/svelte 0.10.1 → 0.12.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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/THEMING.md +2 -2
  3. package/dist/core/address.d.ts +17 -10
  4. package/dist/core/address.js +26 -12
  5. package/dist/core/bloom.js +4 -3
  6. package/dist/core/codec/blocks.js +21 -1
  7. package/dist/core/codec/breaks.d.ts +10 -2
  8. package/dist/core/codec/breaks.js +65 -9
  9. package/dist/core/codec/code.d.ts +6 -4
  10. package/dist/core/codec/code.js +7 -4
  11. package/dist/core/codec/decode.js +103 -68
  12. package/dist/core/codec/encode.d.ts +5 -8
  13. package/dist/core/codec/encode.js +56 -86
  14. package/dist/core/codec/field.js +43 -24
  15. package/dist/core/codec/index.d.ts +2 -1
  16. package/dist/core/codec/index.js +6 -3
  17. package/dist/core/codec/islands.d.ts +16 -11
  18. package/dist/core/codec/islands.js +70 -59
  19. package/dist/core/codec/keymap.js +2 -1
  20. package/dist/core/codec/marks.d.ts +17 -13
  21. package/dist/core/codec/marks.js +39 -42
  22. package/dist/core/codec/positions.d.ts +4 -1
  23. package/dist/core/codec/positions.js +5 -1
  24. package/dist/core/codec/reconcile.d.ts +6 -0
  25. package/dist/core/codec/reconcile.js +19 -0
  26. package/dist/core/codec/schema.d.ts +5 -8
  27. package/dist/core/codec/schema.js +30 -156
  28. package/dist/core/codec/slash.js +0 -0
  29. package/dist/core/codec/table-view.js +77 -26
  30. package/dist/core/codec/table.d.ts +6 -5
  31. package/dist/core/codec/table.js +14 -31
  32. package/dist/core/codec/urls.d.ts +20 -0
  33. package/dist/core/codec/urls.js +45 -0
  34. package/dist/core/rungs.d.ts +8 -0
  35. package/dist/core/rungs.js +24 -0
  36. package/dist/core/theme.css +10 -0
  37. package/dist/preset/recipes.css +1 -1
  38. package/dist/preset/scale.css +7 -4
  39. package/dist/preview/bridge.d.ts +8 -1
  40. package/dist/preview/bridge.js +31 -25
  41. package/dist/preview/controller.js +13 -13
  42. package/dist/preview/geometry.d.ts +12 -1
  43. package/dist/preview/geometry.js +14 -1
  44. package/dist/preview/paint.js +6 -4
  45. package/dist/preview/strings.d.ts +0 -2
  46. package/dist/preview/strings.js +5 -6
  47. package/dist/visual/ArrayField.svelte +600 -173
  48. package/dist/visual/ArrayField.svelte.d.ts +14 -1
  49. package/dist/visual/BooleanField.svelte +10 -7
  50. package/dist/visual/Card.svelte +8 -30
  51. package/dist/visual/DateField.svelte +2 -2
  52. package/dist/visual/Field.svelte +76 -32
  53. package/dist/visual/FieldHint.svelte +1 -1
  54. package/dist/visual/MatrixField.svelte +352 -0
  55. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  56. package/dist/visual/ObjectField.svelte +356 -133
  57. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  58. package/dist/visual/VariantField.svelte +55 -2
  59. package/dist/visual/VariantField.svelte.d.ts +10 -1
  60. package/dist/visual/VisualEditor.svelte +1 -1
  61. package/dist/visual/VisualEditorInner.svelte +118 -67
  62. package/dist/visual/controls.css +39 -9
  63. package/dist/visual/diagnostics.d.ts +26 -2
  64. package/dist/visual/diagnostics.js +52 -5
  65. package/dist/visual/ext.d.ts +12 -13
  66. package/dist/visual/ext.js +22 -19
  67. package/dist/visual/hold.js +2 -2
  68. package/dist/visual/icons/nodes.d.ts +3 -0
  69. package/dist/visual/icons/nodes.js +2 -1
  70. package/dist/visual/index.js +2 -3
  71. package/dist/visual/leaves.d.ts +25 -12
  72. package/dist/visual/links.d.ts +4 -0
  73. package/dist/visual/links.js +15 -4
  74. package/dist/visual/motion.d.ts +14 -5
  75. package/dist/visual/motion.js +13 -11
  76. package/dist/visual/strings.d.ts +26 -13
  77. package/dist/visual/strings.js +7 -2
  78. package/dist/visual/structure.d.ts +101 -8
  79. package/dist/visual/structure.js +252 -13
  80. package/package.json +2 -2
@@ -1,35 +1,50 @@
1
1
  <!--
2
2
  An `array` field → an add/remove repeater. Elements commit by value: every
3
- edit / add / remove rebuilds the whole array and hands it to the parent's typed
3
+ edit / add / remove / move rebuilds the whole array and hands it to the parent's typed
4
4
  `writer.set(field, wholeArray)` (arrays are not op-addressed). Element control
5
5
  by `items.type`: `richtext` / `plaintext` → a prose element
6
- ({@link ProseValue}), `object` → a summary row that opens onto a subform,
7
- everything else → a text input. The
8
- add chip sits in the label header row (space-between with the field label);
9
- {@link Field} skips its own label for array controls and hands this component the
10
- label track with it.
6
+ ({@link ProseValue}), `object` → a summary row that opens onto a subform, or a
7
+ table's row where the array asks for one and the cells are short, everything else →
8
+ a text input. The add chip sits in the label header row (space-between with the field
9
+ label); {@link Field} skips its own label for array controls and hands this component
10
+ the label track with it. An array declaring `max:` disables the chip at the cap and
11
+ draws the count beside it: a disabled button takes no focus, so a `title` reaches
12
+ neither a screen reader nor a touch user, and the count is the carrier.
11
13
 
12
14
  An `object` element collapses: the row is its own summary — a box, titled by the
13
- element's first `string` cell — and opens onto {@link ObjectField}, one at a time.
14
- Stacking the subforms instead would nest a field one level past the depth the
15
- subform's own vertical draws, once per row. Opening is therefore part of a landing rather than something the
16
- user does first: `focusElement` opens the row it is aimed at before it focuses.
15
+ row's `items.ui.title` template or its first short text cell ({@link rowSummary}) —
16
+ and opens onto {@link ObjectField}, one at a time. Stacking the subforms instead would
17
+ nest a field one level past the depth the subform's own vertical draws, once per row.
18
+ Opening is therefore part of a landing rather than something the user does first:
19
+ `focusPath` opens the row it is aimed at before it focuses, and hands the rest of the
20
+ walk to the subform it opened, which is how a row two closed boxes down is reached.
17
21
 
18
- The remove is inside the element: a slab over the end of the element's own box, taking
19
- its two end-side corners. So a row's box is the element's box, and an array's rows end
20
- where every other field's control does. On an object element that box is the summary
21
- rather than the open row: a destructive control belongs to the line it sits on, not to
22
- everything that line has unfolded.
22
+ The table (`layout: 'table'`, VISUAL_EDITOR §"Structure mirrors the schema") is the
23
+ same row machine in another presentation: the same ids, the same splices, the same
24
+ landing and the same cell keys, over rows that are always open, each cell the
25
+ property's ordinary control on the track the header names ({@link ObjectField}
26
+ `bare`). Every row of it open, the whole of it stands where an open row's subform
27
+ stands: a rung inside the field, behind the subform's vertical. A table composes by
28
+ position, so it recurses into nothing: `arrayLayout` declines it for a row holding a
29
+ container or a block prose cell.
23
30
 
24
- Keys carry the list without the mouse: Enter inserts a
25
- sibling below and takes the caret there, Backspace on an empty element removes it
26
- and hands focus back up the list.
31
+ The row's controls are inside the element: a slab over the end of the element's own
32
+ box, taking its two end-side corners — the remove, and on an `object` row the reorder
33
+ pair before it. So a row's box is the element's box, and an array's rows end where
34
+ every other field's control does. On an object element that box is the summary rather
35
+ than the open row: a destructive control belongs to the line it sits on, not to
36
+ everything that line has unfolded. A table's row carries the remove alone, in its last
37
+ track and pinned to the end edge of the box the table scrolls in: every cell of the
38
+ row is on the line, so a mis-order is a retype as it is in a list of strings, and the
39
+ one gesture the row keeps never needs a scroll to reach.
27
40
 
28
- No reorder: an array's order is fixed at declaration/entry order. Elements
29
- carry a parallel session-id list, spliced with the values as one operation at
30
- whatever index a mutation names; so an element holds its id for life and the
31
- surviving order never permutes, which is what lets a keyed prose element survive an
32
- insert or a remove above it rather than remounting.
41
+ Keys carry the list without the mouse: Enter inserts a sibling below and takes the
42
+ caret there, Backspace on an empty element removes it and hands focus back up the
43
+ list, Alt+↑/↓ anywhere in an `object` row of the list moves it, the nearest row
44
+ answering and never the record around a nested one. A move is one splice of the ids
45
+ and of the values together, the mechanism insert and remove use, so an element keeps
46
+ its id for life and no prose leaf inside it remounts; the open row stays open across
47
+ its own move, and `animate:reorder` holds the moving row as it holds a card.
33
48
  -->
34
49
  <script lang="ts">
35
50
  import { wording } from './strings.js';
@@ -41,13 +56,24 @@
41
56
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
42
57
  import { emptyContent } from '../core/codec/index.js';
43
58
  import { createLifespan } from '../core/teardown.js';
44
- import { IdSeq, controlKind } from './structure.js';
59
+ import {
60
+ IdSeq,
61
+ controlKind,
62
+ humanize,
63
+ obliged,
64
+ rowSummary,
65
+ type ArrayLayout
66
+ } from './structure.js';
67
+ import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
68
+ import type { LandingBox } from './leaves.js';
45
69
  import { holdInView } from './hold.js';
70
+ import { reorder, reorderArm } from './motion.js';
46
71
  import Icon from './icons/Icon.svelte';
47
72
  import TextField from './TextField.svelte';
48
73
  import ObjectField from './ObjectField.svelte';
49
74
  import ProseValue from './ProseValue.svelte';
50
75
  import FieldLabel from './FieldLabel.svelte';
76
+ import DiagnosticList from './DiagnosticList.svelte';
51
77
  import './controls.css';
52
78
 
53
79
  /** The disclosure glyph, at the size the accordion's own chevron takes: one
@@ -57,6 +83,12 @@
57
83
  interface Props {
58
84
  value: unknown[] | undefined;
59
85
  items: QuillFieldSchema | undefined;
86
+ /** How the elements draw: the record list, or the table an `array<object>` of
87
+ * short cells asks for (`arrayLayout`). */
88
+ layout?: ArrayLayout;
89
+ /** The schema's `max:`, past which the add chip disables and Enter stops
90
+ * inserting; `undefined` is no cap. */
91
+ max?: number;
60
92
  /** Accessible-name prefix for the element controls (`label` + 1-based index). */
61
93
  label?: string;
62
94
  /** No-default field → a persistent required `*` on the label. */
@@ -82,10 +114,16 @@
82
114
  * control has spliced in and whose commit has yet to land, or was refused. */
83
115
  contentAt: (path: PathStep[]) => Content | undefined;
84
116
  onCommit: (arr: unknown[]) => void;
117
+ /** The diagnostics routed into this array, each with its steps still to walk: one
118
+ * naming a row draws under that row — under its head while it is collapsed, under
119
+ * the cell once it is open — and one naming no row draws at the list's foot. */
120
+ diagnostics?: readonly DeepDiagnostic[];
85
121
  }
86
122
  let {
87
123
  value,
88
124
  items,
125
+ layout = 'list',
126
+ max,
89
127
  label,
90
128
  required,
91
129
  description,
@@ -93,7 +131,8 @@
93
131
  descriptionId,
94
132
  idBase,
95
133
  contentAt,
96
- onCommit
134
+ onCommit,
135
+ diagnostics
97
136
  }: Props = $props();
98
137
 
99
138
  // The element control is the item schema's own, with no departure: a content-typed
@@ -101,7 +140,14 @@
101
140
  // `contentAt` whatever the element rests as. An array declaring no `items`
102
141
  // has text elements.
103
142
  const control = $derived(items ? controlKind(items) : 'text');
143
+ const table = $derived(layout === 'table' && control === 'object');
144
+ const columns = $derived(Object.entries(items?.properties ?? {}));
104
145
  const arr = $derived((value ?? []) as unknown[]);
146
+ const atCap = $derived(max != null && arr.length >= max);
147
+ const routed = $derived(splitDeep(diagnostics));
148
+ const foot = $derived(
149
+ unrouted(routed, (step) => typeof step === 'number' && step >= 0 && step < arr.length)
150
+ );
105
151
 
106
152
  // Parallel stable ids, one per element, kept in lockstep with the data below.
107
153
  // Seeded eagerly so a non-empty array renders its rows on the first pass:
@@ -116,10 +162,7 @@
116
162
  if (ids.length === n) return;
117
163
  if (ids.length < n) ids = [...ids, ...seq.take(n - ids.length)];
118
164
  else {
119
- for (const id of ids.slice(n)) {
120
- delete els[id];
121
- delete rowEls[id];
122
- }
165
+ for (const id of ids.slice(n)) drop(id);
123
166
  ids = ids.slice(0, n);
124
167
  }
125
168
  });
@@ -142,35 +185,36 @@
142
185
  const els: Record<string, { focus: () => void; setCaret?: (pos: number) => void } | undefined> =
143
186
  $state({});
144
187
  // The rows' own boxes, on the same key and dropped on the same paths: what an
145
- // element landing blooms in ({@link focusElement}), and what an object row's
188
+ // element landing blooms in ({@link focusPath}), and what an object row's
146
189
  // summary is found through. A row is a box whatever its element type, so the two
147
190
  // element shapes register alike.
148
191
  const rowEls: Record<string, HTMLElement | undefined> = $state({});
192
+ // A table row's cells, on the same key: the subform a landing and the column-keeping
193
+ // Enter hop reach into. Every table row is open, so there is one per row where the
194
+ // list holds one for the open row alone.
195
+ type Subform = {
196
+ focus: () => void;
197
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
198
+ };
199
+ const cellEls: Record<string, Subform | undefined> = $state({});
149
200
  let addEl: HTMLButtonElement | undefined = $state();
150
- let rootEl: HTMLElement | undefined = $state();
151
201
  let rowsEl: HTMLElement | undefined = $state();
202
+ function drop(id: string): void {
203
+ delete els[id];
204
+ delete rowEls[id];
205
+ delete cellEls[id];
206
+ }
152
207
 
153
208
  // ── Object elements: one open at a time ──────────────────────────────────────
154
209
  // So an array of ten records is ten lines and one figure, whatever its length.
155
210
  let openId = $state<string | undefined>(undefined);
156
211
  // The open row's subform, for the landing below. One entry, never a map: only one
157
212
  // row is open, so the ref is singular by the same rule the state is.
158
- let openObjEl = $state<{ focus: () => void } | undefined>();
213
+ let openObjEl = $state<Subform | undefined>();
159
214
 
160
- /** The property whose value titles a collapsed row: the first `string` cell in
161
- * declaration order, which is the order a schema states its own priority in. */
162
- const titleKey = $derived.by(() => {
163
- for (const [k, sub] of Object.entries(items?.properties ?? {})) {
164
- if (controlKind(sub) === 'text') return k;
165
- }
166
- return undefined;
167
- });
168
- /** A collapsed row's own words: the title cell's committed value, or `undefined`
169
- * while it has none. */
215
+ /** A collapsed row's own words, or `undefined` while it has none. */
170
216
  function elementTitle(k: number): string | undefined {
171
- const el = arr[k] as Record<string, unknown> | undefined;
172
- const v = titleKey ? el?.[titleKey] : undefined;
173
- return typeof v === 'string' && v.trim() ? v : undefined;
217
+ return rowSummary(items, arr[k]);
174
218
  }
175
219
  /** What an untitled row reads as: the name its `aria-label` already spends,
176
220
  * `label` + the 1-based index. */
@@ -184,10 +228,26 @@
184
228
  openId = openId === id ? undefined : id;
185
229
  });
186
230
  }
231
+ const rowName = (k: number): string | undefined =>
232
+ label != null ? `${label} ${k + 1}` : undefined;
233
+ /** A row control's name: its action, and the row, where the row has a name. */
234
+ const rowAction = (action: string, k: number): string => {
235
+ const row = rowName(k);
236
+ return row != null ? t.strings.arrayRowAction(action, row) : action;
237
+ };
238
+ // The count's id, for the chip it describes: off the field's id space where there is
239
+ // one, else this instance's own.
240
+ const uid = $props.id();
241
+ const countId = $derived(`${idBase ?? `qm-${uid}`}-count`);
242
+ const columnTitle = (key: string, sub: QuillFieldSchema): string =>
243
+ sub.ui?.title ?? humanize(key);
187
244
 
188
245
  // The awaited flush below is the only work that outlives a gesture here, so the
189
- // span carries no cancellers: it is the liveness `focusAfterFlush` asks for.
246
+ // span carries the reorder's frame and nothing else: it is the liveness
247
+ // `focusAfterFlush` asks for.
190
248
  const span = createLifespan();
249
+ const arm = reorderArm();
250
+ span.onEnd(arm.cancel);
191
251
  onDestroy(() => span.end());
192
252
 
193
253
  function emptyElement(): unknown {
@@ -205,8 +265,11 @@
205
265
  copy[k] = next === undefined ? emptyElement() : next;
206
266
  onCommit(copy);
207
267
  }
208
- /** Insert an empty element after `k` (`-1` prepends) and take focus to it. */
209
- function insertAfter(k: number): void {
268
+ /** Insert an empty element after `k` (`-1` prepends) and take focus to it: into
269
+ * `column` where the gesture came from a table cell, so Enter keeps its column. A
270
+ * no-op at the cap: no editor gesture commits an element past `max:`. */
271
+ function insertAfter(k: number, column?: string): void {
272
+ if (atCap) return;
210
273
  const id = seq.next();
211
274
  const at = k + 1;
212
275
  ids = [...ids.slice(0, at), id, ...ids.slice(at)];
@@ -215,27 +278,62 @@
215
278
  onCommit(next);
216
279
  // A row added is a row to fill in, so an object element arrives open: landing on
217
280
  // a collapsed empty summary would make adding one a two-press gesture.
218
- if (control === 'object') openId = id;
219
- focusAfterFlush(id);
281
+ if (control === 'object' && !table) openId = id;
282
+ void focusAfterFlush(id, column);
220
283
  }
221
284
  function add(): void {
222
285
  insertAfter(ids.length - 1);
223
286
  }
224
- function remove(k: number): void {
287
+ /** Remove element `k`. Focus lands on the element before it, or on the one that
288
+ * slid into its place; on the add affordance once the list is empty, which is then
289
+ * the only thing left to hold it. Clicking the remove needs this as much as the key
290
+ * does: the button under the pointer is part of what it destroys. A table lands on
291
+ * the control the gesture came from: `column`, so Backspace keeps its column as
292
+ * Enter does, or the remove where `fromRemove` says one was pressed, which is
293
+ * pinned and so keeps the clip where the pointer left it, where the first cell
294
+ * would scroll the box back to its start on every press. */
295
+ function remove(k: number, column?: string, fromRemove = false): void {
225
296
  const dropped = ids[k];
226
297
  const next = ids.filter((_, i) => i !== k);
227
298
  ids = next;
228
- delete els[dropped];
229
- delete rowEls[dropped];
299
+ drop(dropped);
230
300
  // The open row can be the one removed; `openId` is cleared with it rather than
231
301
  // left naming an element that has gone.
232
302
  if (openId === dropped) openId = undefined;
233
303
  onCommit(arr.filter((_, i) => i !== k));
234
- // Focus lands on the element before the removed one, or on the one that slid
235
- // into its place; on the add affordance once the list is empty, which is then
236
- // the only thing left to hold it. Clicking the remove needs this as much as the
237
- // key does: the button under the pointer is part of what it destroys.
238
- focusAfterFlush(next[Math.max(k - 1, 0)]);
304
+ void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
305
+ }
306
+ /**
307
+ * Move element `k` one slot: one splice of the ids and of the values together, so
308
+ * the element keeps its id and everything mounted under it. A no-op at either edge.
309
+ * The control that was pressed rides with the row, so it is focused again once the
310
+ * flush has moved it: a node moved in the DOM is a node the browser blurred. An arrow
311
+ * the move carried to its edge is disabled and takes no focus, so its twin does.
312
+ */
313
+ function move(k: number, dir: -1 | 1): void {
314
+ const to = k + dir;
315
+ if (to < 0 || to >= ids.length) return;
316
+ const pressed = document.activeElement;
317
+ arm.arm();
318
+ const nextIds = ids.slice();
319
+ const [id] = nextIds.splice(k, 1);
320
+ nextIds.splice(to, 0, id);
321
+ ids = nextIds;
322
+ const next = arr.slice();
323
+ const [v] = next.splice(k, 1);
324
+ next.splice(to, 0, v);
325
+ onCommit(next);
326
+ void (async () => {
327
+ if (!(await span.resumes(tick()))) return;
328
+ if (!(pressed instanceof HTMLElement) || !pressed.isConnected) return;
329
+ const target =
330
+ pressed instanceof HTMLButtonElement && pressed.disabled
331
+ ? [pressed.previousElementSibling, pressed.nextElementSibling].find(
332
+ (b) => b instanceof HTMLButtonElement && !b.disabled
333
+ )
334
+ : pressed;
335
+ if (target instanceof HTMLElement) target.focus();
336
+ })();
239
337
  }
240
338
  /** Take the caret: the first element, or the add affordance when the list is empty;
241
339
  * which is then the only thing there is to land on, and the next thing the user
@@ -243,6 +341,7 @@
243
341
  * ask one function so they cannot disagree (`Field`, `leaves.ts`). */
244
342
  export function focus(): void {
245
343
  if (ids.length === 0) return void addEl?.focus();
344
+ if (table) return void cellEls[ids[0]]?.focus();
246
345
  if (control === 'object') return focusObjectRow(ids[0]);
247
346
  els[ids[0]]?.focus();
248
347
  }
@@ -260,35 +359,47 @@
260
359
  export function washBox(): HTMLElement | undefined {
261
360
  return rowsEl;
262
361
  }
263
- /** Take the caret to element `k`, at USV `pos` where the row's control can take one:
264
- * what a landing on an element address resolves to (`leaves.ts`). The index resolves
265
- * to the element's session id here, at the call, never carried as one — an index is
266
- * stale the moment anything above it splices. Past the live list it falls back to
267
- * {@link focus}: the field is right and the row is gone, which is a landing off a
268
- * compile the document has moved past.
362
+ /**
363
+ * Land at `steps`: element `steps[0]`, and the rest of the walk inside its subform,
364
+ * at USV `pos` where the leaf the walk ends in can take one ({@link
365
+ * FieldControl.focusPath}). The index resolves to the element's session id here, at
366
+ * the call, never carried as one — an index is stale the moment anything above it
367
+ * splices. Past the live list it falls back to {@link focus}: the field is right and
368
+ * the row is gone, which is a landing off a compile the document has moved past.
269
369
  *
270
- * An absent `pos` is the placement rung, exactly as on `Landing`. A row that takes
271
- * no offset gets the bare focus: the JSON element, whose textarea has no coordinate
272
- * to spend one in, and a `string` element, which the compile never addresses.
370
+ * A collapsed row holds no control for a caret to land in, so opening it is part of
371
+ * the landing rather than something the user does first, and the subform it opens
372
+ * exists one flush later: that arm answers after the flush, with the rest of the
373
+ * walk handed to the subform then. An absent `pos` is the placement rung, exactly as
374
+ * on `Landing`; a row that takes no offset gets the bare focus.
273
375
  *
274
- * The row's box comes back with the landing: the arrival wash is the address's own
275
- * granularity, and one row is what an element address named. A fallback to the
276
- * field answers `undefined`, which is the field's box again (`leaves.ts`). */
277
- export function focusElement(k: number, pos?: number): HTMLElement | undefined {
278
- const id = ids[k];
279
- // A collapsed row holds no control for a caret to land in, so opening it is part
280
- // of the landing rather than something the user does first: a preview click that
281
- // resolves into an element has to arrive somewhere the caret can sit.
376
+ * The row's box comes back with the landing, or the innermost row a deeper walk
377
+ * opened: the arrival wash is the address's own granularity. A fallback to the field
378
+ * answers `undefined`, which is the field's box again (`leaves.ts`).
379
+ */
380
+ export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
381
+ const [k, ...rest] = steps;
382
+ const id = typeof k === 'number' ? ids[k] : undefined;
383
+ if (id === undefined) {
384
+ focus();
385
+ return undefined;
386
+ }
387
+ if (table) {
388
+ const cells = cellEls[id];
389
+ if (rest.length && cells) void cells.focusPath(rest, pos);
390
+ else cells?.focus();
391
+ return rowEls[id];
392
+ }
282
393
  if (control === 'object') {
283
- if (id === undefined) {
284
- focus();
285
- return undefined;
286
- }
287
394
  openId = id;
288
- void focusAfterFlush(id);
289
- return rowEls[id];
395
+ return (async () => {
396
+ if (!(await span.resumes(tick()))) return undefined;
397
+ if (rest.length && openObjEl) return (await openObjEl.focusPath(rest, pos)) ?? rowEls[id];
398
+ focusObjectRow(id);
399
+ return rowEls[id];
400
+ })();
290
401
  }
291
- const el = id === undefined ? undefined : els[id];
402
+ const el = els[id];
292
403
  if (!el) {
293
404
  focus();
294
405
  return undefined;
@@ -299,53 +410,113 @@
299
410
  }
300
411
  /** Focus element `id` after the flush, never in the same tick: a mutation commits
301
412
  * the array by value, so the parent re-derives and the row does not exist until
302
- * then. `undefined` is the empty list: the add affordance.
413
+ * then. `undefined` is the empty list: the add affordance. `column` is a table
414
+ * cell to land in rather than the row's first, and `fromRemove` the row's remove.
303
415
  *
304
416
  * The commit that schedules this can also remove the card holding the field, which
305
417
  * unmounts this component inside the window (core/teardown.ts). */
306
- async function focusAfterFlush(id: string | undefined): Promise<void> {
418
+ async function focusAfterFlush(
419
+ id: string | undefined,
420
+ column?: string,
421
+ fromRemove = false
422
+ ): Promise<void> {
307
423
  if (!(await span.resumes(tick()))) return;
308
424
  if (id === undefined) return void addEl?.focus();
425
+ if (table) {
426
+ // Without the focus scroll: the remove is pinned, so it is in view where it
427
+ // stands, and the scroll the browser would make is to its unpinned place at
428
+ // the row's end.
429
+ if (fromRemove)
430
+ return void rowEls[id]
431
+ ?.querySelector<HTMLElement>('.qm-remove')
432
+ ?.focus({ preventScroll: true });
433
+ const cells = cellEls[id];
434
+ if (column && cells) void cells.focusPath([column]);
435
+ else cells?.focus();
436
+ return;
437
+ }
309
438
  if (control === 'object') return focusObjectRow(id);
310
439
  els[id]?.focus();
311
440
  }
441
+ /** Whether a cell's committed value is at its blank: unset, the empty string, or a
442
+ * `Content` with no characters. A boolean or a number is never blank. */
443
+ function cellBlank(v: unknown): boolean {
444
+ if (v === undefined || v === '') return true;
445
+ return typeof v === 'object' && v !== null && 'text' in v && (v as Content).text === '';
446
+ }
312
447
  /** Whether element `k` reads empty to the user. A text element's committed value
313
448
  * lags the input: a cleared field commits at `change`, not per keystroke
314
449
  * ({@link TextField}); so the input's own value is the truth. A prose element
315
450
  * commits every edit, so the committed `Content` is; an authored string, the
316
- * transport-door rest, is empty when it has no characters. */
317
- function elementEmpty(k: number, target: EventTarget | null): boolean {
451
+ * transport-door rest, is empty when it has no characters. A table's row is empty
452
+ * when the cell under the caret is and so is every cell beside it — beside, because
453
+ * the caret's own committed value is the one that lags, and counting it twice would
454
+ * hold the row full until a blur. */
455
+ function elementEmpty(k: number, target: EventTarget | null, column?: string): boolean {
318
456
  if (control === 'prose') {
319
457
  const el = arr[k];
320
458
  if (typeof el === 'string') return el.length === 0;
321
459
  return !(el as Content | undefined)?.text;
322
460
  }
461
+ if (table) {
462
+ const row = (arr[k] ?? {}) as Record<string, unknown>;
463
+ const under =
464
+ target instanceof HTMLInputElement
465
+ ? !target.value
466
+ : !target || !(target as HTMLElement).textContent;
467
+ const beside = Object.entries(row).every(([c, v]) => c === column || cellBlank(v));
468
+ return under && beside;
469
+ }
323
470
  return target instanceof HTMLInputElement && !target.value;
324
471
  }
325
472
  /**
326
473
  * The element keyboard contract. Both keys ride the element control's own keydown
327
474
  * (the input's, or the PM view's through `handleDOMEvents`) since neither
328
- * surface is a place a keymap of this component's could sit. The JSON element is
329
- * out: Enter is a newline in a textarea.
475
+ * surface is a place a keymap of this component's could sit. A collapsed `object`
476
+ * element wires neither: its row is a summary button, whose Enter is the
477
+ * disclosure's. A table's cell wires both through the subform ({@link ObjectField}
478
+ * `onCellKey`), Enter keeping the column it was pressed in.
479
+ *
480
+ * Riding the control is what decides which cells answer: a text cell and a prose
481
+ * cell leave both keys free, where a select opens its list on Enter, a switch
482
+ * toggles on it and a date segment edits on Backspace. So a table's row keys answer
483
+ * from its text and prose columns.
330
484
  */
331
- function onElementKey(e: KeyboardEvent, k: number): void {
332
- if (control === 'object' || e.isComposing) return;
485
+ function onElementKey(e: KeyboardEvent, k: number, column?: string): void {
486
+ if (e.isComposing) return;
333
487
  if (e.key === 'Enter') {
334
488
  e.preventDefault();
335
- insertAfter(k);
336
- } else if (e.key === 'Backspace' && !e.repeat && elementEmpty(k, e.target)) {
489
+ insertAfter(k, column);
490
+ } else if (e.key === 'Backspace' && !e.repeat && elementEmpty(k, e.target, column)) {
337
491
  // Destructive with nothing to undo it, so it takes a deliberate press:
338
492
  // `repeat` is a held key running on past the character it just cleared, and
339
493
  // the emptiness test reads the state before this keystroke applies; so the
340
494
  // press that empties an element never also removes it.
341
495
  e.preventDefault();
342
- remove(k);
496
+ remove(k, column);
343
497
  }
344
498
  }
499
+ /** The reorder's keyboard twin, on the row so it answers from the summary and from
500
+ * any cell an open row holds: Alt+↑/↓, the table island's own binding. The nearest
501
+ * row answers, or none does: a press inside a nested row is that row's whether it
502
+ * holds a reorder or not, so a key in a table's cell or a list of strings' input
503
+ * moves nothing, and never the record around it. */
504
+ function onRowKey(e: KeyboardEvent, k: number): void {
505
+ if (!e.altKey || e.shiftKey || e.ctrlKey || e.metaKey || e.isComposing) return;
506
+ // A cell that took the key — a select opening, a date segment stepping, a prose
507
+ // leaf joining — owns it.
508
+ if (e.defaultPrevented) return;
509
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
510
+ const nearest = e.target instanceof Element ? e.target.closest('.qm-array-row') : null;
511
+ if (nearest !== e.currentTarget) return;
512
+ const dir = e.key === 'ArrowUp' ? -1 : 1;
513
+ if (k + dir < 0 || k + dir >= ids.length) return;
514
+ e.preventDefault();
515
+ move(k, dir);
516
+ }
345
517
  </script>
346
518
 
347
519
  <div
348
- bind:this={rootEl}
349
520
  class="qm-array"
350
521
  class:empty={ids.length === 0}
351
522
  role="group"
@@ -365,67 +536,129 @@
365
536
  {:else}
366
537
  <span></span>
367
538
  {/if}
368
- <button
369
- type="button"
370
- class="qm-add-el qm-chip qm-focus-ring qm-tap-floor"
371
- bind:this={addEl}
372
- onclick={add}>{t.strings.arrayAdd}</button
373
- >
539
+ <!-- The cap is drawn, not parked in a `title`: a disabled chip takes no focus and
540
+ announces nothing, and disabled is a promise that there is a state in which
541
+ you could, so the count says which state. -->
542
+ <span class="qm-array-add-slot">
543
+ {#if max != null}
544
+ <span class="qm-array-count" id={countId}>{t.strings.arrayCount(arr.length, max)}</span>
545
+ {/if}
546
+ <button
547
+ type="button"
548
+ class="qm-add-el qm-chip qm-focus-ring qm-tap-floor"
549
+ bind:this={addEl}
550
+ aria-describedby={max != null ? countId : undefined}
551
+ disabled={atCap}
552
+ onclick={add}>{t.strings.arrayAdd}</button
553
+ >
554
+ </span>
374
555
  </div>
375
- <div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
376
- {#each ids as id, k (id)}
377
- {#if control === 'object'}
378
- {@const open = openId === id}
379
- {@const shown = elementTitle(k)}
380
- <div class="qm-array-row qm-element" class:open bind:this={rowEls[id]}>
556
+ <!-- Three lists, one per row shape, because `animate:` is granted only to a keyed
557
+ each block's one child: a branch inside the block would stand between them. -->
558
+ {#if table}
559
+ <div class="qm-array-rows qm-array-table" class:empty={ids.length === 0} bind:this={rowsEl}>
560
+ <div class="qm-array-table-scroller" style:--table-cols={columns.length}>
561
+ <!-- The header names the columns, so a cell carries no label of its own: its
562
+ accessible name is the row's and the column's composed (`ObjectField`). The
563
+ obligation mark and the guidance affordance ride the header, being the
564
+ column's rather than any one cell's. -->
565
+ <div class="qm-array-table-head">
566
+ {#each columns as [key, sub] (key)}
567
+ <div class="qm-array-table-col" class:mark={controlKind(sub) === 'boolean'}>
568
+ <FieldLabel
569
+ label={columnTitle(key, sub)}
570
+ required={obliged(sub)}
571
+ description={sub.description}
572
+ />
573
+ </div>
574
+ {/each}
575
+ <span class="qm-array-table-head-end"></span>
576
+ </div>
577
+ {#each ids as id, k (id)}
578
+ <div class="qm-array-row qm-array-table-row" bind:this={rowEls[id]}>
579
+ <ObjectField
580
+ bare
581
+ bind:this={cellEls[id]}
582
+ value={(arr[k] ?? {}) as Record<string, unknown>}
583
+ properties={items?.properties}
584
+ label={rowName(k)}
585
+ contentAt={(path) => contentAt([k, ...path])}
586
+ onCommit={(obj) => commitElement(k, obj)}
587
+ onCellKey={(e, column) => onElementKey(e, k, column)}
588
+ diagnostics={routed.below.get(k)}
589
+ />
590
+ {@render removeButton(k)}
591
+ </div>
592
+ {/each}
593
+ </div>
594
+ </div>
595
+ {:else if control === 'object'}
596
+ <div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
597
+ {#each ids as id, k (id)}
598
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
599
+ <!-- The handler catches a key from the controls inside the row and adds no
600
+ interaction of the row's own: the row is no tab stop. -->
601
+ <div
602
+ class="qm-array-row qm-element"
603
+ class:open={openId === id}
604
+ bind:this={rowEls[id]}
605
+ animate:reorder={arm.armed}
606
+ onkeydown={(e) => onRowKey(e, k)}
607
+ >
381
608
  <!-- The head is the row in collapsed form, and it is a box: the element IS
382
- a value, the way the enum trigger is, and the remove slab's grammar
383
- (the box's two end-side corners) needs corners to take. So a list of
384
- records measures like a list of inputs whatever the element type. -->
609
+ a value, the way the enum trigger is, and the slab's grammar (the box's
610
+ end-side corners) needs corners to take. So a list of records measures
611
+ like a list of inputs whatever the element type. -->
385
612
  <div class="qm-element-head">
386
613
  <button
387
614
  type="button"
388
615
  class="qm-control-box qm-focus-ring qm-element-summary"
389
- aria-expanded={open}
616
+ aria-expanded={openId === id}
390
617
  onclick={(e) => toggleRow(id, e.currentTarget)}
391
618
  >
392
619
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
393
620
  screen, where this unfolds in place. Same glyph, same rotation and
394
621
  same rung as the accordion's, so the surface has one disclosure. -->
395
622
  <Icon name="chevron-right" class="qm-el-chevron" size={CHEVRON} />
396
- {#if shown}
397
- <span class="qm-element-title">{shown}</span>
623
+ {#if elementTitle(k)}
624
+ <span class="qm-element-title">{elementTitle(k)}</span>
398
625
  {:else}
399
626
  <span class="qm-element-title untitled">{untitled(k)}</span>
400
627
  {/if}
401
628
  </button>
402
- <button
403
- type="button"
404
- class="qm-icon-btn qm-remove qm-focus-ring"
405
- title={t.strings.arrayRemove}
406
- onclick={() => remove(k)}><Icon name="minus" /></button
407
- >
629
+ <div class="qm-row-actions">
630
+ {@render rowActions(k)}
631
+ </div>
408
632
  </div>
409
- {#if open}
633
+ {#if openId === id}
410
634
  <ObjectField
411
635
  bind:this={openObjEl}
412
636
  value={(arr[k] ?? {}) as Record<string, unknown>}
413
637
  properties={items?.properties}
414
- label={label != null ? `${label} ${k + 1}` : undefined}
638
+ label={rowName(k)}
415
639
  idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
416
640
  contentAt={(path) => contentAt([k, ...path])}
417
641
  onCommit={(obj) => commitElement(k, obj)}
642
+ diagnostics={routed.below.get(k)}
418
643
  />
644
+ {:else}
645
+ <!-- A collapsed row draws no cell, so what its cells would have said is said
646
+ under the head, where the row can be seen: the message names the leaf. -->
647
+ <DiagnosticList diagnostics={routed.below.get(k)?.map((d) => d.diagnostic)} />
419
648
  {/if}
420
649
  </div>
421
- {:else}
650
+ {/each}
651
+ </div>
652
+ {:else}
653
+ <div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
654
+ {#each ids as id, k (id)}
422
655
  <div class="qm-array-row" bind:this={rowEls[id]}>
423
656
  {#if control === 'prose'}
424
657
  <ProseValue
425
658
  bind:this={els[id]}
426
659
  content={() => contentAt([k]) ?? emptyContent()}
427
660
  plaintext={items?.type === 'plaintext'}
428
- label={label != null ? `${label} ${k + 1}` : undefined}
661
+ label={rowName(k)}
429
662
  onChange={(rt) => commitElement(k, rt)}
430
663
  onKey={(e) => onElementKey(e, k)}
431
664
  />
@@ -433,23 +666,56 @@
433
666
  <TextField
434
667
  bind:this={els[id]}
435
668
  value={String(arr[k] ?? '')}
436
- label={label != null ? `${label} ${k + 1}` : undefined}
669
+ label={rowName(k)}
437
670
  onCommit={(v) => commitElement(k, v)}
438
671
  onKey={(e) => onElementKey(e, k)}
439
672
  />
440
673
  {/if}
441
- <button
442
- type="button"
443
- class="qm-icon-btn qm-remove qm-focus-ring"
444
- title={t.strings.arrayRemove}
445
- onclick={() => remove(k)}><Icon name="minus" /></button
446
- >
674
+ <div class="qm-row-actions">
675
+ {@render removeButton(k)}
676
+ </div>
677
+ <DiagnosticList diagnostics={routed.below.get(k)?.map((d) => d.diagnostic)} />
447
678
  </div>
448
- {/if}
449
- {/each}
450
- </div>
679
+ {/each}
680
+ </div>
681
+ {/if}
682
+ <DiagnosticList diagnostics={foot} />
451
683
  </div>
452
684
 
685
+ <!-- The remove, one button wherever a row ends: a scalar row's slab, a record row's
686
+ slab after its reorder pair, a table row's last track. -->
687
+ {#snippet removeButton(k: number)}
688
+ <button
689
+ type="button"
690
+ class="qm-icon-btn qm-row-btn qm-remove qm-focus-ring"
691
+ title={t.strings.arrayRemove}
692
+ aria-label={rowAction(t.strings.arrayRemove, k)}
693
+ onclick={() => remove(k, undefined, true)}><Icon name="minus" /></button
694
+ >
695
+ {/snippet}
696
+
697
+ <!-- An `object` row's controls: the reorder pair, disabled at its edge as the card
698
+ header's is, then the remove. -->
699
+ {#snippet rowActions(k: number)}
700
+ <button
701
+ type="button"
702
+ class="qm-icon-btn qm-row-btn qm-focus-ring"
703
+ title={t.strings.arrayMoveUp}
704
+ aria-label={rowAction(t.strings.arrayMoveUp, k)}
705
+ disabled={k === 0}
706
+ onclick={() => move(k, -1)}><Icon name="chevron-up" /></button
707
+ >
708
+ <button
709
+ type="button"
710
+ class="qm-icon-btn qm-row-btn qm-focus-ring"
711
+ title={t.strings.arrayMoveDown}
712
+ aria-label={rowAction(t.strings.arrayMoveDown, k)}
713
+ disabled={k === ids.length - 1}
714
+ onclick={() => move(k, 1)}><Icon name="chevron-down" /></button
715
+ >
716
+ {@render removeButton(k)}
717
+ {/snippet}
718
+
453
719
  <style>
454
720
  .qm-array {
455
721
  display: flex;
@@ -466,6 +732,18 @@
466
732
  justify-content: space-between;
467
733
  gap: var(--_qm-space-2);
468
734
  }
735
+ .qm-array-add-slot {
736
+ display: inline-flex;
737
+ align-items: center;
738
+ gap: var(--_qm-space-2);
739
+ }
740
+ /* The count reads at the label rung: it is a fact about the field, stated beside the
741
+ chip in the register the chip rests at, and never a value. */
742
+ .qm-array-count {
743
+ font-size: var(--_qm-text-label);
744
+ color: var(--_qm-ink-label);
745
+ font-variant-numeric: tabular-nums;
746
+ }
469
747
  /* The elements, in a box of their own: the arrival wash blooms here rather than over
470
748
  the wrapper `Field` hands this component (`washBox`), which is the label's box too.
471
749
  Positioned for the wash's inset child, and rounded to the rung a row's own box
@@ -484,73 +762,114 @@
484
762
  .qm-array-rows.empty {
485
763
  display: none;
486
764
  }
487
- /* A grid rather than a block: an `<input>` and a `<textarea>` are inline-level and
488
- would sit on a baseline, standing the row a descender taller than the box the slab
489
- measures itself against. `minmax(0, …)` because a long unbroken value grows an `auto`
490
- track, and the edge with it. */
765
+ /* A grid rather than a block: an `<input>` is inline-level and would sit on a
766
+ baseline, standing the row a descender taller than the box the slab measures itself
767
+ against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
768
+ edge with it. */
491
769
  .qm-array-row {
492
770
  position: relative;
493
771
  display: grid;
494
772
  grid-template-columns: minmax(0, 1fr);
773
+ row-gap: var(--_qm-space);
495
774
  /* The corners the row's wash takes, an element landing blooming here rather than
496
- over the whole list (`focusElement`): the row draws no box of its own, so the
775
+ over the whole list (`focusPath`): the row draws no box of its own, so the
497
776
  radius it lends the wash is the one its element's box draws. */
498
777
  border-radius: var(--_qm-radius-inner);
499
778
  }
500
- /* The end inset the slab stands in, taken off whichever box the element drew:
501
- `.qm-input` is the text element and the JSON textarea, `.qm-control-box` the prose
502
- one. `:global`, because the box belongs to the child component's markup and the scope
503
- class stops at this component's. The longhand beats the family's `padding` shorthand
504
- without a specificity fight: this block is unlayered and `controls.css` is not. */
505
- .qm-array-row :global(.qm-input),
506
- .qm-array-row :global(.qm-control-box) {
779
+ /* The end inset the slabs stand in, taken off whichever box the element drew:
780
+ `.qm-input` is the text element, `.qm-control-box` the prose one — the row's own
781
+ child, so a table's cells, which are boxes too, keep their inset — and an object
782
+ row's summary, which stands three slabs off its end. `:global`, because the box
783
+ belongs to the child component's markup and the scope class stops at this
784
+ component's. The longhand beats the family's `padding` shorthand without a
785
+ specificity fight: `controls.css` ranks the family at zero inside `:where()`. */
786
+ .qm-array-row > :global(.qm-input),
787
+ .qm-array-row > :global(.qm-control-box) {
507
788
  padding-inline-end: var(--_qm-tap-min);
508
789
  }
790
+ .qm-element-summary {
791
+ padding-inline-end: calc(3 * var(--_qm-tap-min));
792
+ }
509
793
  /* The box's end wall, floor to ceiling: a height of its own would leave a sliver of
510
794
  well above or below, and the corners it takes are the box's — the end-side pair keeps
511
- `.qm-icon-btn`'s radius, which is the same rung the box draws.
795
+ `.qm-icon-btn`'s radius, which is the same rung the box draws; a slab standing before
796
+ another gives up its corners on both sides.
512
797
 
513
798
  It comes up on its own row rather than on the field: a destructive control is offered
514
- by the row the pointer is on, not by every row at once. Hover is where it says
515
- destructive, ink with fill, a tint alone being a wash under a label-toned glyph. */
516
- .qm-remove {
799
+ by the row the pointer is on, not by every row at once, and the reorder pair beside
800
+ it comes up with it. Hover is where the remove says destructive, ink with fill, a
801
+ tint alone being a wash under a label-toned glyph. */
802
+ .qm-row-actions {
517
803
  position: absolute;
518
804
  inset-block: 0;
519
805
  inset-inline-end: 0;
520
- width: var(--_qm-tap-min);
806
+ display: flex;
521
807
  color: var(--_qm-ink-label);
522
808
  opacity: var(--_qm-opacity-idle);
523
- border-start-start-radius: 0;
524
- border-end-start-radius: 0;
809
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
810
+ }
811
+ .qm-row-btn {
812
+ height: 100%;
813
+ width: var(--_qm-tap-min);
814
+ padding: 0;
815
+ border-radius: 0;
525
816
  transition:
526
- opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
527
817
  background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
528
818
  color var(--_qm-duration-fast) var(--_qm-ease-reverse);
529
819
  }
530
- .qm-remove :global(svg) {
820
+ .qm-row-btn:last-child {
821
+ border-start-end-radius: var(--_qm-radius-inner);
822
+ border-end-end-radius: var(--_qm-radius-inner);
823
+ }
824
+ .qm-row-btn :global(svg) {
531
825
  width: var(--_qm-glyph-control);
532
826
  height: var(--_qm-glyph-control);
533
827
  }
534
- .qm-array-row:hover .qm-remove,
535
- .qm-array-row:focus-within .qm-remove {
828
+ /* Child combinators: a nested array shares this scope, and a row's hover is not its
829
+ open subform's rows'. */
830
+ .qm-array-row:hover > .qm-row-actions,
831
+ .qm-array-row:focus-within > .qm-row-actions,
832
+ .qm-array-row:hover > .qm-element-head > .qm-row-actions,
833
+ .qm-array-row:focus-within > .qm-element-head > .qm-row-actions {
536
834
  opacity: 1;
537
835
  }
538
- .qm-remove:hover {
836
+ .qm-remove:hover:not(:disabled) {
539
837
  background: var(--_qm-danger-tint);
540
838
  color: var(--_qm-danger);
541
839
  }
542
840
  /* ── An object element ──────────────────────────────────────────────────────
543
- The row is a summary and the subform hangs under it. `row-gap` rather than a
544
- margin on the subform, because the distance between a control and what it has
545
- unfolded belongs to the thing stacking them: the variant field spends its own `gap` on
546
- exactly this, and what `ObjectField` adds is the cap on its own stroke, equal at both
547
- of its ends. */
841
+ The row is a summary and the subform hangs under it. `row-gap` rather than a margin
842
+ on the subform, because the distance between a control and what it has unfolded
843
+ belongs to the thing stacking them; what `ObjectField` adds is the cap on its own
844
+ stroke, equal at both of its ends.
845
+
846
+ The distance inside an open row is the list's own, and the row then takes a rung of
847
+ air from the rows either side of it, so what it unfolded is nearer the summary it
848
+ hangs off than the sibling below. The wider distance on the inside is the inversion:
849
+ a subform reading as the next row's preamble. A variant spends the wider rung on the
850
+ same join and needs no such guard, a field carrying one control and no siblings for
851
+ its cells to drift toward. */
548
852
  .qm-element {
549
- row-gap: var(--_qm-space-2);
853
+ row-gap: var(--_qm-space);
854
+ }
855
+ .qm-element.open {
856
+ margin-block: var(--_qm-space);
550
857
  }
551
- /* The slab measures the head, not the row: an open element is the head plus
552
- everything it unfolded, and a destructive control belongs to the line it sits on
553
- rather than to all of that. Positioned, so `.qm-remove` anchors here. */
858
+ /* The inline distance is the stacker's too, and here it is a rung: the summary stands
859
+ for the row rather than being a cell of it, so its subform's vertical takes the
860
+ disclosure's own column — `--_qm-nest` in, the distance every stroke on the ladder
861
+ keeps from the one outside it (ARCHITECTURE §"A plane is a tone"). Flush with the
862
+ row's edge that stroke is collinear with the box above it and with the sibling row
863
+ below, so a row's cells read as the list's own rung and the content of an open row
864
+ stands outboard of the chevron that opened it. A variant's cells and a matrix's
865
+ columns keep the flush edge: those sit beside the discriminant and the tick they are
866
+ stored with, not under them. */
867
+ .qm-element > :global(.qm-object) {
868
+ margin-inline-start: var(--_qm-nest);
869
+ }
870
+ /* The slabs measure the head, not the row: an open element is the head plus
871
+ everything it unfolded, and the controls belong to the line they sit on rather than
872
+ to all of that. Positioned, so `.qm-row-actions` anchors here. */
554
873
  .qm-element-head {
555
874
  position: relative;
556
875
  display: grid;
@@ -560,8 +879,7 @@
560
879
  in collapsed form the way the enum trigger is one, and the slab's grammar — the
561
880
  box's two end-side corners — needs a box with corners to take. It carries
562
881
  `.qm-control-box`, so the fill, the radius, the inset and the type are the recipe's
563
- (controls.css) and a list of records measures like the list of inputs beside it.
564
- The end inset the slab stands in arrives from the row's own rule above. */
882
+ (controls.css) and a list of records measures like the list of inputs beside it. */
565
883
  .qm-element-summary {
566
884
  display: flex;
567
885
  align-items: center;
@@ -572,7 +890,10 @@
572
890
  cursor: pointer;
573
891
  }
574
892
  /* No hover fill: a well does not fill under the pointer anywhere on this surface.
575
- The chevron's ink is the cue, which is the accordion header's own ladder. */
893
+ The chevron's ink is the cue, which is the accordion header's own ladder. An open
894
+ row's rule reaches its own head and stops: a row's subform holds rows of this same
895
+ shape under this same scope, so a descendant's would turn every collapsed chevron
896
+ inside an open row to the open face. */
576
897
  .qm-element-summary :global(.qm-el-chevron) {
577
898
  flex-shrink: 0;
578
899
  display: block;
@@ -584,10 +905,10 @@
584
905
  color var(--_qm-duration-fast) var(--_qm-ease-reverse);
585
906
  }
586
907
  .qm-element-summary:hover :global(.qm-el-chevron),
587
- .qm-element.open :global(.qm-el-chevron) {
908
+ .qm-element.open > .qm-element-head :global(.qm-el-chevron) {
588
909
  color: var(--_qm-ink);
589
910
  }
590
- .qm-element.open :global(.qm-el-chevron) {
911
+ .qm-element.open > .qm-element-head :global(.qm-el-chevron) {
591
912
  transform: rotate(90deg);
592
913
  }
593
914
  /* The title is the element's own value, so it reads at the ink a written value
@@ -602,11 +923,117 @@
602
923
  color: var(--_qm-ink-label);
603
924
  font-style: italic;
604
925
  }
926
+ /* ── A table ────────────────────────────────────────────────────────────────
927
+ Every row of it is open, so it stands where an open row's subform stands: a rung
928
+ inside the field, behind the subform's vertical and between its caps (`ObjectField`,
929
+ ARCHITECTURE §"A plane is a tone"). The stroke is what says the header's labels are
930
+ the field's insides: on the field's own edge at the label's register, labels abreast
931
+ over boxes are the figure a row of compact fields draws. A corner reads square at a
932
+ stroke, so the wash takes none here.
933
+
934
+ The grid is a box inside the stroke, and the one that scrolls: the stroke and the
935
+ inset hold still while the columns move, and the clip is the inset's edge rather than
936
+ the stroke's. One grid over the header and every row: a column per property, then a
937
+ track for the row's remove. The header and each row subgrid onto it, so a
938
+ cell's edge is its column's whatever the row above it holds. The row packs at the
939
+ start rather than sharing the field's leftover width between the columns: a table
940
+ carries its own rhythm, and stretching four short cells across a field sets them
941
+ apart by the width the card happens to have.
942
+
943
+ A track rests at what its cells cannot shrink below and grows no further than its
944
+ widest, so a field too narrow for them all overflows the grid's box and scrolls
945
+ sideways. A fixed floor as the track's base would instead hand each column a share of
946
+ the width and leave a control that does not shrink — a date's segments — painting
947
+ over the column beside it; the floor rides the header cell, which every column has.
948
+
949
+ The pad is the clip box's, the ring on a cell at that box's own edge reaching past
950
+ its content; the margin takes the same distance back, so the tracks stand a nest in
951
+ from the label row above them and the caps keep their rung. The box keeps the
952
+ remove's track clear of the scrolls it makes itself, so a cell scrolled into view
953
+ lands beside the pinned button rather than under it.
954
+
955
+ The names are the array's own: `.qm-table` is the codec's island (`prose.css`), an
956
+ unscoped stylesheet in this package, and a box wearing that name takes the island's
957
+ hairline whatever this block says. */
958
+ .qm-array-rows.qm-array-table {
959
+ border-inline-start: var(--_qm-vertical-width) solid var(--_qm-border);
960
+ padding-inline-start: var(--_qm-nest);
961
+ padding-block: var(--_qm-space);
962
+ border-radius: 0;
963
+ }
964
+ .qm-array-table-scroller {
965
+ display: grid;
966
+ grid-template-columns:
967
+ repeat(var(--table-cols), minmax(min-content, max-content))
968
+ auto;
969
+ justify-content: start;
970
+ gap: var(--_qm-space);
971
+ overflow-x: auto;
972
+ scroll-padding-inline-end: calc(var(--_qm-tap-min) + var(--_qm-space));
973
+ padding: var(--_qm-ring-reach);
974
+ margin: calc(-1 * var(--_qm-ring-reach));
975
+ }
976
+ .qm-array-table-head,
977
+ .qm-array-row.qm-array-table-row {
978
+ display: grid;
979
+ grid-column: 1 / -1;
980
+ grid-template-columns: subgrid;
981
+ column-gap: var(--_qm-space);
982
+ }
983
+ .qm-array-row.qm-array-table-row {
984
+ align-items: start;
985
+ }
986
+ /* The header's cells are labels standing over columns, at the rung a field label
987
+ stands at over its control, and where the column's floor is stated: a column is a
988
+ property and every one of them has a header, so the label carries the track's.
989
+
990
+ A switch column carries none. The floor is the width a control that fills its track
991
+ stays usable at, and a switch is a mark: one width at every width, so the rest of
992
+ the floor is whitespace the column spends on nothing and the table scrolls that much
993
+ sooner. Without it the column rests at its header's own width, its cells still
994
+ clearing the mark, which no track can shrink below. It is the table's to state
995
+ because the table is where a column is a width of its own; in a section's grid and
996
+ in a subform's, a boolean stands in a track its neighbours share. */
997
+ .qm-array-table-col {
998
+ min-width: var(--_qm-track-min);
999
+ }
1000
+ .qm-array-table-col.mark {
1001
+ min-width: 0;
1002
+ }
1003
+ /* The remove is the row's last track, pinned to the end edge of the box the table
1004
+ scrolls in: sticky, so it rests in its track while the table fits and rides over
1005
+ the cells once the box scrolls, on the card's plane so what passes under it is
1006
+ hidden rather than overprinted, and stretched to the row so no cell shows above or
1007
+ below it. Always drawn: a table's rows are many and the eye finds the control by
1008
+ its column. The header's end cell pins with it, or the labels would scroll under
1009
+ nothing while the cells scroll under a plane.
1010
+
1011
+ The inset is the content box's, so the ring's reach fits inside the clip; the plane
1012
+ spread over that same reach hides the sliver of cell the pad would show past the
1013
+ button, and the ring paints over it.
1014
+
1015
+ The slab's grammar goes with the slab: a button on the row's own plane takes the
1016
+ icon family's radius at all four corners, where one cut into a box takes that box's
1017
+ end-side pair and squares the rest. */
1018
+ .qm-array-table-row > .qm-remove,
1019
+ .qm-array-table-head-end {
1020
+ position: sticky;
1021
+ inset-inline-end: 0;
1022
+ background: var(--_qm-surface);
1023
+ box-shadow: 0 0 0 var(--_qm-ring-reach) var(--_qm-surface);
1024
+ }
1025
+ .qm-array-table-row > .qm-remove {
1026
+ align-self: stretch;
1027
+ height: auto;
1028
+ border-radius: var(--_qm-radius-inner);
1029
+ color: var(--_qm-ink-label);
1030
+ }
605
1031
  /* The label line's own type: size, weight and leading are `.qm-field-label`'s, so
606
1032
  the two read as one register. Inner radius: the chip family is the card's, and this
607
1033
  box is a line of type. The chip's tap floor is given back (`.qm-tap-floor`),
608
1034
  and the padding with it, so the drawn box is the line and the target is the floor.
609
- It rests dim and comes up under the pointer; empty, it is the way in. */
1035
+ It rests dim and comes up under the pointer; empty, it is the way in; at the cap it
1036
+ rests at the family's disabled rung and the count beside it says why. */
610
1037
  .qm-add-el {
611
1038
  padding: 0 var(--_qm-space);
612
1039
  font-weight: var(--_qm-weight-mid);
@@ -614,7 +1041,7 @@
614
1041
  border-radius: var(--_qm-radius-inner);
615
1042
  opacity: var(--_qm-opacity-idle);
616
1043
  }
617
- .qm-add-el:hover,
1044
+ .qm-add-el:hover:not(:disabled),
618
1045
  .qm-add-el:focus-visible {
619
1046
  opacity: 1;
620
1047
  }
@@ -623,7 +1050,7 @@
623
1050
  }
624
1051
  @media (hover: none) {
625
1052
  .qm-add-el,
626
- .qm-remove {
1053
+ .qm-row-actions {
627
1054
  opacity: var(--_qm-opacity-muted);
628
1055
  }
629
1056
  }