@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,17 +1,23 @@
1
1
  <!--
2
2
  An `object` field → a nested subform over `properties` (declaration order),
3
- committing the whole object by value on any nested change. Leaf properties: a
4
- scalar draws its own control, a content property mounts the by-value prose leaf
5
- ({@link ProseValue}) over the boundary's nested read, and a nested `array`/`object`
6
- property renders a placeholder instead of recursing.
3
+ committing the whole object by value on any nested change. A scalar property draws
4
+ its own control, a content property mounts the by-value prose leaf ({@link
5
+ ProseValue}) over the boundary's nested read, and a container property — an `array`,
6
+ an `object`, a `matrix`, a variant — mounts its own control here, at the next rung of
7
+ the same figure. Depth is bounded by what is open, not by the schema: a repeater inside
8
+ a subform is collapsed rows again, one open at a time, so an open row under an open
9
+ row is one figure two rungs in.
7
10
 
8
11
  The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
9
12
  an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
10
13
  further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
11
14
 
12
- One figure wherever it mounts: a variant's cells under their discriminant and an array
13
- element's properties under its summary row sit at the depth a field-level subform sits
14
- at, and the box above them states position rather than depth.
15
+ One figure wherever it mounts, and where its stroke lands is the stacker's. A
16
+ variant's cells and a matrix member's columns sit at the depth a field-level subform
17
+ sits at: each stands beside the cell it is stored with — the discriminant, the tick —
18
+ rather than inside it, so the box says which world or which member and not how deep.
19
+ An array element's properties hang a rung further in ({@link ArrayField}), the summary
20
+ above them standing for the whole row.
15
21
 
16
22
  A property's ghosted `default:` is the static schema `sub.default`, not the
17
23
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
@@ -20,13 +26,18 @@
20
26
 
21
27
  Every caller hands down `contentAt`, which is `reader.getContentAt` with the field
22
28
  already bound: a field-level subform passes the property key through untouched, a
23
- variant passes its cell's, and an array's open row prefixes its index. So a cell
24
- reads at whatever depth it sits, which nothing here knows.
29
+ variant passes its cell's, an array's open row prefixes its index and a matrix its
30
+ member's id. So a cell reads at whatever depth it sits, which nothing here knows.
31
+
32
+ `bare` is the table's row: the same cells with no labels and no figure of their own,
33
+ laid onto the tracks the table's header names ({@link ArrayField}).
25
34
  -->
26
35
  <script lang="ts">
27
36
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
28
37
  import { emptyContent } from '../core/codec/index.js';
29
- import { controlKind, humanize, obliged } from './structure.js';
38
+ import { arrayLayout, controlKind, humanize, isContainer, obliged } from './structure.js';
39
+ import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
40
+ import type { LandingBox } from './leaves.js';
30
41
  import { wording } from './strings.js';
31
42
  import { propertyDomIds } from './domid.js';
32
43
  import FieldLabel from './FieldLabel.svelte';
@@ -36,6 +47,12 @@
36
47
  import BooleanField from './BooleanField.svelte';
37
48
  import DateField from './DateField.svelte';
38
49
  import ProseValue from './ProseValue.svelte';
50
+ import ArrayField from './ArrayField.svelte';
51
+ import MatrixField from './MatrixField.svelte';
52
+ import VariantField from './VariantField.svelte';
53
+ import DiagnosticList from './DiagnosticList.svelte';
54
+ import ObjectField from './ObjectField.svelte';
55
+ import './controls.css';
39
56
 
40
57
  interface Props {
41
58
  value: Record<string, unknown> | undefined;
@@ -49,8 +66,8 @@
49
66
  /**
50
67
  * The parent control's DOM id: the base each property's own three names derive
51
68
  * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
52
- * it — the properties fall back to `aria-label`, which names them without a
53
- * `for` target to click.
69
+ * it, or a table's row — the properties fall back to `aria-label`, which names
70
+ * them without a `for` target to click.
54
71
  */
55
72
  idBase?: string;
56
73
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
@@ -62,13 +79,37 @@
62
79
  * `undefined` for a property the stored value does not reach. */
63
80
  contentAt: (path: PathStep[]) => Content | undefined;
64
81
  onCommit: (obj: Record<string, unknown>) => void;
82
+ /** A table's row: the cells alone, on the tracks the table's header names, with
83
+ * no labels and no vertical of their own. */
84
+ bare?: boolean;
85
+ /** Raw keydown off a text or prose cell, for a row whose own keys run through its
86
+ * cells: the table's Enter and Backspace ({@link ArrayField}). */
87
+ onCellKey?: (e: KeyboardEvent, key: string) => void;
88
+ /** The diagnostics routed into this subform, each with its steps still to walk:
89
+ * one naming a property draws under that property's control, and one naming
90
+ * nothing this subform holds draws at its foot (`diagnostics.ts`). */
91
+ diagnostics?: readonly DeepDiagnostic[];
65
92
  }
66
- let { value, properties, labelledBy, describedBy, idBase, label, contentAt, onCommit }: Props =
67
- $props();
93
+ let {
94
+ value,
95
+ properties,
96
+ labelledBy,
97
+ describedBy,
98
+ idBase,
99
+ label,
100
+ contentAt,
101
+ onCommit,
102
+ bare = false,
103
+ onCellKey,
104
+ diagnostics
105
+ }: Props = $props();
68
106
 
69
107
  const t = wording();
70
108
  const entries = $derived(Object.entries(properties ?? {}));
71
109
  const obj = $derived((value ?? {}) as Record<string, unknown>);
110
+ const has = (key: string): boolean => Object.hasOwn(properties ?? {}, key);
111
+ const routed = $derived(splitDeep(diagnostics));
112
+ const foot = $derived(unrouted(routed, (step) => typeof step === 'string' && has(step)));
72
113
 
73
114
  /** Obligation, read off the cell exactly as `fieldModels` reads it off a field
74
115
  * ({@link obliged}). `validate` anchors it per leaf, so a property with no
@@ -83,46 +124,90 @@
83
124
  `${label != null ? `${label} ` : ''}${title(key, sub)}` +
84
125
  (required(sub) ? ` ${t.strings.fieldRequired}` : '');
85
126
 
86
- /** The prose cells' handles, keyed by property name — which is stable, a subform
87
- * drawing its properties by declaration and never by position. A ref only where
88
- * the DOM cannot answer: a `contenteditable` matches no focusable selector, and
89
- * focusing the node is not focusing the view ({@link ProseValue.focus}). A scalar
90
- * control is resolved off the markup, so a handle each would be five refs
91
- * restating document order.
127
+ /** The handles of the cells the DOM cannot answer for, keyed by property name —
128
+ * which is stable, a subform drawing its properties by declaration and never by
129
+ * position. A prose cell, because a `contenteditable` matches no focusable
130
+ * selector and focusing the node is not focusing the view ({@link ProseValue.focus});
131
+ * a container cell, because its focus and its landing are its own. A scalar control
132
+ * is resolved off the markup, so a handle each would be five refs restating document
133
+ * order.
92
134
  *
93
135
  * `$state` for the binding's sake: `bind:this` into a property of a plain object
94
136
  * is a write Svelte cannot track, and it says so once per cell per render
95
137
  * (`ArrayField` keeps its element refs the same way). */
96
- const proseEls: Record<string, { focus: () => void } | undefined> = $state({});
138
+ const proseEls: Record<
139
+ string,
140
+ { focus: () => void; setCaret: (pos: number) => void } | undefined
141
+ > = $state({});
142
+ const nestedEls: Record<
143
+ string,
144
+ | { focus: () => void; focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox }
145
+ | undefined
146
+ > = $state({});
97
147
  let rootEl = $state<HTMLElement | undefined>();
148
+ /** A handle by property name, own keys only: a property can be named `constructor`. */
149
+ const ref = <T,>(map: Record<string, T | undefined>, key: string): T | undefined =>
150
+ Object.hasOwn(map, key) ? map[key] : undefined;
98
151
  const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
99
152
  /** Take the caret: the first property that has somewhere to put it, in document
100
- * order — a subform has no single control of its own to land on, and a property
101
- * standing the unsupported line has none either, so the walk steps past it. An
102
- * empty or wholly unsupported subform lands nothing. */
153
+ * order — a subform has no single control of its own to land on. An empty subform
154
+ * lands nothing. */
103
155
  export function focus(): void {
104
156
  for (const cell of propCells()) if (landOn(cell)) return;
105
157
  }
158
+ /**
159
+ * Land at `steps` inside this subform: the property the first step names, and the
160
+ * rest of the walk inside it where that property is a container ({@link
161
+ * FieldControl.focusPath}). A prose cell takes the offset; a scalar takes the bare
162
+ * focus. A step naming no property here falls back to {@link focus}. What comes back
163
+ * is the innermost row the walk opened, which a property of this subform never is:
164
+ * the caller washes its own box for a landing that ends here.
165
+ */
166
+ export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
167
+ const [key, ...rest] = steps;
168
+ if (typeof key !== 'string' || !has(key)) {
169
+ focus();
170
+ return undefined;
171
+ }
172
+ const nested = ref(nestedEls, key);
173
+ if (nested) return nested.focusPath(rest, pos);
174
+ const prose = ref(proseEls, key);
175
+ if (prose) {
176
+ if (pos != null) prose.setCaret(pos);
177
+ else prose.focus();
178
+ return undefined;
179
+ }
180
+ focusProp(key);
181
+ return undefined;
182
+ }
106
183
  /** A label click for the properties `for` cannot reach: the date control, whose
107
- * focus lives on a segment, and a prose cell, which is a `contenteditable`. Scoped
108
- * to that property's own cell, so the click lands where the label is rather than on
109
- * the subform's first control. */
184
+ * focus lives on a segment, a prose cell, which is a `contenteditable`, and a
185
+ * nested subform. Scoped to that property's own cell, so the click lands where the
186
+ * label is rather than on the subform's first control. */
110
187
  function focusProp(key: string): void {
111
188
  for (const cell of propCells()) if (cell.dataset.qmProp === key) return void landOn(cell);
112
189
  }
113
190
  // Matched on the dataset: a key is the schema's own string, and spelling one into a
114
- // selector needs `CSS.escape`, which the test DOM does not carry.
191
+ // selector needs `CSS.escape`, which the test DOM does not carry. `:scope >` keeps a
192
+ // nested subform's cells out of this one's walk: they are its own to land in.
115
193
  function propCells(): HTMLElement[] {
116
- return [...(rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? [])];
194
+ return [
195
+ ...(rootEl?.querySelectorAll<HTMLElement>(':scope > .qm-object-grid > [data-qm-prop]') ?? [])
196
+ ];
117
197
  }
118
198
  /** Land in one property's cell, answering whether it took the caret. */
119
199
  function landOn(cell: HTMLElement): boolean {
120
- const prose = proseEls[cell.dataset.qmProp ?? ''];
121
- if (prose) {
122
- prose.focus();
200
+ const key = cell.dataset.qmProp ?? '';
201
+ const owned = ref(nestedEls, key) ?? ref(proseEls, key);
202
+ if (owned) {
203
+ owned.focus();
123
204
  return true;
124
205
  }
125
- const el = cell.querySelector<HTMLElement>(FOCUSABLE);
206
+ // Past the label row: a property carrying a `description` opens with the hint
207
+ // trigger, which is focusable and stands before the control it describes.
208
+ const el = [...cell.querySelectorAll<HTMLElement>(FOCUSABLE)].find(
209
+ (n) => !n.closest('.qm-field-label-row')
210
+ );
126
211
  el?.focus();
127
212
  return !!el;
128
213
  }
@@ -144,98 +229,183 @@
144
229
  <div
145
230
  bind:this={rootEl}
146
231
  class="qm-object"
232
+ class:qm-object-bare={bare}
147
233
  role="group"
148
234
  aria-labelledby={labelledBy}
149
235
  aria-describedby={describedBy}
150
236
  >
151
- {#each entries as [key, sub] (key)}
152
- {@const kind = controlKind(sub)}
153
- {@const ids = idBase ? propertyDomIds(idBase, key) : undefined}
154
- {@const named = ids ? undefined : fallbackName(key, sub)}
155
- {@const describes = sub.description && ids ? ids.description : undefined}
156
- <!-- `for` reaches the four labelable controls; the date field's focus lives on a
157
- segment and a prose cell is a `contenteditable`, so those take the click
158
- handoff instead, exactly as `Field` does one level up. -->
159
- {@const labelable =
160
- kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
161
- <div class="qm-object-prop" data-qm-prop={key}>
162
- {#if ids}
163
- <FieldLabel
164
- label={title(key, sub)}
165
- controlId={labelable ? ids.control : undefined}
166
- id={ids.label}
167
- descriptionId={describes}
168
- onActivate={labelable ? undefined : () => focusProp(key)}
169
- required={required(sub)}
170
- description={sub.description}
171
- />
172
- {/if}
173
- {#if kind === 'enum'}
174
- <EnumField
175
- label={named}
176
- id={ids?.control}
177
- describedBy={describes}
178
- value={obj[key] as string | undefined}
179
- values={sub.values ?? []}
180
- fallback={sub.default as string | undefined}
181
- onCommit={(v) => commitProp(key, v)}
182
- />
183
- {:else if kind === 'number'}
184
- <NumberField
185
- label={named}
186
- id={ids?.control}
187
- describedBy={describes}
188
- value={obj[key] as number | undefined}
189
- integer={sub.type === 'integer'}
190
- fallback={sub.default as number | undefined}
191
- onCommit={(v) => commitProp(key, v)}
192
- />
193
- {:else if kind === 'boolean'}
194
- <BooleanField
195
- label={named}
196
- id={ids?.control}
197
- describedBy={describes}
198
- value={obj[key] as boolean | undefined}
199
- fallback={sub.default as boolean | undefined}
200
- onCommit={(v) => commitProp(key, v)}
201
- />
202
- {:else if kind === 'date'}
203
- <DateField
204
- label={named}
205
- labelledBy={ids?.label}
206
- describedBy={describes}
207
- value={obj[key] as string | undefined}
208
- onCommit={(v) => commitProp(key, v)}
209
- />
210
- {:else if kind === 'text'}
211
- <TextField
212
- label={named}
213
- id={ids?.control}
214
- describedBy={describes}
215
- value={obj[key] as string | undefined}
216
- placeholder={sub.default != null ? String(sub.default) : undefined}
217
- onCommit={(v) => commitProp(key, v)}
218
- />
219
- {:else if kind === 'prose'}
220
- <!-- Keyed by name, so a re-derive of the container this cell commits whole
221
- leaves the leaf mounted and the caret in it. -->
222
- <ProseValue
223
- bind:this={proseEls[key]}
224
- content={() => contentAt([key]) ?? emptyContent()}
225
- plaintext={sub.type === 'plaintext'}
226
- label={named}
227
- labelledBy={ids?.label}
228
- describedBy={describes}
229
- onChange={(rt) => commitProp(key, rt)}
230
- />
231
- {:else}
232
- <!-- A property the subform does not recurse into. What it says is what the
233
- user can do about it, which is edit the field from the source surface;
234
- a version number is the roadmap talking to somebody filling in a form. -->
235
- <span class="qm-unsupported">{t.strings.nestedUnsupported(kind)}</span>
236
- {/if}
237
- </div>
238
- {/each}
237
+ <!-- The grid is one box in, so the wrapper can be the query container it reads its
238
+ capacity from: a container cannot query itself. -->
239
+ <div class="qm-object-grid qm-tracks">
240
+ {#each entries as [key, sub] (key)}
241
+ {@const kind = controlKind(sub)}
242
+ {@const ids = idBase && !bare ? propertyDomIds(idBase, key) : undefined}
243
+ {@const named = ids ? undefined : fallbackName(key, sub)}
244
+ {@const describes = sub.description && ids ? ids.description : undefined}
245
+ {@const deep = routed.below.get(key)}
246
+ {@const own = isContainer(kind) ? undefined : deep?.map((d) => d.diagnostic)}
247
+ <!-- `for` reaches the labelable controls, a variant through its discriminant; the
248
+ date field's focus lives on a segment and a prose cell is a `contenteditable`,
249
+ so those take the click handoff instead, exactly as `Field` does one level up. -->
250
+ {@const labelable =
251
+ kind === 'text' ||
252
+ kind === 'enum' ||
253
+ kind === 'variant' ||
254
+ kind === 'number' ||
255
+ kind === 'boolean'}
256
+ <!-- A container takes the full span at every depth: its rows are the document's
257
+ to count, and a track beside it would stand in a column of whitespace
258
+ (`packable`). -->
259
+ <div class="qm-object-prop" class:qm-prop-full={isContainer(kind)} data-qm-prop={key}>
260
+ {#if ids && kind !== 'array' && kind !== 'matrix'}
261
+ <FieldLabel
262
+ label={title(key, sub)}
263
+ controlId={labelable ? ids.control : undefined}
264
+ id={ids.label}
265
+ descriptionId={describes}
266
+ onActivate={labelable ? undefined : () => focusProp(key)}
267
+ required={required(sub)}
268
+ description={sub.description}
269
+ />
270
+ {/if}
271
+ {#if kind === 'enum'}
272
+ <EnumField
273
+ label={named}
274
+ id={ids?.control}
275
+ describedBy={describes}
276
+ value={obj[key] as string | undefined}
277
+ values={sub.values ?? []}
278
+ fallback={sub.default as string | undefined}
279
+ onCommit={(v) => commitProp(key, v)}
280
+ />
281
+ {:else if kind === 'number'}
282
+ <NumberField
283
+ label={named}
284
+ id={ids?.control}
285
+ describedBy={describes}
286
+ value={obj[key] as number | undefined}
287
+ integer={sub.type === 'integer'}
288
+ fallback={sub.default as number | undefined}
289
+ onCommit={(v) => commitProp(key, v)}
290
+ />
291
+ {:else if kind === 'boolean'}
292
+ <BooleanField
293
+ label={named}
294
+ id={ids?.control}
295
+ describedBy={describes}
296
+ value={obj[key] as boolean | undefined}
297
+ fallback={sub.default as boolean | undefined}
298
+ onCommit={(v) => commitProp(key, v)}
299
+ />
300
+ {:else if kind === 'date'}
301
+ <DateField
302
+ label={named}
303
+ labelledBy={ids?.label}
304
+ describedBy={describes}
305
+ value={obj[key] as string | undefined}
306
+ fallback={sub.default != null ? String(sub.default) : undefined}
307
+ onCommit={(v) => commitProp(key, v)}
308
+ />
309
+ {:else if kind === 'text'}
310
+ <TextField
311
+ label={named}
312
+ id={ids?.control}
313
+ describedBy={describes}
314
+ value={obj[key] as string | undefined}
315
+ placeholder={sub.default != null ? String(sub.default) : undefined}
316
+ onCommit={(v) => commitProp(key, v)}
317
+ onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
318
+ />
319
+ {:else if kind === 'prose'}
320
+ <!-- Keyed by name, so a re-derive of the container this cell commits whole
321
+ leaves the leaf mounted and the caret in it. -->
322
+ <ProseValue
323
+ bind:this={proseEls[key]}
324
+ content={() => contentAt([key]) ?? emptyContent()}
325
+ plaintext={sub.type === 'plaintext'}
326
+ label={named}
327
+ labelledBy={ids?.label}
328
+ describedBy={describes}
329
+ onChange={(rt) => commitProp(key, rt)}
330
+ onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
331
+ />
332
+ {:else if kind === 'array'}
333
+ <!-- The repeater owns its label row, the add chip sharing it (`Field` skips
334
+ its own label for one too), so the names are handed down rather than
335
+ drawn here. Its rows are collapsed rows again, one open at a time: the
336
+ depth an open row under an open row costs is two rungs of the ladder
337
+ and one figure. -->
338
+ <ArrayField
339
+ bind:this={nestedEls[key]}
340
+ value={obj[key] as unknown[] | undefined}
341
+ items={sub.items}
342
+ layout={arrayLayout(sub)}
343
+ max={sub.max}
344
+ label={title(key, sub)}
345
+ required={required(sub)}
346
+ description={sub.description}
347
+ labelId={ids?.label}
348
+ descriptionId={describes}
349
+ idBase={ids?.control}
350
+ contentAt={(path) => contentAt([key, ...path])}
351
+ onCommit={(v) => commitProp(key, v)}
352
+ diagnostics={deep}
353
+ />
354
+ {:else if kind === 'object'}
355
+ <ObjectField
356
+ bind:this={nestedEls[key]}
357
+ value={obj[key] as Record<string, unknown> | undefined}
358
+ properties={sub.properties}
359
+ label={ids ? undefined : fallbackName(key, sub)}
360
+ idBase={ids?.control}
361
+ labelledBy={ids?.label}
362
+ describedBy={describes}
363
+ contentAt={(path) => contentAt([key, ...path])}
364
+ onCommit={(v) => commitProp(key, v)}
365
+ diagnostics={deep}
366
+ />
367
+ {:else if kind === 'matrix'}
368
+ <MatrixField
369
+ bind:this={nestedEls[key]}
370
+ value={obj[key] as Record<string, unknown> | undefined}
371
+ schema={sub}
372
+ label={title(key, sub)}
373
+ description={sub.description}
374
+ labelId={ids?.label}
375
+ descriptionId={describes}
376
+ idBase={ids?.control}
377
+ contentAt={(path) => contentAt([key, ...path])}
378
+ onCommit={(v) => commitProp(key, v)}
379
+ diagnostics={deep}
380
+ />
381
+ {:else if kind === 'variant'}
382
+ <VariantField
383
+ bind:this={nestedEls[key]}
384
+ value={obj[key] as Record<string, unknown> | undefined}
385
+ schema={sub}
386
+ ghostMember={sub.default as string | undefined}
387
+ label={title(key, sub)}
388
+ id={ids?.control}
389
+ labelledBy={ids?.label}
390
+ describedBy={describes}
391
+ contentAt={(path) => contentAt([key, ...path])}
392
+ onCommit={(v) => commitProp(key, v)}
393
+ diagnostics={deep}
394
+ />
395
+ {:else}
396
+ <TextField
397
+ label={named}
398
+ id={ids?.control}
399
+ describedBy={describes}
400
+ value={obj[key] as string | undefined}
401
+ onCommit={(v) => commitProp(key, v)}
402
+ />
403
+ {/if}
404
+ <DiagnosticList diagnostics={own} />
405
+ </div>
406
+ {/each}
407
+ </div>
408
+ <DiagnosticList diagnostics={foot} />
239
409
  </div>
240
410
 
241
411
  <style>
@@ -251,19 +421,32 @@
251
421
  section's (`Card.svelte`). A cap and not a leading gap: what stands the subform off
252
422
  the label or the box above it is still that stacker's own.
253
423
 
254
- The tracks are the section's count over the subform's own width — `--cols` inherits,
255
- so the container query stepping the section's capacity steps this with it, and there
256
- is no second query container and nothing measured. The edges are not the section's:
257
- the inset is the nesting, and a cell lining up with the field above it would claim a
258
- depth it is not at. */
424
+ A query container over its own width: the capacity its grid steps is the width the
425
+ properties get, which three rungs in is three `--_qm-nest` and three verticals
426
+ narrower than the section's, and a subform reading the section's count over that
427
+ box draws four tracks where two fit. The ladder itself is the one shared recipe
428
+ (`.qm-tracks`, controls.css), so this container and the section's step at the same
429
+ widths. Not on a table's row, which is a subgrid of the table and sizes nothing of
430
+ its own. */
259
431
  .qm-object {
260
- display: grid;
261
- grid-template-columns: repeat(var(--cols), 1fr);
262
- gap: var(--_qm-space-2);
432
+ display: flex;
433
+ flex-direction: column;
434
+ gap: var(--_qm-space);
435
+ }
436
+ .qm-object:not(.qm-object-bare) {
437
+ container-type: inline-size;
263
438
  border-inline-start: var(--_qm-vertical-width) solid var(--_qm-border);
264
439
  padding-inline-start: var(--_qm-nest);
265
440
  padding-block: var(--_qm-space);
266
441
  }
442
+ /* The tracks are this subform's own count over its own width (`.qm-tracks`); the
443
+ edges are not the section's: the inset is the nesting, and a cell lining up with the
444
+ field above it would claim a depth it is not at. */
445
+ .qm-object-grid {
446
+ display: grid;
447
+ grid-template-columns: repeat(var(--cols), 1fr);
448
+ gap: var(--_qm-space-2);
449
+ }
267
450
  /* A property measures like a field: two tracks over the subform's own rows, so a
268
451
  label wrapping in one column leaves its control on the row's baseline rather than a
269
452
  line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
@@ -286,9 +469,49 @@
286
469
  .qm-object-prop:only-child {
287
470
  grid-column: span var(--cols-half);
288
471
  }
289
- .qm-unsupported {
290
- font-size: var(--_qm-text-body);
291
- color: var(--_qm-ink-label);
292
- font-style: italic;
472
+ /* A container property takes the whole row and its own rows: its label rides inside
473
+ the control it draws (a repeater's header, a matrix's) or above a subform of its
474
+ own, and neither is a track a scalar's label shares. */
475
+ .qm-object-prop.qm-prop-full,
476
+ .qm-object-prop.qm-prop-full:only-child {
477
+ grid-column: 1 / -1;
478
+ grid-row: auto;
479
+ grid-template-rows: none;
480
+ row-gap: var(--_qm-space);
481
+ }
482
+ /* ── A table's row ──────────────────────────────────────────────────────────
483
+ The cells alone, each on the track the header names: this box and its grid both
484
+ subgrid onto the row that holds them, so a cell's edge is the column's. No
485
+ vertical, no inset, no capacity of its own, and one row per cell, the label having
486
+ moved up to the header. `.qm-array-row` is the row (`ArrayField`), which spends
487
+ the last track on the row's remove; this box takes the rest. */
488
+ .qm-object-bare {
489
+ display: grid;
490
+ grid-template-columns: subgrid;
491
+ grid-column: 1 / -2;
492
+ }
493
+ .qm-object-bare > .qm-object-grid {
494
+ grid-template-columns: subgrid;
495
+ grid-column: 1 / -1;
496
+ /* The table's own gutter, not the subform's: a subgrid that states a wider gap
497
+ than its parent's shifts its items half the difference off the tracks the
498
+ header cells sit on. */
499
+ column-gap: var(--_qm-space);
500
+ }
501
+ .qm-object-bare .qm-object-prop,
502
+ .qm-object-bare .qm-object-prop:only-child {
503
+ grid-column: auto;
504
+ grid-row: auto;
505
+ grid-template-rows: none;
506
+ row-gap: var(--_qm-space-half);
507
+ /* A row is one line of controls, so a cell shorter than the box beside it stands
508
+ on that line: a switch held at the top of a row a text box set the height of
509
+ reads as a cell that slipped its track. */
510
+ align-items: center;
511
+ }
512
+ /* What the row could not hand down stands across it: a diagnostic in the first
513
+ column alone would read as that column's. */
514
+ .qm-object-bare > :global(.qm-diag-list) {
515
+ grid-column: 1 / -1;
293
516
  }
294
517
  </style>
@@ -1,4 +1,8 @@
1
1
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
+ import { type DeepDiagnostic } from './diagnostics.js';
3
+ import type { LandingBox } from './leaves.js';
4
+ import ObjectField from './ObjectField.svelte';
5
+ import './controls.css';
2
6
  interface Props {
3
7
  value: Record<string, unknown> | undefined;
4
8
  properties: Record<string, QuillFieldSchema> | undefined;
@@ -11,8 +15,8 @@ interface Props {
11
15
  /**
12
16
  * The parent control's DOM id: the base each property's own three names derive
13
17
  * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
14
- * it — the properties fall back to `aria-label`, which names them without a
15
- * `for` target to click.
18
+ * it, or a table's row — the properties fall back to `aria-label`, which names
19
+ * them without a `for` target to click.
16
20
  */
17
21
  idBase?: string;
18
22
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
@@ -24,9 +28,20 @@ interface Props {
24
28
  * `undefined` for a property the stored value does not reach. */
25
29
  contentAt: (path: PathStep[]) => Content | undefined;
26
30
  onCommit: (obj: Record<string, unknown>) => void;
31
+ /** A table's row: the cells alone, on the tracks the table's header names, with
32
+ * no labels and no vertical of their own. */
33
+ bare?: boolean;
34
+ /** Raw keydown off a text or prose cell, for a row whose own keys run through its
35
+ * cells: the table's Enter and Backspace ({@link ArrayField}). */
36
+ onCellKey?: (e: KeyboardEvent, key: string) => void;
37
+ /** The diagnostics routed into this subform, each with its steps still to walk:
38
+ * one naming a property draws under that property's control, and one naming
39
+ * nothing this subform holds draws at its foot (`diagnostics.ts`). */
40
+ diagnostics?: readonly DeepDiagnostic[];
27
41
  }
28
42
  declare const ObjectField: import("svelte").Component<Props, {
29
43
  focus: () => void;
44
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
30
45
  }, "">;
31
46
  type ObjectField = ReturnType<typeof ObjectField>;
32
47
  export default ObjectField;