@quillmark/svelte 0.20.1 → 0.21.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 (46) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/atoms.js +25 -10
  3. package/dist/core/codec/blocks.d.ts +5 -5
  4. package/dist/core/codec/blocks.js +23 -6
  5. package/dist/core/codec/code.d.ts +3 -3
  6. package/dist/core/codec/code.js +51 -21
  7. package/dist/core/codec/field.d.ts +9 -7
  8. package/dist/core/codec/field.js +22 -11
  9. package/dist/core/codec/inputrules.d.ts +4 -0
  10. package/dist/core/codec/inputrules.js +43 -5
  11. package/dist/core/codec/link-paste.d.ts +11 -0
  12. package/dist/core/codec/link-paste.js +32 -0
  13. package/dist/core/codec/lists.d.ts +2 -2
  14. package/dist/core/codec/lists.js +66 -26
  15. package/dist/core/codec/prose.css +71 -43
  16. package/dist/core/codec/schema.js +55 -11
  17. package/dist/core/codec/slash.js +0 -0
  18. package/dist/core/codec/table-view.d.ts +5 -0
  19. package/dist/core/codec/table-view.js +232 -103
  20. package/dist/core/rungs.d.ts +6 -0
  21. package/dist/core/rungs.js +11 -0
  22. package/dist/core/theme.css +24 -12
  23. package/dist/visual/ArrayField.svelte +168 -48
  24. package/dist/visual/BooleanField.svelte +12 -1
  25. package/dist/visual/Card.svelte +32 -20
  26. package/dist/visual/Card.svelte.d.ts +1 -0
  27. package/dist/visual/CardControls.svelte +15 -15
  28. package/dist/visual/DateField.svelte +93 -64
  29. package/dist/visual/EnumField.svelte +9 -4
  30. package/dist/visual/FieldHint.svelte +22 -5
  31. package/dist/visual/FieldLabel.svelte +3 -3
  32. package/dist/visual/FormatPopover.svelte +25 -9
  33. package/dist/visual/MatrixField.svelte +10 -7
  34. package/dist/visual/ObjectField.svelte +2 -4
  35. package/dist/visual/TipsCard.svelte +2 -5
  36. package/dist/visual/VisualEditorInner.svelte +108 -26
  37. package/dist/visual/controls.css +2 -1
  38. package/dist/visual/icons/Icon.svelte +3 -2
  39. package/dist/visual/icons/Icon.svelte.d.ts +3 -2
  40. package/dist/visual/icons/nodes.d.ts +25 -0
  41. package/dist/visual/icons/nodes.js +8 -0
  42. package/dist/visual/motion.d.ts +25 -3
  43. package/dist/visual/motion.js +58 -9
  44. package/dist/visual/strings.d.ts +6 -5
  45. package/dist/visual/strings.js +3 -3
  46. package/package.json +2 -2
@@ -33,6 +33,7 @@
33
33
  import { errorMessage, reportError } from '../core/errors.js';
34
34
  import { bloomInside } from '../core/bloom.js';
35
35
  import { createLifespan } from '../core/teardown.js';
36
+ import { spacePx } from '../core/rungs.js';
36
37
  import type {
37
38
  Addr,
38
39
  CardAddr,
@@ -80,11 +81,12 @@
80
81
  } from './diagnostics.js';
81
82
  import { fieldDomIds, groupPanelId } from './domid.js';
82
83
  import { createLeafRegistry, type ActiveProse, type FieldControl } from './leaves.js';
83
- import { reorder, reorderArm, reorderTrips } from './motion.js';
84
+ import { reorder, reorderArm, reorderTrips, scrollSettled, slot } from './motion.js';
84
85
  import { tipsChannel } from './tips.js';
85
86
  import { patchEditorExt } from './ext.js';
86
87
  import { raiseGuidance } from './guidance.js';
87
88
  import Card from './Card.svelte';
89
+ import Icon from './icons/Icon.svelte';
88
90
  import TipsCard from './TipsCard.svelte';
89
91
  import FormatPopover from './FormatPopover.svelte';
90
92
  // The add trigger and the kind menu draw shared recipes; a component carrying a
@@ -194,6 +196,7 @@
194
196
  type CardHandle = {
195
197
  revealLeaf(key: string): void;
196
198
  scrollIntoViewCard(block: ScrollLogicalPosition): void;
199
+ focusHeader(): void;
197
200
  };
198
201
  let mainCard = $state<CardHandle | undefined>(undefined);
199
202
  let cardRefs = $state<(CardHandle | undefined)[]>([]);
@@ -377,10 +380,10 @@
377
380
  * naming one id, on different terms — `center` against `nearest`.
378
381
  */
379
382
  let scrollTicket = 0;
380
- async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
383
+ async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<boolean> {
381
384
  const ticket = ++scrollTicket;
382
- if (!(await span.resumes(tick()))) return;
383
- if (ticket !== scrollTicket) return;
385
+ if (!(await span.resumes(tick()))) return false;
386
+ if (ticket !== scrollTicket) return false;
384
387
  // A reorder is running by this flush and holds the slot at its pre-move position
385
388
  // for the length of the trip (`motion.ts`), so asking now measures the box the
386
389
  // card is leaving — where a `nearest` would take the stack back to for the card
@@ -390,26 +393,33 @@
390
393
  const slot = slotEls[cardIndexOf(id)];
391
394
  const trips = slot ? reorderTrips(slot) : [];
392
395
  if (trips.length) {
393
- if (!(await span.resumes(Promise.allSettled(trips.map((trip) => trip.finished))))) return;
394
- if (ticket !== scrollTicket) return;
396
+ if (!(await span.resumes(Promise.allSettled(trips.map((trip) => trip.finished)))))
397
+ return false;
398
+ if (ticket !== scrollTicket) return false;
395
399
  }
396
400
  const i = cardIndexOf(id);
397
- if (i >= 0) cardRefs[i]?.scrollIntoViewCard(block);
401
+ if (i < 0) return false;
402
+ cardRefs[i]?.scrollIntoViewCard(block);
403
+ return true;
398
404
  }
399
405
  /** Returns the new card's session key, or `undefined` when the kind seeds nothing
400
- * or the insert threw: what `insertCard` hands a host that wants to track it. */
401
- function addCard(atIndex: number, kind: string): CardId | undefined {
406
+ * or the insert threw: what `insertCard` hands a host that wants to track it. The
407
+ * strip's own add `land`s focus in the new card; a host's leaves focus where it is. */
408
+ function addCard(atIndex: number, kind: string, land = false): CardId | undefined {
402
409
  try {
403
410
  const overlay = doc.seedOverlay(kind);
404
411
  const card = quill.seedCard(kind, overlay);
405
412
  if (!card) return undefined;
413
+ const from = document.activeElement;
406
414
  doc.insertCard(card, atIndex);
407
415
  const id = seq.next();
416
+ arm.arm();
408
417
  cardIds = [...cardIds.slice(0, atIndex), id, ...cardIds.slice(atIndex)];
409
418
  bump('structure', id, atIndex);
410
419
  // Centred: the new card is the subject, and the neighbours it landed between
411
420
  // are what say where it went.
412
- void scrollCardIntoView(id, 'center');
421
+ const scrolled = scrollCardIntoView(id, 'center');
422
+ if (land) void landInCard(id, scrolled, from);
413
423
  return id;
414
424
  } catch (e) {
415
425
  reportError(onError, {
@@ -421,8 +431,39 @@
421
431
  return undefined;
422
432
  }
423
433
  }
424
- // The reorder gesture's arming window (`motion.ts`): the reconcile that moves a slot
425
- // is the trip, and every other reconcile that happens to move one is not.
434
+ /**
435
+ * Focus the card the strip just added: its first field, else its body, else its title.
436
+ *
437
+ * After the centring scroll has run rather than beside it: a control's `focus()`
438
+ * takes the browser's own scroll, which cuts a smooth one short wherever it had
439
+ * reached. A focus that went elsewhere while the scroll ran was the user's, and stays.
440
+ */
441
+ async function landInCard(
442
+ id: CardId,
443
+ scrolled: Promise<boolean>,
444
+ from: Element | null
445
+ ): Promise<void> {
446
+ if (!(await scrolled) || !(await span.resumes(scrollSettled()))) return;
447
+ const now = document.activeElement;
448
+ if (now !== from && now !== document.body) return;
449
+ const at = cardIndexOf(id);
450
+ const shown = model.cards[at];
451
+ if (!shown) return;
452
+ const first = (shown.sections.find((s) => s.group == null) ?? shown.sections[0])?.fields[0];
453
+ const path = first || shown.hasBody ? pathFor({ card: at, field: first?.name }) : undefined;
454
+ const found = path && (await revealLeaf(path));
455
+ if (found) await land(found, undefined);
456
+ else cardRefs[cardIndexOf(id)]?.focusHeader();
457
+ }
458
+ // A kind picked off the menu lands focus in the card it added, so the menu's close
459
+ // hands none back to its trigger; an Escape out of the menu still does.
460
+ let picked = false;
461
+ function addPicked(atIndex: number, kind: string): void {
462
+ picked = addCard(atIndex, kind, true) != null;
463
+ }
464
+
465
+ // The list gesture's arming window (`motion.ts`): the reconcile that moves a slot is
466
+ // the trip, and every other reconcile that happens to move one is not.
426
467
  const arm = reorderArm();
427
468
  span.onEnd(arm.cancel);
428
469
 
@@ -445,7 +486,12 @@
445
486
  function removeCardById(id: string): void {
446
487
  const i = cardIndexOf(id);
447
488
  if (i < 0) return;
489
+ // Held visibly, which a pointer's press on delete is not: some engines focus a
490
+ // pressed button, and the hand-on is the keyboard's.
491
+ const focused = document.activeElement;
492
+ const held = !!focused && !!slotEls[i]?.contains(focused) && focused.matches(':focus-visible');
448
493
  doc.removeCard(i);
494
+ arm.arm();
449
495
  cardIds = cardIds.filter((_, k) => k !== i);
450
496
  if (activeCardId === id) {
451
497
  activeCardId = undefined;
@@ -460,6 +506,15 @@
460
506
  // No addr: the removed card has none left and every survivor's shifted. The
461
507
  // stack changed, not a leaf, and the id is the only handle the removal leaves.
462
508
  bump('structure', id);
509
+ if (held) void focusAfterRemove(i);
510
+ }
511
+ /** Hand the focus a removed card held to the title of the card before it, else the
512
+ * one that took its place, else the strip that adds one: a focused node the flush
513
+ * drops leaves the focus on the page's body, and the keyboard's place with it. */
514
+ async function focusAfterRemove(at: number): Promise<void> {
515
+ if (!(await span.resumes(tick()))) return;
516
+ if (cardIds.length) cardRefs[Math.max(at - 1, 0)]?.focusHeader();
517
+ else rootEl?.querySelector<HTMLElement>('.qm-add-btn')?.focus();
463
518
  }
464
519
  function retypeCardById(id: string, kind: string): void {
465
520
  const i = cardIndexOf(id);
@@ -481,7 +536,10 @@
481
536
  });
482
537
  }
483
538
  }
539
+ /** A title's blur can fire as the surface is torn down, against a document that may be
540
+ * freed. */
484
541
  function renameCardById(id: string, title: string): void {
542
+ if (!span.alive) return;
485
543
  const i = cardIndexOf(id);
486
544
  if (i < 0) return;
487
545
  patchEditorExt(doc, { card: i }, { title });
@@ -892,7 +950,7 @@
892
950
  rather than being slid across. It is also the shape `animate:` asks for, being the
893
951
  keyed block's only child. -->
894
952
  {#each model.cards as c, i (c.id)}
895
- <div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={arm.armed}>
953
+ <div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={arm.armed} in:slot out:slot>
896
954
  <Card
897
955
  bind:this={cardRefs[i]}
898
956
  card={titled(c)}
@@ -925,9 +983,10 @@
925
983
  {#snippet addAffordance(atIndex: number)}
926
984
  {#if kinds.length}
927
985
  <!-- The strip past the last card is the append point, the one a reader looks for
928
- rather than finds by position, so it alone states itself in words at rest. The
929
- gaps between cards stay bare: the strip is the gap, and the pill each fills on
930
- hover draws what a label would state, the space the new card takes. -->
986
+ rather than finds by position, so it alone states itself in words at rest. A gap
987
+ between cards carries a plus where the words would be: the strip is the gap, the
988
+ pill it fills on hover is the space the new card takes, and the plus says what a
989
+ press there does. -->
931
990
  {@const marked = atIndex === model.cards.length}
932
991
  <div class={['qm-add-card', { 'qm-add-card-marked': marked }]}>
933
992
  <!-- Marked, the words are the accessible name and no `aria-label` doubles
@@ -938,7 +997,8 @@
938
997
  type="button"
939
998
  class="qm-add-btn qm-add-affordance qm-tap-floor"
940
999
  aria-label={marked ? undefined : merged.addCard}
941
- onclick={() => addCard(atIndex, kinds[0])}>{marked ? merged.addCard : ''}</button
1000
+ onclick={() => addCard(atIndex, kinds[0], true)}
1001
+ >{#if marked}{merged.addCard}{:else}<Icon name="plus" class="qm-add-glyph" />{/if}</button
942
1002
  >
943
1003
  {:else}
944
1004
  <!-- Multi-kind add: pick the kind, then seed + insert. A menu rather than a
@@ -950,10 +1010,20 @@
950
1010
  <DropdownMenu.Trigger
951
1011
  class="qm-add-btn qm-add-affordance qm-tap-floor"
952
1012
  aria-label={marked ? undefined : merged.addCard}
953
- >{marked ? merged.addCard : ''}</DropdownMenu.Trigger
1013
+ >{#if marked}{merged.addCard}{:else}<Icon
1014
+ name="plus"
1015
+ class="qm-add-glyph"
1016
+ />{/if}</DropdownMenu.Trigger
954
1017
  >
955
1018
  <DropdownMenu.Portal to={rootEl}>
956
- <DropdownMenu.Content collisionBoundary={rootEl ?? []} sideOffset={4}>
1019
+ <DropdownMenu.Content
1020
+ collisionBoundary={rootEl ?? []}
1021
+ sideOffset={spacePx(rootEl)}
1022
+ onCloseAutoFocus={(e) => {
1023
+ if (picked) e.preventDefault();
1024
+ picked = false;
1025
+ }}
1026
+ >
957
1027
  <!-- Portalled out of the row but into the stack's root, and carrying the
958
1028
  marker itself: floating is still a detached subtree to the
959
1029
  derivation, like FormatPopover and the enum listbox. The root is the
@@ -961,7 +1031,7 @@
961
1031
  the surface where the consumer scrolls one. -->
962
1032
  <div class="qm-menu-surface" data-qm-root>
963
1033
  {#each kinds as k (k)}
964
- <DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}
1034
+ <DropdownMenu.Item class="qm-menu-item" onSelect={() => addPicked(atIndex, k)}
965
1035
  >{kindTitle(quill.schema.card_kinds?.[k], k)}</DropdownMenu.Item
966
1036
  >
967
1037
  {/each}
@@ -1072,9 +1142,10 @@
1072
1142
  /* Unboxed, like every button, and not dashed: a dashed edge is the placeholder idiom
1073
1143
  ("nothing is here yet"), which on a button reads as disabled or as a drop target. It
1074
1144
  stays honest in one place, the un-schemable card (`Card.svelte`), which is a state
1075
- rather than a control. A bare trigger rests invisible, so the fill is the whole of
1076
- what it shows: the pill fills the strip because the strip is what was pressed, and
1077
- the strip is the gap, so the fill is the card-to-be drawn where it will land.
1145
+ rather than a control. A gap's trigger rests invisible, so the fill and the plus are
1146
+ the whole of what it shows: the pill fills the strip because the strip is what was
1147
+ pressed, and the strip is the gap, so the fill is the card-to-be drawn where it will
1148
+ land.
1078
1149
 
1079
1150
  No inset of its own: the pill and the gap are the same rectangle, and a bare strip is
1080
1151
  a band of gutter, so its height is a rhythm rung. The press floor the box gives up is
@@ -1097,8 +1168,15 @@
1097
1168
  color: var(--_qm-ink-label);
1098
1169
  opacity: 0;
1099
1170
  transition:
1100
- opacity var(--_qm-reveal),
1101
- background-color var(--_qm-reveal);
1171
+ opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
1172
+ background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
1173
+ }
1174
+ /* The pill's own height, so a turned-down `--qm-space` shrinks the glyph with the
1175
+ strip rather than spilling it past the pill. */
1176
+ .qm-add-card :global(.qm-add-glyph) {
1177
+ height: 100%;
1178
+ width: auto;
1179
+ aspect-ratio: 1;
1102
1180
  }
1103
1181
  /* The strip that holds words at rest takes the height that centres them, and rests dim
1104
1182
  rather than absent, having something to be seen. */
@@ -1111,11 +1189,15 @@
1111
1189
  /* One arrival, three ways in. Keyboard focus, because the fill is the only thing that
1112
1190
  reads as the trigger on a bare strip; and an open menu, because it portals out of
1113
1191
  the strip, so a pointer moving onto an item has left the row whose hover was drawing
1114
- the pill the menu hangs from. */
1192
+ the pill the menu hangs from. The arrival takes the long trip and the leave the fast
1193
+ rung, a transition being the state's it ends in. */
1115
1194
  .qm-add-card :global(.qm-add-btn:hover),
1116
1195
  .qm-add-card :global(.qm-add-btn:focus-visible),
1117
1196
  .qm-add-card :global(.qm-add-btn[data-state='open']) {
1118
1197
  opacity: 1;
1119
1198
  background: var(--_qm-add-fill);
1199
+ transition:
1200
+ opacity var(--_qm-reveal),
1201
+ background-color var(--_qm-reveal);
1120
1202
  }
1121
1203
  </style>
@@ -233,7 +233,8 @@
233
233
  :where(.qm-chip:hover) {
234
234
  color: var(--_qm-ink);
235
235
  }
236
- :where(.qm-chip svg) {
236
+ :where(.qm-chip svg),
237
+ :where(.qm-icon-btn svg) {
237
238
  flex-shrink: 0;
238
239
  width: var(--_qm-glyph-control);
239
240
  height: var(--_qm-glyph-control);
@@ -9,8 +9,9 @@
9
9
 
10
10
  interface Props extends Omit<SVGAttributes<SVGSVGElement>, 'name'> {
11
11
  name: IconName;
12
- /** The frame's edge, in px. A chip sizes the glyph from `--_qm-glyph-control`
13
- * in CSS, which beats this attribute; callers that are not chips pass a size. */
12
+ /** The frame's edge, in px. A chip or a glyph button sizes the glyph from
13
+ * `--_qm-glyph-control` in CSS, which beats this attribute; other callers pass
14
+ * a size. */
14
15
  size?: number;
15
16
  }
16
17
  let { name, size = 24, ...rest }: Props = $props();
@@ -2,8 +2,9 @@ import { type IconName } from './nodes.js';
2
2
  import type { SVGAttributes } from 'svelte/elements';
3
3
  interface Props extends Omit<SVGAttributes<SVGSVGElement>, 'name'> {
4
4
  name: IconName;
5
- /** The frame's edge, in px. A chip sizes the glyph from `--_qm-glyph-control`
6
- * in CSS, which beats this attribute; callers that are not chips pass a size. */
5
+ /** The frame's edge, in px. A chip or a glyph button sizes the glyph from
6
+ * `--_qm-glyph-control` in CSS, which beats this attribute; other callers pass
7
+ * a size. */
7
8
  size?: number;
8
9
  }
9
10
  declare const Icon: import("svelte").Component<Props, {}, "">;
@@ -15,6 +15,31 @@ export declare const ICONS: {
15
15
  readonly 'chevron-up': readonly [readonly ["path", {
16
16
  readonly d: "m18 15-6-6-6 6";
17
17
  }]];
18
+ readonly 'grip-vertical': readonly [readonly ["circle", {
19
+ readonly cx: "9";
20
+ readonly cy: "12";
21
+ readonly r: "1";
22
+ }], readonly ["circle", {
23
+ readonly cx: "9";
24
+ readonly cy: "5";
25
+ readonly r: "1";
26
+ }], readonly ["circle", {
27
+ readonly cx: "9";
28
+ readonly cy: "19";
29
+ readonly r: "1";
30
+ }], readonly ["circle", {
31
+ readonly cx: "15";
32
+ readonly cy: "12";
33
+ readonly r: "1";
34
+ }], readonly ["circle", {
35
+ readonly cx: "15";
36
+ readonly cy: "5";
37
+ readonly r: "1";
38
+ }], readonly ["circle", {
39
+ readonly cx: "15";
40
+ readonly cy: "19";
41
+ readonly r: "1";
42
+ }]];
18
43
  readonly bold: readonly [readonly ["path", {
19
44
  readonly d: "M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8";
20
45
  }]];
@@ -14,6 +14,14 @@ export const ICONS = {
14
14
  ],
15
15
  'chevron-down': [['path', { d: 'm6 9 6 6 6-6' }]],
16
16
  'chevron-up': [['path', { d: 'm18 15-6-6-6 6' }]],
17
+ 'grip-vertical': [
18
+ ['circle', { cx: '9', cy: '12', r: '1' }],
19
+ ['circle', { cx: '9', cy: '5', r: '1' }],
20
+ ['circle', { cx: '9', cy: '19', r: '1' }],
21
+ ['circle', { cx: '15', cy: '12', r: '1' }],
22
+ ['circle', { cx: '15', cy: '5', r: '1' }],
23
+ ['circle', { cx: '15', cy: '19', r: '1' }]
24
+ ],
17
25
  bold: [['path', { d: 'M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8' }]],
18
26
  italic: [
19
27
  ['line', { x1: '19', x2: '10', y1: '4', y2: '4' }],
@@ -1,4 +1,5 @@
1
1
  import type { AnimationConfig } from 'svelte/animate';
2
+ import type { TransitionConfig } from 'svelte/transition';
2
3
  /** The trips this module is running on `node`. A run holds the slot at its pre-move
3
4
  * position for its whole length, so anything that measures the node's box waits on
4
5
  * these rather than reading the one a transform is holding. */
@@ -23,9 +24,11 @@ export declare function reorderArm(): ReorderArm;
23
24
  * `armed` is why this is the reorder and not every reconcile: `animate:` fires wherever
24
25
  * a slot's rect moved, and a card growing under the caret moves every slot below it,
25
26
  * which is a layout change with no trip in it and one that would draw those slots over
26
- * the content that displaced them for the length of the run. So the reorder command
27
- * arms the gesture and the frame it lands in disarms it, which is the admission test
28
- * read off the endpoints: two positions in the list, not two positions on the page.
27
+ * the content that displaced them for the length of the run. So a command that moves
28
+ * slots in the list arms the gesture and the frame it lands in disarms it, which is the
29
+ * admission test read off the endpoints: two positions in the list, not two positions on
30
+ * the page. A move is one; so, in the card stack, are an insert and a removal, which move
31
+ * every slot past them by one.
29
32
  *
30
33
  * Both endpoints are rest states, so the curve is `--_qm-ease-reverse` (a second click
31
34
  * lands mid-slide) and the rung is `slow` (the trip moves the stack around it).
@@ -34,3 +37,22 @@ export declare function reorder(node: HTMLElement, { from, to }: {
34
37
  from: DOMRect;
35
38
  to: DOMRect;
36
39
  }, armed: () => boolean): AnimationConfig;
40
+ /**
41
+ * A slot entering or leaving the list fades where it stands, while the slots it displaces
42
+ * take the reorder trip: the stack is what slides, and a card is what appears or goes, under
43
+ * the slots crossing it. Opacity alone, because a leaving slot is held at its place by a
44
+ * transform `animate:` writes, which a keyframed transform would override.
45
+ *
46
+ * The returned duration is what keeps a leaving slot mounted until its fade has run, and
47
+ * the fill what keeps it from painting whole in the frame between the two.
48
+ */
49
+ export declare function slot(node: HTMLElement, _params: unknown, { direction }: {
50
+ direction: 'in' | 'out' | 'both';
51
+ }): TransitionConfig;
52
+ /**
53
+ * Resolves once nothing in the document has scrolled for {@link STILL_FRAMES} frames
54
+ * running. A smooth scroll moves its scroller on every frame until it lands, so stillness
55
+ * is its end on every engine, whether or not it reports one; a scroll with nowhere to go
56
+ * is still from the first frame.
57
+ */
58
+ export declare function scrollSettled(): Promise<void>;
@@ -1,9 +1,9 @@
1
- // The card stack's reorder trip: a slot's position ↔ position in a
2
- // keyed list, the one row of the mechanism table no CSS rule can serve, because the
3
- // two frames belong to different elements. Svelte's `animate:` supplies the halves CSS
4
- // cannot see (the rects either side of the reconciliation) and WAAPI paints the trip,
5
- // the same seam `core/bloom.ts` takes to keep a duration in the derivation while a
6
- // script runs the animation.
1
+ // The card stack's trips: a slot's position ↔ position in a keyed list, the one row of
2
+ // the mechanism table no CSS rule can serve, because the two frames belong to different
3
+ // elements, and a slot entering or leaving the list. Svelte's `animate:` and `in:`/`out:`
4
+ // supply the halves CSS cannot see (the rects either side of the reconciliation, a node
5
+ // the list no longer holds) and WAAPI paints the trip, the same seam `core/bloom.ts` takes
6
+ // to keep a duration in the derivation while a script runs the animation.
7
7
  import { rungMs, rungStyle } from '../core/rungs.js';
8
8
  /** Marks this module's runs, so a second reorder cancels the first rather than layering
9
9
  * a second transform over it. */
@@ -34,9 +34,11 @@ export function reorderArm() {
34
34
  * `armed` is why this is the reorder and not every reconcile: `animate:` fires wherever
35
35
  * a slot's rect moved, and a card growing under the caret moves every slot below it,
36
36
  * which is a layout change with no trip in it and one that would draw those slots over
37
- * the content that displaced them for the length of the run. So the reorder command
38
- * arms the gesture and the frame it lands in disarms it, which is the admission test
39
- * read off the endpoints: two positions in the list, not two positions on the page.
37
+ * the content that displaced them for the length of the run. So a command that moves
38
+ * slots in the list arms the gesture and the frame it lands in disarms it, which is the
39
+ * admission test read off the endpoints: two positions in the list, not two positions on
40
+ * the page. A move is one; so, in the card stack, are an insert and a removal, which move
41
+ * every slot past them by one.
40
42
  *
41
43
  * Both endpoints are rest states, so the curve is `--_qm-ease-reverse` (a second click
42
44
  * lands mid-slide) and the rung is `slow` (the trip moves the stack around it).
@@ -61,3 +63,50 @@ export function reorder(node, { from, to }, armed) {
61
63
  ], { duration, easing, id: RUN_ID });
62
64
  return {};
63
65
  }
66
+ /**
67
+ * A slot entering or leaving the list fades where it stands, while the slots it displaces
68
+ * take the reorder trip: the stack is what slides, and a card is what appears or goes, under
69
+ * the slots crossing it. Opacity alone, because a leaving slot is held at its place by a
70
+ * transform `animate:` writes, which a keyframed transform would override.
71
+ *
72
+ * The returned duration is what keeps a leaving slot mounted until its fade has run, and
73
+ * the fill what keeps it from painting whole in the frame between the two.
74
+ */
75
+ export function slot(node, _params, { direction }) {
76
+ if (typeof node.animate !== 'function')
77
+ return {};
78
+ const style = rungStyle(node);
79
+ const duration = rungMs(style, '--_qm-duration-slow');
80
+ const easing = style.getPropertyValue('--_qm-ease-reverse').trim();
81
+ if (duration === undefined || !easing)
82
+ return {};
83
+ const opacity = direction === 'out' ? [1, 0] : [0, 1];
84
+ node.animate({ opacity }, { duration, easing, fill: direction === 'out' ? 'forwards' : 'none' });
85
+ return { duration };
86
+ }
87
+ /** Frames nothing may scroll in before a scroll counts as over. More than one, because a
88
+ * smooth scroll asked for in this frame takes its first step in a later one. */
89
+ const STILL_FRAMES = 3;
90
+ /** The wait's ceiling, in frames: a scroller a host keeps moving is not a trip that ends. */
91
+ const MAX_FRAMES = 60;
92
+ /**
93
+ * Resolves once nothing in the document has scrolled for {@link STILL_FRAMES} frames
94
+ * running. A smooth scroll moves its scroller on every frame until it lands, so stillness
95
+ * is its end on every engine, whether or not it reports one; a scroll with nowhere to go
96
+ * is still from the first frame.
97
+ */
98
+ export function scrollSettled() {
99
+ return new Promise((resolve) => {
100
+ let still = 0;
101
+ let frames = 0;
102
+ const moved = () => void (still = 0);
103
+ document.addEventListener('scroll', moved, { capture: true, passive: true });
104
+ const frame = () => {
105
+ if (++still < STILL_FRAMES && ++frames < MAX_FRAMES)
106
+ return void requestAnimationFrame(frame);
107
+ document.removeEventListener('scroll', moved, { capture: true });
108
+ resolve();
109
+ };
110
+ requestAnimationFrame(frame);
111
+ });
112
+ }
@@ -21,9 +21,8 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
21
21
  addCard: string;
22
22
  arrayAdd: string;
23
23
  arrayRemove: string;
24
- /** The reorder pair on an `object` row, accessible names: the buttons are glyphs. */
25
- arrayMoveUp: string;
26
- arrayMoveDown: string;
24
+ /** The drag handle on an `object` row: its tooltip and, with the row, its name. */
25
+ arrayDrag: string;
27
26
  /**
28
27
  * A row control's accessible name, the action and the row it acts on: `Remove
29
28
  * Authors 2`. The action alone stays the tooltip. Parametric, so word order stays
@@ -42,9 +41,11 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
42
41
  * word order stays the translator's.
43
42
  */
44
43
  elementUntitled: (label: string, index: number) => string;
45
- /** The tag on the unset sentinel's row, which is what tells it from the member it
46
- * ghosts ({@link EnumField}). */
44
+ /** The tag on the unset sentinel's row where it ghosts a member, which is what tells
45
+ * it from that member ({@link EnumField}). */
47
46
  enumUnsetTag: string;
47
+ /** The same row's tag where it ghosts no member: no `default:`, or a blank one. */
48
+ enumUnsetTagNone: string;
48
49
  /** The count in the matrix's label row: how many of its items are held. */
49
50
  matrixHeld: (held: number, total: number) => string;
50
51
  /** An open matrix's add box at rest, naming the list it adds to: `Add to Staff…`. The box
@@ -30,12 +30,12 @@ export const DEFAULT_VISUAL_STRINGS = {
30
30
  addCard: '+ Add Card',
31
31
  arrayAdd: '+ Add',
32
32
  arrayRemove: 'Remove',
33
- arrayMoveUp: 'Move up',
34
- arrayMoveDown: 'Move down',
33
+ arrayDrag: 'Drag to move',
35
34
  arrayRowAction: (action, row) => `${action} ${row}`,
36
35
  arrayCount: (count, max) => `${count} / ${max}`,
37
36
  elementUntitled: (label, index) => `${label} ${index}`,
38
- enumUnsetTag: 'default',
37
+ enumUnsetTag: 'Default',
38
+ enumUnsetTagNone: 'Unset',
39
39
  matrixHeld: (held, total) => `${held} of ${total} held`,
40
40
  matrixAdd: (list) => (list ? `Add to ${list}…` : 'Add…'),
41
41
  matrixItemTitle: (list, index) => `${list ? `${list} item` : 'Item'} ${index}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -69,7 +69,7 @@
69
69
  "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
70
70
  },
71
71
  "peerDependencies": {
72
- "@quillmark/wasm": ">=0.124.0-0",
72
+ "@quillmark/wasm": ">=0.125.0-0",
73
73
  "svelte": "^5.33.0"
74
74
  },
75
75
  "dependencies": {