@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
@@ -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: a quiet accent glyph, not an alarm; required-ness is guidance,
120
- the document still edits and renders unmet. */
121
- .qm-field-required {
122
- display: inline-flex;
123
- align-items: center;
124
- color: var(--_qm-danger);
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
  }
@@ -3,15 +3,16 @@
3
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. The active leaf is a
6
- field's or a by-value one (an array's element, a subform's cell): its views either
7
- way, its controller only for a field's. A non-empty selection in the active leaf
6
+ field's or a by-value one (an array's element, a subform's cell), reached through
7
+ its views. A non-empty selection in the active leaf
8
8
  raises it over that leaf's selection rect; each button dispatches a PM `toggleMark`
9
9
  command straight at the leaf's `EditorView`, whose own `dispatchTransaction` commits
10
10
  the result as it commits a keystroke: a field's lowers it to `markOps` through
11
11
  `applyChange` (field.ts), a by-value leaf's hands the re-encoded value to its parent
12
12
  (`ProseValue`). This component never touches the content. The keymap mirror
13
- (Mod-b/i/u) already lives in the codec's keymap (field.ts); this is the pointer
14
- affordance for all six marks.
13
+ (Mod-b/i/u) lives in the codec's keymap (field.ts); this is the pointer
14
+ affordance for all six marks. Mod-K is this surface's own key, the prompt it raises
15
+ being this surface's: a document `keydown` the leaf's keymap left unclaimed.
15
16
 
16
17
  Selection observation. The view's `dispatchTransaction` is the leaf's
17
18
  (read-only), so there is no hook to observe transactions from outside.
@@ -24,7 +25,10 @@
24
25
  A press holds it down, which is what makes the surface answer a selection rather
25
26
  than a pointer: a drag fires `selectionchange` on every frame, so a surface driven
26
27
  by that alone rises on the first character and rides the cursor across the text it
27
- is covering. Keyboard selection holds nothing, so a Shift-arrow syncs as it fires.
28
+ is covering. Keyboard selection holds nothing, so it waits instead: a rise with no
29
+ release behind it comes once the selection has stood still for `SETTLE_MS`, so a
30
+ Shift-arrow run, or a select-all on its way to a cut, raises nothing until it stops.
31
+ A lowering never waits, and an open surface follows each step.
28
32
 
29
33
  Nothing here watches scroll. `sync` decides whether the surface is up, which a
30
34
  scroll does not change; where it sits is the anchor's own question, and the anchor
@@ -61,6 +65,7 @@
61
65
  // off-tree, so this component renders standalone too.
62
66
  const t = wording();
63
67
  import { toggleMark } from 'prosemirror-commands';
68
+ import { keydownHandler } from 'prosemirror-keymap';
64
69
  import { TextSelection, type Command } from 'prosemirror-state';
65
70
  import type { EditorView } from 'prosemirror-view';
66
71
  import type { MarkType } from 'prosemirror-model';
@@ -70,6 +75,7 @@
70
75
  import { hasMarks, rangeAnchor, type RangeAnchor } from '../core/codec/index.js';
71
76
  import type { ActiveProse } from './leaves.js';
72
77
  import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
78
+ import { spacePx } from '../core/rungs.js';
73
79
  import './controls.css';
74
80
 
75
81
  interface Props {
@@ -78,10 +84,9 @@
78
84
  }
79
85
  let { getActiveLeaf }: Props = $props();
80
86
 
81
- const GLYPH = 15;
82
- /** The six content formatting marks. `anchor` is a seventh, drawn separately below:
83
- * a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
84
- * the tooltip and the accessible name; the anchor button spells the two separately. */
87
+ const SETTLE_MS = 450;
88
+ /** The six content formatting marks. `anchor` is withheld: it draws nothing until
89
+ * comments do. One string per mark carries both the tooltip and the accessible name. */
85
90
  const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
86
91
  { name: 'strong', icon: 'bold', title: t.strings.formatBold },
87
92
  { name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
@@ -94,13 +99,14 @@
94
99
  let open = $state(false);
95
100
  /** A pointer is down outside this surface: the selection is still being made. */
96
101
  let pressed = false;
97
- /** Whether an anchor can be minted over the selection: in a field's own view, never
98
- * in a table cell or a by-value leaf (see `sync`). */
99
- let anchorAvailable = $state(true);
100
102
  let linkPromptOpen = $state(false);
101
103
  /** Whether the selection carried a link when the prompt was raised. */
102
104
  let linkPresent = $state(false);
103
105
  let linkValue = $state('');
106
+ /** The view the prompt was raised over. The prompt takes the focus, and a cell view
107
+ * without it is no longer the leaf's `focusedView`. */
108
+ let promptView: EditorView | undefined;
109
+ let promptInput = $state<HTMLInputElement | undefined>(undefined);
104
110
  let contentEl = $state<HTMLElement | undefined>(undefined);
105
111
  /** The root to portal into: `document.body` escapes the editor's subtree and
106
112
  * the consumer's dials with it, so a pane-scoped palette misses this surface.
@@ -115,12 +121,13 @@
115
121
  /** The view the caret is actually in: the leaf's own, or the nested cell view of a
116
122
  * table island (`codec/table-view.ts`). The six marks are the inline schema's
117
123
  * too, so a selection in a cell raises the same popover and toggles the same
118
- * commands; only `anchor` is withheld (below). */
124
+ * commands. */
119
125
  function activeLeafView(): EditorView | undefined {
120
126
  return getActiveLeaf()?.views.focusedView();
121
127
  }
122
128
 
123
- function sync(): void {
129
+ function sync(wait = false): void {
130
+ clearTimeout(settling);
124
131
  const insidePopover =
125
132
  !!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
126
133
  // Interacting with the popover itself (e.g. the link input): leave state as-is.
@@ -128,18 +135,16 @@
128
135
  // returns early, so a surface frozen here still tracks the selection it covers.
129
136
  if (insidePopover) return;
130
137
  if (pressed) return;
131
- const leaf = getActiveLeaf();
132
- const view = leaf?.views.focusedView();
138
+ const view = activeLeafView();
133
139
  // A non-empty text selection. A node selection is non-empty too and has nothing
134
140
  // to format: it covers a leaf block (an island, a rule), which is exactly where
135
141
  // Escape out of a table cell lands.
136
142
  const selection = view?.state.selection;
137
143
  const formattable = selection instanceof TextSelection && !selection.empty;
138
- // …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
139
- // types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
140
- // one entirely, the way it never rises over the text input a `string` field draws:
141
- // the value is literal text, and a mark on it is a value the boundary refuses to
142
- // coerce back.
144
+ // …in a leaf that has formatting at all. A `text` leaf declares no mark types
145
+ // (`plaintextSchema`, `plainSchema`), so this surface withholds itself over one
146
+ // entirely, the way it never rises over a form control: the value is literal
147
+ // text, and a mark on it is a value the boundary refuses to coerce back.
143
148
  if (
144
149
  !view ||
145
150
  !view.hasFocus() ||
@@ -151,16 +156,13 @@
151
156
  linkPromptOpen = false;
152
157
  return;
153
158
  }
159
+ if (wait && !open) {
160
+ settling = setTimeout(() => sync(), SETTLE_MS);
161
+ return;
162
+ }
154
163
  portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
155
164
  const { from, to } = view.state.selection;
156
165
  anchor = rangeAnchor(view, from, to);
157
- // An anchor is minted through a field's controller, which a by-value leaf has
158
- // none of: its anchors are dropped on the parent's value write. A selection inside
159
- // a table cell is not in the field's coordinate space at all: the field's position
160
- // map holds one `atom` run for the whole island, so there is no USV offset to mint
161
- // an anchor at. The button is withheld rather than disabled, because what it would
162
- // toggle does not exist there (CODEC §Islands).
163
- anchorAvailable = !!leaf?.controller && view === leaf.views.view;
164
166
  open = true;
165
167
  }
166
168
 
@@ -186,12 +188,17 @@
186
188
  // selection. `pending` collapses a burst to a single scheduled callback.
187
189
  let pending = false;
188
190
  let rafHandle = 0;
191
+ /** The check in flight answers a pointer's release, which raises at once. */
192
+ let released = false;
193
+ let settling: ReturnType<typeof setTimeout> | undefined;
189
194
  function deferredSync(): void {
190
195
  if (pending) return;
191
196
  pending = true;
192
197
  rafHandle = requestAnimationFrame(() => {
193
198
  pending = false;
194
- sync();
199
+ const wait = !released;
200
+ released = false;
201
+ sync(wait);
195
202
  });
196
203
  }
197
204
 
@@ -205,14 +212,16 @@
205
212
  function onPointerUp(): void {
206
213
  if (!pressed) return;
207
214
  pressed = false;
215
+ released = true;
208
216
  deferredSync();
209
217
  }
210
218
 
211
- // A burst-coalescing rAF may still be in flight at teardown; cancel it so
212
- // `sync()` never runs against the destroyed component.
219
+ // A burst-coalescing rAF or a settling rise may still be in flight at teardown;
220
+ // cancel both so `sync()` never runs against the destroyed component.
213
221
  onDestroy(() => {
214
222
  if (pending) cancelAnimationFrame(rafHandle);
215
223
  pending = false;
224
+ clearTimeout(settling);
216
225
  });
217
226
 
218
227
  // Any close path that bypasses `sync` (e.g. Escape while the link input holds
@@ -250,6 +259,7 @@
250
259
  if (name === 'link') {
251
260
  linkValue = hrefInSelection(view.state);
252
261
  linkPresent = linkValue.length > 0;
262
+ promptView = view;
253
263
  linkPromptOpen = true;
254
264
  return;
255
265
  }
@@ -258,43 +268,12 @@
258
268
  sync();
259
269
  }
260
270
 
261
- /**
262
- * A fresh anchor id: 128 random bits as hex, the id being opaque to the
263
- * boundary and needing only to be unique. `crypto.randomUUID` is
264
- * secure-context-only, and absent on a page served over plain `http` to
265
- * something other than localhost; `getRandomValues` is there either way.
266
- */
267
- function mintAnchorId(): string {
268
- return [...crypto.getRandomValues(new Uint8Array(16))]
269
- .map((b) => b.toString(16).padStart(2, '0'))
270
- .join('');
271
- }
272
-
273
- /**
274
- * Toggle an identity anchor over the selection: the `anchor`
275
- * button's answer to a formatting toggle. If the selection already covers
276
- * anchors, remove them; else insert one at its start with a freshly-minted
277
- * unique id (the caller-supplied, invariant id the 0.97 policy settles).
278
- * Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
279
- * the content, its chrome awaiting comment-thread UX.
280
- */
281
- function toggleAnchor(): void {
282
- const leaf = getActiveLeaf()?.controller;
283
- const view = activeLeafView();
284
- if (!leaf || !view || view.state.selection.empty) return;
285
- const { from, to } = leaf.selectionRange();
286
- const covered = leaf.anchorsInRange(from, to);
287
- if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
288
- else leaf.insertAnchor(mintAnchorId(), from);
289
- view.focus();
290
- sync();
291
- }
292
-
293
271
  /** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
294
272
  * leaf back its focus and the surface its buttons. */
295
273
  function closePrompt(cmd?: Command): void {
296
274
  linkPromptOpen = false;
297
- const view = activeLeafView();
275
+ const view = promptView && !promptView.isDestroyed ? promptView : activeLeafView();
276
+ promptView = undefined;
298
277
  if (view) {
299
278
  cmd?.(view.state, view.dispatch);
300
279
  view.focus();
@@ -309,6 +288,30 @@
309
288
  closePrompt(setLink(normalizeHref(linkValue)));
310
289
  }
311
290
 
291
+ /** Mod-K is a press of the Link button: claimed only where the surface would stand
292
+ * over the selection, and a second press while the prompt is up hands it the focus. */
293
+ function raiseLink(view: EditorView): boolean {
294
+ const inside = !!contentEl?.contains(document.activeElement);
295
+ if (inside && linkPromptOpen) return true;
296
+ if (!inside && !view.hasFocus()) return false;
297
+ sync();
298
+ if (!open) return false;
299
+ const reraised = linkPromptOpen;
300
+ toggle('link');
301
+ if (reraised && promptInput) focusPrompt(promptInput);
302
+ return true;
303
+ }
304
+
305
+ const linkKey = keydownHandler({
306
+ 'Mod-k': (_state, _dispatch, view) => !!view && raiseLink(view)
307
+ });
308
+
309
+ function onKeyDown(e: KeyboardEvent): void {
310
+ if (e.defaultPrevented) return;
311
+ const view = activeLeafView();
312
+ if (view && linkKey(view, e)) e.preventDefault();
313
+ }
314
+
312
315
  function removeLink(): void {
313
316
  closePrompt(clearLink);
314
317
  }
@@ -322,6 +325,7 @@
322
325
  it does with the event. `pointercancel` is the release a gesture the browser takes
323
326
  over gets. -->
324
327
  <svelte:document
328
+ onkeydown={onKeyDown}
325
329
  onselectionchange={deferredSync}
326
330
  onfocusout={deferredSync}
327
331
  onpointerdowncapture={onPointerDown}
@@ -342,7 +346,7 @@
342
346
  side="top"
343
347
  align="center"
344
348
  trapFocus={false}
345
- sideOffset={8}
349
+ sideOffset={spacePx(portalTarget, 2)}
346
350
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
347
351
  onCloseAutoFocus={(e: Event) => e.preventDefault()}
348
352
  >
@@ -375,26 +379,29 @@
375
379
  >
376
380
  <input
377
381
  {@attach focusPrompt}
378
- class="qm-link-input"
382
+ bind:this={promptInput}
383
+ class="qm-link-input qm-focus-ring"
379
384
  type="text"
380
385
  placeholder={t.strings.linkPlaceholder}
381
386
  aria-label={t.strings.formatLink}
382
387
  bind:value={linkValue}
383
388
  />
384
- <button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
389
+ <button type="submit" class="qm-icon-btn qm-mark-btn qm-focus-ring"
390
+ >{t.strings.linkApply}</button
391
+ >
385
392
  <!-- Only where there is a link to remove: an arm that does nothing is
386
393
  an arm to read past on every other visit to the prompt. -->
387
394
  {#if linkPresent}
388
395
  <button
389
396
  type="button"
390
- class="qm-icon-btn qm-mark-btn"
397
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
391
398
  onmousedown={keepFocus}
392
399
  onclick={removeLink}>{t.strings.linkRemove}</button
393
400
  >
394
401
  {/if}
395
402
  <button
396
403
  type="button"
397
- class="qm-icon-btn qm-mark-btn"
404
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
398
405
  onmousedown={keepFocus}
399
406
  onclick={cancelLink}>{t.strings.linkCancel}</button
400
407
  >
@@ -407,23 +414,13 @@
407
414
  {#each MARKS as m (m.name)}
408
415
  <button
409
416
  type="button"
410
- class="qm-icon-btn qm-mark-btn"
417
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
411
418
  title={m.title}
412
419
  aria-label={m.title}
413
420
  onmousedown={keepFocus}
414
- onclick={() => toggle(m.name)}><Icon name={m.icon} size={GLYPH} /></button
421
+ onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
415
422
  >
416
423
  {/each}
417
- {#if anchorAvailable}
418
- <button
419
- type="button"
420
- class="qm-icon-btn qm-mark-btn"
421
- title={t.strings.formatAnchorTitle}
422
- aria-label={t.strings.formatAnchor}
423
- onmousedown={keepFocus}
424
- onclick={toggleAnchor}><Icon name="hash" size={GLYPH} /></button
425
- >
426
- {/if}
427
424
  </div>
428
425
  {/if}
429
426
  </div>
@@ -53,7 +53,7 @@
53
53
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
54
54
  import type { LandingBox } from './leaves.js';
55
55
  import { propertyDomIds } from './domid.js';
56
- import { bloomInside } from '../core/bloom.js';
56
+ import { bloom } from '../core/bloom.js';
57
57
  import Icon from './icons/Icon.svelte';
58
58
  import FieldLabel from './FieldLabel.svelte';
59
59
  import ObjectField from './ObjectField.svelte';
@@ -215,7 +215,7 @@
215
215
  // the box stays where the next item is typed.
216
216
  const first = Object.keys(schema.properties ?? {})[0];
217
217
  if (first !== undefined) ref(colEls, id)?.focusPath([first]);
218
- if (box) bloomInside(box);
218
+ if (box) bloom(box);
219
219
  }
220
220
 
221
221
  /** Take the caret: the first item's control, else the add box. */
@@ -276,13 +276,17 @@
276
276
  </span>
277
277
  </div>
278
278
  <div
279
- class={['qm-matrix-roster', { 'qm-tracks': compact, compact }]}
279
+ class={['qm-matrix-roster qm-wash', { 'qm-tracks': compact, compact }]}
280
280
  data-default={defaulted ? '' : undefined}
281
281
  bind:this={rosterEl}
282
282
  >
283
283
  {#each members as m (m.id)}
284
284
  {@const on = held(m.id)}
285
- <div class={['qm-member', { held: on }]} bind:this={memberEls[m.id]}>
285
+ <div
286
+ class={['qm-member qm-wash', { held: on }]}
287
+ data-qm-prop={m.id}
288
+ bind:this={memberEls[m.id]}
289
+ >
286
290
  <div class="qm-member-head">
287
291
  <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
288
292
  dial reaches, so the input is `appearance: none` and the box and the
@@ -306,7 +310,11 @@
306
310
  {/each}
307
311
  {#each added as item, k (item.id)}
308
312
  {@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
309
- <div class="qm-member held added" bind:this={memberEls[item.id]}>
313
+ <div
314
+ class="qm-member qm-wash held added"
315
+ data-qm-prop={item.id}
316
+ bind:this={memberEls[item.id]}
317
+ >
310
318
  <div class="qm-member-head">
311
319
  <!-- Held by being present: the tick is fixed, and the remove is its untick. -->
312
320
  <span class="qm-tick" aria-hidden="true">
@@ -317,6 +325,7 @@
317
325
  class="qm-input qm-focus-ring qm-member-title-input"
318
326
  type="text"
319
327
  id={itemTitleId(item.id)}
328
+ data-qm-prop={MATRIX_TITLE}
320
329
  aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
321
330
  value={item.title}
322
331
  bind:this={titleEls[item.id]}
@@ -391,11 +400,9 @@
391
400
  color: var(--_qm-ink-label);
392
401
  font-variant-numeric: tabular-nums;
393
402
  }
394
- /* The roster, one member to a row in declaration order. Positioned for the wash's
395
- inset child (`washBox`), and rounded to the rung a row's box draws, as an array's
396
- rows are. */
403
+ /* The roster, one member to a row in declaration order, rounded for the wash
404
+ (`washBox`) to the rung a row's box draws, as an array's rows are. */
397
405
  .qm-matrix-roster {
398
- position: relative;
399
406
  display: flex;
400
407
  flex-direction: column;
401
408
  gap: var(--_qm-space);
@@ -411,10 +418,9 @@
411
418
  align-items: start;
412
419
  column-gap: var(--_qm-space-2);
413
420
  }
414
- /* A member is a row: positioned for the wash a landing on it blooms, rounded to the
415
- rung the rows around it draw. */
421
+ /* A member is a row, rounded for the wash a landing on it blooms to the rung the
422
+ rows around it draw. */
416
423
  .qm-member {
417
- position: relative;
418
424
  display: flex;
419
425
  flex-direction: column;
420
426
  gap: var(--_qm-space);
@@ -525,16 +531,19 @@
525
531
  background: var(--_qm-danger-tint);
526
532
  color: var(--_qm-danger);
527
533
  }
528
- .qm-member-remove :global(svg) {
529
- width: var(--_qm-glyph-control);
530
- height: var(--_qm-glyph-control);
531
- }
532
- /* The default's set is what prints and nothing written: its titles and every value
533
- its columns draw take the default rung (theme.css), and the first gesture, writing
534
- it, takes it off. */
534
+ /* The default's set is what prints and nothing written: its titles, its ticks and
535
+ every value its columns draw take the default rung (theme.css), and the first
536
+ gesture, writing it, takes it off. */
535
537
  .qm-matrix-roster[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
536
538
  .qm-matrix-roster[data-default] :global(.qm-select:not([data-ghosted])),
537
539
  .qm-matrix-roster[data-default] .qm-member.held .qm-member-title {
538
540
  color: var(--_qm-ink-default);
539
541
  }
542
+ .qm-matrix-roster[data-default] :is(.qm-tick-input:checked, .qm-tick-fixed) {
543
+ background: var(--_qm-accent-default);
544
+ border-color: var(--_qm-accent-default);
545
+ }
546
+ .qm-matrix-roster[data-default] :global(.qm-toggle[data-state='checked']) {
547
+ background: var(--_qm-accent-default);
548
+ }
540
549
  </style>
@@ -4,7 +4,8 @@
4
4
  (`-`, `1.`) 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.
7
+ region. Escape takes a pending entry back to what the field holds, as it takes a
8
+ card rename back, and the focus stays; with nothing pending the key is not claimed.
8
9
 
9
10
  An unset field holds its resolved `default:` as its text, at the default rung
10
11
  (theme.css), and an edit that settles writes it as authored, as `TextField` takes
@@ -52,7 +53,8 @@
52
53
  }
53
54
  let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
54
55
 
55
- let local = $derived(value != null ? String(value) : (fallback ?? ''));
56
+ const resting = $derived(value != null ? String(value) : (fallback ?? ''));
57
+ let local = $derived(resting);
56
58
  const defaulted = $derived(value == null && !!fallback && local === fallback);
57
59
 
58
60
  const partial = $derived(
@@ -76,13 +78,37 @@
76
78
  local = el.value;
77
79
  }
78
80
 
81
+ // Where the reverted text differs from the last settled one, a browser fires `change`
82
+ // on leaving; that one commits nothing. An entry that settled outside the grammar was
83
+ // handed up as a string and refused, so taking it back settles the held value again,
84
+ // which clears the refusal drawn on the field.
85
+ let reverted = false;
86
+ let offered = false;
87
+ function revert(e: KeyboardEvent): void {
88
+ if (e.key !== 'Escape' || e.isComposing) return;
89
+ const el = e.currentTarget as HTMLInputElement;
90
+ if (el.value === resting) return;
91
+ e.preventDefault();
92
+ local = el.value = resting;
93
+ reverted = true;
94
+ if (offered) {
95
+ offered = false;
96
+ onCommit(value);
97
+ }
98
+ }
99
+
79
100
  // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
80
101
  // `undefined`, and the input takes the default back: nothing else reconciles it, the
81
102
  // projection having read the default throughout. Onto the element as well, since the
82
103
  // attribute Svelte last wrote may already be the default, and it writes no repeat.
83
104
  function commit(el: HTMLInputElement): void {
105
+ if (reverted) {
106
+ reverted = false;
107
+ return;
108
+ }
84
109
  const raw = el.value;
85
110
  const entry = raw.trim();
111
+ offered = false;
86
112
  if (entry === '') {
87
113
  onCommit(undefined);
88
114
  local = el.value = fallback ?? '';
@@ -90,7 +116,8 @@
90
116
  }
91
117
  // Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
92
118
  const n = whole.test(entry) ? Number(entry) : NaN;
93
- onCommit(Number.isFinite(n) ? n : raw);
119
+ offered = !Number.isFinite(n);
120
+ onCommit(offered ? raw : n);
94
121
  }
95
122
  </script>
96
123
 
@@ -107,6 +134,8 @@
107
134
  onbeforeinput={filter}
108
135
  oninput={(e) => {
109
136
  local = (e.currentTarget as HTMLInputElement).value;
137
+ reverted = false;
110
138
  }}
139
+ onkeydown={revert}
111
140
  onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
112
141
  />