@quillmark/svelte 0.6.0 → 0.7.1

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 (37) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/field.d.ts +15 -4
  3. package/dist/core/codec/field.js +13 -10
  4. package/dist/core/codec/index.d.ts +1 -1
  5. package/dist/core/codec/index.js +1 -1
  6. package/dist/core/codec/table-view.d.ts +4 -0
  7. package/dist/core/codec/table-view.js +10 -1
  8. package/dist/core/theme.css +14 -9
  9. package/dist/preset/recipes.css +15 -4
  10. package/dist/preview/bridge.d.ts +23 -0
  11. package/dist/preview/bridge.js +92 -35
  12. package/dist/preview/controller.d.ts +6 -5
  13. package/dist/preview/controller.js +13 -7
  14. package/dist/preview/index.js +3 -2
  15. package/dist/preview/paint.js +9 -1
  16. package/dist/visual/ArrayField.svelte +30 -23
  17. package/dist/visual/ArrayField.svelte.d.ts +9 -7
  18. package/dist/visual/Card.svelte +88 -45
  19. package/dist/visual/EnumField.svelte +4 -2
  20. package/dist/visual/Field.svelte +22 -2
  21. package/dist/visual/ObjectField.svelte +95 -75
  22. package/dist/visual/ObjectField.svelte.d.ts +7 -3
  23. package/dist/visual/ProseValue.svelte +165 -0
  24. package/dist/visual/ProseValue.svelte.d.ts +33 -0
  25. package/dist/visual/SlashMenu.svelte +7 -0
  26. package/dist/visual/VariantField.svelte +11 -6
  27. package/dist/visual/VariantField.svelte.d.ts +5 -1
  28. package/dist/visual/VisualEditorInner.svelte +29 -11
  29. package/dist/visual/hold.d.ts +17 -0
  30. package/dist/visual/hold.js +39 -0
  31. package/dist/visual/index.js +1 -1
  32. package/dist/visual/motion.d.ts +9 -0
  33. package/dist/visual/motion.js +10 -2
  34. package/dist/visual/strings.d.ts +4 -3
  35. package/package.json +2 -2
  36. package/dist/visual/ProseArrayElement.svelte +0 -139
  37. package/dist/visual/ProseArrayElement.svelte.d.ts +0 -25
@@ -3,7 +3,7 @@
3
3
  edit / add / remove rebuilds the whole array and hands it to the parent's typed
4
4
  `writer.set(field, wholeArray)` (arrays are not op-addressed). Element control
5
5
  by `items.type`: `richtext` / `plaintext` → a prose element
6
- ({@link ProseArrayElement}), `object` → a summary row that opens onto a subform,
6
+ ({@link ProseValue}), `object` → a summary row that opens onto a subform,
7
7
  everything else → a text input. The
8
8
  add affordance sits in the label header row (space-between with the field label);
9
9
  {@link Field} skips its own label for array controls and hands this component the
@@ -11,8 +11,8 @@
11
11
 
12
12
  An `object` element collapses: the row is its own summary — a box, titled by the
13
13
  element's first `string` cell — and opens onto {@link ObjectField}, one at a time.
14
- Stacking the subforms instead would nest a field one level past the depth the band
15
- draws, once per row. Opening is therefore part of a landing rather than something the
14
+ Stacking the subforms instead would nest a field one level past the depth the
15
+ subform's own vertical draws, once per row. Opening is therefore part of a landing rather than something the
16
16
  user does first: `focusElement` opens the row it is aimed at before it focuses.
17
17
 
18
18
  The remove is inside the element: a slab over the end of the element's own box, taking
@@ -38,14 +38,15 @@
38
38
  // off-tree, so this component renders standalone too.
39
39
  const t = wording();
40
40
  import { onDestroy, tick } from 'svelte';
41
- import type { Content, QuillFieldSchema } from '@quillmark/wasm';
41
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
42
42
  import { emptyContent } from '../core/codec/index.js';
43
43
  import { createLifespan } from '../core/teardown.js';
44
44
  import { IdSeq, controlKind } from './structure.js';
45
+ import { holdInView } from './hold.js';
45
46
  import Icon from './icons/Icon.svelte';
46
47
  import TextField from './TextField.svelte';
47
48
  import ObjectField from './ObjectField.svelte';
48
- import ProseArrayElement from './ProseArrayElement.svelte';
49
+ import ProseValue from './ProseValue.svelte';
49
50
  import FieldLabel from './FieldLabel.svelte';
50
51
  import './controls.css';
51
52
 
@@ -72,12 +73,14 @@
72
73
  * properties' own names from, one `-e-<element id>` segment down, so each open
73
74
  * record's cells carry real `<label for>` pairs like every other control. */
74
75
  idBase?: string;
75
- /** Element `i`'s content, decoded by the boundary through the codec `items`
76
- * names (`Field`, `reader.getContentAt`). Asked only for a content-typed
77
- * element: on any other the read is not content and throws. `undefined` for a
78
- * row the stored value does not reach — a slot this control has spliced in and
79
- * whose commit has yet to land, or was refused. */
80
- elementContent: (i: number) => Content | undefined;
76
+ /** The boundary's nested content read, rooted at this field: `path` is a
77
+ * `PathStep[]` from the field to the leaf, so `[i]` is element `i` and
78
+ * `[i, key]` a content cell of an `object` element's subform (`Field`,
79
+ * `reader.getContentAt`). The codec is the leaf's own declared type's. Asked
80
+ * only for a content-typed leaf: on any other the read is not content and
81
+ * throws. `undefined` for a leaf the stored value does not reach — a slot this
82
+ * control has spliced in and whose commit has yet to land, or was refused. */
83
+ contentAt: (path: PathStep[]) => Content | undefined;
81
84
  onCommit: (arr: unknown[]) => void;
82
85
  }
83
86
  let {
@@ -89,13 +92,13 @@
89
92
  labelId,
90
93
  descriptionId,
91
94
  idBase,
92
- elementContent,
95
+ contentAt,
93
96
  onCommit
94
97
  }: Props = $props();
95
98
 
96
99
  // The element control is the item schema's own, with no departure: a content-typed
97
100
  // element mounts the prose leaf its scalar field mounts, reading through
98
- // `elementContent` whatever the element rests as. An array declaring no `items`
101
+ // `contentAt` whatever the element rests as. An array declaring no `items`
99
102
  // has text elements.
100
103
  const control = $derived(items ? controlKind(items) : 'text');
101
104
  const arr = $derived((value ?? []) as unknown[]);
@@ -121,7 +124,7 @@
121
124
  // The focus targets, keyed by element ID rather than index: an index goes stale on
122
125
  // the splice that focus is chasing. An element control exposes `focus()` because a
123
126
  // text element and a prose element disagree on what focusing is: the difference
124
- // is stated on `ProseArrayElement.focus`, which owns it.
127
+ // is stated on `ProseValue.focus`, which owns it.
125
128
  //
126
129
  // Every path that drops an id deletes its entry: `bind:this` teardown nulls the
127
130
  // value on unmount and leaves the key, so a card that outlives its elements
@@ -166,8 +169,12 @@
166
169
  function untitled(k: number): string {
167
170
  return label != null ? t.strings.elementUntitled(label, k + 1) : String(k + 1);
168
171
  }
169
- function toggleRow(id: string): void {
170
- openId = openId === id ? undefined : id;
172
+ /** The summary is the anchor: a row closing above it is what would carry it off the fold,
173
+ * and a row's subform hangs under its own summary (`hold.ts`). */
174
+ function toggleRow(id: string, summary: HTMLElement): void {
175
+ holdInView(summary, () => {
176
+ openId = openId === id ? undefined : id;
177
+ });
171
178
  }
172
179
 
173
180
  // The awaited flush below is the only work that outlives a gesture here, so the
@@ -365,7 +372,7 @@
365
372
  type="button"
366
373
  class="qm-control-box qm-focus-ring qm-element-summary"
367
374
  aria-expanded={open}
368
- onclick={() => toggleRow(id)}
375
+ onclick={(e) => toggleRow(id, e.currentTarget)}
369
376
  >
370
377
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
371
378
  screen, where this unfolds in place. Same glyph, same rotation and
@@ -391,7 +398,7 @@
391
398
  properties={items?.properties}
392
399
  label={label != null ? `${label} ${k + 1}` : undefined}
393
400
  idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
394
- edges="close"
401
+ contentAt={(path) => contentAt([k, ...path])}
395
402
  onCommit={(obj) => commitElement(k, obj)}
396
403
  />
397
404
  {/if}
@@ -399,9 +406,9 @@
399
406
  {:else}
400
407
  <div class="qm-array-row">
401
408
  {#if control === 'prose'}
402
- <ProseArrayElement
409
+ <ProseValue
403
410
  bind:this={els[id]}
404
- content={() => elementContent(k) ?? emptyContent()}
411
+ content={() => contentAt([k]) ?? emptyContent()}
405
412
  plaintext={items?.type === 'plaintext'}
406
413
  label={label != null ? `${label} ${k + 1}` : undefined}
407
414
  onChange={(rt) => commitElement(k, rt)}
@@ -517,9 +524,9 @@
517
524
  /* ── An object element ──────────────────────────────────────────────────────
518
525
  The row is a summary and the subform hangs under it. `row-gap` rather than a
519
526
  margin on the subform, because the distance between a control and what it has
520
- unfolded belongs to the thing stacking them: the variant field spends its own
521
- `gap` on exactly this and `ObjectField` therefore opens flush, carrying no
522
- leading space of its own for a caller to cancel. */
527
+ unfolded belongs to the thing stacking them: the variant field spends its own `gap` on
528
+ exactly this, and what `ObjectField` adds is the cap on its own stroke, equal at both
529
+ of its ends. */
523
530
  .qm-element {
524
531
  row-gap: var(--_qm-space-2);
525
532
  }
@@ -1,4 +1,4 @@
1
- import type { Content, QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import './controls.css';
3
3
  interface Props {
4
4
  value: unknown[] | undefined;
@@ -19,12 +19,14 @@ interface Props {
19
19
  * properties' own names from, one `-e-<element id>` segment down, so each open
20
20
  * record's cells carry real `<label for>` pairs like every other control. */
21
21
  idBase?: string;
22
- /** Element `i`'s content, decoded by the boundary through the codec `items`
23
- * names (`Field`, `reader.getContentAt`). Asked only for a content-typed
24
- * element: on any other the read is not content and throws. `undefined` for a
25
- * row the stored value does not reach — a slot this control has spliced in and
26
- * whose commit has yet to land, or was refused. */
27
- elementContent: (i: number) => Content | undefined;
22
+ /** The boundary's nested content read, rooted at this field: `path` is a
23
+ * `PathStep[]` from the field to the leaf, so `[i]` is element `i` and
24
+ * `[i, key]` a content cell of an `object` element's subform (`Field`,
25
+ * `reader.getContentAt`). The codec is the leaf's own declared type's. Asked
26
+ * only for a content-typed leaf: on any other the read is not content and
27
+ * throws. `undefined` for a leaf the stored value does not reach — a slot this
28
+ * control has spliced in and whose commit has yet to land, or was refused. */
29
+ contentAt: (path: PathStep[]) => Content | undefined;
28
30
  onCommit: (arr: unknown[]) => void;
29
31
  }
30
32
  declare const ArrayField: import("svelte").Component<Props, {
@@ -16,6 +16,7 @@
16
16
  import type { LeafRegistry } from './leaves.js';
17
17
  import type { CardModel, FieldModel } from './structure.js';
18
18
  import { placeFields, humanize, initialExpandedGroup } from './structure.js';
19
+ import { holdInView } from './hold.js';
19
20
  import type { FieldDomIds } from './domid.js';
20
21
  import Field from './Field.svelte';
21
22
  import ProseField from './ProseField.svelte';
@@ -127,9 +128,9 @@
127
128
  const ungrouped = $derived(card.sections.filter((s) => s.group == null));
128
129
  const grouped = $derived(card.sections.filter((s) => s.group != null));
129
130
 
130
- // A schema that declares no fields still gets a body, and a bracket around nothing is
131
- // worse than none: with no content between them the two horizontals land on one y and
132
- // paint as a single stroke of twice the width.
131
+ // A schema that declares no fields still gets a body, and an empty payload block is
132
+ // worse than none: its two rules land on one y and paint as a single stroke of twice
133
+ // the width, and the card's gap counts the block twice besides.
133
134
  const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
134
135
 
135
136
  // Seeded once from the card's shape. Card is keyed by stable id, so this survives the
@@ -157,9 +158,11 @@
157
158
  headers[closing]?.focus();
158
159
  expanded = next;
159
160
  }
161
+ /** The header is the anchor: a section closing above it is what would carry it off the
162
+ * fold, and a section opens under its own header (`hold.ts`). */
160
163
  function toggleGroup(group: string): void {
161
164
  animate = true;
162
- setExpanded(expanded === group ? null : group);
165
+ holdInView(headers[group], () => setExpanded(expanded === group ? null : group));
163
166
  }
164
167
 
165
168
  /**
@@ -190,6 +193,9 @@
190
193
  * an insert, `nearest` for a reorder. The reduced-motion guard is JS rather than the
191
194
  * CSS the accordion uses: a scroll's behaviour is an argument, not a transition a
192
195
  * media query can cancel.
196
+ *
197
+ * The caller is what waits out a reorder's own trip before asking: the run holds the
198
+ * slot this card sits in, and the slot is the stack's (`VisualEditorInner`).
193
199
  */
194
200
  export function scrollIntoViewCard(block: ScrollLogicalPosition): void {
195
201
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
@@ -244,10 +250,6 @@
244
250
  {/if}
245
251
 
246
252
  <div class="qm-card-body">
247
- <!-- One element whose top and bottom edges are the bracket's two horizontals.
248
- Anchored here rather than on the header and on the accordion so an open section's
249
- vertical reaches them, and so a card with no groups still terminates above its
250
- body. -->
251
253
  {#if hasMeta}
252
254
  <div
253
255
  class="qm-card-meta"
@@ -261,8 +263,7 @@
261
263
  {/each}
262
264
 
263
265
  <!-- One open at a time. Rendered only when a card has groups, so an empty
264
- accordion is not the bracket's last child: the bottom rule's inset belongs
265
- to whichever block abuts it. -->
266
+ accordion does not stand a gap under the ungrouped fields. -->
266
267
  {#if grouped.length}
267
268
  <div class="qm-groups" class:qm-instant={!animate}>
268
269
  {#each grouped as section (section.group)}
@@ -409,11 +410,12 @@
409
410
  /* An island: the card is the base plane, the column behind it the sunken one, and the
410
411
  hairline closes it (ARCHITECTURE §Styling). The inset is the same rung as the gap, so
411
412
  what separates the header from the fields is what separates the fields from the
412
- card's edge. */
413
+ card's edge. The inline half reads `--_qm-nest` at that same number: this edge is the
414
+ nesting ladder's first stroke, and its inset the first step. */
413
415
  .qm-card {
414
416
  border: var(--_qm-border-width) solid var(--_qm-border);
415
417
  border-radius: var(--_qm-radius);
416
- padding: var(--_qm-space-3);
418
+ padding: var(--_qm-space-3) var(--_qm-nest);
417
419
  background: var(--_qm-surface);
418
420
  display: flex;
419
421
  flex-direction: column;
@@ -497,37 +499,58 @@
497
499
  .qm-card-title:focus {
498
500
  color: var(--_qm-ink);
499
501
  }
500
- .qm-card-body,
501
- .qm-card-meta {
502
+ .qm-card-body {
502
503
  display: flex;
503
504
  flex-direction: column;
504
505
  gap: var(--_qm-space-3);
505
506
  }
506
- /* The metadata bracket, in three strokes at one rung: these two horizontals and the
507
- open section's vertical below. A bracket whose sides disagree on width or tone reads
508
- as three unrelated lines rather than one figure, which is why the vertical takes
509
- `--_qm-border` and not `--_qm-accent`.
507
+ /* Where two sections meet the distance is three of one rung — the cap closing the one
508
+ above, this gap, the cap opening the one below — the same whether either side is a
509
+ group or the ungrouped block. That total is what the fields inside a section keep
510
+ between their rows (`.qm-fields` row-gap), so a section boundary stands no further
511
+ apart than a field boundary: what ranks them is the stroke, not the air. */
512
+ .qm-card-meta,
513
+ .qm-groups {
514
+ display: flex;
515
+ flex-direction: column;
516
+ gap: var(--_qm-space);
517
+ }
518
+ /* One rung in two places: what a rule insets its own edge by, and what a vertical
519
+ reaches past the box it is drawn on. `--_qm-nest` is the distance a stroke stands off
520
+ the content it holds — the same one the fields keep off the vertical inline — and a
521
+ section's box already spends `--_qm-space` of it capping its own ends, so both spend
522
+ the difference. That is what squares the corner: the field stands the same rung off
523
+ the rule above it as off the vertical beside it. */
524
+ .qm-card-meta {
525
+ --stroke-reach: calc(var(--_qm-nest) - var(--_qm-space));
526
+ }
527
+ /* The card's two horizontals, and ranking is the one thing a horizontal is for: they
528
+ stand the payload against the title above it and the body below it, where saying what
529
+ sits inside what is the verticals' (ARCHITECTURE §"A plane is a tone"). Each is
530
+ conditional on what it ranks against: `main` is headerless, and a card with no body
531
+ has nothing under the payload to divide it from.
510
532
 
511
- Each condition is what its stroke means: a top rule is the line under a card title,
512
- so the headerless `main` has none; a bottom rule divides fields from body, so a card
513
- with no body has nothing to divide. Sometimes two strokes and sometimes one — an open
514
- figure, which is what distinguishes it from a second box inside the card. */
533
+ The inset is the rule's own, so it goes with it: a card drawing one horizontal insets
534
+ that end and not the other. The air outside the pair is the `--_qm-space-3` the card
535
+ spends between its blocks. */
515
536
  .qm-meta-top {
516
537
  border-top: var(--_qm-border-width) solid var(--_qm-border);
538
+ padding-top: var(--stroke-reach);
517
539
  }
518
540
  .qm-meta-bottom {
519
541
  border-bottom: var(--_qm-border-width) solid var(--_qm-border);
542
+ padding-bottom: var(--stroke-reach);
520
543
  }
521
- /* The inset sits on the fields rather than on the block that holds them: an open
522
- section's vertical has to reach the rule to close a corner against it, and padding
523
- between the two is the distance by which it would miss. Unqualified by open — a shut
524
- panel clips it. */
525
- .qm-meta-top > .qm-section {
526
- padding-top: var(--_qm-space-3);
527
- }
528
- .qm-meta-bottom > .qm-section:last-child,
529
- .qm-meta-bottom .qm-group:last-child .qm-group-panel-inner > .qm-fields {
530
- padding-bottom: var(--_qm-space-3);
544
+ /* A section's share of the boundary between sections: one rung at each end, so two of
545
+ them stand three of it apart whichever kind they are (above). The accordion spends the
546
+ same rung from its header and its panel, where it also caps the vertical those two
547
+ edges hold.
548
+
549
+ No stroke and no inset: fields declaring no `ui.group` are the card's own, at the rank
550
+ its body is at, and the card's edge is the only container there is to state. A
551
+ vertical here would mark a section a reader has no name for. */
552
+ .qm-section {
553
+ padding-block: var(--_qm-space);
531
554
  }
532
555
  /* Two query containers, one per kind of section, so capacity follows the width the
533
556
  fields actually get: a panel's inset makes that narrower than the card. */
@@ -562,16 +585,32 @@
562
585
  --cols-half: 2;
563
586
  }
564
587
  }
565
- .qm-groups,
588
+ /* The nesting vertical, one `--_qm-nest` in from the card's edge with the panel's
589
+ fields the same rung off it (ARCHITECTURE §"A plane is a tone"). `--_qm-border` and
590
+ not `--_qm-accent`: a card's edge and the verticals inside it are one stroke at three
591
+ depths, and a tone that changed as it went in would read as unrelated lines rather
592
+ than one figure. A named section is what earns it, which is why the ungrouped block
593
+ above draws none.
594
+
595
+ On the group rather than on its panel: what the stroke marks is the section, and a
596
+ section is its header and what hangs under it. Drawn transparent when closed rather
597
+ than absent, so a toggle moves no text — and closed, there is nothing under the header
598
+ for it to gather.
599
+
600
+ The stroke caps at `--_qm-nest`, at both ends and on every section alike. Its box
601
+ already spends `--_qm-space` of that — the header's block padding above, the panel's
602
+ below, the rung the ungrouped section spends on its own two ends — so the rest is
603
+ grown here and handed straight back as margin: the stroke lengthens, and a section
604
+ boundary keeps the three rungs it stood at. What the stroke reaches into is the air of
605
+ that boundary, or, where the section is the payload's first or last, exactly the inset
606
+ its rule took — so it lands on the rule at a corner with nothing drawn longer for it.
607
+ One section open at a time, so no two strokes are ever visible to meet in between. */
566
608
  .qm-group {
567
609
  display: flex;
568
610
  flex-direction: column;
569
- }
570
- /* The bracket's third stroke, on the section rather than on its panel: spanning the
571
- header is what gives it a corner to close into at the block's edge. Drawn transparent
572
- when closed rather than absent, so a toggle moves no text. */
573
- .qm-group {
574
611
  border-left: var(--_qm-border-width) solid transparent;
612
+ padding-block: var(--stroke-reach);
613
+ margin-block: calc(-1 * var(--stroke-reach));
575
614
  transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
576
615
  }
577
616
  .qm-group.qm-open {
@@ -579,10 +618,13 @@
579
618
  }
580
619
  /* Symmetric vertical padding at the tightest rung that still clears WCAG 2.5.8's
581
620
  24×24 floor: the header is the whole row, so adjacent labels share one rhythm with no
582
- dead strip outside the button. Horizontal is one rung left and zero right — left,
583
- because at zero the chevron stands on the section's vertical with only the icon box's
584
- own bearing between them, and the glyph's rotation swaps which bearing faces the
585
- stroke; right, because the row is the target and an inset there is target given back.
621
+ dead strip outside the button. Horizontal is the nesting step left and zero right —
622
+ left, because the chevron opening the heading starts where the fields under it start,
623
+ so everything the section holds stands off its vertical by one rung and the corner the
624
+ stroke closes against a rule reads square; right, because the row is the target and an
625
+ inset there is target given back. What the glyph gives up by taking the step is the
626
+ gutter: at a tighter rung it stands in the clearance itself, with only its own bearing
627
+ between ink and stroke, and the rotation swaps which bearing faces which.
586
628
 
587
629
  `font: inherit` because a UA button inherits no face, then the body rung, one step
588
630
  over the field labels beneath it: at the label rung the two read as one register and
@@ -595,7 +637,7 @@
595
637
  width: 100%;
596
638
  border: none;
597
639
  background: transparent;
598
- padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-space);
640
+ padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-nest);
599
641
  font: inherit;
600
642
  font-size: var(--_qm-text-body);
601
643
  font-weight: var(--_qm-weight-mid);
@@ -651,12 +693,13 @@
651
693
  .qm-group-panel-inner {
652
694
  min-height: 0;
653
695
  overflow: hidden;
654
- padding-inline-start: var(--_qm-space-4);
696
+ padding-inline-start: var(--_qm-nest);
655
697
  padding-inline-end: var(--_qm-ring-reach);
656
698
  margin-inline-end: calc(-1 * var(--_qm-ring-reach));
657
699
  }
700
+ /* The lower of the two caps an open section's vertical takes (`.qm-group`). */
658
701
  .qm-group-panel-inner > .qm-fields {
659
- padding-block: var(--_qm-space-2);
702
+ padding-block: var(--_qm-space);
660
703
  }
661
704
  .qm-body-leaf {
662
705
  display: flex;
@@ -108,10 +108,12 @@
108
108
  <Icon name="chevron-down" size={14} />
109
109
  </Select.Trigger>
110
110
  <Select.Portal to={portalTarget}>
111
- <Select.Content sideOffset={4}>
111
+ <Select.Content collisionBoundary={portalTarget ?? []} sideOffset={4}>
112
112
  <!-- The list portals out of the trigger's DOM but into the nearest
113
113
  `[data-qm-root]`, and carries the marker itself, like FormatPopover.
114
- The pill is this
114
+ That root is the boundary a flip is measured against too, being the box
115
+ that clips the surface where the consumer scrolls one; the viewport
116
+ default knows nothing of that. The pill is this
115
117
  element (not the primitive's) because scoped CSS keys off which
116
118
  component owns the markup: a `class` passed to a primitive is a
117
119
  plain string and never picks up the scoping hash. -->
@@ -14,7 +14,15 @@
14
14
  `DiagnosticList`, non-gating.
15
15
  -->
16
16
  <script lang="ts">
17
- import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
17
+ import type {
18
+ Document,
19
+ Quill,
20
+ Addr,
21
+ Content,
22
+ Diagnostic,
23
+ PathStep,
24
+ ResolvedField
25
+ } from '@quillmark/wasm';
18
26
  import type { EditorErrorHandler } from '../core/errors.js';
19
27
  import type { LeafRegistry } from './leaves.js';
20
28
  import type { FieldModel, FieldSpan } from './structure.js';
@@ -116,6 +124,16 @@
116
124
  // nothing, and silently.
117
125
  const describedBy = $derived(field.description ? domIds.description : undefined);
118
126
 
127
+ /** The boundary's nested content read with this field's address already bound: an
128
+ * array's elements, an object's properties, a variant's cells, and a subform's cells
129
+ * under an open element. One door rather than one per depth, since `getContentAt`
130
+ * takes the whole path and the walk down is each container prefixing its own step.
131
+ * Read per call and never held: a leaf takes its state at mount, and a control asks
132
+ * once. */
133
+ function contentAt(path: PathStep[]): Content | undefined {
134
+ return quill.reader(doc).getContentAt(addr, path);
135
+ }
136
+
119
137
  // ── Focus: one answer, two callers (`leaves.ts`) ─────────────────────────────
120
138
  // A label click and the editor's `focusField`/`setCaret` ask the same question, so
121
139
  // they read the same function and cannot land in different places. The four
@@ -247,6 +265,7 @@
247
265
  id={domIds.control}
248
266
  labelledBy={domIds.label}
249
267
  {describedBy}
268
+ {contentAt}
250
269
  onCommit={onCommitScalar}
251
270
  {optionAllowed}
252
271
  {enumDisallowed}
@@ -288,7 +307,7 @@
288
307
  labelId={domIds.label}
289
308
  descriptionId={domIds.description}
290
309
  idBase={domIds.control}
291
- elementContent={(i) => quill.reader(doc).getContentAt(addr, [i])}
310
+ {contentAt}
292
311
  onCommit={onCommitScalar}
293
312
  />
294
313
  {:else if field.control === 'object'}
@@ -300,6 +319,7 @@
300
319
  idBase={domIds.control}
301
320
  labelledBy={domIds.label}
302
321
  {describedBy}
322
+ {contentAt}
303
323
  onCommit={onCommitScalar}
304
324
  />
305
325
  {:else}