@quillmark/svelte 0.17.0 → 0.19.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/core/codec/index.d.ts +0 -3
  3. package/dist/core/rebind.svelte.js +5 -8
  4. package/dist/preset/index.d.ts +1 -0
  5. package/dist/preview/Preview.svelte +14 -19
  6. package/dist/preview/Preview.svelte.d.ts +3 -3
  7. package/dist/preview/index.d.ts +0 -1
  8. package/dist/visual/ArrayField.svelte +48 -49
  9. package/dist/visual/ArrayField.svelte.d.ts +1 -2
  10. package/dist/visual/BooleanField.svelte +9 -14
  11. package/dist/visual/BooleanField.svelte.d.ts +0 -1
  12. package/dist/visual/Card.svelte +5 -18
  13. package/dist/visual/CardControls.svelte.d.ts +0 -1
  14. package/dist/visual/DateField.svelte +14 -17
  15. package/dist/visual/DateField.svelte.d.ts +0 -1
  16. package/dist/visual/EnumField.svelte +9 -12
  17. package/dist/visual/EnumField.svelte.d.ts +0 -1
  18. package/dist/visual/Field.svelte +8 -9
  19. package/dist/visual/FieldHint.svelte.d.ts +0 -1
  20. package/dist/visual/FieldLabel.svelte +1 -2
  21. package/dist/visual/FormatPopover.svelte +22 -27
  22. package/dist/visual/FormatPopover.svelte.d.ts +0 -1
  23. package/dist/visual/MatrixAdd.svelte +1 -2
  24. package/dist/visual/MatrixAdd.svelte.d.ts +0 -1
  25. package/dist/visual/MatrixField.svelte +3 -5
  26. package/dist/visual/MatrixField.svelte.d.ts +0 -1
  27. package/dist/visual/NumberField.svelte +6 -10
  28. package/dist/visual/NumberField.svelte.d.ts +0 -1
  29. package/dist/visual/ObjectField.svelte +9 -13
  30. package/dist/visual/ObjectField.svelte.d.ts +0 -1
  31. package/dist/visual/ProseField.svelte +42 -41
  32. package/dist/visual/ProseField.svelte.d.ts +0 -1
  33. package/dist/visual/ProseValue.svelte +56 -56
  34. package/dist/visual/ProseValue.svelte.d.ts +0 -1
  35. package/dist/visual/SlashMenu.svelte +5 -15
  36. package/dist/visual/SlashMenu.svelte.d.ts +0 -1
  37. package/dist/visual/TextField.svelte +5 -9
  38. package/dist/visual/TextField.svelte.d.ts +0 -1
  39. package/dist/visual/TipsCard.svelte +10 -11
  40. package/dist/visual/TipsCard.svelte.d.ts +0 -1
  41. package/dist/visual/VisualEditorInner.svelte +6 -14
  42. package/dist/visual/VisualEditorInner.svelte.d.ts +0 -1
  43. package/dist/visual/index.d.ts +0 -1
  44. package/dist/visual/structure.d.ts +4 -0
  45. package/dist/visual/structure.js +12 -0
  46. package/package.json +20 -16
  47. package/dist/visual/synced.svelte.d.ts +0 -15
  48. package/dist/visual/synced.svelte.js +0 -34
package/README.md CHANGED
@@ -8,7 +8,7 @@ Editor + live-preview components for [Quillmark](https://github.com/borb-sh/quil
8
8
  npm install @quillmark/svelte
9
9
  ```
10
10
 
11
- `svelte@^5` and `@quillmark/wasm` are peer dependencies: the session's handles cross the package boundary, so the consumer supplies the one copy both sides mint them from.
11
+ `svelte@^5.33` and `@quillmark/wasm` are peer dependencies: the session's handles cross the package boundary, so the consumer supplies the one copy both sides mint them from.
12
12
 
13
13
  ## Subpaths
14
14
 
@@ -1,6 +1,3 @@
1
- import 'prosemirror-view/style/prosemirror.css';
2
- import 'prosemirror-gapcursor/style/gapcursor.css';
3
- import './prose.css';
4
1
  export { rangeAnchor } from './anchor.js';
5
2
  export type { RangeAnchor } from './anchor.js';
6
3
  export { createField, emptyContent, heldAttributes, proseAttributes, proseLeafPlugins } from './field.js';
@@ -1,16 +1,13 @@
1
- // The remount contract, enforced. `Preview` binds its props once, in `onMount`, and
2
- // observes nothing after: the vanilla core below it owns scroll position, mounted
3
- // slots and observer sets that a remount would discard on every apply. So a prop
4
- // swapped in place is reported, not dropped in silence, and thrown in a dev build.
1
+ // The remount contract, enforced. `Preview` binds its props once, when its container
2
+ // mounts, and observes nothing after: the vanilla core below it owns scroll position,
3
+ // mounted slots and observer sets that a remount would discard on every apply. So a
4
+ // prop swapped in place is reported, not dropped in silence, and thrown in a dev build.
5
5
  //
6
6
  // A guard over one prop of a set is worse than none: a consumer who learns the
7
7
  // surface complains when it ignores a prop reads silence on the rest as reactivity.
8
8
  // The snapshot is every once-bound prop, and one report names whichever went stale.
9
+ import { DEV } from 'esm-env';
9
10
  import { reportError } from './errors.js';
10
- // True in a dev build whose bundler injects `import.meta.env`. Cast, since the package
11
- // declares no bundler's ambient types; false where nothing injects it, so a toolchain
12
- // that does not answer gets the report alone.
13
- const DEV = import.meta.env?.DEV === true;
14
11
  /**
15
12
  * Report the violation, then throw it in a dev build. Both surfaces raise it here; the
16
13
  * guards themselves stay separate, since the preview snapshots its props once and the
@@ -0,0 +1 @@
1
+ export {};
@@ -7,7 +7,7 @@
7
7
  imperatively (e.g. after `session.apply` elsewhere).
8
8
  -->
9
9
  <script lang="ts">
10
- import { onMount } from 'svelte';
10
+ import { untrack } from 'svelte';
11
11
  import { createPreview, type PreviewController } from './controller.js';
12
12
  import { guardRebind } from '../core/rebind.svelte.js';
13
13
  import type { PreviewStringsInput } from './strings.js';
@@ -16,9 +16,9 @@
16
16
  import type { EditorErrorHandler } from '../core/errors.js';
17
17
 
18
18
  /**
19
- * Remount contract. `createPreview` binds once in `onMount`; a later change to any
20
- * prop it closed over (`session`, `margin`, `onPick`, `onError`, `strings`) is not
21
- * observed, and each reports `rebind-ignored` when swapped. Swap
19
+ * Remount contract. `createPreview` binds once, when its container mounts; a later
20
+ * change to any prop it closed over (`session`, `margin`, `onPick`, `onError`,
21
+ * `strings`) is not observed, and each reports `rebind-ignored` when swapped. Swap
22
22
  * the session by remounting (`{#key session}`, as the playground does); drive
23
23
  * in-place edits through the `refresh(change)` method, not a prop change.
24
24
  *
@@ -44,7 +44,6 @@
44
44
 
45
45
  let { session, margin, onPick, onError, strings, class: className, style }: Props = $props();
46
46
 
47
- let containerEl: HTMLDivElement | undefined = $state();
48
47
  let controller: PreviewController | undefined;
49
48
 
50
49
  guardRebind(
@@ -52,20 +51,16 @@
52
51
  'Remount the preview ({#key session}) to rebind.'
53
52
  );
54
53
 
55
- onMount(() => {
56
- if (!containerEl) return;
57
- controller = createPreview(session, {
58
- container: containerEl,
59
- margin,
60
- onPick,
61
- onError,
62
- strings
54
+ function mountPreview(container: HTMLDivElement): () => void {
55
+ return untrack(() => {
56
+ const mounted = createPreview(session, { container, margin, onPick, onError, strings });
57
+ controller = mounted;
58
+ return () => {
59
+ mounted.destroy();
60
+ controller = undefined;
61
+ };
63
62
  });
64
- return () => {
65
- controller?.destroy();
66
- controller = undefined;
67
- };
68
- });
63
+ }
69
64
 
70
65
  export function refresh(change: ChangeSet): void {
71
66
  controller?.refresh(change);
@@ -88,7 +83,7 @@
88
83
  }
89
84
  </script>
90
85
 
91
- <div bind:this={containerEl} class="qm-preview {className ?? ''}" {style} data-qm-root></div>
86
+ <div {@attach mountPreview} class="qm-preview {className ?? ''}" {style} data-qm-root></div>
92
87
 
93
88
  <style>
94
89
  /* A detached root: the preview is not a descendant of the editor, so it carries
@@ -3,9 +3,9 @@ import type { LiveSession, ChangeSet } from '@quillmark/wasm';
3
3
  import type { DocPath, Landing, Place } from '../core/address.js';
4
4
  import type { EditorErrorHandler } from '../core/errors.js';
5
5
  /**
6
- * Remount contract. `createPreview` binds once in `onMount`; a later change to any
7
- * prop it closed over (`session`, `margin`, `onPick`, `onError`, `strings`) is not
8
- * observed, and each reports `rebind-ignored` when swapped. Swap
6
+ * Remount contract. `createPreview` binds once, when its container mounts; a later
7
+ * change to any prop it closed over (`session`, `margin`, `onPick`, `onError`,
8
+ * `strings`) is not observed, and each reports `rebind-ignored` when swapped. Swap
9
9
  * the session by remounting (`{#key session}`, as the playground does); drive
10
10
  * in-place edits through the `refresh(change)` method, not a prop change.
11
11
  *
@@ -1,4 +1,3 @@
1
- import '../core/theme.css';
2
1
  export { createPreview } from './controller.js';
3
2
  export type { PreviewOptions, PreviewController } from './controller.js';
4
3
  export { default as Preview } from './Preview.svelte';
@@ -12,12 +12,12 @@
12
12
  neither a screen reader nor a touch user, and the count is the carrier.
13
13
 
14
14
  An `object` element collapses: the row is its own summary — a box, titled by the
15
- row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField},
16
- one at a time. Stacking the subforms instead would nest a field one level past the
17
- depth the subform's own vertical draws, once per row.
18
- Opening is therefore part of a landing rather than something the user does first:
15
+ row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField}.
16
+ Each row opens and closes on its own: a press on a summary toggles that row, and
17
+ opening one leaves every other where it stands, so a landing never folds the row the
18
+ author was in. Opening is part of a landing rather than something the user does first:
19
19
  `focusPath` opens the row it is aimed at before it focuses, and hands the rest of the
20
- walk to the subform it opened, which is how a row two closed boxes down is reached.
20
+ walk to that row's subform, which is how a row two closed boxes down is reached.
21
21
 
22
22
  The table (`layout: 'table'`, VISUAL_EDITOR §"Structure mirrors the schema") is the
23
23
  same row machine in another presentation: the same ids, the same splices, the same
@@ -54,6 +54,7 @@
54
54
  Removing the last row commits `[]`, the empty answer.
55
55
  -->
56
56
  <script lang="ts">
57
+ import { SvelteSet } from 'svelte/reactivity';
57
58
  import { wording } from './strings.js';
58
59
 
59
60
  // The surface's words, ambient from the editor root; the package's English
@@ -76,7 +77,6 @@
76
77
  } from './structure.js';
77
78
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
78
79
  import type { LandingBox } from './leaves.js';
79
- import { holdInView } from './hold.js';
80
80
  import { reorder, reorderArm } from './motion.js';
81
81
  import Icon from './icons/Icon.svelte';
82
82
  import TextField from './TextField.svelte';
@@ -91,7 +91,7 @@
91
91
  const CHEVRON = 16;
92
92
 
93
93
  interface Props {
94
- value: unknown[] | undefined;
94
+ value: unknown;
95
95
  /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
96
96
  fallback?: unknown[];
97
97
  items: QuillFieldSchema | undefined;
@@ -156,11 +156,18 @@
156
156
  const table = $derived(layout === 'table' && control === 'object');
157
157
  const columns = $derived(Object.entries(items?.properties ?? {}));
158
158
  const defaulted = $derived(value == null && (fallback?.length ?? 0) > 0);
159
- const arr = $derived((value ?? fallback ?? []) as unknown[]);
160
- /** The rows' content read: the boundary's, or, while the rows are the default, the
161
- * literal's own leaf at the codec its declared type names. */
159
+ // A bare value is the one-element list the render floor wraps it into (canon
160
+ // `SCHEMAS.md` §"Type coercion"), and the reader answers no element of it.
161
+ const bare = $derived(value != null && !Array.isArray(value));
162
+ const arr = $derived(rowsOf(value));
163
+ function rowsOf(v: unknown): unknown[] {
164
+ if (v == null) return fallback ?? [];
165
+ return Array.isArray(v) ? v : [v];
166
+ }
167
+ /** The rows' content read: the boundary's, or, while the rows are the default or a
168
+ * bare value, the literal's own leaf at the codec its declared type names. */
162
169
  function readAt(path: PathStep[]): Content | undefined {
163
- if (!defaulted) return contentAt(path);
170
+ if (!defaulted && !bare) return contentAt(path);
164
171
  const leaf = schemaAt({ type: 'array', items }, path);
165
172
  let v: unknown = arr;
166
173
  for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
@@ -177,7 +184,7 @@
177
184
  // an effect-only seed mounts every element editor in a second render.
178
185
  const seq = new IdSeq();
179
186
  // svelte-ignore state_referenced_locally
180
- let ids = $state<string[]>(seq.take((value ?? fallback ?? []).length));
187
+ let ids = $state<string[]>(seq.take(rowsOf(value).length));
181
188
  // Length reconcile (defend against an out-of-band length change);
182
189
  // order is maintained by the mutators, not here.
183
190
  $effect(() => {
@@ -214,7 +221,7 @@
214
221
  const rowEls: Record<string, HTMLElement | undefined> = $state({});
215
222
  // A table row's cells, on the same key: the subform a landing and the column-keeping
216
223
  // Enter hop reach into. Every table row is open, so there is one per row where the
217
- // list holds one for the open row alone.
224
+ // list holds one per open row.
218
225
  type Subform = {
219
226
  focus: () => void;
220
227
  focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
@@ -226,14 +233,14 @@
226
233
  delete els[id];
227
234
  delete rowEls[id];
228
235
  delete cellEls[id];
236
+ delete objEls[id];
237
+ open.delete(id);
229
238
  }
230
239
 
231
- // ── Object elements: one open at a time ──────────────────────────────────────
232
- // So an array of ten records is ten lines and one figure, whatever its length.
233
- let openId = $state<string | undefined>(undefined);
234
- // The open row's subform, for the landing below. One entry, never a map: only one
235
- // row is open, so the ref is singular by the same rule the state is.
236
- let openObjEl = $state<Subform | undefined>();
240
+ // ── Object elements: each row open on its own ────────────────────────────────
241
+ const open = new SvelteSet<string>();
242
+ // An open row's subform, on the row's id: the landing below walks into it.
243
+ const objEls: Record<string, Subform | undefined> = $state({});
237
244
 
238
245
  /** A collapsed row's own words, or `undefined` while it has none. */
239
246
  function elementTitle(k: number): string | undefined {
@@ -244,12 +251,9 @@
244
251
  function untitled(k: number): string {
245
252
  return label != null ? t.strings.elementUntitled(label, k + 1) : String(k + 1);
246
253
  }
247
- /** The summary is the anchor: a row closing above it is what would carry it off the fold,
248
- * and a row's subform hangs under its own summary (`hold.ts`). */
249
- function toggleRow(id: string, summary: HTMLElement): void {
250
- holdInView(summary, () => {
251
- openId = openId === id ? undefined : id;
252
- });
254
+ function toggleRow(id: string): void {
255
+ if (open.has(id)) open.delete(id);
256
+ else open.add(id);
253
257
  }
254
258
  const rowName = (k: number): string | undefined =>
255
259
  label != null ? `${label} ${k + 1}` : undefined;
@@ -300,7 +304,7 @@
300
304
  onCommit(next);
301
305
  // A row added is a row to fill in, so an object element arrives open: landing on
302
306
  // a collapsed empty summary would make adding one a two-press gesture.
303
- if (control === 'object' && !table) openId = id;
307
+ if (control === 'object' && !table) open.add(id);
304
308
  void focusAfterFlush(id, column);
305
309
  }
306
310
  function add(): void {
@@ -319,9 +323,6 @@
319
323
  const next = ids.filter((_, i) => i !== k);
320
324
  ids = next;
321
325
  drop(dropped);
322
- // The open row can be the one removed; `openId` is cleared with it rather than
323
- // left naming an element that has gone.
324
- if (openId === dropped) openId = undefined;
325
326
  onCommit(arr.filter((_, i) => i !== k));
326
327
  void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
327
328
  }
@@ -370,7 +371,8 @@
370
371
  /** An object row's landing: inside the subform when that row is the open one, on
371
372
  * the row's own summary otherwise — a collapsed row's control is its summary. */
372
373
  function focusObjectRow(id: string): void {
373
- if (id === openId && openObjEl) return openObjEl.focus();
374
+ const sub = objEls[id];
375
+ if (open.has(id) && sub) return sub.focus();
374
376
  rowEls[id]?.querySelector<HTMLElement>('.qm-element-summary')?.focus();
375
377
  }
376
378
  /** The box an arrival wash blooms in (`leaves.ts`, `core/bloom.ts`): the elements,
@@ -413,10 +415,11 @@
413
415
  return rowEls[id];
414
416
  }
415
417
  if (control === 'object') {
416
- openId = id;
418
+ open.add(id);
417
419
  return (async () => {
418
420
  if (!(await span.resumes(tick()))) return undefined;
419
- if (rest.length && openObjEl) return (await openObjEl.focusPath(rest, pos)) ?? rowEls[id];
421
+ const sub = objEls[id];
422
+ if (rest.length && sub) return (await sub.focusPath(rest, pos)) ?? rowEls[id];
420
423
  focusObjectRow(id);
421
424
  return rowEls[id];
422
425
  })();
@@ -539,8 +542,7 @@
539
542
  </script>
540
543
 
541
544
  <div
542
- class="qm-array"
543
- class:empty={ids.length === 0}
545
+ class={['qm-array', { empty: ids.length === 0 }]}
544
546
  role="group"
545
547
  aria-labelledby={label != null ? labelId : undefined}
546
548
  aria-describedby={description ? descriptionId : undefined}
@@ -579,8 +581,7 @@
579
581
  each block's one child: a branch inside the block would stand between them. -->
580
582
  {#if table}
581
583
  <div
582
- class="qm-array-rows qm-array-table"
583
- class:empty={ids.length === 0}
584
+ class={['qm-array-rows qm-array-table', { empty: ids.length === 0 }]}
584
585
  data-default={defaulted ? '' : undefined}
585
586
  bind:this={rowsEl}
586
587
  >
@@ -592,9 +593,10 @@
592
593
  <div class="qm-array-table-head">
593
594
  {#each columns as [key, sub] (key)}
594
595
  <div
595
- class="qm-array-table-col"
596
- class:mark={controlKind(sub) === 'boolean'}
597
- class:prose={controlKind(sub) === 'prose'}
596
+ class={[
597
+ 'qm-array-table-col',
598
+ { mark: controlKind(sub) === 'boolean', prose: controlKind(sub) === 'prose' }
599
+ ]}
598
600
  >
599
601
  <FieldLabel
600
602
  label={columnTitle(key, sub)}
@@ -625,8 +627,7 @@
625
627
  </div>
626
628
  {:else if control === 'object'}
627
629
  <div
628
- class="qm-array-rows"
629
- class:empty={ids.length === 0}
630
+ class={['qm-array-rows', { empty: ids.length === 0 }]}
630
631
  data-default={defaulted ? '' : undefined}
631
632
  bind:this={rowsEl}
632
633
  >
@@ -635,8 +636,7 @@
635
636
  <!-- The handler catches a key from the controls inside the row and adds no
636
637
  interaction of the row's own: the row is no tab stop. -->
637
638
  <div
638
- class="qm-array-row qm-element"
639
- class:open={openId === id}
639
+ class={['qm-array-row qm-element', { open: open.has(id) }]}
640
640
  bind:this={rowEls[id]}
641
641
  animate:reorder={arm.armed}
642
642
  onkeydown={(e) => onRowKey(e, k)}
@@ -649,8 +649,8 @@
649
649
  <button
650
650
  type="button"
651
651
  class="qm-control-box qm-focus-ring qm-element-summary"
652
- aria-expanded={openId === id}
653
- onclick={(e) => toggleRow(id, e.currentTarget)}
652
+ aria-expanded={open.has(id)}
653
+ onclick={() => toggleRow(id)}
654
654
  >
655
655
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
656
656
  screen, where this unfolds in place. Same glyph, same rotation and
@@ -666,9 +666,9 @@
666
666
  {@render rowActions(k)}
667
667
  </div>
668
668
  </div>
669
- {#if openId === id}
669
+ {#if open.has(id)}
670
670
  <ObjectField
671
- bind:this={openObjEl}
671
+ bind:this={objEls[id]}
672
672
  value={(arr[k] ?? {}) as Record<string, unknown>}
673
673
  properties={items?.properties}
674
674
  label={rowName(k)}
@@ -687,8 +687,7 @@
687
687
  </div>
688
688
  {:else}
689
689
  <div
690
- class="qm-array-rows"
691
- class:empty={ids.length === 0}
690
+ class={['qm-array-rows', { empty: ids.length === 0 }]}
692
691
  data-default={defaulted ? '' : undefined}
693
692
  bind:this={rowsEl}
694
693
  >
@@ -2,9 +2,8 @@ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import { type ArrayLayout } from './structure.js';
3
3
  import { type DeepDiagnostic } from './diagnostics.js';
4
4
  import type { LandingBox } from './leaves.js';
5
- import './controls.css';
6
5
  interface Props {
7
- value: unknown[] | undefined;
6
+ value: unknown;
8
7
  /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
9
8
  fallback?: unknown[];
10
9
  items: QuillFieldSchema | undefined;
@@ -13,7 +13,6 @@
13
13
  -->
14
14
  <script lang="ts">
15
15
  import { Switch } from 'bits-ui';
16
- import { syncedLocal } from './synced.svelte.js';
17
16
  import './controls.css';
18
17
 
19
18
  interface Props {
@@ -36,18 +35,14 @@
36
35
  }
37
36
  let { value, fallback, optional = false, label, id, describedBy, onCommit }: Props = $props();
38
37
 
39
- // Local toggle state synced to `value`; own-toggles stay local, only an external
40
- // change reconciles back in (see `syncedLocal`). The primitive is driven
41
- // controlled (`checked` + `onCheckedChange`, never `bind:`) so reconciliation
42
- // stays the package's: a two-way bind hands the primitive a lane around it,
43
- // which repeats the reconciliation hazard in miniature. `undefined` is the unset
44
- // optional cell, the one state with no default to stand in for it.
45
- const local = syncedLocal<boolean | undefined>(
46
- () => value ?? fallback ?? (optional ? undefined : false)
47
- );
48
- const unset = $derived(local.value === undefined);
38
+ // The primitive is driven controlled (`checked` + `onCheckedChange`, never `bind:`)
39
+ // so reconciliation stays the package's: a two-way bind hands the primitive a lane
40
+ // around it, which repeats the reconciliation hazard in miniature. `undefined` is
41
+ // the unset optional cell, the one state with no default to stand in for it.
42
+ let local = $derived<boolean | undefined>(value ?? fallback ?? (optional ? undefined : false));
43
+ const unset = $derived(local === undefined);
49
44
  const tristate = $derived(
50
- optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local.value } : {}
45
+ optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local } : {}
51
46
  );
52
47
  </script>
53
48
 
@@ -57,13 +52,13 @@
57
52
  <span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
58
53
  <Switch.Root
59
54
  class="qm-toggle qm-focus-ring qm-tap-floor"
60
- checked={!!local.value}
55
+ checked={!!local}
61
56
  {id}
62
57
  aria-label={id ? undefined : label}
63
58
  aria-describedby={describedBy}
64
59
  onCheckedChange={(v) => {
65
60
  // From unset the primitive reads the unchecked face, so its toggle is `true`.
66
- local.value = v;
61
+ local = v;
67
62
  onCommit(v);
68
63
  }}
69
64
  >
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: boolean | undefined;
4
3
  fallback?: boolean;
@@ -9,7 +9,6 @@
9
9
  // Ambient from the editor root, with the package's English off-tree, so this
10
10
  // component renders standalone too.
11
11
  const t = wording();
12
- import { untrack } from 'svelte';
13
12
  import Icon from './icons/Icon.svelte';
14
13
  import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
15
14
  import type { EditorErrorHandler } from '../core/errors.js';
@@ -73,15 +72,7 @@
73
72
  leaves
74
73
  }: Props = $props();
75
74
 
76
- // Local title reconcile (external change only), like the scalar controls.
77
- // svelte-ignore state_referenced_locally
78
- let localTitle = $state(card.titleOverride);
79
- $effect(() => {
80
- const incoming = card.titleOverride;
81
- untrack(() => {
82
- if (incoming !== localTitle) localTitle = incoming;
83
- });
84
- });
75
+ let localTitle = $derived(card.titleOverride);
85
76
 
86
77
  // The pre-edit value Escape rolls back to; the rename itself stays live on input.
87
78
  let titleAtFocus = '';
@@ -209,10 +200,8 @@
209
200
  ancestor and nothing else marks the box its guidance belongs in. -->
210
201
  <section
211
202
  bind:this={el}
212
- class="qm-card"
203
+ class={['qm-card', { 'qm-main': card.isMain, 'qm-unschemable': card.unschemable }]}
213
204
  data-qm-card
214
- class:qm-main={card.isMain}
215
- class:qm-unschemable={card.unschemable}
216
205
  >
217
206
  {#if card.unschemable}
218
207
  {@render recoveryShell()}
@@ -253,9 +242,7 @@
253
242
  <div class="qm-card-body">
254
243
  {#if hasMeta}
255
244
  <div
256
- class="qm-card-meta"
257
- class:qm-meta-top={!card.isMain}
258
- class:qm-meta-bottom={card.hasBody}
245
+ class={['qm-card-meta', { 'qm-meta-top': !card.isMain, 'qm-meta-bottom': card.hasBody }]}
259
246
  >
260
247
  {#each ungrouped as section (section.group ?? '_ungrouped')}
261
248
  <div class="qm-section">
@@ -266,14 +253,14 @@
266
253
  <!-- One open at a time. Rendered only when a card has groups, so an empty
267
254
  accordion does not stand a gap under the ungrouped fields. -->
268
255
  {#if grouped.length}
269
- <div class="qm-groups" class:qm-instant={!animate}>
256
+ <div class={['qm-groups', { 'qm-instant': !animate }]}>
270
257
  {#each grouped as section (section.group)}
271
258
  {@const group = section.group as string}
272
259
  {@const isOpen = expanded === group}
273
260
  {@const panelId = ops.panelId(group)}
274
261
  {@const headIds = section.head ? ops.domIds(section.head.name) : undefined}
275
262
  {@const headHint = section.head?.description}
276
- <div class="qm-group" class:qm-open={isOpen}>
263
+ <div class={['qm-group', { 'qm-open': isOpen }]}>
277
264
  <!-- A flat field alone in its section is named by the header, which
278
265
  takes its label id and parks its description: the field draws no
279
266
  label of its own to say the section's name twice. -->
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  isFirst: boolean;
4
3
  isLast: boolean;
@@ -25,9 +25,9 @@
25
25
  values are data, not input: `parseDate` throws on anything malformed, so a bad
26
26
  string degrades to an empty field rather than taking the editor down with it.
27
27
 
28
- `syncedLocal` reconciles by identity, so the local must hold the string, not the
29
- parsed value: a fresh `CalendarDate` is never `===` the last one, which would
30
- make every reconcile fire and re-render all seven segments on each commit.
28
+ The local is a `$derived`, which compares by identity, so it holds the string, not
29
+ the parsed value: a fresh `CalendarDate` is never `===` the last one, so the
30
+ re-derivation each commit causes would re-render all seven segments.
31
31
 
32
32
  The ghost is the default's digits, not a format hint. An unset field
33
33
  carrying a `default:` prints the default's digits in the segments at the default
@@ -51,7 +51,6 @@
51
51
  import { getLocalTimeZone, parseDate, today, type DateValue } from '@internationalized/date';
52
52
  import Icon from './icons/Icon.svelte';
53
53
  import { wording } from './strings.js';
54
- import { syncedLocal } from './synced.svelte.js';
55
54
  import './controls.css';
56
55
 
57
56
  const t = wording();
@@ -109,31 +108,29 @@
109
108
 
110
109
  const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
111
110
  /** The default is the primitive's value: from entering an unset field until an
112
- * edit, a cleared segment or leaving. Part of the projection, so a reconcile
111
+ * edit, a cleared segment or leaving. Part of the projection, so a re-derivation
113
112
  * neither drops a seat nor restores one over the segments a clear left. */
114
113
  let seat = $state(false);
115
- // Local value synced to `value` as a string (see the identity note above);
116
- // own-edits stay local, only an external change reconciles back in. Driven
117
- // controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
118
- // the package's.
119
- const local = syncedLocal(
120
- () => value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
114
+ // Driven controlled (`value` + `onValueChange`, never `bind:`) so reconciliation
115
+ // stays the package's.
116
+ let local = $derived(
117
+ value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
121
118
  );
122
- const parsed = $derived(toDateValue(local.value));
119
+ const parsed = $derived(toDateValue(local));
123
120
  // The date the empty segments ghost, or undefined when there is nothing to ghost:
124
121
  // the field is unset and the default has a date form. A non-blank local that
125
122
  // fails to parse is AUTHORED-but-malformed, which the empty field states
126
123
  // honestly: the ghost would claim it unset. Held as the parsed value rather than
127
124
  // a boolean so the substitution below narrows on the one fact it needs.
128
- const ghost = $derived(local.value === '' ? fallbackDate : undefined);
129
- const seated = $derived(value == null && seat && local.value !== '');
125
+ const ghost = $derived(local === '' ? fallbackDate : undefined);
126
+ const seated = $derived(value == null && seat && local !== '');
130
127
  /** Who put `today` here, while the field follows the render date. */
131
128
  const follows = $derived(
132
- local.value === TODAY ? 'authored' : value == null && fallback === TODAY ? 'default' : undefined
129
+ local === TODAY ? 'authored' : value == null && fallback === TODAY ? 'default' : undefined
133
130
  );
134
131
 
135
132
  function write(next: string): void {
136
- local.value = next;
133
+ local = next;
137
134
  seat = false;
138
135
  onCommit(next);
139
136
  }
@@ -207,7 +204,7 @@
207
204
  // seated default or `today` would write it.
208
205
  const next = d?.toString() ?? '';
209
206
  if (next === (parsed?.toString() ?? '')) return;
210
- local.value = next;
207
+ local = next;
211
208
  if (!d) seat = false;
212
209
  onCommit(d?.toString());
213
210
  }}
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: string | undefined;
4
3
  /** The declared `default:` (`YYYY-MM-DD` or `today`): parsed for display only,