@quillmark/svelte 0.11.0 → 0.13.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 (62) hide show
  1. package/dist/core/address.d.ts +17 -10
  2. package/dist/core/address.js +26 -12
  3. package/dist/core/bloom.js +4 -3
  4. package/dist/core/codec/decode.d.ts +17 -0
  5. package/dist/core/codec/decode.js +78 -19
  6. package/dist/core/codec/encode.js +1 -1
  7. package/dist/core/codec/field.d.ts +64 -15
  8. package/dist/core/codec/field.js +201 -65
  9. package/dist/core/codec/index.d.ts +3 -3
  10. package/dist/core/codec/index.js +3 -3
  11. package/dist/core/codec/islands.d.ts +5 -4
  12. package/dist/core/codec/islands.js +23 -38
  13. package/dist/core/codec/keymap.d.ts +5 -4
  14. package/dist/core/codec/keymap.js +5 -4
  15. package/dist/core/codec/prose.css +42 -0
  16. package/dist/core/codec/schema.d.ts +24 -5
  17. package/dist/core/codec/schema.js +40 -9
  18. package/dist/core/codec/slash.js +0 -0
  19. package/dist/core/errors.d.ts +3 -2
  20. package/dist/core/rungs.d.ts +8 -0
  21. package/dist/core/rungs.js +24 -0
  22. package/dist/core/theme.css +19 -6
  23. package/dist/preview/paint.js +21 -17
  24. package/dist/visual/ArrayField.svelte +649 -176
  25. package/dist/visual/ArrayField.svelte.d.ts +16 -1
  26. package/dist/visual/BooleanField.svelte +36 -11
  27. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  28. package/dist/visual/Card.svelte +6 -25
  29. package/dist/visual/DateField.svelte +66 -31
  30. package/dist/visual/EnumField.svelte +27 -9
  31. package/dist/visual/EnumField.svelte.d.ts +5 -0
  32. package/dist/visual/Field.svelte +113 -38
  33. package/dist/visual/FormatPopover.svelte +4 -3
  34. package/dist/visual/MatrixField.svelte +353 -0
  35. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  36. package/dist/visual/NumberField.svelte +31 -14
  37. package/dist/visual/NumberField.svelte.d.ts +5 -1
  38. package/dist/visual/ObjectField.svelte +404 -139
  39. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  40. package/dist/visual/ProseField.svelte +53 -23
  41. package/dist/visual/ProseField.svelte.d.ts +15 -3
  42. package/dist/visual/ProseValue.svelte +102 -46
  43. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  44. package/dist/visual/TextField.svelte +35 -18
  45. package/dist/visual/TextField.svelte.d.ts +7 -0
  46. package/dist/visual/VariantField.svelte +56 -2
  47. package/dist/visual/VariantField.svelte.d.ts +10 -1
  48. package/dist/visual/VisualEditorInner.svelte +77 -73
  49. package/dist/visual/controls.css +47 -5
  50. package/dist/visual/diagnostics.d.ts +26 -2
  51. package/dist/visual/diagnostics.js +52 -5
  52. package/dist/visual/hold.js +2 -2
  53. package/dist/visual/icons/nodes.d.ts +3 -0
  54. package/dist/visual/icons/nodes.js +2 -1
  55. package/dist/visual/leaves.d.ts +25 -12
  56. package/dist/visual/motion.d.ts +14 -5
  57. package/dist/visual/motion.js +13 -11
  58. package/dist/visual/strings.d.ts +27 -10
  59. package/dist/visual/strings.js +8 -2
  60. package/dist/visual/structure.d.ts +159 -40
  61. package/dist/visual/structure.js +337 -45
  62. package/package.json +2 -2
@@ -1,8 +1,10 @@
1
1
  <!--
2
2
  Type dispatch (VISUAL_EDITOR §"Structure mirrors the schema"). Given one projected
3
3
  {@link FieldModel} and its live value, render the label + the control the type
4
- maps to. Array controls own their label (paired with the add affordance in
5
- {@link ArrayField}); other types render the label here. Prose leaves take a
4
+ maps to. Array and matrix controls own their label (paired with the add affordance in
5
+ {@link ArrayField}, with the held count in {@link MatrixField}); other types render
6
+ the label here. A container control also takes the walk a nested address makes into
7
+ it ({@link FieldControl.focusPath}) and the diagnostics anchored inside it. Prose leaves take a
6
8
  parent-built live `addr` (its `card` a getter over the stable-id→index map) so
7
9
  a reorder re-targets without a remount; scalars, arrays, and objects commit
8
10
  their value up through `onCommitScalar`, which the parent lowers to the typed
@@ -11,7 +13,8 @@
11
13
  `diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
12
14
  `diagByKey`, merging `quill.validate`, local commit errors, and the external
13
15
  `diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
14
- `DiagnosticList`, non-gating.
16
+ `DiagnosticList`, non-gating; each set also re-evaluates a prose leaf's hold
17
+ (`ProseField`).
15
18
  -->
16
19
  <script lang="ts">
17
20
  import type {
@@ -24,9 +27,22 @@
24
27
  ResolvedField
25
28
  } from '@quillmark/wasm';
26
29
  import type { EditorErrorHandler } from '../core/errors.js';
27
- import type { LeafRegistry } from './leaves.js';
30
+ import type { LandingBox, LeafRegistry } from './leaves.js';
28
31
  import type { FieldModel, FieldSpan } from './structure.js';
29
- import { VARIANT_DISCRIMINANT, ghostDefault, stringifyGhost } from './structure.js';
32
+ import {
33
+ VARIANT_DISCRIMINANT,
34
+ arrayLayout,
35
+ baseType,
36
+ exampleGhost,
37
+ ghostDefault,
38
+ isContainer,
39
+ optionalCell,
40
+ printedText,
41
+ stringifyGhost,
42
+ titleText
43
+ } from './structure.js';
44
+ import { wording } from './strings.js';
45
+ import { deepen } from './diagnostics.js';
30
46
  import type { FieldDomIds } from './domid.js';
31
47
  import ProseField from './ProseField.svelte';
32
48
  import TextField from './TextField.svelte';
@@ -37,6 +53,7 @@
37
53
  import ArrayField from './ArrayField.svelte';
38
54
  import ObjectField from './ObjectField.svelte';
39
55
  import VariantField from './VariantField.svelte';
56
+ import MatrixField from './MatrixField.svelte';
40
57
  import DiagnosticList from './DiagnosticList.svelte';
41
58
  import FieldLabel from './FieldLabel.svelte';
42
59
 
@@ -94,12 +111,29 @@
94
111
  diagnostics
95
112
  }: Props = $props();
96
113
 
97
- // The ghost the control shows when unset: the resolved `default:` (provenance,
114
+ const t = wording();
115
+
116
+ // What an unset field shows of its `default:`: the resolved value (provenance,
98
117
  // `source === 'default'`). `ghost` is the raw typed value (enum/number/boolean
99
- // fallbacks); `defaultStr` its string form: the text placeholder and the date
100
- // control's `YYYY-MM-DD`. An object-valued default does not ghost.
118
+ // fallbacks, a content leaf's `Content`), `defaultStr` its string form (the date
119
+ // control's `YYYY-MM-DD`); an object-valued default has none. An array draws its
120
+ // declared literal instead, which is what a take writes (`ArrayField`).
121
+ // A default that prints stands in the control as the value it is, at the default
122
+ // rung, and the first edit takes it (VISUAL_EDITOR §"The commitment ladder"). Where
123
+ // nothing prints, a control draws words: the `none` an optional cell prints, and
124
+ // a free-text field's `example:`.
101
125
  const ghost = $derived(ghostDefault(provenance));
102
- const defaultStr = $derived(stringifyGhost(ghost));
126
+ const defaultStr = $derived(
127
+ field.control === 'prose' ? titleText(ghost) || undefined : stringifyGhost(ghost)
128
+ );
129
+ const printed = $derived(printedText(defaultStr));
130
+ // A text or number control reads its default off the schema, not the resolved row:
131
+ // the row stops naming a default once the field is written, and emptying a written
132
+ // field still has to know that the default prints.
133
+ const declared = $derived(printedText(field.schema.default));
134
+ const optional = $derived(optionalCell(field.schema));
135
+ const none = $derived(optional && value == null ? t.strings.optionalGhost : undefined);
136
+ const example = $derived(value == null ? exampleGhost(field.schema) : undefined);
103
137
  // A variant resolves as one rung whose value is the whole container, so the
104
138
  // discriminant's ghost is that container's own discriminant cell.
105
139
  const ghostMember = $derived(
@@ -123,6 +157,13 @@
123
157
  // reference must vanish with it: `aria-describedby` pointing at nothing describes
124
158
  // nothing, and silently.
125
159
  const describedBy = $derived(field.description ? domIds.description : undefined);
160
+ // Two controls own their label row, the add chip and the held count sharing it.
161
+ const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
162
+
163
+ // A container walks its diagnostics down to the cell each names and draws the rest
164
+ // at its own foot (`diagnostics.ts`); every other control draws them here, under
165
+ // itself, there being nothing inside it for a path to name.
166
+ const deep = $derived(deepen(diagnostics));
126
167
 
127
168
  /** The boundary's nested content read with this field's address already bound: an
128
169
  * array's elements, an object's properties, a variant's cells, and a subform's cells
@@ -145,28 +186,29 @@
145
186
  // restores a selection, a date field lands on its first segment, an array lands on
146
187
  // its first element or on the add affordance that is all an empty one has, an
147
188
  // object on its first property.
189
+ /** What a container control exports: its focus, and the walk into it. */
190
+ type Inside = {
191
+ focus: () => void;
192
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
193
+ washBox?: () => HTMLElement | undefined;
194
+ };
148
195
  let proseEl = $state<{ focus: () => void } | undefined>();
149
196
  let dateEl = $state<{ focus: () => void } | undefined>();
150
- let arrayEl = $state<
151
- | {
152
- focus: () => void;
153
- focusElement: (k: number, pos?: number) => HTMLElement | undefined;
154
- washBox: () => HTMLElement | undefined;
155
- }
156
- | undefined
157
- >();
158
- let objectEl = $state<{ focus: () => void } | undefined>();
197
+ let arrayEl = $state<Inside | undefined>();
198
+ let objectEl = $state<Inside | undefined>();
199
+ let variantEl = $state<Inside | undefined>();
200
+ let matrixEl = $state<Inside | undefined>();
201
+ const inside = (): Inside | undefined => arrayEl ?? objectEl ?? variantEl ?? matrixEl;
159
202
  function focusControl(): void {
160
- const owner = proseEl ?? dateEl ?? arrayEl ?? objectEl;
203
+ const owner = proseEl ?? dateEl ?? inside();
161
204
  if (owner) return owner.focus();
162
205
  document.getElementById(domIds.control)?.focus();
163
206
  }
164
- /** The array's per-ELEMENT landing, for the addresses the preview mints under an
165
- * array field (`leaves.ts`); read at the call, so it tracks the mounted repeater.
166
- * The row it settled in comes back with it, the wash being the landing's own
167
- * granularity. */
168
- function focusElement(k: number, pos?: number): HTMLElement | undefined {
169
- return arrayEl?.focusElement(k, pos);
207
+ /** The landing at depth, for the addresses the boundary mints under a container
208
+ * (`leaves.ts`); read at the call, so it tracks the mounted control. The innermost
209
+ * row it settled in comes back with it, the wash being the landing's own granularity. */
210
+ function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
211
+ return inside()?.focusPath(steps, pos);
170
212
  }
171
213
  // Only where `for` cannot reach; the labelable four are the browser's own, and a
172
214
  // second handler over them would be a focus the label already placed.
@@ -176,17 +218,17 @@
176
218
  * This field's landing handle. The wrapper is the bloom host rather than the
177
219
  * control: `bloomInside` appends an inset child and an `<input>` holds none. The
178
220
  * label is outside it, and stays out of the wash: an arrival marks where the caret
179
- * landed, which is the control. The one control that owns its label is the array, so
180
- * that one names its own box (`ArrayField.washBox`), read at the bloom the way
181
- * `focusElement` is read at the call.
221
+ * landed, which is the control. A control that owns its label (`ownsLabel`: the array
222
+ * and the matrix) holds that row inside itself, so it names its own box
223
+ * (`washBox`) and the bloom reads that in the wrapper's place.
182
224
  *
183
225
  * A prose leaf is absent here — it registers its own controller from inside
184
226
  * `ProseField`, carrying the codec seam this handle has no half of — and reactive
185
227
  * rather than mount-once, because a retype can swap the control under a leaf key
186
228
  * that does not remount.
187
229
  *
188
- * An array carries the per-element lane as well; no other control has elements for
189
- * an address to name. `el` is the field-wide box, which an element landing does not
230
+ * A container carries the walk into it as well; no other control has an inside for
231
+ * an address to name. `el` is the field-wide box, which a landing at depth does not
190
232
  * wash: that lane hands back its own row (`leaves.ts`).
191
233
  */
192
234
  let controlEl = $state<HTMLElement | undefined>();
@@ -197,9 +239,9 @@
197
239
  const wrapper = controlEl;
198
240
  registry.registerControl(key, {
199
241
  focus: focusControl,
200
- focusElement: field.control === 'array' ? focusElement : undefined,
242
+ focusPath: isContainer(field.control) ? focusPath : undefined,
201
243
  get el() {
202
- return arrayEl?.washBox() ?? wrapper;
244
+ return inside()?.washBox?.() ?? wrapper;
203
245
  }
204
246
  });
205
247
  return () => registry.unregisterControl(key);
@@ -217,7 +259,7 @@
217
259
  </script>
218
260
 
219
261
  <div class="qm-field" class:cell={span === 'cell'}>
220
- {#if field.control !== 'array'}
262
+ {#if !ownsLabel}
221
263
  <FieldLabel
222
264
  label={field.label}
223
265
  controlId={labelable ? domIds.control : undefined}
@@ -242,7 +284,10 @@
242
284
  {addr}
243
285
  inline={field.inline}
244
286
  plaintext={field.plaintext}
245
- placeholder={defaultStr}
287
+ fallback={printed ? (ghost as Content) : undefined}
288
+ placeholder={none}
289
+ placeholderUntilEdit
290
+ {example}
246
291
  labelledBy={domIds.label}
247
292
  {describedBy}
248
293
  {leafKey}
@@ -251,12 +296,15 @@
251
296
  {onChange}
252
297
  {onError}
253
298
  {leaves}
299
+ {diagnostics}
254
300
  />
255
301
  {:else if field.control === 'enum'}
256
302
  <EnumField
257
303
  value={value as string | undefined}
258
304
  values={field.schema.values ?? []}
259
305
  fallback={ghost as string | undefined}
306
+ blankTitle={field.schema.ui?.blank_title}
307
+ {optional}
260
308
  id={domIds.control}
261
309
  {describedBy}
262
310
  onCommit={onCommitScalar}
@@ -265,6 +313,7 @@
265
313
  />
266
314
  {:else if field.control === 'variant'}
267
315
  <VariantField
316
+ bind:this={variantEl}
268
317
  value={value as Record<string, unknown> | undefined}
269
318
  schema={field.schema}
270
319
  {ghostMember}
@@ -276,12 +325,14 @@
276
325
  onCommit={onCommitScalar}
277
326
  {optionAllowed}
278
327
  {enumDisallowed}
328
+ diagnostics={deep}
279
329
  />
280
330
  {:else if field.control === 'number'}
281
331
  <NumberField
282
332
  value={value as number | undefined}
283
- integer={field.schema.type === 'integer'}
284
- fallback={ghost as number | undefined}
333
+ integer={baseType(field.schema) === 'integer'}
334
+ fallback={declared}
335
+ placeholder={none}
285
336
  id={domIds.control}
286
337
  {describedBy}
287
338
  onCommit={onCommitScalar}
@@ -290,6 +341,7 @@
290
341
  <BooleanField
291
342
  value={value as boolean | undefined}
292
343
  fallback={ghost as boolean | undefined}
344
+ {optional}
293
345
  id={domIds.control}
294
346
  {describedBy}
295
347
  onCommit={onCommitScalar}
@@ -307,7 +359,10 @@
307
359
  <ArrayField
308
360
  bind:this={arrayEl}
309
361
  value={value as unknown[] | undefined}
362
+ fallback={Array.isArray(field.schema.default) ? field.schema.default : undefined}
310
363
  items={field.schema.items}
364
+ layout={arrayLayout(field.schema)}
365
+ max={field.schema.max}
311
366
  label={field.label}
312
367
  required={field.required}
313
368
  description={field.description}
@@ -316,6 +371,7 @@
316
371
  idBase={domIds.control}
317
372
  {contentAt}
318
373
  onCommit={onCommitScalar}
374
+ diagnostics={deep}
319
375
  />
320
376
  {:else if field.control === 'object'}
321
377
  <ObjectField
@@ -328,11 +384,28 @@
328
384
  {describedBy}
329
385
  {contentAt}
330
386
  onCommit={onCommitScalar}
387
+ diagnostics={deep}
388
+ />
389
+ {:else if field.control === 'matrix'}
390
+ <MatrixField
391
+ bind:this={matrixEl}
392
+ value={value as Record<string, unknown> | undefined}
393
+ schema={field.schema}
394
+ label={field.label}
395
+ description={field.description}
396
+ labelId={domIds.label}
397
+ descriptionId={domIds.description}
398
+ idBase={domIds.control}
399
+ {contentAt}
400
+ onCommit={onCommitScalar}
401
+ diagnostics={deep}
331
402
  />
332
403
  {:else}
333
404
  <TextField
334
405
  value={value as string | undefined}
335
- placeholder={defaultStr}
406
+ fallback={declared}
407
+ placeholder={none}
408
+ {example}
336
409
  id={domIds.control}
337
410
  {describedBy}
338
411
  onCommit={onCommitScalar}
@@ -340,7 +413,9 @@
340
413
  {/if}
341
414
  </div>
342
415
 
343
- <DiagnosticList {diagnostics} />
416
+ {#if !isContainer(field.control)}
417
+ <DiagnosticList {diagnostics} />
418
+ {/if}
344
419
  </div>
345
420
  </div>
346
421
 
@@ -142,9 +142,10 @@
142
142
  const selection = view?.state.selection;
143
143
  const formattable = selection instanceof TextSelection && !selection.empty;
144
144
  // …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
145
- // types (`plaintextSchema`), so this surface withholds itself over one entirely,
146
- // the way it never rises over the text input a `string` field draws: the value is
147
- // literal text, and a mark on it is a value the boundary refuses to coerce back.
145
+ // types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
146
+ // one entirely, the way it never rises over the text input a `string` field draws:
147
+ // the value is literal text, and a mark on it is a value the boundary refuses to
148
+ // coerce back.
148
149
  if (
149
150
  !view ||
150
151
  !view.hasFocus() ||
@@ -0,0 +1,353 @@
1
+ <!--
2
+ A `matrix` field → ticks over the roster, columns unfolding under a held member
3
+ (VISUAL_EDITOR §"Structure mirrors the schema", canon `SCHEMAS.md` §Matrix). The
4
+ roster is one flat list, one member to a row, or abreast on the section's track ladder
5
+ under `ui.compact`; each member is a real checkbox bound to its `held` cell with
6
+ its title as the `<label for>`, an unheld member reading at the label ink and a held
7
+ one at the value ink. A held member unfolds its columns beneath its title, drawn by
8
+ {@link ObjectField} over the matrix's `properties` at the member's key, the way a
9
+ variant unfolds its cells; zero columns is a checklist and unfolds nothing. An unheld
10
+ member's retained columns are held but undrawn, the variant rule: in the document and
11
+ not on the page.
12
+
13
+ The control owns its label row, as an array does: the count of held members sits
14
+ where an array's add chip sits, one piece of chrome with one answer, so {@link Field}
15
+ skips its own label for it.
16
+
17
+ Commit is whole: the sparse map, one `writer.set`. A tick writes the member object
18
+ with an explicit `held`, an untick keeps the columns under `held: false`, and a member
19
+ unheld with no columns leaves the map (`memberWrite`). What is blank per column is the
20
+ engine's rule and not restated: a column control that clears to unset drops its key,
21
+ so the editor drops a member it has emptied rather than deciding skippability for
22
+ itself; one whose default prints clears to the empty answer, which the member holds.
23
+ The read takes both rest forms — a bare tick and `{held?, …columns}`, whose `held` rests
24
+ at `false` where the mapping names none — and the write lands one, on the member edited
25
+ alone; every other member rides through as the document spelled it.
26
+ -->
27
+ <script lang="ts">
28
+ import { tick as flush } from 'svelte';
29
+ import { wording } from './strings.js';
30
+
31
+ // The surface's words, ambient from the editor root; the package's English
32
+ // off-tree, so this component renders standalone too.
33
+ const t = wording();
34
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
35
+ import {
36
+ MATRIX_HELD,
37
+ commitMember,
38
+ matrixColumns,
39
+ matrixDeclares,
40
+ matrixHeld,
41
+ matrixMembers,
42
+ memberValue,
43
+ memberWrite
44
+ } from './structure.js';
45
+ import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
46
+ import type { LandingBox } from './leaves.js';
47
+ import { propertyDomIds } from './domid.js';
48
+ import Icon from './icons/Icon.svelte';
49
+ import FieldLabel from './FieldLabel.svelte';
50
+ import ObjectField from './ObjectField.svelte';
51
+ import DiagnosticList from './DiagnosticList.svelte';
52
+ import './controls.css';
53
+
54
+ interface Props {
55
+ /** The stored sparse map, or undefined while the field is unset. */
56
+ value: Record<string, unknown> | undefined;
57
+ /** The field's own schema: `members` is the roster, `properties` the columns. */
58
+ schema: QuillFieldSchema;
59
+ /** Field label, the naming prefix a member's columns compose with. */
60
+ label?: string;
61
+ /** Schema `description`: the label's help affordance. */
62
+ description?: string;
63
+ /** The label's own DOM id: the group's name, each tick keeping its own `<label>`. */
64
+ labelId?: string;
65
+ /** Where the description parks, for the group's `aria-describedby`. */
66
+ descriptionId?: string;
67
+ /** The field's control id: the base a member's tick and columns derive their own
68
+ * names from, one `-m-<member id>` segment down. */
69
+ idBase?: string;
70
+ /** The boundary's nested content read, rooted at this field: a member's content
71
+ * column reads at `[id, column]` (`reader.getContentAt`). */
72
+ contentAt: (path: PathStep[]) => Content | undefined;
73
+ onCommit: (v: Record<string, unknown> | undefined) => void;
74
+ /** The diagnostics routed into this field, each with its steps still to walk: one
75
+ * naming a member draws under that member, one naming nothing on the roster at the
76
+ * field's foot. */
77
+ diagnostics?: readonly DeepDiagnostic[];
78
+ }
79
+ let {
80
+ value,
81
+ schema,
82
+ label,
83
+ description,
84
+ labelId,
85
+ descriptionId,
86
+ idBase,
87
+ contentAt,
88
+ onCommit,
89
+ diagnostics
90
+ }: Props = $props();
91
+
92
+ const members = $derived(matrixMembers(schema.members));
93
+ const compact = $derived(!!schema.ui?.compact);
94
+ const hasColumns = $derived(Object.keys(schema.properties ?? {}).length > 0);
95
+ const held = (id: string): boolean => matrixHeld(memberValue(value, id));
96
+ const heldCount = $derived(members.filter((m) => held(m.id)).length);
97
+ const routed = $derived(splitDeep(diagnostics));
98
+ const foot = $derived(
99
+ unrouted(routed, (step) => typeof step === 'string' && matrixDeclares(schema, step))
100
+ );
101
+
102
+ // A member with no field id space around it still needs an id: the tick is what its
103
+ // title's `for` names. Stable across SSR and hydration, which a counter is not.
104
+ const uid = $props.id();
105
+ const memberBase = (id: string): string =>
106
+ idBase != null ? `${idBase}-m-${id}` : `qm-${uid}-${id}`;
107
+ /** The tick is the member's `held` cell, so its id is the one that cell's label
108
+ * would take: the column subform below it derives its own names from the same base. */
109
+ const tickId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_HELD).control;
110
+ const titleId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_HELD).label;
111
+
112
+ // The member boxes, the ticks and the column subforms, keyed by member id — the
113
+ // roster's own key, stable for the schema's life. `$state` for the binding's sake
114
+ // (`ArrayField` keeps its element refs the same way).
115
+ type Subform = {
116
+ focus: () => void;
117
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
118
+ };
119
+ const memberEls: Record<string, HTMLElement | undefined> = $state({});
120
+ const tickEls: Record<string, HTMLInputElement | undefined> = $state({});
121
+ const colEls: Record<string, Subform | undefined> = $state({});
122
+ let rosterEl = $state<HTMLElement | undefined>();
123
+ /** A ref by member id, own keys only: an id can be spelled `constructor`. */
124
+ const ref = <T,>(map: Record<string, T | undefined>, id: string): T | undefined =>
125
+ Object.hasOwn(map, id) ? map[id] : undefined;
126
+
127
+ /** A native checkbox carries its own state, so a commit the document declines leaves
128
+ * the face ticked over a map that says otherwise, `checked={held(id)}` having nothing
129
+ * new to write. The reassert after the flush is what the styled controls get from
130
+ * their synced local (`synced.svelte.ts`): the document is what the face reads. */
131
+ async function tick(id: string, on: boolean, el: HTMLInputElement): Promise<void> {
132
+ onCommit(commitMember(value, id, memberWrite(on, matrixColumns(memberValue(value, id)))));
133
+ await flush();
134
+ if (el.isConnected) el.checked = held(id);
135
+ }
136
+ /** A column edit lands on a held member: the subform draws under a tick alone. */
137
+ function commitColumns(id: string, columns: Record<string, unknown>): void {
138
+ onCommit(commitMember(value, id, memberWrite(true, columns)));
139
+ }
140
+
141
+ /** Take the caret: the first member's tick. Reached by a label click and by the
142
+ * editor's landing verbs, which ask one function so they cannot disagree. */
143
+ export function focus(): void {
144
+ const first = members[0];
145
+ if (first) ref(tickEls, first.id)?.focus();
146
+ }
147
+ /** The box an arrival wash blooms in: the roster, not the label row above it, this
148
+ * component owning the field's label as an array does. */
149
+ export function washBox(): HTMLElement | undefined {
150
+ return rosterEl;
151
+ }
152
+ /**
153
+ * Land at `steps`: a member, or a column under it ({@link FieldControl.focusPath}).
154
+ * A member address and its `held` cell focus the tick; a column address focuses
155
+ * that column where the member is held and its columns are drawn, and the tick
156
+ * where they are not — the address names the column, and what is drawn for it is
157
+ * the tick that would open it. One rule keyed on the address, never on the
158
+ * document, so one address lands in one place. A member off the roster falls back
159
+ * to {@link focus}. The innermost row the walk opened comes back, else the member's
160
+ * box: the member is the row the address named.
161
+ */
162
+ export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
163
+ const [id, ...rest] = steps;
164
+ if (typeof id !== 'string' || !matrixDeclares(schema, id)) {
165
+ focus();
166
+ return undefined;
167
+ }
168
+ const column = rest[0];
169
+ const cells = ref(colEls, id);
170
+ const member = ref(memberEls, id);
171
+ if (column !== undefined && column !== MATRIX_HELD && cells) {
172
+ const inner = cells.focusPath(rest, pos);
173
+ return inner instanceof Promise ? inner.then((box) => box ?? member) : (inner ?? member);
174
+ }
175
+ ref(tickEls, id)?.focus();
176
+ return member;
177
+ }
178
+ </script>
179
+
180
+ <div
181
+ class="qm-matrix"
182
+ role="group"
183
+ aria-labelledby={label != null ? labelId : undefined}
184
+ aria-describedby={description ? descriptionId : undefined}
185
+ >
186
+ <div class="qm-matrix-header">
187
+ {#if label != null}
188
+ <FieldLabel {label} id={labelId} {descriptionId} onActivate={focus} {description} />
189
+ {:else}
190
+ <span></span>
191
+ {/if}
192
+ <span class="qm-matrix-count">{t.strings.matrixHeld(heldCount, members.length)}</span>
193
+ </div>
194
+ <div class="qm-matrix-roster" class:qm-tracks={compact} class:compact bind:this={rosterEl}>
195
+ {#each members as m (m.id)}
196
+ {@const on = held(m.id)}
197
+ {@const deep = routed.below.get(m.id)}
198
+ <div class="qm-member" class:held={on} bind:this={memberEls[m.id]}>
199
+ <div class="qm-member-head">
200
+ <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
201
+ dial reaches, so the input is `appearance: none` and the box and the
202
+ mark beside it read the rungs. The mark is the surface's one check
203
+ glyph, shown by the input's own state. -->
204
+ <span class="qm-tick">
205
+ <input
206
+ type="checkbox"
207
+ class="qm-tick-input qm-focus-ring"
208
+ id={tickId(m.id)}
209
+ checked={on}
210
+ bind:this={tickEls[m.id]}
211
+ onchange={(e) => tick(m.id, e.currentTarget.checked, e.currentTarget)}
212
+ />
213
+ <Icon name="check" class="qm-tick-mark" />
214
+ </span>
215
+ <label class="qm-member-title" id={titleId(m.id)} for={tickId(m.id)}>{m.title}</label>
216
+ </div>
217
+ {#if on && hasColumns}
218
+ <!-- The columns, one rung in, the way a variant's cells unfold under the
219
+ discriminant. Keyed by member id above, so a tick elsewhere leaves this
220
+ subform mounted and the caret in it. -->
221
+ <ObjectField
222
+ bind:this={colEls[m.id]}
223
+ value={matrixColumns(memberValue(value, m.id))}
224
+ properties={schema.properties}
225
+ label={`${label ?? ''} ${m.title}`.trim()}
226
+ idBase={memberBase(m.id)}
227
+ labelledBy={titleId(m.id)}
228
+ contentAt={(path) => contentAt([m.id, ...path])}
229
+ onCommit={(columns) => commitColumns(m.id, columns)}
230
+ diagnostics={deep}
231
+ />
232
+ {:else}
233
+ <DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
234
+ {/if}
235
+ </div>
236
+ {/each}
237
+ </div>
238
+ <DiagnosticList diagnostics={foot} />
239
+ </div>
240
+
241
+ <style>
242
+ .qm-matrix {
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap: var(--_qm-space);
246
+ }
247
+ /* The label row, the array's own shape: this component owns the label track
248
+ (`Field.svelte`), and the count stands where the add chip would. */
249
+ .qm-matrix-header {
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: space-between;
253
+ gap: var(--_qm-space-2);
254
+ }
255
+ /* The count reads at the label rung: a fact about the field, never a value. */
256
+ .qm-matrix-count {
257
+ font-size: var(--_qm-text-label);
258
+ color: var(--_qm-ink-label);
259
+ font-variant-numeric: tabular-nums;
260
+ }
261
+ /* The roster, one member to a row in declaration order. Positioned for the wash's
262
+ inset child (`washBox`), and rounded to the rung a row's box draws, as an array's
263
+ rows are. */
264
+ .qm-matrix-roster {
265
+ position: relative;
266
+ display: flex;
267
+ flex-direction: column;
268
+ gap: var(--_qm-space);
269
+ min-width: 0;
270
+ border-radius: var(--_qm-radius-inner);
271
+ }
272
+ /* Compact, the members stand abreast in reading order at the count the ladder gives
273
+ the width (`.qm-tracks`, controls.css), a held member's columns unfolding inside its
274
+ own track. */
275
+ .qm-matrix-roster.compact {
276
+ display: grid;
277
+ grid-template-columns: repeat(var(--cols), 1fr);
278
+ align-items: start;
279
+ column-gap: var(--_qm-space-2);
280
+ }
281
+ /* A member is a row: positioned for the wash a landing on it blooms, rounded to the
282
+ rung the rows around it draw. */
283
+ .qm-member {
284
+ position: relative;
285
+ display: flex;
286
+ flex-direction: column;
287
+ gap: var(--_qm-space);
288
+ border-radius: var(--_qm-radius-inner);
289
+ }
290
+ /* The tick and its title on one line, the line holding the tap floor a tick alone
291
+ would not reach: the title is the target, the `for` carrying its press to the input. */
292
+ .qm-member-head {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: var(--_qm-space-2);
296
+ min-height: var(--_qm-tap-min);
297
+ }
298
+ .qm-tick {
299
+ position: relative;
300
+ display: inline-flex;
301
+ flex-shrink: 0;
302
+ width: var(--_qm-glyph-control);
303
+ height: var(--_qm-glyph-control);
304
+ }
305
+ /* The box: a well, edged, since a mark this small has no plane under it to step off
306
+ by tone alone; held, it takes the accent the switch's track takes when checked. */
307
+ .qm-tick-input {
308
+ appearance: none;
309
+ margin: 0;
310
+ width: 100%;
311
+ height: 100%;
312
+ border: var(--_qm-border-width) solid var(--_qm-border);
313
+ border-radius: calc(var(--_qm-radius-inner) / 2);
314
+ background: var(--_qm-surface-well);
315
+ cursor: pointer;
316
+ transition:
317
+ background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
318
+ border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
319
+ }
320
+ .qm-tick-input:checked {
321
+ background: var(--_qm-accent);
322
+ border-color: var(--_qm-accent);
323
+ }
324
+ /* The mark rides over the input and takes no press of its own; it is drawn by the
325
+ input's state, in the ink the surface reads over the accent. */
326
+ .qm-tick :global(.qm-tick-mark) {
327
+ position: absolute;
328
+ inset: 0;
329
+ width: 100%;
330
+ height: 100%;
331
+ pointer-events: none;
332
+ color: var(--_qm-surface);
333
+ opacity: 0;
334
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
335
+ }
336
+ .qm-tick:has(:checked) :global(.qm-tick-mark) {
337
+ opacity: 1;
338
+ }
339
+ /* Unheld reads at the label ink, held at the value ink: the title is what the
340
+ document says once it is ticked, and until then it is the vocabulary. */
341
+ .qm-member-title {
342
+ font-size: var(--_qm-text-body);
343
+ line-height: var(--_qm-leading-tight);
344
+ color: var(--_qm-ink-label);
345
+ cursor: pointer;
346
+ min-width: 0;
347
+ transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
348
+ }
349
+ .qm-member.held .qm-member-title,
350
+ .qm-member-title:hover {
351
+ color: var(--_qm-ink);
352
+ }
353
+ </style>