@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,7 +10,7 @@
10
10
  // component renders standalone too.
11
11
  const t = wording();
12
12
  import Icon from './icons/Icon.svelte';
13
- import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
13
+ import type { Document, Quill, Addr, Diagnostic, PathStep } from '@quillmark/wasm';
14
14
  import type { EditorErrorHandler } from '../core/errors.js';
15
15
  import type { LeafRegistry } from './leaves.js';
16
16
  import type { CardModel, FieldModel } from './structure.js';
@@ -48,7 +48,7 @@
48
48
  kinds: string[];
49
49
  ops: CardOps;
50
50
  enumDisallowed?: 'hide' | 'disable';
51
- onFocus?: (addr: Addr) => void;
51
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
52
52
  onCaretMove?: (addr: Addr, pos: number) => void;
53
53
  onChange?: (addr: Addr) => void;
54
54
  onError?: EditorErrorHandler;
@@ -72,13 +72,23 @@
72
72
  leaves
73
73
  }: Props = $props();
74
74
 
75
- let localTitle = $derived(card.titleOverride);
75
+ // The rename in flight, written once on blur or Enter. Held apart from the stored
76
+ // title because a re-derive mid-edit hands `card` back with the title it stored.
77
+ let draft = $state<string | undefined>(undefined);
78
+ const shownTitle = $derived(draft ?? card.titleOverride);
76
79
 
77
- // The pre-edit value Escape rolls back to; the rename itself stays live on input.
78
- let titleAtFocus = '';
80
+ // A focus the shell hands over (`focusHeader`) is not armed for replacement: the key
81
+ // that pressed a delete, pressed again, would retitle the card it landed on.
82
+ let handed = false;
79
83
  function onTitleFocus(e: FocusEvent): void {
80
- titleAtFocus = localTitle;
81
- (e.currentTarget as HTMLInputElement).select();
84
+ draft = card.titleOverride;
85
+ const input = e.currentTarget as HTMLInputElement;
86
+ if (handed) input.setSelectionRange(input.value.length, input.value.length);
87
+ else input.select();
88
+ }
89
+ function onTitleBlur(): void {
90
+ if (draft !== undefined && draft !== card.titleOverride) ops.rename(draft);
91
+ draft = undefined;
82
92
  }
83
93
  // A press on an unfocused title would place a caret on mouseup, collapsing the
84
94
  // focus-time select-all. A press while already focused stays normal, so clicking
@@ -102,13 +112,10 @@
102
112
  const el = e.currentTarget as HTMLInputElement;
103
113
  if (e.key === 'Enter') {
104
114
  e.preventDefault();
105
- el.blur(); // commit: the live value already persisted on input
115
+ el.blur();
106
116
  } else if (e.key === 'Escape') {
107
117
  e.preventDefault();
108
- if (localTitle !== titleAtFocus) {
109
- localTitle = titleAtFocus;
110
- ops.rename(titleAtFocus); // roll back the live edits
111
- }
118
+ draft = undefined;
112
119
  el.blur();
113
120
  }
114
121
  }
@@ -193,6 +200,13 @@
193
200
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
194
201
  el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block });
195
202
  }
203
+ /** Focus the title with the caret at its end, or the header's first live control on a
204
+ * recovery shell, which has no title to focus. */
205
+ export function focusHeader(): void {
206
+ handed = true;
207
+ el?.querySelector<HTMLElement>('.qm-card-header :is(input, button:not(:disabled))')?.focus();
208
+ handed = false;
209
+ }
196
210
  </script>
197
211
 
198
212
  <!-- `data-qm-card` names the island for a floating surface that has to stay inside one
@@ -210,20 +224,18 @@
210
224
  <header class="qm-card-header">
211
225
  <!-- svelte-ignore a11y_no_static_element_interactions -->
212
226
  <div class="qm-card-rename" onmousedown={onRenameMousedown}>
213
- <span class="qm-card-title-sizer" data-value={localTitle || card.titlePlaceholder}>
227
+ <span class="qm-card-title-sizer" data-value={shownTitle || card.titlePlaceholder}>
214
228
  <input
215
229
  class="qm-card-title qm-focus-ring"
216
- value={localTitle}
230
+ value={shownTitle}
217
231
  placeholder={card.titlePlaceholder}
218
232
  aria-label={t.strings.cardTitle}
219
233
  size="1"
220
234
  onmousedown={onTitleMousedown}
221
235
  onfocus={onTitleFocus}
236
+ onblur={onTitleBlur}
222
237
  onkeydown={onTitleKeydown}
223
- oninput={(e) => {
224
- localTitle = (e.currentTarget as HTMLInputElement).value;
225
- ops.rename(localTitle);
226
- }}
238
+ oninput={(e) => (draft = (e.currentTarget as HTMLInputElement).value)}
227
239
  />
228
240
  </span>
229
241
  </div>
@@ -267,7 +279,7 @@
267
279
  <div class="qm-group-head">
268
280
  <button
269
281
  type="button"
270
- class="qm-group-header"
282
+ class="qm-group-header qm-focus-ring"
271
283
  id={headIds?.label}
272
284
  bind:this={headers[group]}
273
285
  aria-expanded={isOpen}
@@ -463,10 +475,10 @@
463
475
  what crosses the line. */
464
476
  margin-inline-end: calc(-1 * (var(--_qm-tap-min) - var(--_qm-glyph-control)) / 2);
465
477
  }
466
- /* Focus as well as hover, so a caret in any leaf, the title, or a chevron holds the
467
- reveal: hover alone would strand the pair on keyboard and touch. */
468
- .qm-card:hover :global(.qm-card-reorder),
469
- .qm-card:focus-within :global(.qm-card-reorder) {
478
+ /* Focus as well as hover, so a caret in any leaf, the title, or a control holds the
479
+ reveal: hover alone would strand the row on the keyboard. */
480
+ .qm-card:hover :global(.qm-card-controls),
481
+ .qm-card:focus-within :global(.qm-card-controls) {
470
482
  opacity: 1;
471
483
  }
472
484
  /* The rename hit region is the header's free width at full height, so a press
@@ -526,6 +538,12 @@
526
538
  .qm-card-title:focus {
527
539
  color: var(--_qm-ink);
528
540
  }
541
+ /* The automatic title, at the label ink every placeholder takes rather than the UA's
542
+ grey, and steady under focus, where a typed title steps to the ink. */
543
+ .qm-card-title::placeholder {
544
+ color: var(--_qm-ink-label);
545
+ opacity: 1;
546
+ }
529
547
  .qm-card-body {
530
548
  display: flex;
531
549
  flex-direction: column;
@@ -1,4 +1,4 @@
1
- import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
1
+ import type { Document, Quill, Addr, Diagnostic, PathStep } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
3
  import type { LeafRegistry } from './leaves.js';
4
4
  import type { CardModel } from './structure.js';
@@ -27,7 +27,7 @@ interface Props {
27
27
  kinds: string[];
28
28
  ops: CardOps;
29
29
  enumDisallowed?: 'hide' | 'disable';
30
- onFocus?: (addr: Addr) => void;
30
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
31
31
  onCaretMove?: (addr: Addr, pos: number) => void;
32
32
  onChange?: (addr: Addr) => void;
33
33
  onError?: EditorErrorHandler;
@@ -38,6 +38,7 @@ interface Props {
38
38
  declare const Card: import("svelte").Component<Props, {
39
39
  revealLeaf: (key: string) => void;
40
40
  scrollIntoViewCard: (block: ScrollLogicalPosition) => void;
41
+ focusHeader: () => void;
41
42
  }, "">;
42
43
  type Card = ReturnType<typeof Card>;
43
44
  export default Card;
@@ -1,11 +1,11 @@
1
1
  <!--
2
2
  Card controls, composable cards only.
3
- Right-aligned in the header: a move-up/move-down chevron pair revealed while the
4
- pointer or the caret is in the card (each disabled at its edge), then delete.
3
+ Right-aligned in the header: a move-up/move-down chevron pair (each disabled at its
4
+ edge), then delete, the row revealed while the pointer or the caret is in the card.
5
5
  Reorder is buttons, not drag (V1). All three are chips (controls.css): fill arrives
6
- on hover, and delete rests faded danger, full under the pointer. The card scopes
7
- that reveal, so its condition lives in the parent's CSS; the pair keeps the narrower
8
- guarantee here, that a focused chevron is a visible one.
6
+ on hover, and delete draws faded danger, full under the pointer. The card scopes
7
+ that reveal, so its condition lives in the parent's CSS; the row keeps the narrower
8
+ guarantee here, that a focused control is a visible one.
9
9
  -->
10
10
  <script lang="ts">
11
11
  import { wording } from './strings.js';
@@ -56,21 +56,21 @@
56
56
  display: flex;
57
57
  align-items: center;
58
58
  gap: var(--_qm-space-half);
59
- /* One compositor layer for the row. Opacity on the pair otherwise snaps
60
- that box off the header's half-pixel, against delete which never leaves
61
- the document paint. */
59
+ opacity: 0;
60
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
61
+ /* One compositor layer for the row at rest as well as mid-fade: a box handed
62
+ back to the document paint when its fade ends snaps off the header's
63
+ half-pixel as it lands. */
62
64
  transform: translateZ(0);
63
65
  }
64
66
  .qm-card-reorder {
65
67
  display: flex;
66
- opacity: 0;
67
- transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
68
68
  }
69
- /* Opacity hides the pair without taking it out of the tab order, so the
70
- component holds its own floor: a chevron that has focus is drawn, and its ring
71
- with it. The card's reveal already covers this; the rule is here because the
72
- hidden state is. */
73
- .qm-card-reorder:focus-within {
69
+ /* Opacity hides the row without taking it out of the tab order, which keeps
70
+ delete the keyboard's way to remove a card, so the component holds its own
71
+ floor: a control that has focus is drawn, and its ring with it. The card's
72
+ reveal already covers this; the rule is here because the hidden state is. */
73
+ .qm-card-controls:focus-within {
74
74
  opacity: 1;
75
75
  }
76
76
  /* Squares: the chip's inline pad is for a label. Width and height are one rung
@@ -1,8 +1,8 @@
1
1
  <!--
2
2
  A `date` (or `datetime`) field → a styled segmented date field on bits-ui. The
3
- stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits
4
- `undefined` (the unset rung): the parent removes the field, and the quill's
5
- `default:` applies. The value-object a date field lowers to is a render-time
3
+ stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits as the
4
+ focus leaves it, the blank `""` over a `default:`, which stays blank, and `undefined`
5
+ (the unset rung) where nothing is declared, the parent removing the field. The value-object a date field lowers to is a render-time
6
6
  concern: the editor only sees the stored string.
7
7
 
8
8
  `today` is the render date rather than a written one (canon `SCHEMAS.md`), so its
@@ -43,8 +43,15 @@
43
43
 
44
44
  Entering an unset field seats the default as the primitive's value, still at the
45
45
  default rung, so an edit to one segment commits a whole date with the others the
46
- default's. Leaving without an edit unseats it, and nothing is written; so does
47
- clearing a segment.
46
+ default's. Leaving without an edit unseats it, and nothing is written.
47
+
48
+ A segment edit that leaves a segment empty empties the primitive's value, which is
49
+ held rather than committed while the focus stays inside: the first Backspace through
50
+ a date writes nothing unset, and the empty segment shows its hint, not the default's
51
+ digits beside the ones still standing. Leaving settles it: blank where every segment
52
+ is empty, otherwise the stored date back. A field storing no date puts its segments
53
+ back by remounting the primitive, which re-reads an unchanged empty value as nothing
54
+ to sync.
48
55
  -->
49
56
  <script lang="ts">
50
57
  import { DateField as BitsDateField } from 'bits-ui';
@@ -108,13 +115,18 @@
108
115
 
109
116
  const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
110
117
  /** The default is the primitive's value: from entering an unset field until an
111
- * edit, a cleared segment or leaving. Part of the projection, so a re-derivation
112
- * neither drops a seat nor restores one over the segments a clear left. */
118
+ * edit or leaving. Part of the projection, so a re-derivation keeps it. */
113
119
  let seat = $state(false);
120
+ /** An emptied value held while the focus stays inside (see the header). Part of the
121
+ * projection too, so a re-derivation restores neither the stored date nor the seat
122
+ * over the segments it emptied. */
123
+ let held = $state(false);
124
+ /** Remounts the primitive when bumped (see the header). */
125
+ let generation = $state(0);
114
126
  // Driven controlled (`value` + `onValueChange`, never `bind:`) so reconciliation
115
127
  // stays the package's.
116
128
  let local = $derived(
117
- value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
129
+ held ? '' : (value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : ''))
118
130
  );
119
131
  const parsed = $derived(toDateValue(local));
120
132
  // The date the empty segments ghost, or undefined when there is nothing to ghost:
@@ -122,7 +134,7 @@
122
134
  // fails to parse is AUTHORED-but-malformed, which the empty field states
123
135
  // honestly: the ghost would claim it unset. Held as the parsed value rather than
124
136
  // a boolean so the substitution below narrows on the one fact it needs.
125
- const ghost = $derived(local === '' ? fallbackDate : undefined);
137
+ const ghost = $derived(value == null && local === '' && !held ? fallbackDate : undefined);
126
138
  const seated = $derived(value == null && seat && local !== '');
127
139
  /** Who put `today` here, while the field follows the render date. */
128
140
  const follows = $derived(
@@ -130,6 +142,7 @@
130
142
  );
131
143
 
132
144
  function write(next: string): void {
145
+ held = false;
133
146
  local = next;
134
147
  seat = false;
135
148
  onCommit(next);
@@ -152,7 +165,17 @@
152
165
  if (!within(e) && value == null && fallbackDate) seat = true;
153
166
  }
154
167
  function leave(e: FocusEvent): void {
155
- if (!within(e)) seat = false;
168
+ if (within(e)) return;
169
+ seat = false;
170
+ if (!held) return;
171
+ held = false;
172
+ const filled = [...(wrapEl?.querySelectorAll('[data-segment]') ?? [])].some(
173
+ (s) => s.getAttribute('data-segment') !== 'literal' && !s.hasAttribute('data-ghosted')
174
+ );
175
+ const blank = fallbackDate ? '' : undefined;
176
+ if (!filled) {
177
+ if (value !== blank) onCommit(blank);
178
+ } else if (!value) generation++;
156
179
  }
157
180
 
158
181
  // What one segment prints, and which rung it takes.
@@ -194,61 +217,67 @@
194
217
  onfocusout={leave}
195
218
  onkeydowncapture={shortcut}
196
219
  >
197
- <BitsDateField.Root
198
- value={parsed}
199
- placeholder={fallbackDate}
200
- onValueChange={(d) => {
201
- // `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
202
- // half-typed field yields undefined: the unset rung. The primitive reports
203
- // its own value again as a segment loses focus, which is no edit, and over a
204
- // seated default or `today` would write it.
205
- const next = d?.toString() ?? '';
206
- if (next === (parsed?.toString() ?? '')) return;
207
- local = next;
208
- if (!d) seat = false;
209
- onCommit(d?.toString());
210
- }}
211
- >
212
- <!-- `data-ghosted` states the rung the way the enum trigger does. The date
213
- primitive emits no per-segment placeholder marker (only bits' `select`
214
- does), so the tone rides the same attribute, set per segment from the
215
- substitution itself; which is also what keeps a half-typed date's own
216
- digits at full ink while the segments around them ghost. -->
217
- <BitsDateField.Input
218
- class="qm-date qm-control-box qm-focus-ring-within"
219
- aria-label={labelledBy ? undefined : label}
220
- aria-labelledby={labelledBy}
221
- aria-describedby={describedBy}
222
- data-ghosted={ghost ? '' : undefined}
223
- data-default={seated || follows ? '' : undefined}
220
+ {#key generation}
221
+ <BitsDateField.Root
222
+ value={parsed}
223
+ placeholder={fallbackDate}
224
+ onValueChange={(d) => {
225
+ // `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
226
+ // half-typed field yields undefined, held until the focus leaves. The
227
+ // primitive reports its own value again as a segment loses focus, which is
228
+ // no edit, and over a seated default or `today` would write it.
229
+ const next = d?.toString() ?? '';
230
+ if (next === (parsed?.toString() ?? '')) return;
231
+ if (!d) {
232
+ held = true;
233
+ return;
234
+ }
235
+ held = false;
236
+ local = next;
237
+ onCommit(next);
238
+ }}
224
239
  >
225
- {#snippet children({ segments })}
226
- <!-- Keyed by index: `part` repeats (the `literal` separators between
227
- segments all carry it) so a part-keyed block collides. -->
228
- {#each segments as seg, i (i)}
229
- {@const shown = segmentText(seg.part, seg.value)}
230
- <BitsDateField.Segment
231
- class="qm-date-segment"
232
- part={seg.part}
233
- data-ghosted={shown.ghosted}
234
- >
235
- {shown.text}
236
- </BitsDateField.Segment>
237
- {/each}
238
- {#if !datetime}
239
- <button
240
- type="button"
241
- class="qm-chip qm-tap-floor qm-focus-ring qm-date-today"
242
- aria-pressed={!!follows}
243
- data-default={follows === 'default' ? '' : undefined}
244
- onclick={toggle}
245
- >
246
- <Icon name={follows ? 'calendar-check' : 'calendar'} />{t.strings.dateToday}
247
- </button>
248
- {/if}
249
- {/snippet}
250
- </BitsDateField.Input>
251
- </BitsDateField.Root>
240
+ <!-- `data-ghosted` states the rung the way the enum trigger does. The date
241
+ primitive emits no per-segment placeholder marker (only bits' `select`
242
+ does), so the tone rides the same attribute, set per segment from the
243
+ substitution itself; which is also what keeps a half-typed date's own
244
+ digits at full ink while the segments around them ghost. -->
245
+ <BitsDateField.Input
246
+ class="qm-date qm-control-box qm-focus-ring-within"
247
+ aria-label={labelledBy ? undefined : label}
248
+ aria-labelledby={labelledBy}
249
+ aria-describedby={describedBy}
250
+ data-ghosted={ghost ? '' : undefined}
251
+ data-default={seated || follows ? '' : undefined}
252
+ >
253
+ {#snippet children({ segments })}
254
+ <!-- Keyed by index: `part` repeats (the `literal` separators between
255
+ segments all carry it) so a part-keyed block collides. -->
256
+ {#each segments as seg, i (i)}
257
+ {@const shown = segmentText(seg.part, seg.value)}
258
+ <BitsDateField.Segment
259
+ class="qm-date-segment"
260
+ part={seg.part}
261
+ data-ghosted={shown.ghosted}
262
+ >
263
+ {shown.text}
264
+ </BitsDateField.Segment>
265
+ {/each}
266
+ {#if !datetime}
267
+ <button
268
+ type="button"
269
+ class="qm-chip qm-tap-floor qm-focus-ring qm-date-today"
270
+ aria-pressed={!!follows}
271
+ data-default={follows === 'default' ? '' : undefined}
272
+ onclick={toggle}
273
+ >
274
+ <Icon name={follows ? 'calendar-check' : 'calendar'} />{t.strings.dateToday}
275
+ </button>
276
+ {/if}
277
+ {/snippet}
278
+ </BitsDateField.Input>
279
+ </BitsDateField.Root>
280
+ {/key}
252
281
  </span>
253
282
 
254
283
  <style>
@@ -16,6 +16,7 @@
16
16
  import { Select } from 'bits-ui';
17
17
  import Icon from './icons/Icon.svelte';
18
18
  import { wording } from './strings.js';
19
+ import { spacePx } from '../core/rungs.js';
19
20
  import './controls.css';
20
21
 
21
22
  const t = wording();
@@ -79,6 +80,8 @@
79
80
  * `none`: it draws at the default rung (theme.css). */
80
81
  const ghostsMember = $derived(!optional && !!fallback);
81
82
  const ghostText = $derived(optional ? t.strings.optionalGhost : dash(fallback));
83
+ /** The sentinel's tag says what picking it does: return to the default, or unset. */
84
+ const tag = $derived(ghostsMember ? t.strings.enumUnsetTag : t.strings.enumUnsetTagNone);
82
85
  /** What the closed trigger shows: the pick, or the ghosted default while unset. */
83
86
  const shown = $derived(unset ? ghostText : dash(local));
84
87
 
@@ -113,10 +116,10 @@
113
116
  data-ghosted={unset ? (ghostsMember ? 'default' : '') : undefined}
114
117
  >
115
118
  <span class="qm-select-shown">{shown}</span>
116
- <Icon name="chevron-down" class="qm-select-chevron" size={14} />
119
+ <Icon name="chevron-down" class="qm-select-chevron" />
117
120
  </Select.Trigger>
118
121
  <Select.Portal to={portalTarget}>
119
- <Select.Content collisionBoundary={portalTarget ?? []} sideOffset={4}>
122
+ <Select.Content collisionBoundary={portalTarget ?? []} sideOffset={spacePx(wrapEl)}>
120
123
  <!-- The list portals out of the trigger's DOM but into the nearest
121
124
  `[data-qm-root]`, and carries the marker itself, like FormatPopover.
122
125
  That root is the boundary a flip is measured against too, being the box
@@ -138,11 +141,11 @@
138
141
  <Select.Item
139
142
  class="qm-menu-item qm-select-item qm-select-unset"
140
143
  value={UNSET}
141
- label={optional ? ghostText : `${ghostText} ${t.strings.enumUnsetTag}`}
144
+ label={optional ? ghostText : `${ghostText} ${tag}`}
142
145
  >
143
146
  <span class="qm-select-ghost">{ghostText}</span>
144
147
  {#if !optional}
145
- <span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
148
+ <span class="qm-select-tag">{tag}</span>
146
149
  {/if}
147
150
  </Select.Item>
148
151
  <!-- A refused option still draws under `'disable'`, and under either
@@ -202,6 +205,8 @@
202
205
  }
203
206
  .qm-select-wrap :global(.qm-select-chevron) {
204
207
  flex-shrink: 0;
208
+ width: var(--_qm-glyph-control);
209
+ height: var(--_qm-glyph-control);
205
210
  }
206
211
  /* The focus ring rides `.qm-focus-ring` on the trigger (controls.css). */
207
212
  /* Shown-never-written: the closed control reads muted while unset, at the rung of
@@ -27,7 +27,7 @@
27
27
  ResolvedField
28
28
  } from '@quillmark/wasm';
29
29
  import type { EditorErrorHandler } from '../core/errors.js';
30
- import { seatValueLeaves, type LandingBox, type LeafRegistry } from './leaves.js';
30
+ import { seatValueLeaves, stepsTo, type LandingBox, type LeafRegistry } from './leaves.js';
31
31
  import type { FieldModel, FieldSpan } from './structure.js';
32
32
  import {
33
33
  VARIANT_DISCRIMINANT,
@@ -82,7 +82,9 @@
82
82
  optionAllowed?: (value: string) => boolean;
83
83
  /** How a refused option draws: greyed (default) or left out. */
84
84
  enumDisallowed?: 'hide' | 'disable';
85
- onFocus?: (addr: Addr) => void;
85
+ /** A focus inside the field: its address, and the steps down to the cell holding
86
+ * the focus ({@link stepsTo}), `[]` on the field's own control. */
87
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
86
88
  onCaretMove?: (addr: Addr, pos: number) => void;
87
89
  onChange?: (addr: Addr) => void;
88
90
  onError?: EditorErrorHandler;
@@ -221,7 +223,7 @@
221
223
 
222
224
  /**
223
225
  * This field's landing handle. The wrapper is the bloom host rather than the
224
- * control: `bloomInside` appends an inset child and an `<input>` holds none. The
226
+ * control: the wash is a box's `::after`, which an `<input>` draws none of. The
225
227
  * label is outside it, and stays out of the wash: an arrival marks where the caret
226
228
  * landed, which is the control. A control that owns its label (`ownsLabel`: the array
227
229
  * and the matrix) holds that row inside itself, so it names its own box
@@ -251,7 +253,8 @@
251
253
  }
252
254
 
253
255
  // A by-value prose leaf anywhere inside this field's containers is reached under
254
- // this field's key: the active address names the field, never the element or cell.
256
+ // this field's key: the registry and the active address name the field, never the
257
+ // element or cell.
255
258
  seatValueLeaves({
256
259
  focus: (leaf) => leaves?.focusValue(leafKey, leaf),
257
260
  release: (leaf) => leaves?.releaseValue(leafKey, leaf)
@@ -261,11 +264,16 @@
261
264
  * A form control has no controller to report its focus through, so the wrapper
262
265
  * reports: `focusin` bubbles, so one handler covers a plain input, an array's N
263
266
  * elements and an object's properties alike, and the active leaf names a scalar
264
- * field the way it names a prose one. A prose leaf reports through its own
267
+ * field the way it names a prose one, down to the element or cell the focus is in. A prose leaf reports through its own
265
268
  * controller — the source that drives its caret signals too — and is excluded here
266
269
  * rather than counted twice.
267
270
  */
268
- const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
271
+ const reportFocus = $derived(
272
+ field.control === 'prose'
273
+ ? undefined
274
+ : (e: FocusEvent & { currentTarget: HTMLElement }) =>
275
+ onFocus?.(addr, stepsTo(e.target, e.currentTarget))
276
+ );
269
277
  </script>
270
278
 
271
279
  <div class={['qm-field', { cell: span === 'cell' }]}>
@@ -276,7 +284,6 @@
276
284
  id={domIds.label}
277
285
  descriptionId={domIds.description}
278
286
  {onActivate}
279
- required={field.required}
280
287
  description={field.description}
281
288
  />
282
289
  {/if}
@@ -285,7 +292,7 @@
285
292
  rule below holds why). One markup for both spans; a field owning its row nests a
286
293
  level and measures the same. -->
287
294
  <div class="qm-field-stack">
288
- <div class="qm-field-control" {@attach registerControl} onfocusin={reportFocus}>
295
+ <div class="qm-field-control qm-wash" {@attach registerControl} onfocusin={reportFocus}>
289
296
  {#if field.control === 'prose'}
290
297
  <ProseField
291
298
  {quill}
@@ -296,7 +303,7 @@
296
303
  plaintext={field.plaintext}
297
304
  fallback={printed ? (ghost as Content) : undefined}
298
305
  placeholder={none}
299
- placeholderUntilEdit
306
+ emptyUnsets={optional}
300
307
  labelledBy={domIds.label}
301
308
  {describedBy}
302
309
  {leafKey}
@@ -374,7 +381,6 @@
374
381
  layout={arrayLayout(field.schema)}
375
382
  max={field.schema.max}
376
383
  label={field.label}
377
- required={field.required}
378
384
  description={field.description}
379
385
  labelId={domIds.label}
380
386
  descriptionId={domIds.description}
@@ -389,6 +395,7 @@
389
395
  value={value as Record<string, unknown> | undefined}
390
396
  properties={field.schema.properties}
391
397
  {flat}
398
+ placed
392
399
  label={field.label}
393
400
  idBase={domIds.control}
394
401
  labelledBy={domIds.label}
@@ -476,13 +483,10 @@
476
483
  gap: var(--_qm-space);
477
484
  min-width: 0;
478
485
  }
479
- /* Positioned for the arrival wash a landing inserts (`core/bloom.ts`), the way
480
- `.qm-prose` is: an inset child over the control, since an `<input>` takes none.
481
- The radius is the control's own, so the wash's corners are the box's rather than
482
- square over a rounded one; on an array or a subform it bounds a group, where
483
- there is no single box for it to disagree with. */
486
+ /* The arrival wash's host (`.qm-wash`). The radius is the control's own, so the
487
+ wash's corners are the box's rather than square over a rounded one; on an array or
488
+ a subform it bounds a group, where there is no single box for it to disagree with. */
484
489
  .qm-field-control {
485
- position: relative;
486
490
  border-radius: var(--_qm-radius-inner);
487
491
  }
488
492
  </style>
@@ -1,4 +1,4 @@
1
- import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
1
+ import type { Document, Quill, Addr, Diagnostic, PathStep, ResolvedField } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
3
  import { type LeafRegistry } from './leaves.js';
4
4
  import type { FieldModel, FieldSpan } from './structure.js';
@@ -27,7 +27,9 @@ interface Props {
27
27
  optionAllowed?: (value: string) => boolean;
28
28
  /** How a refused option draws: greyed (default) or left out. */
29
29
  enumDisallowed?: 'hide' | 'disable';
30
- onFocus?: (addr: Addr) => void;
30
+ /** A focus inside the field: its address, and the steps down to the cell holding
31
+ * the focus ({@link stepsTo}), `[]` on the field's own control. */
32
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
31
33
  onCaretMove?: (addr: Addr, pos: number) => void;
32
34
  onChange?: (addr: Addr) => void;
33
35
  onError?: EditorErrorHandler;
@@ -180,7 +180,7 @@
180
180
  e.stopPropagation();
181
181
  open = false;
182
182
  }
183
- }}><Icon name="info" size={13} /></button
183
+ }}><Icon name="info" /></button
184
184
  >
185
185
 
186
186
  <!-- Placement is the description's height. One line rides the label rung beside its own
@@ -249,8 +249,15 @@
249
249
  So the box is the glyph and the target is `.qm-tap-floor`'s (controls.css), which
250
250
  carries that split and the giving-up of the family floor together.
251
251
 
252
- It takes no fill either: the ink step below says hover, and the surface itself
253
- opens on it. */
252
+ It takes no fill either: the surface itself opens on hover.
253
+
254
+ At rest it recedes to the idle rung, a card describing every field otherwise
255
+ standing a mark beside each label before anything is touched. It lifts with the
256
+ scope that owns its row (the field, the property, the array or matrix, the table
257
+ its column heads, the section a lone field is named by), each reached by child
258
+ steps so a nested field's marker stays at rest under its container's hover, and
259
+ while its surface is open. The keyboard's `Mod-/` and the parked description do not
260
+ depend on the lift. */
254
261
  .qm-field-hint {
255
262
  /* The marker recedes to the label rung; the surface carries the text. */
256
263
  color: var(--_qm-ink-label);
@@ -258,10 +265,20 @@
258
265
  cursor: help;
259
266
  padding: 0;
260
267
  background: none;
268
+ opacity: var(--_qm-opacity-idle);
269
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
261
270
  }
271
+ :global(:is(.qm-field, .qm-object-prop):is(:hover, :focus-within) > .qm-field-label-row)
272
+ > .qm-field-hint,
273
+ :global(:is(.qm-array, .qm-matrix):is(:hover, :focus-within) > * > .qm-field-label-row)
274
+ > .qm-field-hint,
275
+ :global(.qm-array-table:is(:hover, :focus-within) .qm-array-table-col > .qm-field-label-row)
276
+ > .qm-field-hint,
277
+ :global(.qm-group:is(:hover, :focus-within) > .qm-group-head) > .qm-field-hint,
262
278
  .qm-field-hint:hover,
263
- .qm-field-hint:focus-visible {
264
- color: var(--_qm-ink-label);
279
+ .qm-field-hint:focus-visible,
280
+ .qm-field-hint[aria-expanded='true'] {
281
+ opacity: 1;
265
282
  }
266
283
  /* The lift, the translucency, the blur and the scale-in come from
267
284
  `.qm-popover-surface` (controls.css). This surface's own is a measure: guidance