@quillmark/svelte 0.21.0 → 0.22.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 (67) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/dist/core/address.d.ts +6 -1
  4. package/dist/core/address.js +12 -3
  5. package/dist/core/bloom.d.ts +9 -4
  6. package/dist/core/bloom.js +18 -49
  7. package/dist/core/codec/atoms.d.ts +1 -1
  8. package/dist/core/codec/atoms.js +1 -1
  9. package/dist/core/codec/blocks.js +4 -2
  10. package/dist/core/codec/code.d.ts +1 -1
  11. package/dist/core/codec/code.js +1 -1
  12. package/dist/core/codec/decode.d.ts +5 -5
  13. package/dist/core/codec/decode.js +18 -12
  14. package/dist/core/codec/encode.js +6 -1
  15. package/dist/core/codec/field.d.ts +15 -10
  16. package/dist/core/codec/field.js +46 -17
  17. package/dist/core/codec/index.d.ts +1 -1
  18. package/dist/core/codec/index.js +2 -2
  19. package/dist/core/codec/inputrules.js +1 -1
  20. package/dist/core/codec/lists.d.ts +3 -2
  21. package/dist/core/codec/lists.js +21 -5
  22. package/dist/core/codec/nesting.d.ts +20 -0
  23. package/dist/core/codec/nesting.js +62 -0
  24. package/dist/core/codec/prose.css +74 -20
  25. package/dist/core/codec/schema.d.ts +2 -2
  26. package/dist/core/codec/schema.js +16 -7
  27. package/dist/core/codec/table-copy.d.ts +17 -0
  28. package/dist/core/codec/table-copy.js +84 -0
  29. package/dist/core/codec/table-view.d.ts +1 -7
  30. package/dist/core/codec/table-view.js +70 -119
  31. package/dist/core/codec/table.d.ts +2 -9
  32. package/dist/core/codec/table.js +10 -19
  33. package/dist/core/codec/tasks.d.ts +19 -0
  34. package/dist/core/codec/tasks.js +140 -0
  35. package/dist/core/errors.d.ts +2 -2
  36. package/dist/visual/ArrayField.svelte +101 -63
  37. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  38. package/dist/visual/Card.svelte +9 -3
  39. package/dist/visual/Card.svelte.d.ts +2 -2
  40. package/dist/visual/Field.svelte +20 -16
  41. package/dist/visual/Field.svelte.d.ts +4 -2
  42. package/dist/visual/FieldLabel.svelte +5 -33
  43. package/dist/visual/FieldLabel.svelte.d.ts +0 -2
  44. package/dist/visual/FormatPopover.svelte +54 -73
  45. package/dist/visual/MatrixField.svelte +18 -12
  46. package/dist/visual/NumberField.svelte +32 -3
  47. package/dist/visual/ObjectField.svelte +38 -20
  48. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  49. package/dist/visual/ProseField.svelte +10 -13
  50. package/dist/visual/ProseField.svelte.d.ts +5 -2
  51. package/dist/visual/ProseValue.svelte +25 -19
  52. package/dist/visual/ProseValue.svelte.d.ts +4 -4
  53. package/dist/visual/TextField.svelte +3 -2
  54. package/dist/visual/TipsCard.svelte +5 -2
  55. package/dist/visual/VariantField.svelte +1 -1
  56. package/dist/visual/VariantField.svelte.d.ts +1 -1
  57. package/dist/visual/VisualEditorInner.svelte +13 -13
  58. package/dist/visual/controls.css +19 -1
  59. package/dist/visual/icons/nodes.d.ts +0 -21
  60. package/dist/visual/icons/nodes.js +1 -7
  61. package/dist/visual/leaves.d.ts +12 -9
  62. package/dist/visual/leaves.js +19 -0
  63. package/dist/visual/strings.d.ts +10 -5
  64. package/dist/visual/strings.js +16 -9
  65. package/dist/visual/structure.d.ts +21 -31
  66. package/dist/visual/structure.js +54 -51
  67. package/package.json +3 -3
@@ -2,7 +2,7 @@
2
2
  An `array` field → an add/remove repeater. Elements commit by value: every
3
3
  edit / add / remove / move 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
- by `items.type`: `richtext` / `plaintext` → a prose element
5
+ by `items.type`: `richtext` / `text` → a prose element
6
6
  ({@link ProseValue}), `object` → a summary row that opens onto a subform, or a
7
7
  table's row where the array asks for one and the cells are short, everything else →
8
8
  a text input. The add chip sits in the label header row (space-between with the field
@@ -71,7 +71,7 @@
71
71
  controlKind,
72
72
  declaredContent,
73
73
  humanize,
74
- obliged,
74
+ literalText,
75
75
  rowSummary,
76
76
  schemaAt,
77
77
  type ArrayLayout
@@ -105,8 +105,6 @@
105
105
  max?: number;
106
106
  /** Accessible-name prefix for the element controls (`label` + 1-based index). */
107
107
  label?: string;
108
- /** No-default field → a persistent required `*` on the label. */
109
- required?: boolean;
110
108
  /** Schema `description`: the label's help affordance. */
111
109
  description?: string;
112
110
  /** The label's own DOM id. An array is a group (N inputs, no single `for`
@@ -140,7 +138,6 @@
140
138
  layout = 'list',
141
139
  max,
142
140
  label,
143
- required,
144
141
  description,
145
142
  labelId,
146
143
  descriptionId,
@@ -519,9 +516,10 @@
519
516
  * any cell an open row holds: Alt+↑/↓, the table island's own binding. The nearest
520
517
  * row answers, or none does: a press inside a nested row is that row's whether it
521
518
  * holds a reorder or not, so a key in a table's cell or a list of strings' input
522
- * moves nothing, and never the record around it. */
519
+ * moves nothing, and never the record around it. A held drag owns the row until its
520
+ * release. */
523
521
  function onRowKey(e: KeyboardEvent, k: number): void {
524
- if (!e.altKey || e.shiftKey || e.ctrlKey || e.metaKey || e.isComposing) return;
522
+ if (!e.altKey || e.shiftKey || e.ctrlKey || e.metaKey || e.isComposing || drag) return;
525
523
  // A cell that took the key — a select opening, a date segment stepping, a prose
526
524
  // leaf joining — owns it.
527
525
  if (e.defaultPrevented) return;
@@ -536,42 +534,53 @@
536
534
 
537
535
  // ── The drag handle ──────────────────────────────────────────────────────────
538
536
  // Press-and-drag the handle moves its row; a press that does not travel past the dead
539
- // zone does nothing. The rows are measured once the drag engages, nothing reflowing
540
- // until the release (the lift is a tone and the drop rule is out of flow), and the
541
- // release is the one splice `move` makes.
537
+ // zone does nothing. The drop is the row under the pointer, read off the rows where
538
+ // they stand, so a scroll by any hand re-picks it. Held in an edge band of its
539
+ // scrollport, the drag scrolls it toward the rows out of view, once the pointer has
540
+ // been outside the band: a press on a row already in the band does not scroll. Nothing
541
+ // reflows until the release (the lift is a tone and the drop rule is out of flow), and
542
+ // the release is the one splice `move` makes. A handle that loses the pointer, or
543
+ // leaves the DOM, ends the drag with no move.
542
544
  const DEAD_ZONE = 3;
543
- type RowDrag = {
544
- k: number;
545
- origin: number;
546
- engaged: boolean;
547
- drop: number;
548
- heads: { start: number; end: number }[];
549
- };
545
+ /** The band at a scrollport's top and bottom edges that scrolls it under a held drag,
546
+ * and the most one frame scrolls, reached at the edge itself. */
547
+ const EDGE = 48;
548
+ const STEP = 16;
549
+ type RowDrag = { k: number; origin: number; y: number; engaged: boolean; drop: number };
550
550
  let drag = $state<RowDrag | undefined>();
551
+ let follow: { grip: Element; port: Element; armed: boolean; frame: number } | undefined;
551
552
  function onHandleDown(e: PointerEvent, k: number): void {
552
553
  if (e.button !== 0) return;
553
554
  e.preventDefault();
554
- (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
555
- drag = { k, origin: e.clientY, engaged: false, drop: k, heads: [] };
555
+ release();
556
+ const grip = e.currentTarget as HTMLElement;
557
+ grip.setPointerCapture?.(e.pointerId);
558
+ drag = { k, origin: e.clientY, y: e.clientY, engaged: false, drop: k };
559
+ follow = { grip, port: scrollport(grip), armed: false, frame: 0 };
556
560
  }
557
561
  function onHandleMove(e: PointerEvent): void {
558
562
  if (!drag) return;
563
+ drag.y = e.clientY;
559
564
  if (!drag.engaged) {
560
565
  if (Math.abs(e.clientY - drag.origin) < DEAD_ZONE) return;
561
566
  drag.engaged = true;
562
- drag.heads = ids.map((id) => {
563
- const box = rowEls[id]?.getBoundingClientRect();
564
- return { start: box?.top ?? 0, end: box?.bottom ?? 0 };
565
- });
567
+ addEventListener('scroll', repick, { capture: true, passive: true });
568
+ if (follow) follow.frame = requestAnimationFrame(edgeScroll);
566
569
  }
567
- drag.drop = dropIndex(drag, e.clientY);
570
+ repick();
571
+ }
572
+ function repick(): void {
573
+ if (drag?.engaged) drag.drop = dropIndex(drag.k, drag.y);
568
574
  }
575
+ const rowBox = (i: number): DOMRect | undefined => rowEls[ids[i]]?.getBoundingClientRect();
569
576
  /** The row the pointer is over, or the nearest one past either end. */
570
- function dropIndex(d: RowDrag, y: number): number {
571
- let best = d.k;
577
+ function dropIndex(k: number, y: number): number {
578
+ let best = k;
572
579
  let nearest = Infinity;
573
- d.heads.forEach((head, i) => {
574
- const gap = y < head.start ? head.start - y : y > head.end ? y - head.end : 0;
580
+ ids.forEach((_, i) => {
581
+ const box = rowBox(i);
582
+ if (!box) return;
583
+ const gap = y < box.top ? box.top - y : y > box.bottom ? y - box.bottom : 0;
575
584
  if (gap < nearest) {
576
585
  nearest = gap;
577
586
  best = i;
@@ -579,9 +588,45 @@
579
588
  });
580
589
  return best;
581
590
  }
591
+ const page = (): Element => document.scrollingElement ?? document.documentElement;
592
+ /** The nearest ancestor that scrolls vertically, else the page. */
593
+ function scrollport(from: Element): Element {
594
+ for (let el = from.parentElement; el; el = el.parentElement) {
595
+ const { overflowY } = getComputedStyle(el);
596
+ if (/auto|scroll/.test(overflowY) && el.scrollHeight > el.clientHeight) return el;
597
+ }
598
+ return page();
599
+ }
600
+ function edgeScroll(): void {
601
+ if (!follow || !drag?.engaged) return;
602
+ if (!follow.grip.isConnected) {
603
+ drag = undefined;
604
+ release();
605
+ return;
606
+ }
607
+ const { port } = follow;
608
+ const box = port === page() ? undefined : port.getBoundingClientRect();
609
+ const top = Math.max(box?.top ?? 0, 0);
610
+ const bottom = Math.min(box?.bottom ?? innerHeight, innerHeight);
611
+ const into = Math.min(drag.y - top - EDGE, 0) + Math.max(drag.y - bottom + EDGE, 0);
612
+ if (!into) follow.armed = true;
613
+ // Only toward rows still out of view: past the list's end there is nothing to reach.
614
+ const beyond =
615
+ into < 0 ? (rowBox(0)?.top ?? top) < top : (rowBox(ids.length - 1)?.bottom ?? 0) > bottom;
616
+ if (into && follow.armed && beyond)
617
+ port.scrollBy({ top: STEP * Math.max(-1, Math.min(1, into / EDGE)), behavior: 'instant' });
618
+ follow.frame = requestAnimationFrame(edgeScroll);
619
+ }
620
+ function release(): void {
621
+ if (follow) cancelAnimationFrame(follow.frame);
622
+ follow = undefined;
623
+ removeEventListener('scroll', repick, { capture: true });
624
+ }
625
+ onDestroy(release);
582
626
  function onHandleUp(e: PointerEvent): void {
583
627
  const done = drag;
584
628
  drag = undefined;
629
+ release();
585
630
  if (!done?.engaged || e.type !== 'pointerup') return;
586
631
  move(done.k, done.drop);
587
632
  }
@@ -600,14 +645,7 @@
600
645
  >
601
646
  <div class="qm-array-header">
602
647
  {#if label != null}
603
- <FieldLabel
604
- {label}
605
- id={labelId}
606
- {descriptionId}
607
- onActivate={focus}
608
- {required}
609
- {description}
610
- />
648
+ <FieldLabel {label} id={labelId} {descriptionId} onActivate={focus} {description} />
611
649
  {:else}
612
650
  <span></span>
613
651
  {/if}
@@ -632,15 +670,15 @@
632
670
  each block's one child: a branch inside the block would stand between them. -->
633
671
  {#if table}
634
672
  <div
635
- class={['qm-array-rows qm-array-table', { empty: ids.length === 0 }]}
673
+ class={['qm-array-rows qm-array-table qm-wash', { empty: ids.length === 0 }]}
636
674
  data-default={defaulted ? '' : undefined}
637
675
  bind:this={rowsEl}
638
676
  >
639
677
  <div class="qm-array-table-scroller" style:--table-cols={columns.length}>
640
678
  <!-- The header names the columns, so a cell carries no label of its own: its
641
679
  accessible name is the row's and the column's composed (`ObjectField`). The
642
- obligation mark and the guidance affordance ride the header, being the
643
- column's rather than any one cell's. -->
680
+ guidance affordance rides the header, being the column's rather than any
681
+ one cell's. -->
644
682
  <div class="qm-array-table-head">
645
683
  {#each columns as [key, sub] (key)}
646
684
  <div
@@ -652,7 +690,6 @@
652
690
  <FieldLabel
653
691
  label={columnTitle(key, sub)}
654
692
  descriptionId={idBase != null ? propertyDomIds(idBase, key).description : undefined}
655
- required={obliged(sub)}
656
693
  description={sub.description}
657
694
  />
658
695
  </div>
@@ -660,7 +697,11 @@
660
697
  <span class="qm-array-table-head-end"></span>
661
698
  </div>
662
699
  {#each ids as id, k (id)}
663
- <div class="qm-array-row qm-array-table-row" bind:this={rowEls[id]}>
700
+ <div
701
+ class="qm-array-row qm-array-table-row qm-wash"
702
+ data-qm-index={k}
703
+ bind:this={rowEls[id]}
704
+ >
664
705
  <ObjectField
665
706
  bare
666
707
  bind:this={cellEls[id]}
@@ -680,7 +721,7 @@
680
721
  </div>
681
722
  {:else if control === 'object'}
682
723
  <div
683
- class={['qm-array-rows', { empty: ids.length === 0 }]}
724
+ class={['qm-array-rows qm-wash', { empty: ids.length === 0 }]}
684
725
  data-default={defaulted ? '' : undefined}
685
726
  bind:this={rowsEl}
686
727
  >
@@ -690,10 +731,11 @@
690
731
  interaction of the row's own: the row is no tab stop. -->
691
732
  <div
692
733
  class={[
693
- 'qm-array-row qm-element',
734
+ 'qm-array-row qm-element qm-wash',
694
735
  { open: open.has(id), lifted: drag?.engaged && drag.k === k }
695
736
  ]}
696
737
  data-drop={dropSide(k)}
738
+ data-qm-index={k}
697
739
  bind:this={rowEls[id]}
698
740
  animate:reorder={arm.armed}
699
741
  onkeydown={(e) => onRowKey(e, k)}
@@ -708,6 +750,7 @@
708
750
  type="button"
709
751
  class="qm-control-box qm-focus-ring qm-element-summary"
710
752
  aria-expanded={open.has(id)}
753
+ aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown"
711
754
  onclick={() => toggleRow(id)}
712
755
  >
713
756
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
@@ -745,18 +788,18 @@
745
788
  </div>
746
789
  {:else}
747
790
  <div
748
- class={['qm-array-rows', { empty: ids.length === 0 }]}
791
+ class={['qm-array-rows qm-wash', { empty: ids.length === 0 }]}
749
792
  data-default={defaulted ? '' : undefined}
750
793
  bind:this={rowsEl}
751
794
  >
752
795
  {#each ids as id, k (id)}
753
- <div class="qm-array-row" bind:this={rowEls[id]}>
796
+ <div class="qm-array-row qm-wash" data-qm-index={k} bind:this={rowEls[id]}>
754
797
  {#if control === 'prose'}
755
798
  <ProseValue
756
799
  bind:this={els[id]}
757
800
  content={() => readAt([k])}
758
801
  value={arr[k]}
759
- plaintext={items != null && baseType(items) === 'plaintext'}
802
+ plaintext={items != null && literalText(items)}
760
803
  label={rowName(k)}
761
804
  onChange={(rt) => commitElement(k, rt)}
762
805
  onKey={(e) => onElementKey(e, k)}
@@ -794,20 +837,20 @@
794
837
  {/snippet}
795
838
 
796
839
  <!-- An `object` row's drag handle, a table grip's twin hung off the row's start edge.
797
- It holds no focus, as a grip does not: its keyboard twin is Alt+↑/↓ from anywhere in
798
- the row. -->
840
+ Pointer-only and out of the accessibility tree: a press on it does nothing, so a
841
+ button there would be one a screen reader activates to no effect. Its twin is
842
+ Alt+↑/↓ from anywhere in the row, which the summary announces. -->
799
843
  {#snippet handle(k: number)}
800
- <button
801
- type="button"
844
+ <span
802
845
  class="qm-row-handle"
803
- tabindex="-1"
846
+ aria-hidden="true"
804
847
  title={t.strings.arrayDrag}
805
- aria-label={rowAction(t.strings.arrayDrag, k)}
806
848
  onpointerdown={(e) => onHandleDown(e, k)}
807
849
  onpointermove={onHandleMove}
808
850
  onpointerup={onHandleUp}
809
851
  onpointercancel={onHandleUp}
810
- ><span class="qm-row-handle-bar"><Icon name="grip-vertical" /></span></button
852
+ onlostpointercapture={onHandleUp}
853
+ ><span class="qm-row-handle-bar"><Icon name="grip-vertical" /></span></span
811
854
  >
812
855
  {/snippet}
813
856
 
@@ -841,14 +884,13 @@
841
884
  }
842
885
  /* The elements, in a box of their own: the arrival wash blooms here rather than over
843
886
  the wrapper `Field` hands this component (`washBox`), which is the label's box too.
844
- Positioned for the wash's inset child, and rounded to the rung a row's own box
845
- draws, the way `.qm-field-control` is for every other control.
887
+ Rounded to the rung a row's own box draws, the way `.qm-field-control` is for every
888
+ other control.
846
889
 
847
890
  `display: none` when there are no elements, so the header does not stand a gap above
848
891
  an empty box; a wash over it then paints nothing, which is what an array with
849
892
  nothing in it has to show. */
850
893
  .qm-array-rows {
851
- position: relative;
852
894
  display: flex;
853
895
  flex-direction: column;
854
896
  gap: var(--_qm-space);
@@ -873,7 +915,6 @@
873
915
  against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
874
916
  edge with it. */
875
917
  .qm-array-row {
876
- position: relative;
877
918
  display: grid;
878
919
  grid-template-columns: minmax(0, 1fr);
879
920
  row-gap: var(--_qm-space);
@@ -953,9 +994,6 @@
953
994
  display: flex;
954
995
  align-items: center;
955
996
  justify-content: center;
956
- padding: 0;
957
- border: none;
958
- background: none;
959
997
  color: var(--_qm-ink-label);
960
998
  cursor: grab;
961
999
  touch-action: none;
@@ -988,7 +1026,7 @@
988
1026
  .qm-element.lifted > .qm-element-head > .qm-element-summary {
989
1027
  opacity: var(--_qm-opacity-muted);
990
1028
  }
991
- .qm-element[data-drop]::after {
1029
+ .qm-element[data-drop]::before {
992
1030
  content: '';
993
1031
  position: absolute;
994
1032
  inset-inline: 0;
@@ -997,10 +1035,10 @@
997
1035
  border-radius: var(--_qm-radius-pill);
998
1036
  pointer-events: none;
999
1037
  }
1000
- .qm-element[data-drop='before']::after {
1038
+ .qm-element[data-drop='before']::before {
1001
1039
  top: calc(-0.5 * (var(--_qm-space) + var(--_qm-ring-width)));
1002
1040
  }
1003
- .qm-element[data-drop='after']::after {
1041
+ .qm-element[data-drop='after']::before {
1004
1042
  bottom: calc(-0.5 * (var(--_qm-space) + var(--_qm-ring-width)));
1005
1043
  }
1006
1044
  /* ── An object element ──────────────────────────────────────────────────────
@@ -15,8 +15,6 @@ interface Props {
15
15
  max?: number;
16
16
  /** Accessible-name prefix for the element controls (`label` + 1-based index). */
17
17
  label?: string;
18
- /** No-default field → a persistent required `*` on the label. */
19
- required?: boolean;
20
18
  /** Schema `description`: the label's help affordance. */
21
19
  description?: string;
22
20
  /** The label's own DOM id. An array is a group (N inputs, no single `for`
@@ -10,7 +10,7 @@
10
10
  // component renders standalone too.
11
11
  const t = wording();
12
12
  import Icon from './icons/Icon.svelte';
13
- import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
13
+ import type { Document, Quill, Addr, Diagnostic, PathStep } from '@quillmark/wasm';
14
14
  import type { EditorErrorHandler } from '../core/errors.js';
15
15
  import type { LeafRegistry } from './leaves.js';
16
16
  import type { CardModel, FieldModel } from './structure.js';
@@ -48,7 +48,7 @@
48
48
  kinds: string[];
49
49
  ops: CardOps;
50
50
  enumDisallowed?: 'hide' | 'disable';
51
- onFocus?: (addr: Addr) => void;
51
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
52
52
  onCaretMove?: (addr: Addr, pos: number) => void;
53
53
  onChange?: (addr: Addr) => void;
54
54
  onError?: EditorErrorHandler;
@@ -279,7 +279,7 @@
279
279
  <div class="qm-group-head">
280
280
  <button
281
281
  type="button"
282
- class="qm-group-header"
282
+ class="qm-group-header qm-focus-ring"
283
283
  id={headIds?.label}
284
284
  bind:this={headers[group]}
285
285
  aria-expanded={isOpen}
@@ -538,6 +538,12 @@
538
538
  .qm-card-title:focus {
539
539
  color: var(--_qm-ink);
540
540
  }
541
+ /* The automatic title, at the label ink every placeholder takes rather than the UA's
542
+ grey, and steady under focus, where a typed title steps to the ink. */
543
+ .qm-card-title::placeholder {
544
+ color: var(--_qm-ink-label);
545
+ opacity: 1;
546
+ }
541
547
  .qm-card-body {
542
548
  display: flex;
543
549
  flex-direction: column;
@@ -1,4 +1,4 @@
1
- import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
1
+ import type { Document, Quill, Addr, Diagnostic, PathStep } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
3
  import type { LeafRegistry } from './leaves.js';
4
4
  import type { CardModel } from './structure.js';
@@ -27,7 +27,7 @@ interface Props {
27
27
  kinds: string[];
28
28
  ops: CardOps;
29
29
  enumDisallowed?: 'hide' | 'disable';
30
- onFocus?: (addr: Addr) => void;
30
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
31
31
  onCaretMove?: (addr: Addr, pos: number) => void;
32
32
  onChange?: (addr: Addr) => void;
33
33
  onError?: EditorErrorHandler;
@@ -27,7 +27,7 @@
27
27
  ResolvedField
28
28
  } from '@quillmark/wasm';
29
29
  import type { EditorErrorHandler } from '../core/errors.js';
30
- import { seatValueLeaves, type LandingBox, type LeafRegistry } from './leaves.js';
30
+ import { seatValueLeaves, stepsTo, type LandingBox, type LeafRegistry } from './leaves.js';
31
31
  import type { FieldModel, FieldSpan } from './structure.js';
32
32
  import {
33
33
  VARIANT_DISCRIMINANT,
@@ -82,7 +82,9 @@
82
82
  optionAllowed?: (value: string) => boolean;
83
83
  /** How a refused option draws: greyed (default) or left out. */
84
84
  enumDisallowed?: 'hide' | 'disable';
85
- onFocus?: (addr: Addr) => void;
85
+ /** A focus inside the field: its address, and the steps down to the cell holding
86
+ * the focus ({@link stepsTo}), `[]` on the field's own control. */
87
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
86
88
  onCaretMove?: (addr: Addr, pos: number) => void;
87
89
  onChange?: (addr: Addr) => void;
88
90
  onError?: EditorErrorHandler;
@@ -221,7 +223,7 @@
221
223
 
222
224
  /**
223
225
  * This field's landing handle. The wrapper is the bloom host rather than the
224
- * control: `bloomInside` appends an inset child and an `<input>` holds none. The
226
+ * control: the wash is a box's `::after`, which an `<input>` draws none of. The
225
227
  * label is outside it, and stays out of the wash: an arrival marks where the caret
226
228
  * landed, which is the control. A control that owns its label (`ownsLabel`: the array
227
229
  * and the matrix) holds that row inside itself, so it names its own box
@@ -251,7 +253,8 @@
251
253
  }
252
254
 
253
255
  // A by-value prose leaf anywhere inside this field's containers is reached under
254
- // this field's key: the active address names the field, never the element or cell.
256
+ // this field's key: the registry and the active address name the field, never the
257
+ // element or cell.
255
258
  seatValueLeaves({
256
259
  focus: (leaf) => leaves?.focusValue(leafKey, leaf),
257
260
  release: (leaf) => leaves?.releaseValue(leafKey, leaf)
@@ -261,11 +264,16 @@
261
264
  * A form control has no controller to report its focus through, so the wrapper
262
265
  * reports: `focusin` bubbles, so one handler covers a plain input, an array's N
263
266
  * elements and an object's properties alike, and the active leaf names a scalar
264
- * field the way it names a prose one. A prose leaf reports through its own
267
+ * field the way it names a prose one, down to the element or cell the focus is in. A prose leaf reports through its own
265
268
  * controller — the source that drives its caret signals too — and is excluded here
266
269
  * rather than counted twice.
267
270
  */
268
- const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
271
+ const reportFocus = $derived(
272
+ field.control === 'prose'
273
+ ? undefined
274
+ : (e: FocusEvent & { currentTarget: HTMLElement }) =>
275
+ onFocus?.(addr, stepsTo(e.target, e.currentTarget))
276
+ );
269
277
  </script>
270
278
 
271
279
  <div class={['qm-field', { cell: span === 'cell' }]}>
@@ -276,7 +284,6 @@
276
284
  id={domIds.label}
277
285
  descriptionId={domIds.description}
278
286
  {onActivate}
279
- required={field.required}
280
287
  description={field.description}
281
288
  />
282
289
  {/if}
@@ -285,7 +292,7 @@
285
292
  rule below holds why). One markup for both spans; a field owning its row nests a
286
293
  level and measures the same. -->
287
294
  <div class="qm-field-stack">
288
- <div class="qm-field-control" {@attach registerControl} onfocusin={reportFocus}>
295
+ <div class="qm-field-control qm-wash" {@attach registerControl} onfocusin={reportFocus}>
289
296
  {#if field.control === 'prose'}
290
297
  <ProseField
291
298
  {quill}
@@ -296,7 +303,7 @@
296
303
  plaintext={field.plaintext}
297
304
  fallback={printed ? (ghost as Content) : undefined}
298
305
  placeholder={none}
299
- placeholderUntilEdit
306
+ emptyUnsets={optional}
300
307
  labelledBy={domIds.label}
301
308
  {describedBy}
302
309
  {leafKey}
@@ -374,7 +381,6 @@
374
381
  layout={arrayLayout(field.schema)}
375
382
  max={field.schema.max}
376
383
  label={field.label}
377
- required={field.required}
378
384
  description={field.description}
379
385
  labelId={domIds.label}
380
386
  descriptionId={domIds.description}
@@ -389,6 +395,7 @@
389
395
  value={value as Record<string, unknown> | undefined}
390
396
  properties={field.schema.properties}
391
397
  {flat}
398
+ placed
392
399
  label={field.label}
393
400
  idBase={domIds.control}
394
401
  labelledBy={domIds.label}
@@ -476,13 +483,10 @@
476
483
  gap: var(--_qm-space);
477
484
  min-width: 0;
478
485
  }
479
- /* Positioned for the arrival wash a landing inserts (`core/bloom.ts`), the way
480
- `.qm-prose` is: an inset child over the control, since an `<input>` takes none.
481
- The radius is the control's own, so the wash's corners are the box's rather than
482
- square over a rounded one; on an array or a subform it bounds a group, where
483
- there is no single box for it to disagree with. */
486
+ /* The arrival wash's host (`.qm-wash`). The radius is the control's own, so the
487
+ wash's corners are the box's rather than square over a rounded one; on an array or
488
+ a subform it bounds a group, where there is no single box for it to disagree with. */
484
489
  .qm-field-control {
485
- position: relative;
486
490
  border-radius: var(--_qm-radius-inner);
487
491
  }
488
492
  </style>
@@ -1,4 +1,4 @@
1
- import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
1
+ import type { Document, Quill, Addr, Diagnostic, PathStep, ResolvedField } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
3
  import { type LeafRegistry } from './leaves.js';
4
4
  import type { FieldModel, FieldSpan } from './structure.js';
@@ -27,7 +27,9 @@ interface Props {
27
27
  optionAllowed?: (value: string) => boolean;
28
28
  /** How a refused option draws: greyed (default) or left out. */
29
29
  enumDisallowed?: 'hide' | 'disable';
30
- onFocus?: (addr: Addr) => void;
30
+ /** A focus inside the field: its address, and the steps down to the cell holding
31
+ * the focus ({@link stepsTo}), `[]` on the field's own control. */
32
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
31
33
  onCaretMove?: (addr: Addr, pos: number) => void;
32
34
  onChange?: (addr: Addr) => void;
33
35
  onError?: EditorErrorHandler;
@@ -10,15 +10,8 @@
10
10
  hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
11
11
  element is inert and invalid.
12
12
 
13
- Two non-gating label decorations:
14
- • a persistent required `*` for a field the schema obliges — one that declares no
15
- `default:` (DOCUMENT_MODEL). Its
16
- accessible name is "required", so a screen reader announces the word, not the
17
- glyph; inside the `<label>`, so it names the control along with the text
18
- ("Subject required") rather than announcing as a node adjacent to it;
19
-
20
- • the `description` as an info marker raising a themed popover
21
- ({@link FieldHint}).
13
+ One non-gating label decoration: the `description` as an info marker raising a
14
+ themed popover ({@link FieldHint}).
22
15
 
23
16
  The description describes, it does not name. It is parked in an always-present
24
17
  visually-hidden node here and the control carries `aria-describedby`: name then
@@ -32,11 +25,6 @@
32
25
  control's label decorates the same way.
33
26
  -->
34
27
  <script lang="ts">
35
- import { wording } from './strings.js';
36
-
37
- // The surface's words, ambient from the editor root; the package's English
38
- // off-tree, so this component renders standalone too.
39
- const t = wording();
40
28
  import FieldHint from './FieldHint.svelte';
41
29
 
42
30
  interface Props {
@@ -50,12 +38,10 @@
50
38
  descriptionId?: string;
51
39
  /** Focus handoff for a control `for` cannot reach; a label click calls it. */
52
40
  onActivate?: () => void;
53
- /** No-default field → a persistent required `*`. */
54
- required?: boolean;
55
41
  /** Schema `description`, or undefined: the affordance renders only when set. */
56
42
  description?: string;
57
43
  }
58
- let { label, controlId, id, descriptionId, onActivate, required, description }: Props = $props();
44
+ let { label, controlId, id, descriptionId, onActivate, description }: Props = $props();
59
45
  </script>
60
46
 
61
47
  <div class="qm-field-label-row">
@@ -71,9 +57,6 @@
71
57
  onclick={onActivate}
72
58
  >
73
59
  <span>{label}</span>
74
- {#if required}
75
- <span class="qm-field-required" aria-label={t.strings.fieldRequired}>*</span>
76
- {/if}
77
60
  </label>
78
61
  {#if description}
79
62
  <!-- Outside the `<label>`, both of them: a trigger inside it would put its own
@@ -91,10 +74,8 @@
91
74
  /* The row, not the label, is the field's grid child: `Field`'s subgrid counts
92
75
  two tracks and the hint sits beside the label rather than under it.
93
76
 
94
- A rung wider than the label's own gap below, so the two separations rank: the
95
- required glyph is part of the name and sits at the tighter one, the guidance
96
- marker is a second affordance beside it. The marker's box is its glyph
97
- (`FieldHint`), so this gap is the whole of what stands between them. */
77
+ The marker's box is its glyph (`FieldHint`), so this gap is the whole of what
78
+ stands between it and the label. */
98
79
  .qm-field-label-row {
99
80
  display: flex;
100
81
  align-items: center;
@@ -104,7 +85,6 @@
104
85
  .qm-field-label {
105
86
  display: inline-flex;
106
87
  align-items: center;
107
- gap: var(--_qm-space-half);
108
88
  font-size: var(--_qm-text-label);
109
89
  font-weight: var(--_qm-weight-mid);
110
90
  line-height: var(--_qm-leading-tight);
@@ -116,14 +96,6 @@
116
96
  .qm-field-label.targeted {
117
97
  cursor: pointer;
118
98
  }
119
- /* Required marker: the label's own ink, not an alarm; required-ness is guidance,
120
- the document still edits and renders unmet, and the danger hue is a diagnostic's. */
121
- .qm-field-required {
122
- display: inline-flex;
123
- align-items: center;
124
- color: var(--_qm-ink-label);
125
- line-height: 1;
126
- }
127
99
  /* The parked description: in the accessibility tree, out of the layout, and never
128
100
  a focus stop. Clipped rather than `display: none` / `hidden`, which take a node
129
101
  out of the tree entirely and would make the `aria-describedby` dangle. Out of
@@ -9,8 +9,6 @@ interface Props {
9
9
  descriptionId?: string;
10
10
  /** Focus handoff for a control `for` cannot reach; a label click calls it. */
11
11
  onActivate?: () => void;
12
- /** No-default field → a persistent required `*`. */
13
- required?: boolean;
14
12
  /** Schema `description`, or undefined: the affordance renders only when set. */
15
13
  description?: string;
16
14
  }