@quillmark/svelte 0.20.1 → 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 (85) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/THEMING.md +1 -1
  4. package/dist/core/address.d.ts +6 -1
  5. package/dist/core/address.js +12 -3
  6. package/dist/core/bloom.d.ts +9 -4
  7. package/dist/core/bloom.js +18 -49
  8. package/dist/core/codec/atoms.d.ts +1 -1
  9. package/dist/core/codec/atoms.js +26 -11
  10. package/dist/core/codec/blocks.d.ts +5 -5
  11. package/dist/core/codec/blocks.js +27 -8
  12. package/dist/core/codec/code.d.ts +4 -4
  13. package/dist/core/codec/code.js +52 -22
  14. package/dist/core/codec/decode.d.ts +5 -5
  15. package/dist/core/codec/decode.js +18 -12
  16. package/dist/core/codec/encode.js +6 -1
  17. package/dist/core/codec/field.d.ts +22 -15
  18. package/dist/core/codec/field.js +66 -26
  19. package/dist/core/codec/index.d.ts +1 -1
  20. package/dist/core/codec/index.js +2 -2
  21. package/dist/core/codec/inputrules.d.ts +4 -0
  22. package/dist/core/codec/inputrules.js +44 -6
  23. package/dist/core/codec/link-paste.d.ts +11 -0
  24. package/dist/core/codec/link-paste.js +32 -0
  25. package/dist/core/codec/lists.d.ts +5 -4
  26. package/dist/core/codec/lists.js +87 -31
  27. package/dist/core/codec/nesting.d.ts +20 -0
  28. package/dist/core/codec/nesting.js +62 -0
  29. package/dist/core/codec/prose.css +145 -63
  30. package/dist/core/codec/schema.d.ts +2 -2
  31. package/dist/core/codec/schema.js +71 -18
  32. package/dist/core/codec/slash.js +0 -0
  33. package/dist/core/codec/table-copy.d.ts +17 -0
  34. package/dist/core/codec/table-copy.js +84 -0
  35. package/dist/core/codec/table-view.d.ts +6 -7
  36. package/dist/core/codec/table-view.js +281 -201
  37. package/dist/core/codec/table.d.ts +2 -9
  38. package/dist/core/codec/table.js +10 -19
  39. package/dist/core/codec/tasks.d.ts +19 -0
  40. package/dist/core/codec/tasks.js +140 -0
  41. package/dist/core/errors.d.ts +2 -2
  42. package/dist/core/rungs.d.ts +6 -0
  43. package/dist/core/rungs.js +11 -0
  44. package/dist/core/theme.css +24 -12
  45. package/dist/visual/ArrayField.svelte +236 -78
  46. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  47. package/dist/visual/BooleanField.svelte +12 -1
  48. package/dist/visual/Card.svelte +41 -23
  49. package/dist/visual/Card.svelte.d.ts +3 -2
  50. package/dist/visual/CardControls.svelte +15 -15
  51. package/dist/visual/DateField.svelte +93 -64
  52. package/dist/visual/EnumField.svelte +9 -4
  53. package/dist/visual/Field.svelte +20 -16
  54. package/dist/visual/Field.svelte.d.ts +4 -2
  55. package/dist/visual/FieldHint.svelte +22 -5
  56. package/dist/visual/FieldLabel.svelte +5 -33
  57. package/dist/visual/FieldLabel.svelte.d.ts +0 -2
  58. package/dist/visual/FormatPopover.svelte +78 -81
  59. package/dist/visual/MatrixField.svelte +28 -19
  60. package/dist/visual/NumberField.svelte +32 -3
  61. package/dist/visual/ObjectField.svelte +40 -24
  62. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  63. package/dist/visual/ProseField.svelte +10 -13
  64. package/dist/visual/ProseField.svelte.d.ts +5 -2
  65. package/dist/visual/ProseValue.svelte +25 -19
  66. package/dist/visual/ProseValue.svelte.d.ts +4 -4
  67. package/dist/visual/TextField.svelte +3 -2
  68. package/dist/visual/TipsCard.svelte +7 -7
  69. package/dist/visual/VariantField.svelte +1 -1
  70. package/dist/visual/VariantField.svelte.d.ts +1 -1
  71. package/dist/visual/VisualEditorInner.svelte +121 -39
  72. package/dist/visual/controls.css +21 -2
  73. package/dist/visual/icons/Icon.svelte +3 -2
  74. package/dist/visual/icons/Icon.svelte.d.ts +3 -2
  75. package/dist/visual/icons/nodes.d.ts +25 -21
  76. package/dist/visual/icons/nodes.js +9 -7
  77. package/dist/visual/leaves.d.ts +12 -9
  78. package/dist/visual/leaves.js +19 -0
  79. package/dist/visual/motion.d.ts +25 -3
  80. package/dist/visual/motion.js +58 -9
  81. package/dist/visual/strings.d.ts +15 -9
  82. package/dist/visual/strings.js +19 -12
  83. package/dist/visual/structure.d.ts +21 -31
  84. package/dist/visual/structure.js +54 -51
  85. 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
@@ -29,8 +29,8 @@
29
29
  container or a block prose cell.
30
30
 
31
31
  The row's controls are inside the element: a slab over the end of the element's own
32
- box, taking its two end-side corners — the remove, and on an `object` row the reorder
33
- pair before it. So a row's box is the element's box, and an array's rows end where
32
+ box, taking its two end-side corners: the remove. An `object` row's drag handle hangs
33
+ off its start edge instead, as a table grip does. So a row's box is the element's box, and an array's rows end where
34
34
  every other field's control does. On an object element that box is the summary rather
35
35
  than the open row: a destructive control belongs to the line it sits on, not to
36
36
  everything that line has unfolded. A table's row carries the remove alone, in its last
@@ -41,7 +41,8 @@
41
41
  Keys carry the list without the mouse: Enter inserts a sibling below and takes the
42
42
  caret there, Backspace on an empty element removes it and hands focus back up the
43
43
  list, Alt+↑/↓ anywhere in an `object` row of the list moves it, the nearest row
44
- answering and never the record around a nested one. A move is one splice of the ids
44
+ answering and never the record around a nested one, and is the twin of the handle's
45
+ drag. A move is one splice of the ids
45
46
  and of the values together, the mechanism insert and remove use, so an element keeps
46
47
  its id for life and no prose leaf inside it remounts; the open row stays open across
47
48
  its own move, and `animate:reorder` holds the moving row as it holds a card.
@@ -70,7 +71,7 @@
70
71
  controlKind,
71
72
  declaredContent,
72
73
  humanize,
73
- obliged,
74
+ literalText,
74
75
  rowSummary,
75
76
  schemaAt,
76
77
  type ArrayLayout
@@ -104,8 +105,6 @@
104
105
  max?: number;
105
106
  /** Accessible-name prefix for the element controls (`label` + 1-based index). */
106
107
  label?: string;
107
- /** No-default field → a persistent required `*` on the label. */
108
- required?: boolean;
109
108
  /** Schema `description`: the label's help affordance. */
110
109
  description?: string;
111
110
  /** The label's own DOM id. An array is a group (N inputs, no single `for`
@@ -139,7 +138,6 @@
139
138
  layout = 'list',
140
139
  max,
141
140
  label,
142
- required,
143
141
  description,
144
142
  labelId,
145
143
  descriptionId,
@@ -328,15 +326,13 @@
328
326
  void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
329
327
  }
330
328
  /**
331
- * Move element `k` one slot: one splice of the ids and of the values together, so
332
- * the element keeps its id and everything mounted under it. A no-op at either edge.
333
- * The control that was pressed rides with the row, so it is focused again once the
334
- * flush has moved it: a node moved in the DOM is a node the browser blurred. An arrow
335
- * the move carried to its edge is disabled and takes no focus, so its twin does.
329
+ * Move element `k` to slot `to`: one splice of the ids and of the values together, so
330
+ * the element keeps its id and everything mounted under it. A no-op outside the list.
331
+ * The control that held the focus rides with the row, so it is focused again once the
332
+ * flush has moved it: a node moved in the DOM is a node the browser blurred.
336
333
  */
337
- function move(k: number, dir: -1 | 1): void {
338
- const to = k + dir;
339
- if (to < 0 || to >= ids.length) return;
334
+ function move(k: number, to: number): void {
335
+ if (to === k || to < 0 || to >= ids.length) return;
340
336
  const pressed = document.activeElement;
341
337
  arm.arm();
342
338
  const nextIds = ids.slice();
@@ -349,14 +345,8 @@
349
345
  onCommit(next);
350
346
  void (async () => {
351
347
  if (!(await span.resumes(tick()))) return;
352
- if (!(pressed instanceof HTMLElement) || !pressed.isConnected) return;
353
- const target =
354
- pressed instanceof HTMLButtonElement && pressed.disabled
355
- ? [pressed.previousElementSibling, pressed.nextElementSibling].find(
356
- (b) => b instanceof HTMLButtonElement && !b.disabled
357
- )
358
- : pressed;
359
- if (target instanceof HTMLElement) target.focus();
348
+ if (pressed instanceof HTMLElement && pressed.isConnected && rowEls[id]?.contains(pressed))
349
+ pressed.focus();
360
350
  })();
361
351
  }
362
352
  /** Take the caret: the first element, or the add affordance when the list is empty;
@@ -526,9 +516,10 @@
526
516
  * any cell an open row holds: Alt+↑/↓, the table island's own binding. The nearest
527
517
  * row answers, or none does: a press inside a nested row is that row's whether it
528
518
  * holds a reorder or not, so a key in a table's cell or a list of strings' input
529
- * 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. */
530
521
  function onRowKey(e: KeyboardEvent, k: number): void {
531
- 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;
532
523
  // A cell that took the key — a select opening, a date segment stepping, a prose
533
524
  // leaf joining — owns it.
534
525
  if (e.defaultPrevented) return;
@@ -538,7 +529,111 @@
538
529
  const dir = e.key === 'ArrowUp' ? -1 : 1;
539
530
  if (k + dir < 0 || k + dir >= ids.length) return;
540
531
  e.preventDefault();
541
- move(k, dir);
532
+ move(k, k + dir);
533
+ }
534
+
535
+ // ── The drag handle ──────────────────────────────────────────────────────────
536
+ // Press-and-drag the handle moves its row; a press that does not travel past the dead
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.
544
+ const DEAD_ZONE = 3;
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
+ let drag = $state<RowDrag | undefined>();
551
+ let follow: { grip: Element; port: Element; armed: boolean; frame: number } | undefined;
552
+ function onHandleDown(e: PointerEvent, k: number): void {
553
+ if (e.button !== 0) return;
554
+ e.preventDefault();
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 };
560
+ }
561
+ function onHandleMove(e: PointerEvent): void {
562
+ if (!drag) return;
563
+ drag.y = e.clientY;
564
+ if (!drag.engaged) {
565
+ if (Math.abs(e.clientY - drag.origin) < DEAD_ZONE) return;
566
+ drag.engaged = true;
567
+ addEventListener('scroll', repick, { capture: true, passive: true });
568
+ if (follow) follow.frame = requestAnimationFrame(edgeScroll);
569
+ }
570
+ repick();
571
+ }
572
+ function repick(): void {
573
+ if (drag?.engaged) drag.drop = dropIndex(drag.k, drag.y);
574
+ }
575
+ const rowBox = (i: number): DOMRect | undefined => rowEls[ids[i]]?.getBoundingClientRect();
576
+ /** The row the pointer is over, or the nearest one past either end. */
577
+ function dropIndex(k: number, y: number): number {
578
+ let best = k;
579
+ let nearest = Infinity;
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;
584
+ if (gap < nearest) {
585
+ nearest = gap;
586
+ best = i;
587
+ }
588
+ });
589
+ return best;
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);
626
+ function onHandleUp(e: PointerEvent): void {
627
+ const done = drag;
628
+ drag = undefined;
629
+ release();
630
+ if (!done?.engaged || e.type !== 'pointerup') return;
631
+ move(done.k, done.drop);
632
+ }
633
+ /** Where the drop rule draws on row `k`, while a drag would land the row there. */
634
+ function dropSide(k: number): 'before' | 'after' | undefined {
635
+ if (!drag?.engaged || drag.drop !== k || drag.drop === drag.k) return undefined;
636
+ return drag.drop > drag.k ? 'after' : 'before';
542
637
  }
543
638
  </script>
544
639
 
@@ -550,14 +645,7 @@
550
645
  >
551
646
  <div class="qm-array-header">
552
647
  {#if label != null}
553
- <FieldLabel
554
- {label}
555
- id={labelId}
556
- {descriptionId}
557
- onActivate={focus}
558
- {required}
559
- {description}
560
- />
648
+ <FieldLabel {label} id={labelId} {descriptionId} onActivate={focus} {description} />
561
649
  {:else}
562
650
  <span></span>
563
651
  {/if}
@@ -582,15 +670,15 @@
582
670
  each block's one child: a branch inside the block would stand between them. -->
583
671
  {#if table}
584
672
  <div
585
- 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 }]}
586
674
  data-default={defaulted ? '' : undefined}
587
675
  bind:this={rowsEl}
588
676
  >
589
677
  <div class="qm-array-table-scroller" style:--table-cols={columns.length}>
590
678
  <!-- The header names the columns, so a cell carries no label of its own: its
591
679
  accessible name is the row's and the column's composed (`ObjectField`). The
592
- obligation mark and the guidance affordance ride the header, being the
593
- 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. -->
594
682
  <div class="qm-array-table-head">
595
683
  {#each columns as [key, sub] (key)}
596
684
  <div
@@ -602,7 +690,6 @@
602
690
  <FieldLabel
603
691
  label={columnTitle(key, sub)}
604
692
  descriptionId={idBase != null ? propertyDomIds(idBase, key).description : undefined}
605
- required={obliged(sub)}
606
693
  description={sub.description}
607
694
  />
608
695
  </div>
@@ -610,7 +697,11 @@
610
697
  <span class="qm-array-table-head-end"></span>
611
698
  </div>
612
699
  {#each ids as id, k (id)}
613
- <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
+ >
614
705
  <ObjectField
615
706
  bare
616
707
  bind:this={cellEls[id]}
@@ -630,7 +721,7 @@
630
721
  </div>
631
722
  {:else if control === 'object'}
632
723
  <div
633
- class={['qm-array-rows', { empty: ids.length === 0 }]}
724
+ class={['qm-array-rows qm-wash', { empty: ids.length === 0 }]}
634
725
  data-default={defaulted ? '' : undefined}
635
726
  bind:this={rowsEl}
636
727
  >
@@ -639,7 +730,12 @@
639
730
  <!-- The handler catches a key from the controls inside the row and adds no
640
731
  interaction of the row's own: the row is no tab stop. -->
641
732
  <div
642
- class={['qm-array-row qm-element', { open: open.has(id) }]}
733
+ class={[
734
+ 'qm-array-row qm-element qm-wash',
735
+ { open: open.has(id), lifted: drag?.engaged && drag.k === k }
736
+ ]}
737
+ data-drop={dropSide(k)}
738
+ data-qm-index={k}
643
739
  bind:this={rowEls[id]}
644
740
  animate:reorder={arm.armed}
645
741
  onkeydown={(e) => onRowKey(e, k)}
@@ -649,10 +745,12 @@
649
745
  end-side corners) needs corners to take. So a list of records measures
650
746
  like a list of inputs whatever the element type. -->
651
747
  <div class="qm-element-head">
748
+ {@render handle(k)}
652
749
  <button
653
750
  type="button"
654
751
  class="qm-control-box qm-focus-ring qm-element-summary"
655
752
  aria-expanded={open.has(id)}
753
+ aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown"
656
754
  onclick={() => toggleRow(id)}
657
755
  >
658
756
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
@@ -666,7 +764,7 @@
666
764
  {/if}
667
765
  </button>
668
766
  <div class="qm-row-actions">
669
- {@render rowActions(k)}
767
+ {@render removeButton(k)}
670
768
  </div>
671
769
  </div>
672
770
  {#if open.has(id)}
@@ -690,18 +788,18 @@
690
788
  </div>
691
789
  {:else}
692
790
  <div
693
- class={['qm-array-rows', { empty: ids.length === 0 }]}
791
+ class={['qm-array-rows qm-wash', { empty: ids.length === 0 }]}
694
792
  data-default={defaulted ? '' : undefined}
695
793
  bind:this={rowsEl}
696
794
  >
697
795
  {#each ids as id, k (id)}
698
- <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]}>
699
797
  {#if control === 'prose'}
700
798
  <ProseValue
701
799
  bind:this={els[id]}
702
800
  content={() => readAt([k])}
703
801
  value={arr[k]}
704
- plaintext={items != null && baseType(items) === 'plaintext'}
802
+ plaintext={items != null && literalText(items)}
705
803
  label={rowName(k)}
706
804
  onChange={(rt) => commitElement(k, rt)}
707
805
  onKey={(e) => onElementKey(e, k)}
@@ -738,26 +836,22 @@
738
836
  >
739
837
  {/snippet}
740
838
 
741
- <!-- An `object` row's controls: the reorder pair, disabled at its edge as the card
742
- header's is, then the remove. -->
743
- {#snippet rowActions(k: number)}
744
- <button
745
- type="button"
746
- class="qm-icon-btn qm-row-btn qm-focus-ring"
747
- title={t.strings.arrayMoveUp}
748
- aria-label={rowAction(t.strings.arrayMoveUp, k)}
749
- disabled={k === 0}
750
- onclick={() => move(k, -1)}><Icon name="chevron-up" /></button
839
+ <!-- An `object` row's drag handle, a table grip's twin hung off the row's start edge.
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. -->
843
+ {#snippet handle(k: number)}
844
+ <span
845
+ class="qm-row-handle"
846
+ aria-hidden="true"
847
+ title={t.strings.arrayDrag}
848
+ onpointerdown={(e) => onHandleDown(e, k)}
849
+ onpointermove={onHandleMove}
850
+ onpointerup={onHandleUp}
851
+ onpointercancel={onHandleUp}
852
+ onlostpointercapture={onHandleUp}
853
+ ><span class="qm-row-handle-bar"><Icon name="grip-vertical" /></span></span
751
854
  >
752
- <button
753
- type="button"
754
- class="qm-icon-btn qm-row-btn qm-focus-ring"
755
- title={t.strings.arrayMoveDown}
756
- aria-label={rowAction(t.strings.arrayMoveDown, k)}
757
- disabled={k === ids.length - 1}
758
- onclick={() => move(k, 1)}><Icon name="chevron-down" /></button
759
- >
760
- {@render removeButton(k)}
761
855
  {/snippet}
762
856
 
763
857
  <style>
@@ -790,14 +884,13 @@
790
884
  }
791
885
  /* The elements, in a box of their own: the arrival wash blooms here rather than over
792
886
  the wrapper `Field` hands this component (`washBox`), which is the label's box too.
793
- Positioned for the wash's inset child, and rounded to the rung a row's own box
794
- 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.
795
889
 
796
890
  `display: none` when there are no elements, so the header does not stand a gap above
797
891
  an empty box; a wash over it then paints nothing, which is what an array with
798
892
  nothing in it has to show. */
799
893
  .qm-array-rows {
800
- position: relative;
801
894
  display: flex;
802
895
  flex-direction: column;
803
896
  gap: var(--_qm-space);
@@ -807,18 +900,21 @@
807
900
  display: none;
808
901
  }
809
902
  /* The default's rows are what prints and nothing written: every value they draw takes
810
- the default rung (theme.css), and the first gesture, writing them, takes it off. */
903
+ the default rung (theme.css), a checked switch's track included, and the first
904
+ gesture, writing them, takes it off. */
811
905
  .qm-array-rows[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
812
906
  .qm-array-rows[data-default] :global(.qm-select:not([data-ghosted])),
813
907
  .qm-array-rows[data-default] .qm-element-title:not(.untitled) {
814
908
  color: var(--_qm-ink-default);
815
909
  }
910
+ .qm-array-rows[data-default] :global(.qm-toggle[data-state='checked']) {
911
+ background: var(--_qm-accent-default);
912
+ }
816
913
  /* A grid rather than a block: an `<input>` is inline-level and would sit on a
817
914
  baseline, standing the row a descender taller than the box the slab measures itself
818
915
  against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
819
916
  edge with it. */
820
917
  .qm-array-row {
821
- position: relative;
822
918
  display: grid;
823
919
  grid-template-columns: minmax(0, 1fr);
824
920
  row-gap: var(--_qm-space);
@@ -830,7 +926,7 @@
830
926
  /* The end inset the slabs stand in, taken off whichever box the element drew:
831
927
  `.qm-input` is the text element, `.qm-control-box` the prose one — the row's own
832
928
  child, so a table's cells, which are boxes too, keep their inset — and an object
833
- row's summary, which stands three slabs off its end. `:global`, because the box
929
+ row's summary, which stands one slab off its end. `:global`, because the box
834
930
  belongs to the child component's markup and the scope class stops at this
835
931
  component's. The longhand beats the family's `padding` shorthand without a
836
932
  specificity fight: `controls.css` ranks the family at zero inside `:where()`. */
@@ -839,7 +935,7 @@
839
935
  padding-inline-end: var(--_qm-tap-min);
840
936
  }
841
937
  .qm-element-summary {
842
- padding-inline-end: calc(3 * var(--_qm-tap-min));
938
+ padding-inline-end: var(--_qm-tap-min);
843
939
  }
844
940
  /* The box's end wall, floor to ceiling: a height of its own would leave a sliver of
845
941
  well above or below, and the corners it takes are the box's — the end-side pair keeps
@@ -847,8 +943,7 @@
847
943
  another gives up its corners on both sides.
848
944
 
849
945
  It comes up on its own row rather than on the field: a destructive control is offered
850
- by the row the pointer is on, not by every row at once, and the reorder pair beside
851
- it comes up with it. Hover is where the remove says destructive, ink with fill, a
946
+ by the row the pointer is on, not by every row at once. Hover is where the remove says destructive, ink with fill, a
852
947
  tint alone being a wash under a label-toned glyph. */
853
948
  .qm-row-actions {
854
949
  position: absolute;
@@ -872,10 +967,6 @@
872
967
  border-start-end-radius: var(--_qm-radius-inner);
873
968
  border-end-end-radius: var(--_qm-radius-inner);
874
969
  }
875
- .qm-row-btn :global(svg) {
876
- width: var(--_qm-glyph-control);
877
- height: var(--_qm-glyph-control);
878
- }
879
970
  /* Child combinators: a nested array shares this scope, and a row's hover is not its
880
971
  open subform's rows'. */
881
972
  .qm-array-row:hover > .qm-row-actions,
@@ -888,6 +979,68 @@
888
979
  background: var(--_qm-danger-tint);
889
980
  color: var(--_qm-danger);
890
981
  }
982
+ /* The handle is a table grip's twin (prose.css §"One grip per line"): a pill carrying
983
+ the grip's marks, hung off the row's start edge in the `--_qm-nest` every row stands
984
+ in from the stroke outside it, which a group's panel clips at. It rests
985
+ at nothing, where the remove rests faint, and comes up with the row the pointer is
986
+ on, staying up through the drag it carries; a band flush with the row keeps the
987
+ pointer on the row all the way to it. The pointer owns the gesture, not the page's
988
+ scroll. */
989
+ .qm-row-handle {
990
+ position: absolute;
991
+ inset-block: 0;
992
+ inset-inline-end: 100%;
993
+ width: var(--_qm-nest);
994
+ display: flex;
995
+ align-items: center;
996
+ justify-content: center;
997
+ color: var(--_qm-ink-label);
998
+ cursor: grab;
999
+ touch-action: none;
1000
+ opacity: 0;
1001
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
1002
+ }
1003
+ .qm-row-handle-bar {
1004
+ display: flex;
1005
+ align-items: center;
1006
+ justify-content: center;
1007
+ width: 100%;
1008
+ height: var(--_qm-tap-min);
1009
+ border-radius: var(--_qm-radius-pill);
1010
+ background: var(--_qm-surface-band);
1011
+ }
1012
+ .qm-row-handle-bar :global(svg) {
1013
+ width: var(--_qm-space-3);
1014
+ height: var(--_qm-space-3);
1015
+ }
1016
+ .qm-array-row:hover > .qm-element-head > .qm-row-handle,
1017
+ .qm-element.lifted > .qm-element-head > .qm-row-handle {
1018
+ opacity: 1;
1019
+ }
1020
+ .qm-element.lifted > .qm-element-head > .qm-row-handle {
1021
+ cursor: grabbing;
1022
+ }
1023
+ /* The lifted row is a tone, and the drop is one rule on the edge it would land
1024
+ against, drawn out of flow so nothing reflows under the drag: the table island's
1025
+ own pair. */
1026
+ .qm-element.lifted > .qm-element-head > .qm-element-summary {
1027
+ opacity: var(--_qm-opacity-muted);
1028
+ }
1029
+ .qm-element[data-drop]::before {
1030
+ content: '';
1031
+ position: absolute;
1032
+ inset-inline: 0;
1033
+ height: var(--_qm-ring-width);
1034
+ background: var(--_qm-accent);
1035
+ border-radius: var(--_qm-radius-pill);
1036
+ pointer-events: none;
1037
+ }
1038
+ .qm-element[data-drop='before']::before {
1039
+ top: calc(-0.5 * (var(--_qm-space) + var(--_qm-ring-width)));
1040
+ }
1041
+ .qm-element[data-drop='after']::before {
1042
+ bottom: calc(-0.5 * (var(--_qm-space) + var(--_qm-ring-width)));
1043
+ }
891
1044
  /* ── An object element ──────────────────────────────────────────────────────
892
1045
  The row is a summary and the subform hangs under it. `row-gap` rather than a margin
893
1046
  on the subform, because the distance between a control and what it has unfolded
@@ -1106,10 +1259,15 @@
1106
1259
  .qm-array.empty .qm-add-el {
1107
1260
  opacity: 1;
1108
1261
  }
1262
+ /* No hover to come up on: the controls stand at the muted rung, the handle with
1263
+ them. */
1109
1264
  @media (hover: none) {
1110
1265
  .qm-add-el,
1111
1266
  .qm-row-actions {
1112
1267
  opacity: var(--_qm-opacity-muted);
1113
1268
  }
1269
+ .qm-row-handle {
1270
+ opacity: 1;
1271
+ }
1114
1272
  }
1115
1273
  </style>
@@ -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,6 +10,9 @@
10
10
  `mixed` there reads as false), so an optional boolean's control is a `checkbox`, the
11
11
  toggle role that does, announcing `mixed` while unset. A press from unset writes
12
12
  `true`; nothing here returns the cell to unset.
13
+
14
+ An unset switch draws its `default:` at the default rung, as every control draws a
15
+ printed default: a checked track takes the accent's default rung.
13
16
  -->
14
17
  <script lang="ts">
15
18
  import { Switch } from 'bits-ui';
@@ -41,6 +44,7 @@
41
44
  // the unset optional cell, the one state with no default to stand in for it.
42
45
  let local = $derived<boolean | undefined>(value ?? fallback ?? (optional ? undefined : false));
43
46
  const unset = $derived(local === undefined);
47
+ const defaulted = $derived(value === undefined && fallback !== undefined);
44
48
  const tristate = $derived(
45
49
  optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local } : {}
46
50
  );
@@ -49,7 +53,11 @@
49
53
  <!-- Not `.qm-switch`: that name is the preset's pane band, whose narrow-viewport rule
50
54
  stands every child of it at `--qmh-tap`, so a track wearing it grows a thumb three times
51
55
  its own height over the labels around it (`preset/recipes.css`, held by `check:style`). -->
52
- <span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
56
+ <span
57
+ class="qm-toggle-wrap"
58
+ data-unset={unset ? '' : undefined}
59
+ data-default={defaulted ? '' : undefined}
60
+ >
53
61
  <Switch.Root
54
62
  class="qm-toggle qm-focus-ring qm-tap-floor"
55
63
  checked={!!local}
@@ -101,6 +109,9 @@
101
109
  .qm-toggle-wrap :global(.qm-toggle[data-state='checked']) {
102
110
  background: var(--_qm-accent);
103
111
  }
112
+ .qm-toggle-wrap[data-default] :global(.qm-toggle[data-state='checked']) {
113
+ background: var(--_qm-accent-default);
114
+ }
104
115
  /* The focus ring rides `.qm-focus-ring` on the switch (controls.css). */
105
116
  /* The thumb reads on tone alone: the base surface over the track's `hover` rung
106
117
  unchecked, and over `--_qm-accent` checked. No edge of its own: a hairline around