@quillmark/svelte 0.17.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 (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 +35 -43
  9. package/dist/visual/ArrayField.svelte.d.ts +0 -1
  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 +7 -8
  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 +8 -12
  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';
@@ -214,7 +214,7 @@
214
214
  const rowEls: Record<string, HTMLElement | undefined> = $state({});
215
215
  // A table row's cells, on the same key: the subform a landing and the column-keeping
216
216
  // 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.
217
+ // list holds one per open row.
218
218
  type Subform = {
219
219
  focus: () => void;
220
220
  focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
@@ -226,14 +226,14 @@
226
226
  delete els[id];
227
227
  delete rowEls[id];
228
228
  delete cellEls[id];
229
+ delete objEls[id];
230
+ open.delete(id);
229
231
  }
230
232
 
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>();
233
+ // ── Object elements: each row open on its own ────────────────────────────────
234
+ const open = new SvelteSet<string>();
235
+ // An open row's subform, on the row's id: the landing below walks into it.
236
+ const objEls: Record<string, Subform | undefined> = $state({});
237
237
 
238
238
  /** A collapsed row's own words, or `undefined` while it has none. */
239
239
  function elementTitle(k: number): string | undefined {
@@ -244,12 +244,9 @@
244
244
  function untitled(k: number): string {
245
245
  return label != null ? t.strings.elementUntitled(label, k + 1) : String(k + 1);
246
246
  }
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
- });
247
+ function toggleRow(id: string): void {
248
+ if (open.has(id)) open.delete(id);
249
+ else open.add(id);
253
250
  }
254
251
  const rowName = (k: number): string | undefined =>
255
252
  label != null ? `${label} ${k + 1}` : undefined;
@@ -300,7 +297,7 @@
300
297
  onCommit(next);
301
298
  // A row added is a row to fill in, so an object element arrives open: landing on
302
299
  // a collapsed empty summary would make adding one a two-press gesture.
303
- if (control === 'object' && !table) openId = id;
300
+ if (control === 'object' && !table) open.add(id);
304
301
  void focusAfterFlush(id, column);
305
302
  }
306
303
  function add(): void {
@@ -319,9 +316,6 @@
319
316
  const next = ids.filter((_, i) => i !== k);
320
317
  ids = next;
321
318
  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
319
  onCommit(arr.filter((_, i) => i !== k));
326
320
  void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
327
321
  }
@@ -370,7 +364,8 @@
370
364
  /** An object row's landing: inside the subform when that row is the open one, on
371
365
  * the row's own summary otherwise — a collapsed row's control is its summary. */
372
366
  function focusObjectRow(id: string): void {
373
- if (id === openId && openObjEl) return openObjEl.focus();
367
+ const sub = objEls[id];
368
+ if (open.has(id) && sub) return sub.focus();
374
369
  rowEls[id]?.querySelector<HTMLElement>('.qm-element-summary')?.focus();
375
370
  }
376
371
  /** The box an arrival wash blooms in (`leaves.ts`, `core/bloom.ts`): the elements,
@@ -413,10 +408,11 @@
413
408
  return rowEls[id];
414
409
  }
415
410
  if (control === 'object') {
416
- openId = id;
411
+ open.add(id);
417
412
  return (async () => {
418
413
  if (!(await span.resumes(tick()))) return undefined;
419
- if (rest.length && openObjEl) return (await openObjEl.focusPath(rest, pos)) ?? rowEls[id];
414
+ const sub = objEls[id];
415
+ if (rest.length && sub) return (await sub.focusPath(rest, pos)) ?? rowEls[id];
420
416
  focusObjectRow(id);
421
417
  return rowEls[id];
422
418
  })();
@@ -539,8 +535,7 @@
539
535
  </script>
540
536
 
541
537
  <div
542
- class="qm-array"
543
- class:empty={ids.length === 0}
538
+ class={['qm-array', { empty: ids.length === 0 }]}
544
539
  role="group"
545
540
  aria-labelledby={label != null ? labelId : undefined}
546
541
  aria-describedby={description ? descriptionId : undefined}
@@ -579,8 +574,7 @@
579
574
  each block's one child: a branch inside the block would stand between them. -->
580
575
  {#if table}
581
576
  <div
582
- class="qm-array-rows qm-array-table"
583
- class:empty={ids.length === 0}
577
+ class={['qm-array-rows qm-array-table', { empty: ids.length === 0 }]}
584
578
  data-default={defaulted ? '' : undefined}
585
579
  bind:this={rowsEl}
586
580
  >
@@ -592,9 +586,10 @@
592
586
  <div class="qm-array-table-head">
593
587
  {#each columns as [key, sub] (key)}
594
588
  <div
595
- class="qm-array-table-col"
596
- class:mark={controlKind(sub) === 'boolean'}
597
- class:prose={controlKind(sub) === 'prose'}
589
+ class={[
590
+ 'qm-array-table-col',
591
+ { mark: controlKind(sub) === 'boolean', prose: controlKind(sub) === 'prose' }
592
+ ]}
598
593
  >
599
594
  <FieldLabel
600
595
  label={columnTitle(key, sub)}
@@ -625,8 +620,7 @@
625
620
  </div>
626
621
  {:else if control === 'object'}
627
622
  <div
628
- class="qm-array-rows"
629
- class:empty={ids.length === 0}
623
+ class={['qm-array-rows', { empty: ids.length === 0 }]}
630
624
  data-default={defaulted ? '' : undefined}
631
625
  bind:this={rowsEl}
632
626
  >
@@ -635,8 +629,7 @@
635
629
  <!-- The handler catches a key from the controls inside the row and adds no
636
630
  interaction of the row's own: the row is no tab stop. -->
637
631
  <div
638
- class="qm-array-row qm-element"
639
- class:open={openId === id}
632
+ class={['qm-array-row qm-element', { open: open.has(id) }]}
640
633
  bind:this={rowEls[id]}
641
634
  animate:reorder={arm.armed}
642
635
  onkeydown={(e) => onRowKey(e, k)}
@@ -649,8 +642,8 @@
649
642
  <button
650
643
  type="button"
651
644
  class="qm-control-box qm-focus-ring qm-element-summary"
652
- aria-expanded={openId === id}
653
- onclick={(e) => toggleRow(id, e.currentTarget)}
645
+ aria-expanded={open.has(id)}
646
+ onclick={() => toggleRow(id)}
654
647
  >
655
648
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
656
649
  screen, where this unfolds in place. Same glyph, same rotation and
@@ -666,9 +659,9 @@
666
659
  {@render rowActions(k)}
667
660
  </div>
668
661
  </div>
669
- {#if openId === id}
662
+ {#if open.has(id)}
670
663
  <ObjectField
671
- bind:this={openObjEl}
664
+ bind:this={objEls[id]}
672
665
  value={(arr[k] ?? {}) as Record<string, unknown>}
673
666
  properties={items?.properties}
674
667
  label={rowName(k)}
@@ -687,8 +680,7 @@
687
680
  </div>
688
681
  {:else}
689
682
  <div
690
- class="qm-array-rows"
691
- class:empty={ids.length === 0}
683
+ class={['qm-array-rows', { empty: ids.length === 0 }]}
692
684
  data-default={defaulted ? '' : undefined}
693
685
  bind:this={rowsEl}
694
686
  >
@@ -2,7 +2,6 @@ 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
6
  value: unknown[] | undefined;
8
7
  /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
@@ -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,
@@ -15,7 +15,6 @@
15
15
  <script lang="ts">
16
16
  import { Select } from 'bits-ui';
17
17
  import Icon from './icons/Icon.svelte';
18
- import { syncedLocal } from './synced.svelte.js';
19
18
  import { wording } from './strings.js';
20
19
  import './controls.css';
21
20
 
@@ -67,23 +66,21 @@
67
66
  // the like), so it never collides with a real option.
68
67
  const UNSET = '__qm_unset__';
69
68
 
70
- // Local selection synced to `value` (sentinel when unauthored); own-picks stay
71
- // local, only an external change reconciles back in (see `syncedLocal`). Driven
72
- // controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
73
- // the package's rather than the primitive's.
74
- const local = syncedLocal(() => value ?? UNSET);
69
+ // Driven controlled (`value` + `onValueChange`, never `bind:`) so reconciliation
70
+ // stays the package's rather than the primitive's.
71
+ let local = $derived(value ?? UNSET);
75
72
 
76
73
  /** The blank has no glyph of its own: the quill's `ui.blank_title` stands in for
77
74
  * it, else an em dash. */
78
75
  const dash = (v: string | undefined) => v || blankTitle || '—';
79
76
 
80
- const unset = $derived(local.value === UNSET);
77
+ const unset = $derived(local === UNSET);
81
78
  /** The ghost is a member the render prints, rather than a word for the blank or for
82
79
  * `none`: it draws at the default rung (theme.css). */
83
80
  const ghostsMember = $derived(!optional && !!fallback);
84
81
  const ghostText = $derived(optional ? t.strings.optionalGhost : dash(fallback));
85
82
  /** What the closed trigger shows: the pick, or the ghosted default while unset. */
86
- const shown = $derived(unset ? ghostText : dash(local.value));
83
+ const shown = $derived(unset ? ghostText : dash(local));
87
84
 
88
85
  /** The root to portal into: `document.body` would escape the consumer's dials
89
86
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
@@ -99,10 +96,10 @@
99
96
  <Select.Root
100
97
  type="single"
101
98
  allowDeselect={false}
102
- value={local.value}
99
+ value={local}
103
100
  onValueChange={(v) => {
104
101
  if (v == null) return;
105
- local.value = v;
102
+ local = v;
106
103
  // Sentinel → unset (parent `removeField`, default renders); any real pick
107
104
  // (incl. the default value) → a genuine write.
108
105
  onCommit(v === UNSET ? undefined : v);
@@ -152,12 +149,12 @@
152
149
  policy when it is the one selected: a listbox whose selected value
153
150
  has no row shows nothing for what the document says, and offers none
154
151
  for the primitive to mark, type-ahead to, or key onto
155
- (VISUAL_EDITOR §"Enum policy"). Off `local.value`, not `value`: the
152
+ (VISUAL_EDITOR §"Enum policy"). Off `local`, not `value`: the
156
153
  row that exists is the row the control has selected, through the
157
154
  window where an own pick has not reconciled back. -->
158
155
  {#each values as v (v)}
159
156
  {@const allowed = optionAllowed?.(v) !== false}
160
- {#if allowed || enumDisallowed === 'disable' || v === local.value}
157
+ {#if allowed || enumDisallowed === 'disable' || v === local}
161
158
  <Select.Item
162
159
  class="qm-menu-item qm-select-item"
163
160
  value={v}
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: string | undefined;
4
3
  values: string[];