@quillmark/svelte 0.1.1 → 0.3.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 (124) hide show
  1. package/README.md +16 -12
  2. package/THEMING.md +48 -25
  3. package/dist/core/address.d.ts +42 -7
  4. package/dist/core/address.js +49 -21
  5. package/dist/core/bloom.d.ts +5 -5
  6. package/dist/core/bloom.js +8 -8
  7. package/dist/core/codec/anchor.d.ts +1 -1
  8. package/dist/core/codec/anchor.js +1 -1
  9. package/dist/core/codec/atoms.d.ts +10 -0
  10. package/dist/core/codec/atoms.js +78 -0
  11. package/dist/core/codec/clipboard.d.ts +59 -0
  12. package/dist/core/codec/clipboard.js +202 -0
  13. package/dist/core/codec/code.d.ts +1 -1
  14. package/dist/core/codec/code.js +4 -4
  15. package/dist/core/codec/decode.d.ts +1 -1
  16. package/dist/core/codec/decode.js +8 -8
  17. package/dist/core/codec/encode.d.ts +4 -4
  18. package/dist/core/codec/encode.js +14 -11
  19. package/dist/core/codec/field.d.ts +22 -27
  20. package/dist/core/codec/field.js +33 -32
  21. package/dist/core/codec/index.js +5 -8
  22. package/dist/core/codec/inputrules.js +56 -18
  23. package/dist/core/codec/islands.d.ts +11 -7
  24. package/dist/core/codec/islands.js +16 -10
  25. package/dist/core/codec/keymap.d.ts +5 -6
  26. package/dist/core/codec/keymap.js +8 -7
  27. package/dist/core/codec/lists.d.ts +3 -3
  28. package/dist/core/codec/lists.js +32 -13
  29. package/dist/core/codec/marks.d.ts +1 -1
  30. package/dist/core/codec/marks.js +1 -1
  31. package/dist/core/codec/positions.js +1 -1
  32. package/dist/core/codec/prose.css +266 -228
  33. package/dist/core/codec/schema.js +4 -4
  34. package/dist/core/codec/slash.d.ts +39 -41
  35. package/dist/core/codec/slash.js +0 -0
  36. package/dist/core/codec/table-view.d.ts +12 -16
  37. package/dist/core/codec/table-view.js +488 -292
  38. package/dist/core/codec/table.d.ts +42 -15
  39. package/dist/core/codec/table.js +73 -27
  40. package/dist/core/errors.d.ts +17 -7
  41. package/dist/core/index.d.ts +1 -1
  42. package/dist/core/index.js +6 -5
  43. package/dist/core/lifecycle.d.ts +21 -5
  44. package/dist/core/lifecycle.js +34 -8
  45. package/dist/core/rebind.svelte.d.ts +18 -4
  46. package/dist/core/rebind.svelte.js +30 -12
  47. package/dist/core/teardown.d.ts +1 -1
  48. package/dist/core/teardown.js +1 -1
  49. package/dist/core/theme.css +173 -58
  50. package/dist/preset/index.css +1 -1
  51. package/dist/preset/recipes.css +79 -30
  52. package/dist/preset/scale.css +40 -28
  53. package/dist/preview/Preview.svelte +22 -19
  54. package/dist/preview/Preview.svelte.d.ts +8 -8
  55. package/dist/preview/bridge.d.ts +6 -4
  56. package/dist/preview/bridge.js +19 -9
  57. package/dist/preview/controller.d.ts +13 -6
  58. package/dist/preview/controller.js +8 -5
  59. package/dist/preview/geometry.d.ts +17 -2
  60. package/dist/preview/geometry.js +21 -1
  61. package/dist/preview/index.d.ts +1 -0
  62. package/dist/preview/index.js +9 -2
  63. package/dist/preview/overlay.d.ts +2 -1
  64. package/dist/preview/overlay.js +26 -11
  65. package/dist/preview/paint.js +19 -17
  66. package/dist/visual/ArrayField.svelte +103 -83
  67. package/dist/visual/ArrayField.svelte.d.ts +5 -4
  68. package/dist/visual/BooleanField.svelte +8 -6
  69. package/dist/visual/BooleanField.svelte.d.ts +2 -2
  70. package/dist/visual/Card.svelte +161 -333
  71. package/dist/visual/Card.svelte.d.ts +5 -4
  72. package/dist/visual/CardControls.svelte +1 -1
  73. package/dist/visual/DateField.svelte +19 -19
  74. package/dist/visual/DateField.svelte.d.ts +3 -3
  75. package/dist/visual/DiagnosticList.svelte +2 -2
  76. package/dist/visual/EnumField.svelte +48 -28
  77. package/dist/visual/EnumField.svelte.d.ts +5 -4
  78. package/dist/visual/Field.svelte +207 -132
  79. package/dist/visual/Field.svelte.d.ts +11 -7
  80. package/dist/visual/FieldHint.svelte +79 -48
  81. package/dist/visual/FieldHint.svelte.d.ts +1 -1
  82. package/dist/visual/FieldLabel.svelte +8 -8
  83. package/dist/visual/FormatPopover.svelte +82 -35
  84. package/dist/visual/NumberField.svelte +5 -5
  85. package/dist/visual/NumberField.svelte.d.ts +1 -1
  86. package/dist/visual/ObjectField.svelte +28 -9
  87. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  88. package/dist/visual/ProseArrayElement.svelte +12 -19
  89. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -2
  90. package/dist/visual/ProseField.svelte +62 -85
  91. package/dist/visual/ProseField.svelte.d.ts +14 -17
  92. package/dist/visual/SlashMenu.svelte +42 -16
  93. package/dist/visual/TextField.svelte +5 -5
  94. package/dist/visual/TextField.svelte.d.ts +1 -1
  95. package/dist/visual/TipsCard.svelte +43 -28
  96. package/dist/visual/VisualEditor.svelte +18 -19
  97. package/dist/visual/VisualEditor.svelte.d.ts +2 -3
  98. package/dist/visual/VisualEditorInner.svelte +251 -184
  99. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
  100. package/dist/visual/controls.css +85 -38
  101. package/dist/visual/diagnostics.d.ts +13 -21
  102. package/dist/visual/diagnostics.js +7 -19
  103. package/dist/visual/domid.js +3 -3
  104. package/dist/visual/ext.d.ts +1 -1
  105. package/dist/visual/ext.js +1 -1
  106. package/dist/visual/icons/nodes.d.ts +3 -0
  107. package/dist/visual/icons/nodes.js +2 -1
  108. package/dist/visual/index.d.ts +1 -0
  109. package/dist/visual/index.js +10 -3
  110. package/dist/visual/leaves.d.ts +47 -0
  111. package/dist/visual/leaves.js +31 -0
  112. package/dist/visual/links.d.ts +24 -0
  113. package/dist/visual/links.js +67 -0
  114. package/dist/visual/props.d.ts +33 -12
  115. package/dist/visual/signals.d.ts +6 -6
  116. package/dist/visual/strings.d.ts +14 -19
  117. package/dist/visual/strings.js +5 -7
  118. package/dist/visual/structure.d.ts +24 -17
  119. package/dist/visual/structure.js +8 -8
  120. package/dist/visual/synced.svelte.d.ts +1 -1
  121. package/dist/visual/synced.svelte.js +4 -4
  122. package/dist/visual/tips.d.ts +4 -2
  123. package/dist/visual/tips.js +7 -4
  124. package/package.json +2 -2
@@ -1,13 +1,13 @@
1
1
  <!--
2
- The schema `description` as a themed floating surface: the second that is READ
2
+ The schema `description` as a themed floating surface: the second that is read
3
3
  rather than picked from, so it shares {@link FormatPopover}'s translucent recipe
4
4
  and portals into the nearest `[data-qm-root]` like every
5
5
  other floating surface, so a consumer's dials still reach it.
6
6
 
7
- It replaces the native `title` tooltip, which reached no dial, waited ~1s, could
8
- not be dismissed, and (the part that made it a hole rather than a blemish)
9
- DOES NOT EXIST ON TOUCH. Guidance the reference quill puts on nearly every field
10
- was, on a tablet, reachable only through a screen reader.
7
+ Not the native `title` tooltip: that reaches no dial, waits ~1s, cannot be
8
+ dismissed, and (the part that makes it a hole rather than a blemish) does not exist
9
+ on touch, which leaves guidance the reference quill puts on nearly every field
10
+ reachable on a tablet only through a screen reader.
11
11
 
12
12
  Floating rather than inline under the label: the reference quill's descriptions run
13
13
  to a median of ~110 characters and past 250 at the tail: permanently in flow that
@@ -15,14 +15,14 @@
15
15
  to avoid. Measured, not assumed; a quill whose descriptions are all one short line
16
16
  would be better served inline.
17
17
 
18
- THE POPOVER IS CHROME, NOT THE ANNOUNCEMENT. Its content is `aria-hidden`: the
18
+ The popover is chrome, not the announcement. Its content is `aria-hidden`: the
19
19
  description reaches assistive tech through the always-present parked node
20
20
  {@link FieldLabel} renders and the `aria-describedby` the control carries, which
21
21
  holds whether the surface is open or not. So no reader depends on a hover.
22
22
 
23
23
  Three ways in, one per input modality: hover (pointer), focus (keyboard), tap
24
24
  (touch); wired here rather than taken from `Tooltip`, whose trigger returns early
25
- on `pointerType === 'touch'` and closes on click (the exact gap being fixed).
25
+ on `pointerType === 'touch'` and closes on click.
26
26
  -->
27
27
  <script lang="ts">
28
28
  import { Popover } from 'bits-ui';
@@ -35,65 +35,110 @@
35
35
  * do not all announce alike. */
36
36
  label: string;
37
37
  /** The parked description node; the trigger points at it too, so a reader
38
- * that lands HERE announces the guidance rather than only the field's own. */
38
+ * that lands here announces the guidance rather than only the field's own. */
39
39
  describedBy?: string;
40
40
  }
41
41
  let { description, label, describedBy }: Props = $props();
42
42
 
43
43
  let open = $state(false);
44
44
  let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
45
- /** The root to portal INTO: `document.body` would escape the consumer's dials
45
+ /** The root to portal into: `document.body` would escape the consumer's dials
46
46
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
47
47
  const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
48
+
49
+ /** Placement, re-measured on each opening: the surface rides the label rung beside
50
+ * the glyph, or stands over the field. */
51
+ let beside = $state(false);
52
+
53
+ /** Whether this description sets one line. Off a probe rather than the surface
54
+ * itself, since the side is a prop the surface is positioned from, and a side chosen
55
+ * after the fact moves a surface the reader is already looking at. The probe wears
56
+ * the surface's own class inside the portal target, so it wraps to the same measure
57
+ * under the same dials; `width: max-content` keeps that measure the class's rather
58
+ * than the containing block's. Line boxes are counted off a range over the text
59
+ * rather than derived from a height, which would need the line rung and the inset
60
+ * restated here as numbers. A DOM with no layout yields none, which stands the
61
+ * surface over the field. */
62
+ function ridesTheRung(): boolean {
63
+ const host = portalTarget ?? document.body;
64
+ const probe = document.createElement('div');
65
+ probe.className = 'qm-hint-popover';
66
+ probe.style.cssText =
67
+ 'position:absolute;width:max-content;visibility:hidden;pointer-events:none';
68
+ probe.textContent = description;
69
+ host.appendChild(probe);
70
+ const range = document.createRange();
71
+ range.selectNodeContents(probe);
72
+ const lines = range.getClientRects().length;
73
+ probe.remove();
74
+ return lines === 1;
75
+ }
76
+
77
+ function raise(): void {
78
+ beside = ridesTheRung();
79
+ open = true;
80
+ }
48
81
  </script>
49
82
 
50
- <!-- The trigger is OURS, not `Popover.Trigger`, and the surface anchors to it:
83
+ <!-- The trigger is ours, not `Popover.Trigger`, and the surface anchors to it:
51
84
  the same shape {@link FormatPopover} uses. The primitive's trigger owns a click
52
- TOGGLE, which fights every one of the three openings below: a mouse click after
85
+ toggle, which fights every one of the three openings below: a mouse click after
53
86
  a hover-open would close, and on touch the focus that precedes the click opens
54
87
  it just in time for the click to close it again. -->
55
88
  <button
56
89
  bind:this={triggerEl}
57
90
  type="button"
58
- class="qm-field-hint qm-icon-btn qm-focus-ring"
91
+ class="qm-field-hint qm-icon-btn qm-focus-ring qm-tap-floor"
59
92
  aria-label="{label} guidance"
60
93
  aria-expanded={open}
61
94
  aria-describedby={describedBy}
62
95
  onpointerenter={(e) => {
63
- if (e.pointerType !== 'touch') open = true;
96
+ if (e.pointerType !== 'touch') raise();
64
97
  }}
65
98
  onpointerleave={(e) => {
66
99
  if (e.pointerType !== 'touch') open = false;
67
100
  }}
68
101
  onpointerup={(e) => {
69
- // Touch only, and a TOGGLE: the one modality with no hover to leave and no
102
+ // Touch only, and a toggle: the one modality with no hover to leave and no
70
103
  // blur on the way out. A mouse press falls through to hover, which already
71
104
  // has it open and will close it on the way off.
72
- if (e.pointerType === 'touch') open = !open;
105
+ if (e.pointerType === 'touch') {
106
+ if (open) open = false;
107
+ else raise();
108
+ }
73
109
  }}
74
110
  onfocus={(e) => {
75
111
  // Keyboard arrival only. A pointer press focuses the button too, and opening
76
112
  // on that would re-open what the tap just toggled shut; `:focus-visible` is
77
113
  // the UA's own answer to which arrival this was.
78
- if (e.currentTarget.matches(':focus-visible')) open = true;
114
+ if (e.currentTarget.matches(':focus-visible')) raise();
79
115
  }}
80
116
  onblur={() => (open = false)}
81
117
  onkeydown={(e) => {
82
118
  if (e.key === 'Escape' && open) {
83
- // Dismissable, which the `title` tooltip never was. Stopped here so the
84
- // key does not travel on to a card- or editor-level Escape.
119
+ // Stopped here so the key does not travel on to a card- or editor-level
120
+ // Escape.
85
121
  e.stopPropagation();
86
122
  open = false;
87
123
  }
88
124
  }}><Icon name="info" size={13} /></button
89
125
  >
90
126
 
127
+ <!-- Placement is the description'S height. One line rides the label rung beside its own
128
+ glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
129
+ Anything taller stands over the field: beside the glyph it would hang onto the control
130
+ it describes, the one overlap a reader cannot clear by reaching for what is hidden,
131
+ since a pointer leaving the trigger closes the surface. Over the field it covers a
132
+ neighbour's control instead, which reaching for it dismisses. -->
133
+ <!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
134
+ than the glyph's line box would hang half its height onto the field's own control. -->
135
+
91
136
  <Popover.Root bind:open>
92
137
  <Popover.Portal to={portalTarget}>
93
138
  <Popover.Content
94
139
  customAnchor={triggerEl ?? null}
95
- side="top"
96
- align="start"
140
+ side={beside ? 'right' : 'top'}
141
+ align={beside ? 'center' : 'start'}
97
142
  sideOffset={6}
98
143
  trapFocus={false}
99
144
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
@@ -128,18 +173,15 @@
128
173
  <style>
129
174
  /* Chrome, type and ring are the glyph-button family's (`.qm-icon-btn` +
130
175
  `.qm-focus-ring`, controls.css): this trigger is one more glyph button and
131
- assembles none of that itself. What it does NOT take is the family's tap floor
176
+ assembles none of that itself. What it does not take is the family's tap floor
132
177
  and the fill that floor sizes, because this is the one glyph button that sits in
133
- a LINE OF TEXT rather than in a row of its own: the floor is half again the label
178
+ a line of text rather than in a row of its own: the floor is half again the label
134
179
  rung's line box, so a target-sized paint box overhangs the line at each end by
135
180
  most of the field's label-to-control gap: the hover fill landing on the input's
136
181
  top border, the focus ring crossing it.
137
182
 
138
- So the box is the GLYPH and the target is the `::after` below. A target is a
139
- region of the screen, not a paint box, and separating them is what lets both be
140
- correct at once: the row keeps the line box's height and the press keeps the
141
- floor. Every other glyph button has a row to itself and keeps the floor as its
142
- box.
183
+ So the box is the glyph and the target is `.qm-tap-floor`'s (controls.css), which
184
+ carries that split and the giving-up of the family floor together.
143
185
 
144
186
  It takes no fill either: the ink step below says hover, and the surface itself
145
187
  opens on it. */
@@ -148,38 +190,27 @@
148
190
  color: var(--_qm-ink-ghost);
149
191
  /* Not the family's `pointer`: the glyph raises guidance, it does not act. */
150
192
  cursor: help;
151
- position: relative;
152
- min-width: 0;
153
- min-height: 0;
154
193
  padding: 0;
155
194
  background: none;
156
195
  }
157
- /* The tap floor, out of flow and centred on the glyph: WCAG 2.5.8 measures the
158
- target, and this is it. Unpainted, so it enlarges the press and not the mark, and
159
- out of the accessibility tree by construction: a pseudo-element has no node. */
160
- .qm-field-hint::after {
161
- content: '';
162
- position: absolute;
163
- top: 50%;
164
- left: 50%;
165
- width: var(--_qm-tap-min);
166
- height: var(--_qm-tap-min);
167
- transform: translate(-50%, -50%);
168
- }
169
196
  .qm-field-hint:hover,
170
197
  .qm-field-hint:focus-visible {
171
- color: var(--_qm-ink-meta);
198
+ color: var(--_qm-ink-label);
172
199
  }
173
200
  /* The lift, the translucency, the blur and the scale-in come from
174
- `.qm-popover-surface` (controls.css). This surface's own is a MEASURE: guidance
201
+ `.qm-popover-surface` (controls.css). This surface's own is a measure: guidance
175
202
  is prose, and prose past ~40 characters a line stops being scannable; plus the
176
203
  inset and the meta type rung that keep it a note about a field rather than a
177
- second field. */
178
- .qm-hint-popover {
204
+ second field.
205
+
206
+ Unscoped, because the placement probe wears this class on a node Svelte never
207
+ compiled: a scoped rule would leave the probe wrapping to a different measure
208
+ than the surface it stands in for, which is the one thing the probe must not do. */
209
+ :global(.qm-hint-popover) {
179
210
  max-width: 22rem;
180
211
  padding: var(--_qm-space) var(--_qm-space-2);
181
- font-size: var(--_qm-text-meta);
212
+ font-size: var(--_qm-text-label);
182
213
  line-height: var(--_qm-leading-body);
183
- color: var(--_qm-ink-meta);
214
+ color: var(--_qm-ink-label);
184
215
  }
185
216
  </style>
@@ -5,7 +5,7 @@ interface Props {
5
5
  * do not all announce alike. */
6
6
  label: string;
7
7
  /** The parked description node; the trigger points at it too, so a reader
8
- * that lands HERE announces the guidance rather than only the field's own. */
8
+ * that lands here announces the guidance rather than only the field's own. */
9
9
  describedBy?: string;
10
10
  }
11
11
  declare const FieldHint: import("svelte").Component<Props, {}, "">;
@@ -1,26 +1,26 @@
1
1
  <!--
2
2
  A field's label plus its guidance chrome.
3
3
 
4
- IT NAMES THE CONTROL. A real `<label>`, so a click lands the caret in the field:
4
+ It names the control. A real `<label>`, so a click lands the caret in the field:
5
5
  the widest, easiest target in a dense row, and the most conventional affordance a
6
- form has. `for` reaches a LABELABLE control (the inputs, the enum trigger, the
6
+ form has. `for` reaches a labelable control (the inputs, the enum trigger, the
7
7
  switch) and the browser does the rest; a control `for` cannot reach (the prose
8
8
  leaf's `contenteditable`, the date field's segments, an array's N inputs) leaves
9
9
  `controlId` unset, points `aria-labelledby` back at this label's own `id`, and
10
10
  hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
11
- element is inert AND invalid.
11
+ element is inert and invalid.
12
12
 
13
13
  Two non-gating label decorations:
14
14
  • a persistent required `*` when the field has no `default:`: the
15
15
  "Unendorsed"/must_fill set (DOCUMENT_MODEL: no separate `required` axis). Its
16
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
17
+ glyph; inside the `<label>`, so it names the control along with the text
18
18
  ("Subject required") rather than announcing as a node adjacent to it;
19
19
 
20
20
  • the `description` as an info marker raising a themed popover
21
21
  ({@link FieldHint}).
22
22
 
23
- THE DESCRIPTION DESCRIBES, IT DOES NOT NAME. It is parked in an always-present
23
+ The description describes, it does not name. It is parked in an always-present
24
24
  visually-hidden node here and the control carries `aria-describedby`: name then
25
25
  role then description, which is the announcement the markup visually implies. In
26
26
  the name it would instead prepend ~110 characters of guidance (the reference
@@ -77,7 +77,7 @@
77
77
  {/if}
78
78
  </label>
79
79
  {#if description}
80
- <!-- OUTSIDE the `<label>`, both of them: a trigger inside it would put its own
80
+ <!-- Outside the `<label>`, both of them: a trigger inside it would put its own
81
81
  name in the control's, and the parked copy would put all ~110 characters
82
82
  there. A `<button>` in a label is at least safe from the other hazard:
83
83
  the spec makes a label's activation behaviour do nothing for a click
@@ -90,7 +90,7 @@
90
90
 
91
91
  <style>
92
92
  /* The row, not the label, is the field's grid child: `Field`'s subgrid counts
93
- three tracks and the hint sits beside the label rather than under it.
93
+ two tracks and the hint sits beside the label rather than under it.
94
94
 
95
95
  A rung wider than the label's own gap below, so the two separations rank: the
96
96
  required glyph is part of the name and sits at the tighter one, the guidance
@@ -107,7 +107,7 @@
107
107
  align-items: center;
108
108
  gap: var(--_qm-space-half);
109
109
  font-size: var(--_qm-text-label);
110
- font-weight: var(--_qm-weight-label);
110
+ font-weight: var(--_qm-weight-mid);
111
111
  line-height: var(--_qm-leading-tight);
112
112
  color: var(--_qm-ink-label);
113
113
  min-width: 0;
@@ -1,6 +1,6 @@
1
1
  <!--
2
- The formatting selection popover (VISUAL_EDITOR §Chrome). ONE popover,
3
- shell-owned, observing the ACTIVE leaf through
2
+ The formatting selection popover (VISUAL_EDITOR §Chrome). One popover,
3
+ shell-owned, observing the active leaf through
4
4
  `getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
5
5
  the whole of what this surface knows about the editor. A non-empty selection in
6
6
  the active leaf raises it over
@@ -11,20 +11,20 @@
11
11
  content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
12
12
  (field.ts); this is the pointer affordance for all six marks.
13
13
 
14
- SELECTION OBSERVATION. The view's `dispatchTransaction` is the codec's
14
+ Selection observation. The view's `dispatchTransaction` is the codec's
15
15
  (read-only), so there is no hook to observe transactions from outside.
16
16
  Fallback: a document-level `selectionchange` listener, coalesced to one
17
17
  `requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
18
- microtask is NOT late enough; verified a "select all" gesture fires several
18
+ microtask is not late enough; verified a "select all" gesture fires several
19
19
  transient `selectionchange` events first). `focusout` keeps the popover honest
20
20
  when focus leaves the leaf.
21
21
 
22
- Nothing here watches SCROLL. `sync` decides whether the surface is up and what
22
+ Nothing here watches scroll. `sync` decides whether the surface is up and what
23
23
  its buttons read, which a scroll changes neither of; where it sits is the
24
24
  anchor's own question, and the anchor answers it by measuring when floating-ui
25
25
  asks rather than by being handed a rect (`codec/anchor.ts`).
26
26
 
27
- FOCUS DISCIPLINE. Three failure modes a naive Popover.Content invites:
27
+ Focus discipline. Three failure modes a naive Popover.Content invites:
28
28
  (1) its default `trapFocus` (true) redirects any focus landing outside its
29
29
  DOM back inside; which would make it impossible to click back into
30
30
  the editor to change the selection while the popover is showing. Fixed
@@ -41,7 +41,7 @@
41
41
  the viewport off the clicked field. Fixed by `onCloseAutoFocus`
42
42
  calling `preventDefault`: the intentional returns after mark toggle
43
43
  / link submit go through `view.focus` in `toggle`/`submitLink`.
44
- A mark button ALSO swallows its own `mousedown` (prosemirror-menu's own
44
+ A mark button also swallows its own `mousedown` (prosemirror-menu's own
45
45
  trick): without it, the browser's default mousedown action focuses the
46
46
  button before `click` fires, blurring the editor and collapsing the
47
47
  selection the command is supposed to act on.
@@ -53,7 +53,7 @@
53
53
  // off-tree, so this component renders standalone too.
54
54
  const t = wording();
55
55
  import { toggleMark } from 'prosemirror-commands';
56
- import { TextSelection } from 'prosemirror-state';
56
+ import { TextSelection, type Command } from 'prosemirror-state';
57
57
  import type { EditorView } from 'prosemirror-view';
58
58
  import type { MarkType } from 'prosemirror-model';
59
59
  import { Popover } from 'bits-ui';
@@ -65,6 +65,7 @@
65
65
  type LeafViews,
66
66
  type RangeAnchor
67
67
  } from '../core/codec/index.js';
68
+ import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
68
69
  import './controls.css';
69
70
 
70
71
  type LeafWithView = FieldController & Partial<LeafViews>;
@@ -77,7 +78,9 @@
77
78
 
78
79
  /** Mark size: the shared control-glyph rule for the popover icons. */
79
80
  const GLYPH = 15;
80
- /** The six content formatting marks; `anchor` is a 7th, rendered separately (a decoration toggle, not a PM `toggleMark` (see the button below). Each carries a glyph) the icon *is* the label. */
81
+ /** The six content formatting marks. `anchor` is a seventh, drawn separately below:
82
+ * a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
83
+ * the tooltip and the accessible name; the anchor button spells the two separately. */
81
84
  const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
82
85
  { name: 'strong', icon: 'bold', title: t.strings.formatBold },
83
86
  { name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
@@ -89,23 +92,24 @@
89
92
 
90
93
  let open = $state(false);
91
94
  let activeMarks = $state<Record<string, boolean>>({});
92
- /** Whether the selection is in the FIELD's coordinate space, which an anchor needs
95
+ /** Whether the selection is in the field's coordinate space, which an anchor needs
93
96
  * and a table cell is not (see `sync`). */
94
97
  let anchorAvailable = $state(true);
95
98
  let linkPromptOpen = $state(false);
96
99
  let linkValue = $state('');
100
+ let linkInputEl = $state<HTMLInputElement | undefined>(undefined);
97
101
  let contentEl = $state<HTMLElement | undefined>(undefined);
98
- /** The root to portal INTO: `document.body` escapes the editor's subtree and
102
+ /** The root to portal into: `document.body` escapes the editor's subtree and
99
103
  * the consumer's dials with it, so a pane-scoped palette misses this surface.
100
104
  * Resolved from the active leaf's own DOM, so the popover lands inside whichever
101
105
  * root raised it; `undefined` falls back to bits-ui's `document.body` for a leaf
102
106
  * mounted outside any root. */
103
107
  let portalTarget = $state<HTMLElement | undefined>(undefined);
104
108
 
105
- /** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the RANGE moves. A NEW object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
109
+ /** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the range moves. A new object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
106
110
  let anchor = $state<RangeAnchor | undefined>(undefined);
107
111
 
108
- /** The view the caret is actually in: the leaf's own, or the NESTED cell view of a
112
+ /** The view the caret is actually in: the leaf's own, or the nested cell view of a
109
113
  * table island (`codec/table-view.ts`). The six marks are the inline schema's
110
114
  * too, so a selection in a cell raises the same popover and toggles the same
111
115
  * commands; only `anchor` is withheld (below). */
@@ -114,7 +118,7 @@
114
118
  return leaf?.focusedView?.() ?? leaf?.view;
115
119
  }
116
120
 
117
- /** Recompute open/anchor/active-marks from the active leaf's CURRENT PM selection. */
121
+ /** Recompute open/anchor/active-marks from the active leaf's current PM selection. */
118
122
  function sync(): void {
119
123
  const insidePopover =
120
124
  !!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
@@ -124,7 +128,7 @@
124
128
  if (insidePopover) return;
125
129
  const leaf = getActiveLeaf() as LeafWithView | undefined;
126
130
  const view = leaf?.focusedView?.() ?? leaf?.view;
127
- // A non-empty TEXT selection. A NODE selection is non-empty too and has nothing
131
+ // A non-empty text selection. A node selection is non-empty too and has nothing
128
132
  // to format: it covers a leaf block (an island, a rule), which is exactly where
129
133
  // Escape out of a table cell lands.
130
134
  const selection = view?.state.selection;
@@ -145,7 +149,7 @@
145
149
  // `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
146
150
  // whether the selection covers an identity anchor, read off the leaf in USV.
147
151
  //
148
- // A selection inside a table CELL is not in that coordinate space at all: the
152
+ // A selection inside a table cell is not in that coordinate space at all: the
149
153
  // field's position map holds one `atom` run for the whole island, so there is
150
154
  // no USV offset to mint an anchor at. The button is withheld rather than
151
155
  // disabled, because what it would toggle does not exist there (CODEC §Islands).
@@ -159,8 +163,8 @@
159
163
  }
160
164
 
161
165
  // A burst of DOM events (verified: browser "select all" processing fires
162
- // `selectionchange` several times, transiently EMPTY before landing on the
163
- // final range) must coalesce to ONE settled check, not one `sync()` per
166
+ // `selectionchange` several times, transiently empty before landing on the
167
+ // final range) must coalesce to one settled check, not one `sync()` per
164
168
  // event: reacting to each intermediate state mounts/unmounts
165
169
  // `Popover.Content` repeatedly within a few event-loop turns, which is both
166
170
  // visibly unstable (a moving, momentarily detached click target) and races
@@ -169,7 +173,7 @@
169
173
  // microtask is not late enough: those transient events are themselves
170
174
  // separated by microtasks. `requestAnimationFrame` is: it runs once
171
175
  // per-frame, after the browser has finished dispatching every synchronous
172
- // consequence of the user gesture, so it always reads the FINAL, settled
176
+ // consequence of the user gesture, so it always reads the final, settled
173
177
  // selection. `pending` collapses a burst to a single scheduled callback.
174
178
  let pending = false;
175
179
  let rafHandle = 0;
@@ -202,19 +206,36 @@
202
206
  if (!open) linkPromptOpen = false;
203
207
  });
204
208
 
209
+ // The prompt takes its own focus. Nothing else moves it: open-auto-focus is
210
+ // prevented (focus discipline above), and the prompt replaces the buttons on a
211
+ // surface already open, so without this the URL is typed into the document.
212
+ // Selected rather than merely focused, the input arriving seeded and replacing
213
+ // that value being what the prompt is usually raised for.
214
+ $effect(() => {
215
+ if (linkPromptOpen && linkInputEl) {
216
+ linkInputEl.focus();
217
+ linkInputEl.select();
218
+ }
219
+ });
220
+
205
221
  /** Swallow the button's mousedown so focus/selection never leave the leaf. */
206
222
  function keepFocus(e: MouseEvent): void {
207
223
  e.preventDefault();
208
224
  }
209
225
 
226
+ // A link is a value, so its button raises the prompt rather than toggling — one
227
+ // meaning whether or not the selection carries one, and the seeded prompt is the
228
+ // only place the document's href is legible. Removal is an arm inside the
229
+ // prompt, not a second meaning for the button. The five value-less marks keep
230
+ // the toggle.
210
231
  function toggle(name: string): void {
211
232
  const view = activeLeafView();
212
233
  if (!view) return;
213
234
  const type: MarkType | undefined = view.state.schema.marks[name];
214
235
  if (!type) return;
215
- if (name === 'link' && !activeMarks.link) {
236
+ if (name === 'link') {
237
+ linkValue = hrefInSelection(view.state);
216
238
  linkPromptOpen = true;
217
- linkValue = '';
218
239
  return;
219
240
  }
220
241
  toggleMark(type)(view.state, view.dispatch);
@@ -242,21 +263,31 @@
242
263
  sync();
243
264
  }
244
265
 
245
- function submitLink(): void {
246
- const view = activeLeafView();
247
- const type = view?.state.schema.marks.link;
266
+ /** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
267
+ * leaf back its focus and the surface its buttons. */
268
+ function closePrompt(cmd?: Command): void {
248
269
  linkPromptOpen = false;
249
- if (!view || !type) return;
250
- const href = linkValue.trim();
251
- if (href) toggleMark(type, { href })(view.state, view.dispatch);
252
- view.focus();
270
+ const view = activeLeafView();
271
+ if (view) {
272
+ cmd?.(view.state, view.dispatch);
273
+ view.focus();
274
+ }
253
275
  sync();
254
276
  }
255
277
 
278
+ /** Apply the typed value, normalized (`links.ts`). A blank value is nothing to
279
+ * apply and only closes the prompt: removal has its own arm, and a second door
280
+ * onto it would be a second thing to keep true. */
281
+ function submitLink(): void {
282
+ closePrompt(setLink(normalizeHref(linkValue)));
283
+ }
284
+
285
+ function removeLink(): void {
286
+ closePrompt(clearLink);
287
+ }
288
+
256
289
  function cancelLink(): void {
257
- linkPromptOpen = false;
258
- activeLeafView()?.focus();
259
- sync();
290
+ closePrompt();
260
291
  }
261
292
  </script>
262
293
 
@@ -271,7 +302,7 @@
271
302
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
272
303
  onCloseAutoFocus={(e: Event) => e.preventDefault()}
273
304
  >
274
- <!-- The `child` snippet, so the surface IS the primitive's content node
305
+ <!-- The `child` snippet, so the surface is the primitive's content node
275
306
  rather than a pill inside it: the dismissal is a CSS animation and the
276
307
  primitive unmounts on the content node's own animations finishing. It
277
308
  also keeps the recipe reachable from this
@@ -299,12 +330,24 @@
299
330
  }}
300
331
  >
301
332
  <input
333
+ bind:this={linkInputEl}
302
334
  class="qm-link-input"
303
335
  type="text"
304
336
  placeholder={t.strings.linkPlaceholder}
337
+ aria-label={t.strings.formatLink}
305
338
  bind:value={linkValue}
306
339
  />
307
340
  <button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
341
+ <!-- Only where there is a link to remove: an arm that does nothing is
342
+ an arm to read past on every other visit to the prompt. -->
343
+ {#if activeMarks.link}
344
+ <button
345
+ type="button"
346
+ class="qm-icon-btn qm-mark-btn"
347
+ onmousedown={keepFocus}
348
+ onclick={removeLink}>{t.strings.linkRemove}</button
349
+ >
350
+ {/if}
308
351
  <button
309
352
  type="button"
310
353
  class="qm-icon-btn qm-mark-btn"
@@ -349,7 +392,7 @@
349
392
  </Popover.Root>
350
393
 
351
394
  <style>
352
- /* This surface PORTALS out of the leaf's DOM but INTO the nearest `[data-qm-root]`
395
+ /* This surface portals out of the leaf's DOM but into the nearest `[data-qm-root]`
353
396
  (the target is resolved in `sync`), so it inherits the consumer's dials like
354
397
  every other surface. It carries the marker itself too: floating over content is
355
398
  still a detached subtree for the derivation's purposes, and the marker is what
@@ -385,12 +428,16 @@
385
428
  gap: var(--_qm-space);
386
429
  align-items: center;
387
430
  }
431
+ /* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
432
+ stretch this to the popover. The fill is the box's own rung and there is no
433
+ edge, as everywhere else a value is typed (controls.css). */
388
434
  .qm-link-input {
389
435
  font: inherit;
390
436
  font-size: var(--_qm-text-body);
391
- padding: var(--_qm-space) var(--_qm-space-2);
392
- border: var(--_qm-border-width) solid var(--_qm-border);
437
+ padding: var(--_qm-inset-control);
438
+ border: none;
393
439
  border-radius: var(--_qm-radius-inner);
440
+ background: var(--_qm-surface-well);
394
441
  min-width: 12rem;
395
442
  }
396
443
  </style>
@@ -1,20 +1,20 @@
1
1
  <!--
2
2
  A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
3
- Commits at `change` (blur/Enter), NOT per keystroke: a partial numeric entry
3
+ Commits at `change` (blur/Enter), not per keystroke: a partial numeric entry
4
4
  (`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
5
5
  committing it live flashes a coercion diagnostic + `console.error` on every
6
6
  intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
7
- region. A blank entry commits `undefined`: the UNSET rung of the
7
+ region. A blank entry commits `undefined`: the unset rung of the
8
8
  commitment ladder: the parent removes the field and the engine renders the
9
9
  ghosted `default:`. Settling at `change` also keeps
10
10
  select-all-and-retype from flashing the preview through the default mid-keystroke.
11
11
 
12
- `type="text"`, not `type="number"`: a native number input SANITIZES an
12
+ `type="text"`, not `type="number"`: a native number input sanitizes an
13
13
  invalid string to `""` before the DOM `value` setter even runs (verified:
14
14
  `.value = "abc"` on `type="number"` never lands), which would make a
15
15
  genuinely bad entry untypeable. The commit-time coercion diagnostic
16
16
  (VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
17
- UI, so a non-blank entry that fails to parse forwards the RAW STRING to
17
+ UI, so a non-blank entry that fails to parse forwards the raw string to
18
18
  `onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
19
19
  (throws a `QuillmarkError` the parent turns into a field diagnostic), not a
20
20
  client-side guess. `inputmode` keeps the numeric mobile keyboard.
@@ -27,7 +27,7 @@
27
27
  value: number | undefined;
28
28
  integer?: boolean;
29
29
  fallback?: number;
30
- /** Accessible name for an input NOTHING else names: an object property, whose
30
+ /** Accessible name for an input nothing else names: an object property, whose
31
31
  * name is the field label plus the property's. A field's own input takes `id`
32
32
  * instead and is named by the `<label for>` beside it. */
33
33
  label?: string;
@@ -3,7 +3,7 @@ interface Props {
3
3
  value: number | undefined;
4
4
  integer?: boolean;
5
5
  fallback?: number;
6
- /** Accessible name for an input NOTHING else names: an object property, whose
6
+ /** Accessible name for an input nothing else names: an object property, whose
7
7
  * name is the field label plus the property's. A field's own input takes `id`
8
8
  * instead and is named by the `<label for>` beside it. */
9
9
  label?: string;