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