@quillmark/svelte 0.16.0 → 0.18.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 (54) hide show
  1. package/README.md +4 -2
  2. package/dist/core/codec/index.d.ts +0 -3
  3. package/dist/core/rebind.svelte.js +5 -8
  4. package/dist/core/theme.css +12 -1
  5. package/dist/preset/index.d.ts +1 -0
  6. package/dist/preview/Preview.svelte +14 -19
  7. package/dist/preview/Preview.svelte.d.ts +3 -3
  8. package/dist/preview/index.d.ts +0 -1
  9. package/dist/visual/ArrayField.svelte +44 -41
  10. package/dist/visual/ArrayField.svelte.d.ts +0 -1
  11. package/dist/visual/BooleanField.svelte +16 -14
  12. package/dist/visual/BooleanField.svelte.d.ts +0 -1
  13. package/dist/visual/Card.svelte +65 -35
  14. package/dist/visual/CardControls.svelte.d.ts +0 -1
  15. package/dist/visual/DateField.svelte +14 -17
  16. package/dist/visual/DateField.svelte.d.ts +0 -1
  17. package/dist/visual/EnumField.svelte +9 -12
  18. package/dist/visual/EnumField.svelte.d.ts +0 -1
  19. package/dist/visual/Field.svelte +17 -10
  20. package/dist/visual/Field.svelte.d.ts +3 -0
  21. package/dist/visual/FieldHint.svelte.d.ts +0 -1
  22. package/dist/visual/FieldLabel.svelte +1 -2
  23. package/dist/visual/FormatPopover.svelte +22 -27
  24. package/dist/visual/FormatPopover.svelte.d.ts +0 -1
  25. package/dist/visual/MatrixAdd.svelte +210 -0
  26. package/dist/visual/MatrixAdd.svelte.d.ts +15 -0
  27. package/dist/visual/MatrixField.svelte +329 -84
  28. package/dist/visual/MatrixField.svelte.d.ts +8 -6
  29. package/dist/visual/NumberField.svelte +6 -10
  30. package/dist/visual/NumberField.svelte.d.ts +0 -1
  31. package/dist/visual/ObjectField.svelte +30 -17
  32. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  33. package/dist/visual/ProseField.svelte +42 -41
  34. package/dist/visual/ProseField.svelte.d.ts +0 -1
  35. package/dist/visual/ProseValue.svelte +56 -56
  36. package/dist/visual/ProseValue.svelte.d.ts +0 -1
  37. package/dist/visual/SlashMenu.svelte +5 -15
  38. package/dist/visual/SlashMenu.svelte.d.ts +0 -1
  39. package/dist/visual/TextField.svelte +5 -9
  40. package/dist/visual/TextField.svelte.d.ts +0 -1
  41. package/dist/visual/TipsCard.svelte +10 -11
  42. package/dist/visual/TipsCard.svelte.d.ts +0 -1
  43. package/dist/visual/VisualEditorInner.svelte +21 -14
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +0 -1
  45. package/dist/visual/checklists.d.ts +82 -0
  46. package/dist/visual/checklists.js +171 -0
  47. package/dist/visual/index.d.ts +0 -1
  48. package/dist/visual/strings.d.ts +15 -1
  49. package/dist/visual/strings.js +6 -0
  50. package/dist/visual/structure.d.ts +63 -31
  51. package/dist/visual/structure.js +122 -44
  52. package/package.json +20 -16
  53. package/dist/visual/synced.svelte.d.ts +0 -15
  54. package/dist/visual/synced.svelte.js +0 -34
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: number | undefined;
4
3
  integer?: boolean;
@@ -5,8 +5,8 @@
5
5
  ProseValue}) over the boundary's nested read, and a container property — an `array`,
6
6
  an `object`, a `matrix`, a variant — mounts its own control here, at the next rung of
7
7
  the same figure. Depth is bounded by what is open, not by the schema: a repeater inside
8
- a subform is collapsed rows again, one open at a time, so an open row under an open
9
- row is one figure two rungs in.
8
+ a subform is collapsed rows again, so an open row under an open row is one figure two
9
+ rungs in.
10
10
 
11
11
  The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
12
12
  an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
@@ -30,7 +30,9 @@
30
30
  member's id. So a cell reads at whatever depth it sits, which nothing here knows.
31
31
 
32
32
  `bare` is the table's row: the same cells with no labels and no figure of their own,
33
- laid onto the tracks the table's header names ({@link ArrayField}).
33
+ laid onto the tracks the table's header names ({@link ArrayField}). `flat` is
34
+ `ui.layout: flat`: the cells keep their labels and lose the figure, standing at the
35
+ depth of whatever holds the subform.
34
36
  -->
35
37
  <script lang="ts">
36
38
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
@@ -39,12 +41,14 @@
39
41
  baseType,
40
42
  controlKind,
41
43
  declaredContent,
44
+ flatObject,
42
45
  humanize,
43
46
  isContainer,
44
47
  mountKey,
45
48
  obliged,
46
49
  optionalCell,
47
50
  printedText,
51
+ renderedBoolean,
48
52
  shortCell
49
53
  } from './structure.js';
50
54
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
@@ -93,6 +97,9 @@
93
97
  /** A table's row: the cells alone, on the tracks the table's header names, with
94
98
  * no labels and no vertical of their own. */
95
99
  bare?: boolean;
100
+ /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
101
+ * list the subform stands in. */
102
+ flat?: boolean;
96
103
  /** Raw keydown off a text or prose cell, for a row whose own keys run through its
97
104
  * cells: the table's Enter and Backspace ({@link ArrayField}). */
98
105
  onCellKey?: (e: KeyboardEvent, key: string) => void;
@@ -111,6 +118,7 @@
111
118
  contentAt,
112
119
  onCommit,
113
120
  bare = false,
121
+ flat = false,
114
122
  onCellKey,
115
123
  diagnostics
116
124
  }: Props = $props();
@@ -248,8 +256,7 @@
248
256
 
249
257
  <div
250
258
  bind:this={rootEl}
251
- class="qm-object"
252
- class:qm-object-bare={bare}
259
+ class={['qm-object', { 'qm-object-bare': bare, 'qm-object-flat': flat }]}
253
260
  role="group"
254
261
  aria-labelledby={labelledBy}
255
262
  aria-describedby={describedBy}
@@ -278,9 +285,7 @@
278
285
  (`packable`). A block prose cell spans it too, and keeps its label row. -->
279
286
  {@const block = kind === 'prose' && !shortCell(sub)}
280
287
  <div
281
- class="qm-object-prop"
282
- class:qm-prop-full={isContainer(kind)}
283
- class:qm-prop-wide={block}
288
+ class={['qm-object-prop', { 'qm-prop-full': isContainer(kind), 'qm-prop-wide': block }]}
284
289
  data-qm-prop={key}
285
290
  >
286
291
  {#if ids && kind !== 'array' && kind !== 'matrix'}
@@ -322,7 +327,7 @@
322
327
  label={named}
323
328
  id={ids?.control}
324
329
  describedBy={describes}
325
- value={obj[key] as boolean | undefined}
330
+ value={renderedBoolean(obj[key])}
326
331
  fallback={sub.default as boolean | undefined}
327
332
  optional={optionalCell(sub)}
328
333
  onCommit={(v) => commitProp(key, v)}
@@ -367,9 +372,8 @@
367
372
  {:else if kind === 'array'}
368
373
  <!-- The repeater owns its label row, the add chip sharing it (`Field` skips
369
374
  its own label for one too), so the names are handed down rather than
370
- drawn here. Its rows are collapsed rows again, one open at a time: the
371
- depth an open row under an open row costs is two rungs of the ladder
372
- and one figure. -->
375
+ drawn here. Its rows are collapsed rows again: the depth an open row
376
+ under an open row costs is two rungs of the ladder and one figure. -->
373
377
  <ArrayField
374
378
  bind:this={nestedEls[key]}
375
379
  value={obj[key] as unknown[] | undefined}
@@ -392,6 +396,7 @@
392
396
  bind:this={nestedEls[key]}
393
397
  value={obj[key] as Record<string, unknown> | undefined}
394
398
  properties={sub.properties}
399
+ flat={flatObject(sub)}
395
400
  label={ids ? undefined : fallbackName(key, sub)}
396
401
  idBase={ids?.control}
397
402
  labelledBy={ids?.label}
@@ -471,6 +476,8 @@
471
476
  }
472
477
  .qm-object:not(.qm-object-bare) {
473
478
  container-type: inline-size;
479
+ }
480
+ .qm-object:not(.qm-object-bare, .qm-object-flat) {
474
481
  border-inline-start: var(--_qm-vertical-width) solid var(--_qm-border);
475
482
  padding-inline-start: var(--_qm-nest);
476
483
  padding-block: var(--_qm-space);
@@ -481,7 +488,14 @@
481
488
  .qm-object-grid {
482
489
  display: grid;
483
490
  grid-template-columns: repeat(var(--cols), 1fr);
484
- gap: var(--_qm-space-2);
491
+ column-gap: var(--_qm-space-2);
492
+ --row-gap: var(--_qm-space-2);
493
+ row-gap: var(--row-gap);
494
+ }
495
+ /* Flat, the rows keep the rhythm of the list the subform stands in: a section's
496
+ (`Card.svelte`) or an enclosing subform's, inherited rather than restated. */
497
+ .qm-object-flat > .qm-object-grid {
498
+ --row-gap: inherit;
485
499
  }
486
500
  /* A property measures like a field: two tracks over the subform's own rows, so a
487
501
  label wrapping in one column leaves its control on the row's baseline rather than a
@@ -546,10 +560,9 @@
546
560
  grid-row: auto;
547
561
  grid-template-rows: none;
548
562
  row-gap: var(--_qm-space-half);
549
- /* A row is one line of controls, so a cell shorter than the box beside it stands
550
- on that line: a switch held at the top of a row a text box set the height of
551
- reads as a cell that slipped its track. */
552
- align-items: center;
563
+ /* A row reads along its first line: a prose cell that wraps grows downward, and
564
+ the cells beside it stay on the line it started on. */
565
+ align-items: start;
553
566
  }
554
567
  /* What the row could not hand down stands across it: a diagnostic in the first
555
568
  column alone would read as that column's. */
@@ -2,7 +2,6 @@ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import { type DeepDiagnostic } from './diagnostics.js';
3
3
  import type { LandingBox } from './leaves.js';
4
4
  import ObjectField from './ObjectField.svelte';
5
- import './controls.css';
6
5
  interface Props {
7
6
  value: Record<string, unknown> | undefined;
8
7
  properties: Record<string, QuillFieldSchema> | undefined;
@@ -31,6 +30,9 @@ interface Props {
31
30
  /** A table's row: the cells alone, on the tracks the table's header names, with
32
31
  * no labels and no vertical of their own. */
33
32
  bare?: boolean;
33
+ /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
34
+ * list the subform stands in. */
35
+ flat?: boolean;
34
36
  /** Raw keydown off a text or prose cell, for a row whose own keys run through its
35
37
  * cells: the table's Enter and Backspace ({@link ArrayField}). */
36
38
  onCellKey?: (e: KeyboardEvent, key: string) => void;
@@ -5,7 +5,7 @@
5
5
  this leaf's commits with the caret riding the untouched view.
6
6
  -->
7
7
  <script lang="ts">
8
- import { onMount } from 'svelte';
8
+ import { untrack } from 'svelte';
9
9
  import { createField, type FieldController, type SlashState } from '../core/codec/index.js';
10
10
  import type { LeafRegistry } from './leaves.js';
11
11
  import SlashMenu from './SlashMenu.svelte';
@@ -82,7 +82,6 @@
82
82
  diagnostics
83
83
  }: Props = $props();
84
84
 
85
- let containerEl: HTMLDivElement | undefined = $state();
86
85
  let held = $state(false);
87
86
  const uid = $props.id();
88
87
  const heldId = `${uid}-held`;
@@ -108,41 +107,43 @@
108
107
  controller?.focus();
109
108
  }
110
109
 
111
- onMount(() => {
112
- if (!containerEl) return;
113
- controller = createField({
114
- doc,
115
- quill,
116
- addr,
117
- container: containerEl,
118
- inline,
119
- plaintext,
120
- label,
121
- labelledBy,
122
- describedBy,
123
- fallback,
124
- placeholder,
125
- placeholderUntilEdit,
126
- tableStrings: () => t.strings,
127
- onSlash: (next) => {
128
- slash = next;
129
- },
130
- onFocus,
131
- onCaretMove,
132
- onChange,
133
- onError,
134
- heldNoteId: heldId,
135
- onHold: (next) => {
136
- held = next;
137
- }
110
+ function mountField(container: HTMLDivElement): () => void {
111
+ return untrack(() => {
112
+ const mounted = createField({
113
+ doc,
114
+ quill,
115
+ addr,
116
+ container,
117
+ inline,
118
+ plaintext,
119
+ label,
120
+ labelledBy,
121
+ describedBy,
122
+ fallback,
123
+ placeholder,
124
+ placeholderUntilEdit,
125
+ tableStrings: () => t.strings,
126
+ onSlash: (next) => {
127
+ slash = next;
128
+ },
129
+ onFocus,
130
+ onCaretMove,
131
+ onChange,
132
+ onError,
133
+ heldNoteId: heldId,
134
+ onHold: (next) => {
135
+ held = next;
136
+ }
137
+ });
138
+ controller = mounted;
139
+ leaves?.registerProse(leafKey, mounted);
140
+ return () => {
141
+ leaves?.unregisterProse(leafKey);
142
+ mounted.destroy();
143
+ controller = undefined;
144
+ };
138
145
  });
139
- leaves?.registerProse(leafKey, controller);
140
- return () => {
141
- leaves?.unregisterProse(leafKey);
142
- controller?.destroy();
143
- controller = undefined;
144
- };
145
- });
146
+ }
146
147
 
147
148
  // A retype does not remount the leaf (its key is the card's session id), so the new
148
149
  // kind's ghost and default are pushed into the live view rather than paid for with
@@ -162,11 +163,11 @@
162
163
  </script>
163
164
 
164
165
  <div
165
- bind:this={containerEl}
166
- class="qm-prose"
167
- class:qm-control-box={!unframed}
168
- class:qm-focus-ring-within={!unframed}
169
- class:qm-prose-block={block}
166
+ {@attach mountField}
167
+ class={[
168
+ 'qm-prose',
169
+ { 'qm-control-box': !unframed, 'qm-focus-ring-within': !unframed, 'qm-prose-block': block }
170
+ ]}
170
171
  data-leaf-key={leafKey}
171
172
  >
172
173
  {#if held}
@@ -1,5 +1,4 @@
1
1
  import type { LeafRegistry } from './leaves.js';
2
- import './controls.css';
3
2
  import type { Document, Quill, Addr, Content, Diagnostic } from '@quillmark/wasm';
4
3
  import type { EditorErrorHandler } from '../core/errors.js';
5
4
  interface Props {
@@ -31,7 +31,7 @@
31
31
  schema, read-only, with a note inside its box, and commits nothing.
32
32
  -->
33
33
  <script lang="ts">
34
- import { onMount } from 'svelte';
34
+ import { untrack } from 'svelte';
35
35
  import { EditorState, Selection } from 'prosemirror-state';
36
36
  import { EditorView } from 'prosemirror-view';
37
37
  import {
@@ -101,7 +101,6 @@
101
101
  const t = wording();
102
102
  const uid = $props.id();
103
103
  const heldId = `${uid}-held`;
104
- let editorEl: HTMLDivElement | undefined = $state();
105
104
  let held = $state(false);
106
105
  let view: EditorView | undefined;
107
106
  /** Take the caret: what a parent placing focus on this leaf calls. The view's
@@ -134,61 +133,62 @@
134
133
  view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
135
134
  }
136
135
 
137
- onMount(() => {
138
- if (!editorEl) return;
139
- // The same keymap and plugin stack a `createField` leaf mounts (shared
140
- // `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
141
- // the parent's value write, per the header.
142
- const inline = !block;
143
- const stored = content();
144
- const rt = stored ?? fallback ?? emptyContent();
145
- let defaulted = stored === undefined && !!fallback;
146
- held = !fitsLeaf(rt, { inline, plaintext });
147
- const named = held
148
- ? heldAttributes({ label, labelledBy, describedBy }, heldId)
149
- : (proseAttributes({ label, labelledBy, describedBy }) ?? {});
150
- const defaultNamed = { ...named, 'data-default': '' };
151
- const schema = held ? blockSchema : leafSchema({ plaintext, inline });
152
- const state = EditorState.create({
153
- doc: decode(rt, schema),
154
- plugins: held
155
- ? []
156
- : proseLeafPlugins(schema, {
157
- inline,
158
- placeholder: () => placeholder,
159
- placeholderUntilEdit: true
160
- })
161
- });
162
- const mounted = new EditorView(
163
- { mount: editorEl },
164
- {
165
- state,
166
- editable: () => !held,
167
- // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
168
- // (`proseAttributes`), so a cell carrying a label element and a row carrying
169
- // none cannot name their regions by different rules.
170
- attributes: () => (defaulted ? defaultNamed : named),
171
- dispatchTransaction(tr) {
172
- const next = mounted.state.apply(tr);
173
- const edit = tr.docChanged && !held;
174
- if (edit) defaulted = false;
175
- mounted.updateState(next);
176
- if (edit) onChange(pmToContent(next.doc));
177
- },
178
- handleDOMEvents: {
179
- keydown: (_v, e) => {
180
- onKey?.(e);
181
- return false;
136
+ function mountView(mount: HTMLDivElement): () => void {
137
+ return untrack(() => {
138
+ // The same keymap and plugin stack a `createField` leaf mounts (shared
139
+ // `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
140
+ // the parent's value write, per the header.
141
+ const inline = !block;
142
+ const stored = content();
143
+ const rt = stored ?? fallback ?? emptyContent();
144
+ let defaulted = stored === undefined && !!fallback;
145
+ held = !fitsLeaf(rt, { inline, plaintext });
146
+ const named = held
147
+ ? heldAttributes({ label, labelledBy, describedBy }, heldId)
148
+ : (proseAttributes({ label, labelledBy, describedBy }) ?? {});
149
+ const defaultNamed = { ...named, 'data-default': '' };
150
+ const schema = held ? blockSchema : leafSchema({ plaintext, inline });
151
+ const state = EditorState.create({
152
+ doc: decode(rt, schema),
153
+ plugins: held
154
+ ? []
155
+ : proseLeafPlugins(schema, {
156
+ inline,
157
+ placeholder: () => placeholder,
158
+ placeholderUntilEdit: true
159
+ })
160
+ });
161
+ const mounted = new EditorView(
162
+ { mount },
163
+ {
164
+ state,
165
+ editable: () => !held,
166
+ // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
167
+ // (`proseAttributes`), so a cell carrying a label element and a row carrying
168
+ // none cannot name their regions by different rules.
169
+ attributes: () => (defaulted ? defaultNamed : named),
170
+ dispatchTransaction(tr) {
171
+ const next = mounted.state.apply(tr);
172
+ const edit = tr.docChanged && !held;
173
+ if (edit) defaulted = false;
174
+ mounted.updateState(next);
175
+ if (edit) onChange(pmToContent(next.doc));
176
+ },
177
+ handleDOMEvents: {
178
+ keydown: (_v, e) => {
179
+ onKey?.(e);
180
+ return false;
181
+ }
182
182
  }
183
183
  }
184
- }
185
- );
186
- view = mounted;
187
- return () => {
188
- view = undefined;
189
- mounted.destroy();
190
- };
191
- });
184
+ );
185
+ view = mounted;
186
+ return () => {
187
+ view = undefined;
188
+ mounted.destroy();
189
+ };
190
+ });
191
+ }
192
192
  </script>
193
193
 
194
194
  <!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
@@ -197,7 +197,7 @@
197
197
  line of prose measure one line. Width is the row's or the cell's to give: the leaf
198
198
  fills the track it is placed in. -->
199
199
  <div class="qm-prose-value qm-control-box qm-focus-ring-within">
200
- <div bind:this={editorEl}></div>
200
+ <div {@attach mountView}></div>
201
201
  <!-- Inside the box, so the cell or row holds one child either way: a subform cell's
202
202
  subgrid has a row for the label and one for the box, and an array row's slabs
203
203
  stand the box's height. -->
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  import type { Content } from '@quillmark/wasm';
3
2
  interface Props {
4
3
  /** This leaf's content, read at mount (the parent's boundary read), `undefined`
@@ -33,30 +33,20 @@
33
33
  }
34
34
  let { menu, leaf, label }: Props = $props();
35
35
 
36
- /** Raised whenever the codec reports offers. Mirrored into local state rather than
37
- * derived, because the primitive owns this prop: its own dismissal layer (an
38
- * outside press) writes it, and a derived it cannot write to is an error rather
39
- * than a dismissal. The next report re-syncs, so the codec stays the authority on
40
- * whether a run is live. */
41
- let open = $state(false);
42
- $effect(() => {
43
- open = !!menu && menu.items.length > 0;
44
- });
36
+ let open = $derived(!!menu && menu.items.length > 0);
45
37
 
46
38
  /** The root to portal into, resolved from the leaf's own DOM so the menu lands
47
39
  * inside whichever `[data-qm-root]` raised it and inherits the consumer's dials;
48
40
  * `undefined` falls back to bits-ui's `document.body`. */
49
41
  const portalTarget = $derived(leaf()?.el.closest<HTMLElement>('[data-qm-root]') ?? undefined);
50
42
 
51
- let surface = $state<HTMLElement | undefined>();
52
-
53
43
  /** Keep the keyboard's cursor in the port: the arrows move an index rather than a
54
44
  * focus, so a row the list scrolled past has nothing bringing it back. `nearest`
55
45
  * scrolls the least that works, and no ancestor the row is already visible in. */
56
- $effect(() => {
46
+ function followHighlight(surface: HTMLElement): void {
57
47
  void menu?.index;
58
- surface?.querySelector('[data-highlighted]')?.scrollIntoView({ block: 'nearest' });
59
- });
48
+ surface.querySelector('[data-highlighted]')?.scrollIntoView({ block: 'nearest' });
49
+ }
60
50
 
61
51
  /** Swallow the item's own mousedown: without it the browser focuses the item and
62
52
  * blurs the leaf, taking the trigger run's caret with it. */
@@ -98,7 +88,7 @@
98
88
  a thing to click, tab into, or read. -->
99
89
  <div
100
90
  {...props}
101
- bind:this={surface}
91
+ {@attach followHighlight}
102
92
  class="qm-slash-menu qm-menu-surface"
103
93
  data-qm-root
104
94
  role="group"
@@ -1,5 +1,4 @@
1
1
  import type { FieldController, SlashState } from '../core/codec/index.js';
2
- import './controls.css';
3
2
  interface Props {
4
3
  /** The live menu, or `undefined` when closed: the codec's report channel. Named
5
4
  * `menu` rather than `state`, which is the rune's word in this file. */
@@ -14,7 +14,6 @@
14
14
  unanswered, and an optional cell returns to `none`.
15
15
  -->
16
16
  <script lang="ts">
17
- import { syncedLocal } from './synced.svelte.js';
18
17
  import './controls.css';
19
18
 
20
19
  interface Props {
@@ -40,11 +39,8 @@
40
39
  }
41
40
  let { value, fallback, placeholder, label, id, describedBy, onCommit, onKey }: Props = $props();
42
41
 
43
- // Local input state synced to `value`, or to the default an unset field holds:
44
- // own-typing stays local, only an external change reconciles back in (see
45
- // `syncedLocal`).
46
- const local = syncedLocal(() => value ?? fallback ?? '');
47
- const defaulted = $derived(value == null && !!fallback && local.value === fallback);
42
+ let local = $derived(value ?? fallback ?? '');
43
+ const defaulted = $derived(value == null && !!fallback && local === fallback);
48
44
 
49
45
  let inputEl: HTMLInputElement | undefined = $state();
50
46
  /** Take the caret: what a parent placing focus on this control calls. */
@@ -57,7 +53,7 @@
57
53
  bind:this={inputEl}
58
54
  class="qm-input qm-focus-ring"
59
55
  type="text"
60
- value={local.value}
56
+ value={local}
61
57
  {id}
62
58
  {placeholder}
63
59
  data-default={defaulted ? '' : undefined}
@@ -65,7 +61,7 @@
65
61
  aria-describedby={describedBy}
66
62
  onkeydown={onKey}
67
63
  oninput={(e) => {
68
- local.value = (e.currentTarget as HTMLInputElement).value;
69
- onCommit(local.value !== '' ? local.value : fallback ? '' : undefined);
64
+ local = (e.currentTarget as HTMLInputElement).value;
65
+ onCommit(local !== '' ? local : fallback ? '' : undefined);
70
66
  }}
71
67
  />
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: string | undefined;
4
3
  /** The resolved `default:` where it prints: the text an unset field holds. */
@@ -40,20 +40,19 @@
40
40
  const index = $derived(Math.min(cursor, tips.length - 1));
41
41
  const isLast = $derived(index >= tips.length - 1);
42
42
 
43
- // The tip string is derived before the effect reads it, and that is load-bearing.
44
- // Reading `tips[index]` inside the effect makes the parent's `model` derive the
45
- // dependency (a fresh object every `revision` bump) so the effect would re-run
46
- // on every unrelated commit, re-crossing the WASM boundary and rebuilding this
47
- // `aria-live` region per keystroke. A derived string short-circuits on `===`.
43
+ // The tip string is derived before the attachment reads it, and that is
44
+ // load-bearing. Reading `tips[index]` inside the attachment makes the parent's
45
+ // `model` derive the dependency (a fresh object every `revision` bump) so the
46
+ // attachment would re-run on every unrelated commit, re-crossing the WASM boundary
47
+ // and rebuilding this `aria-live` region per keystroke. A derived string
48
+ // short-circuits on `===`.
48
49
  const tip = $derived(tips[index] ?? '');
49
50
 
50
51
  // The tip is DOM, not text: `renderTip` returns a fragment the codec's own
51
52
  // `toDOM` built, so it is written in rather than interpolated.
52
- let bodyEl: HTMLDivElement | undefined = $state();
53
- $effect(() => {
54
- const el = bodyEl;
55
- if (el) el.replaceChildren(renderTip(tip, onError));
56
- });
53
+ function paintTip(body: HTMLDivElement): void {
54
+ body.replaceChildren(renderTip(tip, onError));
55
+ }
57
56
 
58
57
  function advance(): void {
59
58
  if (isLast) onDismiss();
@@ -64,7 +63,7 @@
64
63
  <aside class="qm-tips" aria-label={t.strings.tipsLabel}>
65
64
  <!-- Advancing swaps the text under a button that keeps focus, so the region
66
65
  announces rather than the change passing silently. -->
67
- <div class="qm-tips-body" aria-live="polite" bind:this={bodyEl}></div>
66
+ <div class="qm-tips-body" aria-live="polite" {@attach paintTip}></div>
68
67
  <div class="qm-tips-foot">
69
68
  <!-- One tip is its own whole set, so a lone dot reports nothing the card does
70
69
  not already show. -->
@@ -1,5 +1,4 @@
1
1
  import type { EditorErrorHandler } from '../core/errors.js';
2
- import './controls.css';
3
2
  interface Props {
4
3
  /** The narrowed channel (`tipsChannel`); never empty; the parent gates on length. */
5
4
  tips: string[];