@quillmark/svelte 0.12.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 (42) hide show
  1. package/dist/core/codec/decode.d.ts +17 -0
  2. package/dist/core/codec/decode.js +57 -4
  3. package/dist/core/codec/field.d.ts +64 -15
  4. package/dist/core/codec/field.js +201 -65
  5. package/dist/core/codec/index.d.ts +3 -3
  6. package/dist/core/codec/index.js +3 -3
  7. package/dist/core/codec/keymap.d.ts +5 -4
  8. package/dist/core/codec/keymap.js +5 -4
  9. package/dist/core/codec/prose.css +42 -0
  10. package/dist/core/codec/schema.d.ts +24 -5
  11. package/dist/core/codec/schema.js +40 -9
  12. package/dist/core/errors.d.ts +3 -2
  13. package/dist/core/theme.css +9 -6
  14. package/dist/preview/paint.js +21 -17
  15. package/dist/visual/ArrayField.svelte +66 -22
  16. package/dist/visual/ArrayField.svelte.d.ts +2 -0
  17. package/dist/visual/BooleanField.svelte +28 -6
  18. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  19. package/dist/visual/Card.svelte +3 -3
  20. package/dist/visual/DateField.svelte +66 -31
  21. package/dist/visual/EnumField.svelte +27 -9
  22. package/dist/visual/EnumField.svelte.d.ts +5 -0
  23. package/dist/visual/Field.svelte +45 -10
  24. package/dist/visual/FormatPopover.svelte +4 -3
  25. package/dist/visual/MatrixField.svelte +3 -2
  26. package/dist/visual/NumberField.svelte +31 -14
  27. package/dist/visual/NumberField.svelte.d.ts +5 -1
  28. package/dist/visual/ObjectField.svelte +62 -15
  29. package/dist/visual/ProseField.svelte +53 -23
  30. package/dist/visual/ProseField.svelte.d.ts +15 -3
  31. package/dist/visual/ProseValue.svelte +102 -46
  32. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  33. package/dist/visual/TextField.svelte +35 -18
  34. package/dist/visual/TextField.svelte.d.ts +7 -0
  35. package/dist/visual/VariantField.svelte +1 -0
  36. package/dist/visual/VisualEditorInner.svelte +24 -24
  37. package/dist/visual/controls.css +16 -5
  38. package/dist/visual/strings.d.ts +8 -1
  39. package/dist/visual/strings.js +2 -0
  40. package/dist/visual/structure.d.ts +77 -43
  41. package/dist/visual/structure.js +132 -57
  42. package/package.json +2 -2
@@ -7,8 +7,8 @@ export declare const blockSchema: Schema<string, string>;
7
7
  * a table cell, which is the same content unit). */
8
8
  export declare const inlineSchema: Schema<string, string>;
9
9
  /**
10
- * The inline schema with no mark types at all: a `plaintext` field, whose value is
11
- * literal text the boundary refuses to coerce back once it carries a mark.
10
+ * The inline schema with no mark types at all: a `plaintext(inline)` field, whose
11
+ * value is literal text the boundary refuses to coerce back once it carries a mark.
12
12
  *
13
13
  * Declaring none is what makes that structural rather than a rule each path
14
14
  * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
@@ -16,11 +16,30 @@ export declare const inlineSchema: Schema<string, string>;
16
16
  * carrying (CODEC §Inline mode).
17
17
  */
18
18
  export declare const plaintextSchema: Schema<string, never>;
19
- /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
20
- * alone. What a mark command asks before it offers itself. */
19
+ /**
20
+ * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
21
+ * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
22
+ * for the reason {@link plaintextSchema} is.
23
+ *
24
+ * A paragraph and a break both store one `\n`, so a paragraph here is a line and
25
+ * says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
26
+ * boundary reads as the one line down that a break does.
27
+ */
28
+ export declare const plainSchema: Schema<"text" | "doc" | "paragraph" | "hard_break", never>;
29
+ /** The schema a leaf of the declared type mounts: `inline` narrows either type to one
30
+ * textblock, and `plaintext` takes its marks away. */
31
+ export declare function leafSchema(opts: {
32
+ plaintext?: boolean;
33
+ inline?: boolean;
34
+ }): Schema;
35
+ /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
36
+ * What a mark command asks before it offers itself. */
21
37
  export declare function hasMarks(schema: Schema): boolean;
22
- /** True for the constrained inline schema (no block containers); decode branches on it. */
38
+ /** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
23
39
  export declare function isInlineSchema(schema: Schema): boolean;
40
+ /** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
41
+ * leaf's island, gap-cursor and slash machinery mounts on. */
42
+ export declare function isBlockSchema(schema: Schema): boolean;
24
43
  /** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
25
44
  * What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
26
45
  * does what decode puts between a block's continued segments. */
@@ -4,9 +4,10 @@
4
4
  // Each set is closed upstream, so the schema names its whole vocabulary and carries
5
5
  // nothing inert. `blockSchema` is the full field; `inlineSchema` is the constrained
6
6
  // single-textblock form for `richtext(inline)` (one paragraph, no block split, no
7
- // containers, no islands) and `plaintextSchema` is that one without marks: same
8
- // decode/lower/position machinery, narrower shape. Anchors are not marks here
9
- // (decorations).
7
+ // containers, no islands) and `plaintextSchema` is that one without marks, for
8
+ // `plaintext(inline)`; `plainSchema` is a `plaintext` field without `inline`,
9
+ // paragraphs and hard breaks and nothing else. Same decode/lower/position machinery,
10
+ // narrower shapes. Anchors are not marks here (decorations).
10
11
  //
11
12
  // `toDOM` and `parseDOM` are one tier, not two halves of a rendering: a copy and a
12
13
  // paste inside one body run the whole document through them (CODEC §"Markdown at the
@@ -188,8 +189,8 @@ const inlineNodes = {
188
189
  * a table cell, which is the same content unit). */
189
190
  export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
190
191
  /**
191
- * The inline schema with no mark types at all: a `plaintext` field, whose value is
192
- * literal text the boundary refuses to coerce back once it carries a mark.
192
+ * The inline schema with no mark types at all: a `plaintext(inline)` field, whose
193
+ * value is literal text the boundary refuses to coerce back once it carries a mark.
193
194
  *
194
195
  * Declaring none is what makes that structural rather than a rule each path
195
196
  * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
@@ -197,14 +198,44 @@ export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
197
198
  * carrying (CODEC §Inline mode).
198
199
  */
199
200
  export const plaintextSchema = new Schema({ nodes: inlineNodes, marks: {} });
200
- /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
201
- * alone. What a mark command asks before it offers itself. */
201
+ /**
202
+ * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
203
+ * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
204
+ * for the reason {@link plaintextSchema} is.
205
+ *
206
+ * A paragraph and a break both store one `\n`, so a paragraph here is a line and
207
+ * says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
208
+ * boundary reads as the one line down that a break does.
209
+ */
210
+ export const plainSchema = new Schema({
211
+ nodes: {
212
+ doc: { content: 'paragraph+' },
213
+ paragraph: { ...blockNodes.paragraph, toDOM: () => ['p', { 'data-qm-line': '' }, 0] },
214
+ text: inlineLeafNodes.text,
215
+ hard_break: inlineLeafNodes.hard_break
216
+ },
217
+ marks: {}
218
+ });
219
+ /** The schema a leaf of the declared type mounts: `inline` narrows either type to one
220
+ * textblock, and `plaintext` takes its marks away. */
221
+ export function leafSchema(opts) {
222
+ if (opts.plaintext)
223
+ return opts.inline ? plaintextSchema : plainSchema;
224
+ return opts.inline ? inlineSchema : blockSchema;
225
+ }
226
+ /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
227
+ * What a mark command asks before it offers itself. */
202
228
  export function hasMarks(schema) {
203
229
  return Object.keys(schema.marks).length > 0;
204
230
  }
205
- /** True for the constrained inline schema (no block containers); decode branches on it. */
231
+ /** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
206
232
  export function isInlineSchema(schema) {
207
- return !schema.nodes.blockquote;
233
+ return !schema.nodes.hard_break;
234
+ }
235
+ /** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
236
+ * leaf's island, gap-cursor and slash machinery mounts on. */
237
+ export function isBlockSchema(schema) {
238
+ return !!schema.nodes.blockquote;
208
239
  }
209
240
  /** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
210
241
  * What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
@@ -11,8 +11,9 @@ export type EditorErrorCode =
11
11
  * the whole projection instead, so the store is correct and this field's
12
12
  * identity anchors were paid. */
13
13
  | 'commit-fallback'
14
- /** The overwrite fallback also failed: the optimistic PM state stands and the
15
- * store is stale for this field. The one code here that leaves damage. */
14
+ /** The overwrite fallback also failed, or the typed writer refused a `plaintext`
15
+ * leaf's text: the optimistic PM state stands and the store is stale for this
16
+ * field. The one code here that leaves damage. */
16
17
  | 'commit-lost'
17
18
  /** A card operation (add, move, remove, retype) that threw. The document is
18
19
  * unchanged; the boundary's mutators are transactional. */
@@ -163,13 +163,16 @@
163
163
  relationship at any mix: a stroke against the surface behind it, not a fill
164
164
  stacked on one. */
165
165
  --_qm-border-faint: color-mix(in oklab, var(--_qm-bg), var(--_qm-border) 50%);
166
- /* Two rungs, and there is no third: a step below `label` lands under 3:1 on the
167
- well a field draws it against, and what would take it — a placeholder, an
168
- unset value, the resolved `default:` — is content a reader has to read
169
- (WCAG 1.4.3), not decoration. So tone carries hierarchy alone, and a leaf
170
- saying shown-never-written says it against the `ink` a written value takes,
171
- in italic where it has no written neighbour to be read against. */
166
+ /* Three rungs, and nothing under the last: a step below `label` lands under 3:1 on
167
+ the well a field draws it against, and what would take it — an example, an unset
168
+ value's word — is content a reader has to read (WCAG 1.4.3), not decoration.
169
+ `ink` is written. `default` is the resolved `default:` drawn as the value it
170
+ prints: near enough to `ink` to read as an answer, a step off it so it never
171
+ reads as a written one. `label` is words about a value rather than the value — an
172
+ `example:`, `None`, a format hint — and the chrome that names fields; in italic
173
+ where it is a leaf's ghost, which has no written neighbour to be read against. */
172
174
  --_qm-ink: var(--_qm-fg);
175
+ --_qm-ink-default: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 20%);
173
176
  --_qm-ink-label: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 35%);
174
177
  /* The arrival wash (`core/bloom.ts`). Its alpha is a peak, not a tint: the
175
178
  wash animates from and back to zero, never held, so it reads over the leaf it
@@ -1,6 +1,11 @@
1
1
  // Vertical breathing room between stacked pages; cosmetic only, and a rung like
2
2
  // any other gap: the stack breathes on the same rhythm the cards do.
3
3
  const PAGE_GAP = 'var(--_qm-space-4)';
4
+ // The least raster density, in device pixels per CSS pixel. Glyphs rasterize
5
+ // unhinted, so a raster at a 1× or fractional ratio (1.25, 1.5; browser zoom)
6
+ // is resampled nearly 1:1 and small text smears; a raster at least twice the
7
+ // box is downsampled instead, which keeps body text legible.
8
+ const MIN_DENSITY = 2;
4
9
  export function createPaintLoop(session, container, margin,
5
10
  // Notified when a slot's `session.paint` throws: the loop swallows the throw
6
11
  // (see `paintSlot`) so one bad page never aborts the band sweep; the caller
@@ -65,16 +70,19 @@ onPaintError) {
65
70
  return { page, size, el };
66
71
  }
67
72
  // Rasterize `slot` into its canvas (creating the canvas if this is its first
68
- // paint since mounting). `layoutScale` is derived from the slot's own current
69
- // CSS width, so layout width tracks the container per the zoom settled
70
- // decision; `canvas.style.*` is then set from the authoritative PaintResult,
71
- // never guessed.
73
+ // paint since mounting), at the slot's own current CSS width.
72
74
  function paintSlot(slot) {
73
75
  let canvas = canvases.get(slot.page);
74
76
  if (!canvas) {
75
77
  canvas = document.createElement('canvas');
76
78
  canvas.className = 'qm-page-canvas';
77
- Object.assign(canvas.style, { position: 'absolute', inset: '0', display: 'block' });
79
+ Object.assign(canvas.style, {
80
+ position: 'absolute',
81
+ inset: '0',
82
+ display: 'block',
83
+ width: '100%',
84
+ height: '100%'
85
+ });
78
86
  }
79
87
  // Contextless canvas: bail before registering, so `updateBand` still sees
80
88
  // the page as unmounted and retries instead of keeping a blank page.
@@ -86,11 +94,9 @@ onPaintError) {
86
94
  canvases.set(slot.page, canvas);
87
95
  }
88
96
  const layoutScale = (slot.el.clientWidth || slot.size.widthPt) / slot.size.widthPt;
89
- const densityScale = (window.devicePixelRatio || 1) * zoom;
97
+ const densityScale = Math.max(window.devicePixelRatio || 1, MIN_DENSITY) * zoom;
90
98
  try {
91
- const result = session.paint(ctx, slot.page, { layoutScale, densityScale });
92
- canvas.style.width = `${result.layoutWidth}px`;
93
- canvas.style.height = `${result.layoutHeight}px`;
99
+ session.paint(ctx, slot.page, layoutScale * densityScale);
94
100
  }
95
101
  catch (err) {
96
102
  // A paint that throws must not abort the band loop; `updateBand` runs in
@@ -162,14 +168,12 @@ onPaintError) {
162
168
  // Slots are built on the first `refresh` (the controller calls it immediately),
163
169
  // not here: reconcile → `session.pageSize` must run after the controller's
164
170
  // `pageCount` gate, since `pageSize` throws for a page the count excludes.
165
- // ── Keep mounted rasters in step with the display ───────────────────────────
166
- // Every paint freezes `canvas.style.width/height` to the box width at that
167
- // paint (paintSlot). A page that stays mounted while the container's CSS width
168
- // or `devicePixelRatio` shifts would otherwise keep a stale raster the %-space
169
- // click/scroll math silently drifts off of: the box tracks the container, the
170
- // ink does not. Two observers close the gap by repainting mounted pages from
171
- // their current box; canvases are `position:absolute`, so a repaint can't feed
172
- // back into layout (and thus can't re-trigger the resize observer).
171
+ // ── Keep mounted rasters sharp ──────────────────────────────────────────────
172
+ // A canvas stretches with its slot, so a page that stays mounted while the
173
+ // container's CSS width or `devicePixelRatio` shifts keeps its place and goes
174
+ // soft. Two observers repaint mounted pages from their current box; canvases
175
+ // are `position:absolute`, so a repaint can't feed back into layout (and thus
176
+ // can't re-trigger the resize observer).
173
177
  // Repaint mounted pages (all, or a given subset) from their live geometry: the
174
178
  // one path resize, DPR, and zoom share; each `paintSlot` re-reads the slot's
175
179
  // current `clientWidth`. A no-op for an unmounted page.
@@ -12,9 +12,9 @@
12
12
  neither a screen reader nor a touch user, and the count is the carrier.
13
13
 
14
14
  An `object` element collapses: the row is its own summary — a box, titled by the
15
- row's `items.ui.title` template or its first short text cell ({@link rowSummary}) —
16
- and opens onto {@link ObjectField}, one at a time. Stacking the subforms instead would
17
- nest a field one level past the depth the subform's own vertical draws, once per row.
15
+ row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField},
16
+ one at a time. Stacking the subforms instead would nest a field one level past the
17
+ depth the subform's own vertical draws, once per row.
18
18
  Opening is therefore part of a landing rather than something the user does first:
19
19
  `focusPath` opens the row it is aimed at before it focuses, and hands the rest of the
20
20
  walk to the subform it opened, which is how a row two closed boxes down is reached.
@@ -45,6 +45,13 @@
45
45
  and of the values together, the mechanism insert and remove use, so an element keeps
46
46
  its id for life and no prose leaf inside it remounts; the open row stays open across
47
47
  its own move, and `animate:reorder` holds the moving row as it holds a card.
48
+
49
+ An unset array whose `default:` holds elements draws them as its rows, at the default
50
+ rung (`data-default` on the rows' box), since they are what prints. The rows are the
51
+ declared literal rather than resolve's form, which completes a record against `items`:
52
+ every gesture — an edit in a row, Enter, a remove, a move, the add chip — commits the
53
+ whole array with the change in it, and a take writes only what the author declared.
54
+ Removing the last row commits `[]`, the empty answer.
48
55
  -->
49
56
  <script lang="ts">
50
57
  import { wording } from './strings.js';
@@ -58,10 +65,13 @@
58
65
  import { createLifespan } from '../core/teardown.js';
59
66
  import {
60
67
  IdSeq,
68
+ baseType,
61
69
  controlKind,
70
+ declaredContent,
62
71
  humanize,
63
72
  obliged,
64
73
  rowSummary,
74
+ schemaAt,
65
75
  type ArrayLayout
66
76
  } from './structure.js';
67
77
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
@@ -82,6 +92,8 @@
82
92
 
83
93
  interface Props {
84
94
  value: unknown[] | undefined;
95
+ /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
96
+ fallback?: unknown[];
85
97
  items: QuillFieldSchema | undefined;
86
98
  /** How the elements draw: the record list, or the table an `array<object>` of
87
99
  * short cells asks for (`arrayLayout`). */
@@ -121,6 +133,7 @@
121
133
  }
122
134
  let {
123
135
  value,
136
+ fallback,
124
137
  items,
125
138
  layout = 'list',
126
139
  max,
@@ -142,7 +155,17 @@
142
155
  const control = $derived(items ? controlKind(items) : 'text');
143
156
  const table = $derived(layout === 'table' && control === 'object');
144
157
  const columns = $derived(Object.entries(items?.properties ?? {}));
145
- const arr = $derived((value ?? []) as unknown[]);
158
+ const defaulted = $derived(value == null && (fallback?.length ?? 0) > 0);
159
+ const arr = $derived((value ?? fallback ?? []) as unknown[]);
160
+ /** The rows' content read: the boundary's, or, while the rows are the default, the
161
+ * literal's own leaf at the codec its declared type names. */
162
+ function readAt(path: PathStep[]): Content | undefined {
163
+ if (!defaulted) return contentAt(path);
164
+ const leaf = schemaAt({ type: 'array', items }, path);
165
+ let v: unknown = arr;
166
+ for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
167
+ return leaf ? declaredContent(v, baseType(leaf) === 'richtext') : undefined;
168
+ }
146
169
  const atCap = $derived(max != null && arr.length >= max);
147
170
  const routed = $derived(splitDeep(diagnostics));
148
171
  const foot = $derived(
@@ -154,7 +177,7 @@
154
177
  // an effect-only seed mounts every element editor in a second render.
155
178
  const seq = new IdSeq();
156
179
  // svelte-ignore state_referenced_locally
157
- let ids = $state<string[]>(seq.take((value ?? []).length));
180
+ let ids = $state<string[]>(seq.take((value ?? fallback ?? []).length));
158
181
  // Length reconcile (defend against an out-of-band length change);
159
182
  // order is maintained by the mutators, not here.
160
183
  $effect(() => {
@@ -239,8 +262,7 @@
239
262
  // one, else this instance's own.
240
263
  const uid = $props.id();
241
264
  const countId = $derived(`${idBase ?? `qm-${uid}`}-count`);
242
- const columnTitle = (key: string, sub: QuillFieldSchema): string =>
243
- sub.ui?.title ?? humanize(key);
265
+ const columnTitle = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
244
266
 
245
267
  // The awaited flush below is the only work that outlives a gesture here, so the
246
268
  // span carries the reorder's frame and nothing else: it is the liveness
@@ -444,14 +466,14 @@
444
466
  if (v === undefined || v === '') return true;
445
467
  return typeof v === 'object' && v !== null && 'text' in v && (v as Content).text === '';
446
468
  }
447
- /** Whether element `k` reads empty to the user. A text element's committed value
448
- * lags the input: a cleared field commits at `change`, not per keystroke
449
- * ({@link TextField}); so the input's own value is the truth. A prose element
450
- * commits every edit, so the committed `Content` is; an authored string, the
451
- * transport-door rest, is empty when it has no characters. A table's row is empty
452
- * when the cell under the caret is and so is every cell beside it — beside, because
453
- * the caret's own committed value is the one that lags, and counting it twice would
454
- * hold the row full until a blur. */
469
+ /** Whether element `k` reads empty to the user. A text element's input is the truth,
470
+ * being the control the keystroke lands in. A prose element commits every edit, so
471
+ * the committed `Content` is; an authored string, the transport-door rest, is empty
472
+ * when it has no characters. A table's row is empty when the cell under the caret is
473
+ * and so is every cell beside it, read off the row: a cell showing only its schema
474
+ * default holds nothing written, as in a row just added. The caret's cell is read off
475
+ * its control, which a default the cell holds unwritten leaves full while the
476
+ * committed row has no key. */
455
477
  function elementEmpty(k: number, target: EventTarget | null, column?: string): boolean {
456
478
  if (control === 'prose') {
457
479
  const el = arr[k];
@@ -556,7 +578,12 @@
556
578
  <!-- Three lists, one per row shape, because `animate:` is granted only to a keyed
557
579
  each block's one child: a branch inside the block would stand between them. -->
558
580
  {#if table}
559
- <div class="qm-array-rows qm-array-table" class:empty={ids.length === 0} bind:this={rowsEl}>
581
+ <div
582
+ class="qm-array-rows qm-array-table"
583
+ class:empty={ids.length === 0}
584
+ data-default={defaulted ? '' : undefined}
585
+ bind:this={rowsEl}
586
+ >
560
587
  <div class="qm-array-table-scroller" style:--table-cols={columns.length}>
561
588
  <!-- The header names the columns, so a cell carries no label of its own: its
562
589
  accessible name is the row's and the column's composed (`ObjectField`). The
@@ -582,7 +609,7 @@
582
609
  value={(arr[k] ?? {}) as Record<string, unknown>}
583
610
  properties={items?.properties}
584
611
  label={rowName(k)}
585
- contentAt={(path) => contentAt([k, ...path])}
612
+ contentAt={(path) => readAt([k, ...path])}
586
613
  onCommit={(obj) => commitElement(k, obj)}
587
614
  onCellKey={(e, column) => onElementKey(e, k, column)}
588
615
  diagnostics={routed.below.get(k)}
@@ -593,7 +620,12 @@
593
620
  </div>
594
621
  </div>
595
622
  {:else if control === 'object'}
596
- <div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
623
+ <div
624
+ class="qm-array-rows"
625
+ class:empty={ids.length === 0}
626
+ data-default={defaulted ? '' : undefined}
627
+ bind:this={rowsEl}
628
+ >
597
629
  {#each ids as id, k (id)}
598
630
  <!-- svelte-ignore a11y_no_static_element_interactions -->
599
631
  <!-- The handler catches a key from the controls inside the row and adds no
@@ -637,7 +669,7 @@
637
669
  properties={items?.properties}
638
670
  label={rowName(k)}
639
671
  idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
640
- contentAt={(path) => contentAt([k, ...path])}
672
+ contentAt={(path) => readAt([k, ...path])}
641
673
  onCommit={(obj) => commitElement(k, obj)}
642
674
  diagnostics={routed.below.get(k)}
643
675
  />
@@ -650,14 +682,19 @@
650
682
  {/each}
651
683
  </div>
652
684
  {:else}
653
- <div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
685
+ <div
686
+ class="qm-array-rows"
687
+ class:empty={ids.length === 0}
688
+ data-default={defaulted ? '' : undefined}
689
+ bind:this={rowsEl}
690
+ >
654
691
  {#each ids as id, k (id)}
655
692
  <div class="qm-array-row" bind:this={rowEls[id]}>
656
693
  {#if control === 'prose'}
657
694
  <ProseValue
658
695
  bind:this={els[id]}
659
- content={() => contentAt([k]) ?? emptyContent()}
660
- plaintext={items?.type === 'plaintext'}
696
+ content={() => readAt([k])}
697
+ plaintext={items != null && baseType(items) === 'plaintext'}
661
698
  label={rowName(k)}
662
699
  onChange={(rt) => commitElement(k, rt)}
663
700
  onKey={(e) => onElementKey(e, k)}
@@ -762,6 +799,13 @@
762
799
  .qm-array-rows.empty {
763
800
  display: none;
764
801
  }
802
+ /* The default's rows are what prints and nothing written: every value they draw takes
803
+ the default rung (theme.css), and the first gesture, writing them, takes it off. */
804
+ .qm-array-rows[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
805
+ .qm-array-rows[data-default] :global(.qm-select:not([data-ghosted])),
806
+ .qm-array-rows[data-default] .qm-element-title:not(.untitled) {
807
+ color: var(--_qm-ink-default);
808
+ }
765
809
  /* A grid rather than a block: an `<input>` is inline-level and would sit on a
766
810
  baseline, standing the row a descender taller than the box the slab measures itself
767
811
  against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
@@ -5,6 +5,8 @@ import type { LandingBox } from './leaves.js';
5
5
  import './controls.css';
6
6
  interface Props {
7
7
  value: unknown[] | undefined;
8
+ /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
9
+ fallback?: unknown[];
8
10
  items: QuillFieldSchema | undefined;
9
11
  /** How the elements draw: the record list, or the table an `array<object>` of
10
12
  * short cells asks for (`arrayLayout`). */
@@ -4,6 +4,12 @@
4
4
  Styled rather than a native checkbox: the native box's face is UA-owned shadow
5
5
  DOM, so no dial reaches it. The a11y comes with the primitive:
6
6
  Switch.Root renders `role="switch"` with its checked state and keyboard handling.
7
+
8
+ An unset `boolean?` prints `none`, which neither face of a switch says, so it draws a
9
+ third: the thumb at the track's centre. `role="switch"` admits no third state (a
10
+ `mixed` there reads as false), so an optional boolean's control is a `checkbox`, the
11
+ toggle role that does, announcing `mixed` while unset. A press from unset writes
12
+ `true`; nothing here returns the cell to unset.
7
13
  -->
8
14
  <script lang="ts">
9
15
  import { Switch } from 'bits-ui';
@@ -13,6 +19,8 @@
13
19
  interface Props {
14
20
  value: boolean | undefined;
15
21
  fallback?: boolean;
22
+ /** A `boolean?` cell, which has a third state: unset, printing `none`. */
23
+ optional?: boolean;
16
24
  /** Accessible name for a switch nothing else names: an object property, whose
17
25
  * name is the field label plus the property's. A field's own switch takes `id`
18
26
  * instead and is named by the `<label for>` beside it. */
@@ -26,32 +34,42 @@
26
34
  describedBy?: string;
27
35
  onCommit: (v: boolean) => void;
28
36
  }
29
- let { value, fallback, label, id, describedBy, onCommit }: Props = $props();
37
+ let { value, fallback, optional = false, label, id, describedBy, onCommit }: Props = $props();
30
38
 
31
39
  // Local toggle state synced to `value`; own-toggles stay local, only an external
32
40
  // change reconciles back in (see `syncedLocal`). The primitive is driven
33
41
  // controlled (`checked` + `onCheckedChange`, never `bind:`) so reconciliation
34
42
  // stays the package's: a two-way bind hands the primitive a lane around it,
35
- // which repeats the reconciliation hazard in miniature.
36
- const local = syncedLocal(() => value ?? fallback ?? false);
43
+ // which repeats the reconciliation hazard in miniature. `undefined` is the unset
44
+ // optional cell, the one state with no default to stand in for it.
45
+ const local = syncedLocal<boolean | undefined>(
46
+ () => value ?? fallback ?? (optional ? undefined : false)
47
+ );
48
+ const unset = $derived(local.value === undefined);
49
+ const tristate = $derived(
50
+ optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local.value } : {}
51
+ );
37
52
  </script>
38
53
 
39
54
  <!-- Not `.qm-switch`: that name is the preset's pane band, whose narrow-viewport rule
40
55
  stands every child of it at `--qmh-tap`, so a track wearing it grows a thumb three times
41
56
  its own height over the labels around it (`preset/recipes.css`, held by `check:style`). -->
42
- <span class="qm-toggle-wrap">
57
+ <span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
43
58
  <Switch.Root
44
59
  class="qm-toggle qm-focus-ring qm-tap-floor"
45
- checked={local.value}
60
+ checked={!!local.value}
46
61
  {id}
47
62
  aria-label={id ? undefined : label}
48
63
  aria-describedby={describedBy}
49
64
  onCheckedChange={(v) => {
65
+ // From unset the primitive reads the unchecked face, so its toggle is `true`.
50
66
  local.value = v;
51
67
  onCommit(v);
52
68
  }}
53
69
  >
54
- <Switch.Thumb class="qm-toggle-thumb" />
70
+ {#snippet child({ props })}
71
+ <button {...props} {...tristate}><Switch.Thumb class="qm-toggle-thumb" /></button>
72
+ {/snippet}
55
73
  </Switch.Root>
56
74
  </span>
57
75
 
@@ -95,4 +113,8 @@
95
113
  .qm-toggle-wrap :global(.qm-toggle-thumb[data-state='checked']) {
96
114
  translate: 0.75rem;
97
115
  }
116
+ /* Unset, the thumb stands at neither end: halfway along its travel. */
117
+ .qm-toggle-wrap[data-unset] :global(.qm-toggle-thumb) {
118
+ translate: 0.375rem;
119
+ }
98
120
  </style>
@@ -2,6 +2,8 @@ import './controls.css';
2
2
  interface Props {
3
3
  value: boolean | undefined;
4
4
  fallback?: boolean;
5
+ /** A `boolean?` cell, which has a third state: unset, printing `none`. */
6
+ optional?: boolean;
5
7
  /** Accessible name for a switch nothing else names: an object property, whose
6
8
  * name is the field label plus the property's. A field's own switch takes `id`
7
9
  * instead and is named by the `<label for>` beside it. */
@@ -15,7 +15,7 @@
15
15
  import type { EditorErrorHandler } from '../core/errors.js';
16
16
  import type { LeafRegistry } from './leaves.js';
17
17
  import type { CardModel, FieldModel } from './structure.js';
18
- import { placeFields, humanize, initialExpandedGroup } from './structure.js';
18
+ import { placeFields, humanize, initialExpandedGroup, kindTitle, mountKey } from './structure.js';
19
19
  import { holdInView } from './hold.js';
20
20
  import type { FieldDomIds } from './domid.js';
21
21
  import Field from './Field.svelte';
@@ -333,7 +333,7 @@
333
333
 
334
334
  {#snippet sectionFields(fields: FieldModel[])}
335
335
  <div class="qm-fields qm-tracks">
336
- {#each placeFields(fields) as { field: f, span } (f.name)}
336
+ {#each placeFields(fields) as { field: f, span } (mountKey(f.name, f.schema))}
337
337
  <Field
338
338
  field={f}
339
339
  {span}
@@ -402,7 +402,7 @@
402
402
 
403
403
  {#snippet kindOptions()}
404
404
  {#each kinds as k (k)}
405
- <option value={k}>{humanize(k)}</option>
405
+ <option value={k}>{kindTitle(quill.schema.card_kinds?.[k], k)}</option>
406
406
  {/each}
407
407
  {/snippet}
408
408