@quillmark/svelte 0.20.0 → 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 +8 -8
@@ -117,9 +117,9 @@
117
117
 
118
118
  Two planes sink. `sunken` is the ground — the column behind the cards, the
119
119
  desk behind the sheets — and lifting the plane above it takes very little
120
- at the light pole. `well` is the hole cut into a card: a field, a table's
121
- header band, the one native select, none of which draw an edge, so the
122
- step is the whole of what says a value is typed there. The order at the
120
+ at the light pole. `well` is the hole cut into a card: a field, the one
121
+ native select, neither of which draws an edge, so the step is the whole of
122
+ what says a value is typed there. The order at the
123
123
  light pole is card, ground, well; at the dark pole the two sink to within
124
124
  a channel of each other, which is where a card with little light left to
125
125
  give up leaves them.
@@ -148,6 +148,10 @@
148
148
  dark. */
149
149
  --_qm-surface-raised: color-mix(in oklab, var(--_qm-bg), #fff 8%);
150
150
  --_qm-surface-hover: color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 8%);
151
+ /* A band laid on the card rather than cut into it: a table's header, and the pill a
152
+ grip draws. It steps toward the ink at both poles, a shade under white in light
153
+ and a shade over the card in dark, where `well` sinks to a black stripe. */
154
+ --_qm-surface-band: color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 6%);
151
155
  /* The one rung whose arms are not a single ratio: a stroke is read against what it
152
156
  separates, and the mix that reads as a hairline on the dark default draws the
153
157
  light card in a line heavier than the type it frames. The light arm is
@@ -157,12 +161,11 @@
157
161
  color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 6%),
158
162
  color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 17%)
159
163
  );
160
- /* A rule that separates without structuring: the lines between a table's cells,
161
- where the frame around them is the structure. Half the border, and read off
162
- it rather than restated as a percentage of its own, so the two hold their
163
- relationship at any mix: a stroke against the surface behind it, not a fill
164
- stacked on one. */
165
- --_qm-border-faint: color-mix(in oklab, var(--_qm-bg), var(--_qm-border) 50%);
164
+ /* A stroke ruled through content rather than round it: a table's frame and every
165
+ line between its cells. The light arm of `border` is a card's edge against the
166
+ ground, and drawn between lines of type it vanishes, so a grid takes one ratio at
167
+ both poles, which lands within a few points of `border` in dark. */
168
+ --_qm-rule: color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 14%);
166
169
  /* Three rungs, and nothing under the last: a step below `label` lands under 3:1 on
167
170
  the well a field draws it against, and what would take it — an unset value's word,
168
171
  an invitation — is content a reader has to read (WCAG 1.4.3), not decoration.
@@ -186,6 +189,11 @@
186
189
  stacks over whichever surface rung the cell already draws instead of replacing
187
190
  it. */
188
191
  --_qm-accent-tint: color-mix(in oklab, var(--_qm-accent) 12%, transparent);
192
+ /* The accent at the default rung: a mark's fill where the `default:` supplies it, a
193
+ switch's checked track and a held tick's box, stepped toward the card by the
194
+ ratio `default` steps the ink. What is drawn over it in the card's colour, the
195
+ thumb and the check, holds 3:1 at both poles, at 3.3:1 and 4.2:1. */
196
+ --_qm-accent-default: color-mix(in oklab, var(--_qm-accent), var(--_qm-bg) 20%);
189
197
  /* The danger hue held: what a destructive control fills with where the family's
190
198
  neutral hover rung would read as one more button. Mixed at the accent tint's
191
199
  strength, so a held tint is a held tint whichever hue it carries. */
@@ -281,6 +289,10 @@
281
289
  arrival settles into its rest instead of stopping at it, and long enough that a
282
290
  control resting at nothing reads as arriving rather than as switching. */
283
291
  --_qm-reveal: 500ms cubic-bezier(0.4, 0, 0.2, 1);
292
+ /* A wait rather than a trip: how long a caret rests on an empty line before the
293
+ slash hint comes up. Longer than the gap between two keystrokes, so typing on
294
+ through an Enter never draws it. */
295
+ --_qm-hint-wait: 500ms;
284
296
 
285
297
  /* ── Geometry ──────────────────────────────────────────────────────────*/
286
298
  /* One stroke width for every edge the chrome draws and every rule it ranks
@@ -342,9 +354,9 @@
342
354
  it does not move when `--qm-space` does. Every button family reads it, so the
343
355
  floor is one number instead of one expression per control. */
344
356
  --_qm-tap-min: 24px;
345
- /* The chrome glyph: a chip's, and the in-well remove's. Sized in CSS rather than
346
- through the icon's `size`, so a stylesheet beats the SVG presentation attribute
347
- and one rung is the whole of what a control draws. */
357
+ /* The chrome glyph: a chip's, a glyph button's, the enum's chevron. Sized in CSS
358
+ rather than through the icon's `size`, so a stylesheet beats the SVG presentation
359
+ attribute and one rung is the whole of what a control draws. */
348
360
  --_qm-glyph-control: 14px;
349
361
  /* The narrowest track a control that fills its width stays usable in: a table's
350
362
  column floors at it, rises no further than its content, and a row of them scrolls
@@ -29,8 +29,8 @@
29
29
  container or a block prose cell.
30
30
 
31
31
  The row's controls are inside the element: a slab over the end of the element's own
32
- box, taking its two end-side corners — the remove, and on an `object` row the reorder
33
- pair before it. So a row's box is the element's box, and an array's rows end where
32
+ box, taking its two end-side corners: the remove. An `object` row's drag handle hangs
33
+ off its start edge instead, as a table grip does. So a row's box is the element's box, and an array's rows end where
34
34
  every other field's control does. On an object element that box is the summary rather
35
35
  than the open row: a destructive control belongs to the line it sits on, not to
36
36
  everything that line has unfolded. A table's row carries the remove alone, in its last
@@ -41,7 +41,8 @@
41
41
  Keys carry the list without the mouse: Enter inserts a sibling below and takes the
42
42
  caret there, Backspace on an empty element removes it and hands focus back up the
43
43
  list, Alt+↑/↓ anywhere in an `object` row of the list moves it, the nearest row
44
- answering and never the record around a nested one. A move is one splice of the ids
44
+ answering and never the record around a nested one, and is the twin of the handle's
45
+ drag. A move is one splice of the ids
45
46
  and of the values together, the mechanism insert and remove use, so an element keeps
46
47
  its id for life and no prose leaf inside it remounts; the open row stays open across
47
48
  its own move, and `animate:reorder` holds the moving row as it holds a card.
@@ -328,15 +329,13 @@
328
329
  void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
329
330
  }
330
331
  /**
331
- * Move element `k` one slot: one splice of the ids and of the values together, so
332
- * the element keeps its id and everything mounted under it. A no-op at either edge.
333
- * The control that was pressed rides with the row, so it is focused again once the
334
- * flush has moved it: a node moved in the DOM is a node the browser blurred. An arrow
335
- * the move carried to its edge is disabled and takes no focus, so its twin does.
332
+ * Move element `k` to slot `to`: one splice of the ids and of the values together, so
333
+ * the element keeps its id and everything mounted under it. A no-op outside the list.
334
+ * The control that held the focus rides with the row, so it is focused again once the
335
+ * flush has moved it: a node moved in the DOM is a node the browser blurred.
336
336
  */
337
- function move(k: number, dir: -1 | 1): void {
338
- const to = k + dir;
339
- if (to < 0 || to >= ids.length) return;
337
+ function move(k: number, to: number): void {
338
+ if (to === k || to < 0 || to >= ids.length) return;
340
339
  const pressed = document.activeElement;
341
340
  arm.arm();
342
341
  const nextIds = ids.slice();
@@ -349,14 +348,8 @@
349
348
  onCommit(next);
350
349
  void (async () => {
351
350
  if (!(await span.resumes(tick()))) return;
352
- if (!(pressed instanceof HTMLElement) || !pressed.isConnected) return;
353
- const target =
354
- pressed instanceof HTMLButtonElement && pressed.disabled
355
- ? [pressed.previousElementSibling, pressed.nextElementSibling].find(
356
- (b) => b instanceof HTMLButtonElement && !b.disabled
357
- )
358
- : pressed;
359
- if (target instanceof HTMLElement) target.focus();
351
+ if (pressed instanceof HTMLElement && pressed.isConnected && rowEls[id]?.contains(pressed))
352
+ pressed.focus();
360
353
  })();
361
354
  }
362
355
  /** Take the caret: the first element, or the add affordance when the list is empty;
@@ -538,7 +531,64 @@
538
531
  const dir = e.key === 'ArrowUp' ? -1 : 1;
539
532
  if (k + dir < 0 || k + dir >= ids.length) return;
540
533
  e.preventDefault();
541
- move(k, dir);
534
+ move(k, k + dir);
535
+ }
536
+
537
+ // ── The drag handle ──────────────────────────────────────────────────────────
538
+ // Press-and-drag the handle moves its row; a press that does not travel past the dead
539
+ // zone does nothing. The rows are measured once the drag engages, nothing reflowing
540
+ // until the release (the lift is a tone and the drop rule is out of flow), and the
541
+ // release is the one splice `move` makes.
542
+ const DEAD_ZONE = 3;
543
+ type RowDrag = {
544
+ k: number;
545
+ origin: number;
546
+ engaged: boolean;
547
+ drop: number;
548
+ heads: { start: number; end: number }[];
549
+ };
550
+ let drag = $state<RowDrag | undefined>();
551
+ function onHandleDown(e: PointerEvent, k: number): void {
552
+ if (e.button !== 0) return;
553
+ e.preventDefault();
554
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
555
+ drag = { k, origin: e.clientY, engaged: false, drop: k, heads: [] };
556
+ }
557
+ function onHandleMove(e: PointerEvent): void {
558
+ if (!drag) return;
559
+ if (!drag.engaged) {
560
+ if (Math.abs(e.clientY - drag.origin) < DEAD_ZONE) return;
561
+ drag.engaged = true;
562
+ drag.heads = ids.map((id) => {
563
+ const box = rowEls[id]?.getBoundingClientRect();
564
+ return { start: box?.top ?? 0, end: box?.bottom ?? 0 };
565
+ });
566
+ }
567
+ drag.drop = dropIndex(drag, e.clientY);
568
+ }
569
+ /** The row the pointer is over, or the nearest one past either end. */
570
+ function dropIndex(d: RowDrag, y: number): number {
571
+ let best = d.k;
572
+ let nearest = Infinity;
573
+ d.heads.forEach((head, i) => {
574
+ const gap = y < head.start ? head.start - y : y > head.end ? y - head.end : 0;
575
+ if (gap < nearest) {
576
+ nearest = gap;
577
+ best = i;
578
+ }
579
+ });
580
+ return best;
581
+ }
582
+ function onHandleUp(e: PointerEvent): void {
583
+ const done = drag;
584
+ drag = undefined;
585
+ if (!done?.engaged || e.type !== 'pointerup') return;
586
+ move(done.k, done.drop);
587
+ }
588
+ /** Where the drop rule draws on row `k`, while a drag would land the row there. */
589
+ function dropSide(k: number): 'before' | 'after' | undefined {
590
+ if (!drag?.engaged || drag.drop !== k || drag.drop === drag.k) return undefined;
591
+ return drag.drop > drag.k ? 'after' : 'before';
542
592
  }
543
593
  </script>
544
594
 
@@ -639,7 +689,11 @@
639
689
  <!-- The handler catches a key from the controls inside the row and adds no
640
690
  interaction of the row's own: the row is no tab stop. -->
641
691
  <div
642
- class={['qm-array-row qm-element', { open: open.has(id) }]}
692
+ class={[
693
+ 'qm-array-row qm-element',
694
+ { open: open.has(id), lifted: drag?.engaged && drag.k === k }
695
+ ]}
696
+ data-drop={dropSide(k)}
643
697
  bind:this={rowEls[id]}
644
698
  animate:reorder={arm.armed}
645
699
  onkeydown={(e) => onRowKey(e, k)}
@@ -649,6 +703,7 @@
649
703
  end-side corners) needs corners to take. So a list of records measures
650
704
  like a list of inputs whatever the element type. -->
651
705
  <div class="qm-element-head">
706
+ {@render handle(k)}
652
707
  <button
653
708
  type="button"
654
709
  class="qm-control-box qm-focus-ring qm-element-summary"
@@ -666,7 +721,7 @@
666
721
  {/if}
667
722
  </button>
668
723
  <div class="qm-row-actions">
669
- {@render rowActions(k)}
724
+ {@render removeButton(k)}
670
725
  </div>
671
726
  </div>
672
727
  {#if open.has(id)}
@@ -738,26 +793,22 @@
738
793
  >
739
794
  {/snippet}
740
795
 
741
- <!-- An `object` row's controls: the reorder pair, disabled at its edge as the card
742
- header's is, then the remove. -->
743
- {#snippet rowActions(k: number)}
796
+ <!-- An `object` row's drag handle, a table grip's twin hung off the row's start edge.
797
+ It holds no focus, as a grip does not: its keyboard twin is Alt+↑/↓ from anywhere in
798
+ the row. -->
799
+ {#snippet handle(k: number)}
744
800
  <button
745
801
  type="button"
746
- class="qm-icon-btn qm-row-btn qm-focus-ring"
747
- title={t.strings.arrayMoveUp}
748
- aria-label={rowAction(t.strings.arrayMoveUp, k)}
749
- disabled={k === 0}
750
- onclick={() => move(k, -1)}><Icon name="chevron-up" /></button
802
+ class="qm-row-handle"
803
+ tabindex="-1"
804
+ title={t.strings.arrayDrag}
805
+ aria-label={rowAction(t.strings.arrayDrag, k)}
806
+ onpointerdown={(e) => onHandleDown(e, k)}
807
+ onpointermove={onHandleMove}
808
+ onpointerup={onHandleUp}
809
+ onpointercancel={onHandleUp}
810
+ ><span class="qm-row-handle-bar"><Icon name="grip-vertical" /></span></button
751
811
  >
752
- <button
753
- type="button"
754
- class="qm-icon-btn qm-row-btn qm-focus-ring"
755
- title={t.strings.arrayMoveDown}
756
- aria-label={rowAction(t.strings.arrayMoveDown, k)}
757
- disabled={k === ids.length - 1}
758
- onclick={() => move(k, 1)}><Icon name="chevron-down" /></button
759
- >
760
- {@render removeButton(k)}
761
812
  {/snippet}
762
813
 
763
814
  <style>
@@ -807,12 +858,16 @@
807
858
  display: none;
808
859
  }
809
860
  /* The default's rows are what prints and nothing written: every value they draw takes
810
- the default rung (theme.css), and the first gesture, writing them, takes it off. */
861
+ the default rung (theme.css), a checked switch's track included, and the first
862
+ gesture, writing them, takes it off. */
811
863
  .qm-array-rows[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
812
864
  .qm-array-rows[data-default] :global(.qm-select:not([data-ghosted])),
813
865
  .qm-array-rows[data-default] .qm-element-title:not(.untitled) {
814
866
  color: var(--_qm-ink-default);
815
867
  }
868
+ .qm-array-rows[data-default] :global(.qm-toggle[data-state='checked']) {
869
+ background: var(--_qm-accent-default);
870
+ }
816
871
  /* A grid rather than a block: an `<input>` is inline-level and would sit on a
817
872
  baseline, standing the row a descender taller than the box the slab measures itself
818
873
  against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
@@ -830,7 +885,7 @@
830
885
  /* The end inset the slabs stand in, taken off whichever box the element drew:
831
886
  `.qm-input` is the text element, `.qm-control-box` the prose one — the row's own
832
887
  child, so a table's cells, which are boxes too, keep their inset — and an object
833
- row's summary, which stands three slabs off its end. `:global`, because the box
888
+ row's summary, which stands one slab off its end. `:global`, because the box
834
889
  belongs to the child component's markup and the scope class stops at this
835
890
  component's. The longhand beats the family's `padding` shorthand without a
836
891
  specificity fight: `controls.css` ranks the family at zero inside `:where()`. */
@@ -839,7 +894,7 @@
839
894
  padding-inline-end: var(--_qm-tap-min);
840
895
  }
841
896
  .qm-element-summary {
842
- padding-inline-end: calc(3 * var(--_qm-tap-min));
897
+ padding-inline-end: var(--_qm-tap-min);
843
898
  }
844
899
  /* The box's end wall, floor to ceiling: a height of its own would leave a sliver of
845
900
  well above or below, and the corners it takes are the box's — the end-side pair keeps
@@ -847,8 +902,7 @@
847
902
  another gives up its corners on both sides.
848
903
 
849
904
  It comes up on its own row rather than on the field: a destructive control is offered
850
- by the row the pointer is on, not by every row at once, and the reorder pair beside
851
- it comes up with it. Hover is where the remove says destructive, ink with fill, a
905
+ by the row the pointer is on, not by every row at once. Hover is where the remove says destructive, ink with fill, a
852
906
  tint alone being a wash under a label-toned glyph. */
853
907
  .qm-row-actions {
854
908
  position: absolute;
@@ -872,10 +926,6 @@
872
926
  border-start-end-radius: var(--_qm-radius-inner);
873
927
  border-end-end-radius: var(--_qm-radius-inner);
874
928
  }
875
- .qm-row-btn :global(svg) {
876
- width: var(--_qm-glyph-control);
877
- height: var(--_qm-glyph-control);
878
- }
879
929
  /* Child combinators: a nested array shares this scope, and a row's hover is not its
880
930
  open subform's rows'. */
881
931
  .qm-array-row:hover > .qm-row-actions,
@@ -888,6 +938,71 @@
888
938
  background: var(--_qm-danger-tint);
889
939
  color: var(--_qm-danger);
890
940
  }
941
+ /* The handle is a table grip's twin (prose.css §"One grip per line"): a pill carrying
942
+ the grip's marks, hung off the row's start edge in the `--_qm-nest` every row stands
943
+ in from the stroke outside it, which a group's panel clips at. It rests
944
+ at nothing, where the remove rests faint, and comes up with the row the pointer is
945
+ on, staying up through the drag it carries; a band flush with the row keeps the
946
+ pointer on the row all the way to it. The pointer owns the gesture, not the page's
947
+ scroll. */
948
+ .qm-row-handle {
949
+ position: absolute;
950
+ inset-block: 0;
951
+ inset-inline-end: 100%;
952
+ width: var(--_qm-nest);
953
+ display: flex;
954
+ align-items: center;
955
+ justify-content: center;
956
+ padding: 0;
957
+ border: none;
958
+ background: none;
959
+ color: var(--_qm-ink-label);
960
+ cursor: grab;
961
+ touch-action: none;
962
+ opacity: 0;
963
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
964
+ }
965
+ .qm-row-handle-bar {
966
+ display: flex;
967
+ align-items: center;
968
+ justify-content: center;
969
+ width: 100%;
970
+ height: var(--_qm-tap-min);
971
+ border-radius: var(--_qm-radius-pill);
972
+ background: var(--_qm-surface-band);
973
+ }
974
+ .qm-row-handle-bar :global(svg) {
975
+ width: var(--_qm-space-3);
976
+ height: var(--_qm-space-3);
977
+ }
978
+ .qm-array-row:hover > .qm-element-head > .qm-row-handle,
979
+ .qm-element.lifted > .qm-element-head > .qm-row-handle {
980
+ opacity: 1;
981
+ }
982
+ .qm-element.lifted > .qm-element-head > .qm-row-handle {
983
+ cursor: grabbing;
984
+ }
985
+ /* The lifted row is a tone, and the drop is one rule on the edge it would land
986
+ against, drawn out of flow so nothing reflows under the drag: the table island's
987
+ own pair. */
988
+ .qm-element.lifted > .qm-element-head > .qm-element-summary {
989
+ opacity: var(--_qm-opacity-muted);
990
+ }
991
+ .qm-element[data-drop]::after {
992
+ content: '';
993
+ position: absolute;
994
+ inset-inline: 0;
995
+ height: var(--_qm-ring-width);
996
+ background: var(--_qm-accent);
997
+ border-radius: var(--_qm-radius-pill);
998
+ pointer-events: none;
999
+ }
1000
+ .qm-element[data-drop='before']::after {
1001
+ top: calc(-0.5 * (var(--_qm-space) + var(--_qm-ring-width)));
1002
+ }
1003
+ .qm-element[data-drop='after']::after {
1004
+ bottom: calc(-0.5 * (var(--_qm-space) + var(--_qm-ring-width)));
1005
+ }
891
1006
  /* ── An object element ──────────────────────────────────────────────────────
892
1007
  The row is a summary and the subform hangs under it. `row-gap` rather than a margin
893
1008
  on the subform, because the distance between a control and what it has unfolded
@@ -1106,10 +1221,15 @@
1106
1221
  .qm-array.empty .qm-add-el {
1107
1222
  opacity: 1;
1108
1223
  }
1224
+ /* No hover to come up on: the controls stand at the muted rung, the handle with
1225
+ them. */
1109
1226
  @media (hover: none) {
1110
1227
  .qm-add-el,
1111
1228
  .qm-row-actions {
1112
1229
  opacity: var(--_qm-opacity-muted);
1113
1230
  }
1231
+ .qm-row-handle {
1232
+ opacity: 1;
1233
+ }
1114
1234
  }
1115
1235
  </style>
@@ -10,6 +10,9 @@
10
10
  `mixed` there reads as false), so an optional boolean's control is a `checkbox`, the
11
11
  toggle role that does, announcing `mixed` while unset. A press from unset writes
12
12
  `true`; nothing here returns the cell to unset.
13
+
14
+ An unset switch draws its `default:` at the default rung, as every control draws a
15
+ printed default: a checked track takes the accent's default rung.
13
16
  -->
14
17
  <script lang="ts">
15
18
  import { Switch } from 'bits-ui';
@@ -41,6 +44,7 @@
41
44
  // the unset optional cell, the one state with no default to stand in for it.
42
45
  let local = $derived<boolean | undefined>(value ?? fallback ?? (optional ? undefined : false));
43
46
  const unset = $derived(local === undefined);
47
+ const defaulted = $derived(value === undefined && fallback !== undefined);
44
48
  const tristate = $derived(
45
49
  optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local } : {}
46
50
  );
@@ -49,7 +53,11 @@
49
53
  <!-- Not `.qm-switch`: that name is the preset's pane band, whose narrow-viewport rule
50
54
  stands every child of it at `--qmh-tap`, so a track wearing it grows a thumb three times
51
55
  its own height over the labels around it (`preset/recipes.css`, held by `check:style`). -->
52
- <span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
56
+ <span
57
+ class="qm-toggle-wrap"
58
+ data-unset={unset ? '' : undefined}
59
+ data-default={defaulted ? '' : undefined}
60
+ >
53
61
  <Switch.Root
54
62
  class="qm-toggle qm-focus-ring qm-tap-floor"
55
63
  checked={!!local}
@@ -101,6 +109,9 @@
101
109
  .qm-toggle-wrap :global(.qm-toggle[data-state='checked']) {
102
110
  background: var(--_qm-accent);
103
111
  }
112
+ .qm-toggle-wrap[data-default] :global(.qm-toggle[data-state='checked']) {
113
+ background: var(--_qm-accent-default);
114
+ }
104
115
  /* The focus ring rides `.qm-focus-ring` on the switch (controls.css). */
105
116
  /* The thumb reads on tone alone: the base surface over the track's `hover` rung
106
117
  unchecked, and over `--_qm-accent` checked. No edge of its own: a hairline around
@@ -72,13 +72,23 @@
72
72
  leaves
73
73
  }: Props = $props();
74
74
 
75
- let localTitle = $derived(card.titleOverride);
75
+ // The rename in flight, written once on blur or Enter. Held apart from the stored
76
+ // title because a re-derive mid-edit hands `card` back with the title it stored.
77
+ let draft = $state<string | undefined>(undefined);
78
+ const shownTitle = $derived(draft ?? card.titleOverride);
76
79
 
77
- // The pre-edit value Escape rolls back to; the rename itself stays live on input.
78
- let titleAtFocus = '';
80
+ // A focus the shell hands over (`focusHeader`) is not armed for replacement: the key
81
+ // that pressed a delete, pressed again, would retitle the card it landed on.
82
+ let handed = false;
79
83
  function onTitleFocus(e: FocusEvent): void {
80
- titleAtFocus = localTitle;
81
- (e.currentTarget as HTMLInputElement).select();
84
+ draft = card.titleOverride;
85
+ const input = e.currentTarget as HTMLInputElement;
86
+ if (handed) input.setSelectionRange(input.value.length, input.value.length);
87
+ else input.select();
88
+ }
89
+ function onTitleBlur(): void {
90
+ if (draft !== undefined && draft !== card.titleOverride) ops.rename(draft);
91
+ draft = undefined;
82
92
  }
83
93
  // A press on an unfocused title would place a caret on mouseup, collapsing the
84
94
  // focus-time select-all. A press while already focused stays normal, so clicking
@@ -102,13 +112,10 @@
102
112
  const el = e.currentTarget as HTMLInputElement;
103
113
  if (e.key === 'Enter') {
104
114
  e.preventDefault();
105
- el.blur(); // commit: the live value already persisted on input
115
+ el.blur();
106
116
  } else if (e.key === 'Escape') {
107
117
  e.preventDefault();
108
- if (localTitle !== titleAtFocus) {
109
- localTitle = titleAtFocus;
110
- ops.rename(titleAtFocus); // roll back the live edits
111
- }
118
+ draft = undefined;
112
119
  el.blur();
113
120
  }
114
121
  }
@@ -193,6 +200,13 @@
193
200
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
194
201
  el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block });
195
202
  }
203
+ /** Focus the title with the caret at its end, or the header's first live control on a
204
+ * recovery shell, which has no title to focus. */
205
+ export function focusHeader(): void {
206
+ handed = true;
207
+ el?.querySelector<HTMLElement>('.qm-card-header :is(input, button:not(:disabled))')?.focus();
208
+ handed = false;
209
+ }
196
210
  </script>
197
211
 
198
212
  <!-- `data-qm-card` names the island for a floating surface that has to stay inside one
@@ -210,20 +224,18 @@
210
224
  <header class="qm-card-header">
211
225
  <!-- svelte-ignore a11y_no_static_element_interactions -->
212
226
  <div class="qm-card-rename" onmousedown={onRenameMousedown}>
213
- <span class="qm-card-title-sizer" data-value={localTitle || card.titlePlaceholder}>
227
+ <span class="qm-card-title-sizer" data-value={shownTitle || card.titlePlaceholder}>
214
228
  <input
215
229
  class="qm-card-title qm-focus-ring"
216
- value={localTitle}
230
+ value={shownTitle}
217
231
  placeholder={card.titlePlaceholder}
218
232
  aria-label={t.strings.cardTitle}
219
233
  size="1"
220
234
  onmousedown={onTitleMousedown}
221
235
  onfocus={onTitleFocus}
236
+ onblur={onTitleBlur}
222
237
  onkeydown={onTitleKeydown}
223
- oninput={(e) => {
224
- localTitle = (e.currentTarget as HTMLInputElement).value;
225
- ops.rename(localTitle);
226
- }}
238
+ oninput={(e) => (draft = (e.currentTarget as HTMLInputElement).value)}
227
239
  />
228
240
  </span>
229
241
  </div>
@@ -463,10 +475,10 @@
463
475
  what crosses the line. */
464
476
  margin-inline-end: calc(-1 * (var(--_qm-tap-min) - var(--_qm-glyph-control)) / 2);
465
477
  }
466
- /* Focus as well as hover, so a caret in any leaf, the title, or a chevron holds the
467
- reveal: hover alone would strand the pair on keyboard and touch. */
468
- .qm-card:hover :global(.qm-card-reorder),
469
- .qm-card:focus-within :global(.qm-card-reorder) {
478
+ /* Focus as well as hover, so a caret in any leaf, the title, or a control holds the
479
+ reveal: hover alone would strand the row on the keyboard. */
480
+ .qm-card:hover :global(.qm-card-controls),
481
+ .qm-card:focus-within :global(.qm-card-controls) {
470
482
  opacity: 1;
471
483
  }
472
484
  /* The rename hit region is the header's free width at full height, so a press
@@ -38,6 +38,7 @@ interface Props {
38
38
  declare const Card: import("svelte").Component<Props, {
39
39
  revealLeaf: (key: string) => void;
40
40
  scrollIntoViewCard: (block: ScrollLogicalPosition) => void;
41
+ focusHeader: () => void;
41
42
  }, "">;
42
43
  type Card = ReturnType<typeof Card>;
43
44
  export default Card;
@@ -1,11 +1,11 @@
1
1
  <!--
2
2
  Card controls, composable cards only.
3
- Right-aligned in the header: a move-up/move-down chevron pair revealed while the
4
- pointer or the caret is in the card (each disabled at its edge), then delete.
3
+ Right-aligned in the header: a move-up/move-down chevron pair (each disabled at its
4
+ edge), then delete, the row revealed while the pointer or the caret is in the card.
5
5
  Reorder is buttons, not drag (V1). All three are chips (controls.css): fill arrives
6
- on hover, and delete rests faded danger, full under the pointer. The card scopes
7
- that reveal, so its condition lives in the parent's CSS; the pair keeps the narrower
8
- guarantee here, that a focused chevron is a visible one.
6
+ on hover, and delete draws faded danger, full under the pointer. The card scopes
7
+ that reveal, so its condition lives in the parent's CSS; the row keeps the narrower
8
+ guarantee here, that a focused control is a visible one.
9
9
  -->
10
10
  <script lang="ts">
11
11
  import { wording } from './strings.js';
@@ -56,21 +56,21 @@
56
56
  display: flex;
57
57
  align-items: center;
58
58
  gap: var(--_qm-space-half);
59
- /* One compositor layer for the row. Opacity on the pair otherwise snaps
60
- that box off the header's half-pixel, against delete which never leaves
61
- the document paint. */
59
+ opacity: 0;
60
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
61
+ /* One compositor layer for the row at rest as well as mid-fade: a box handed
62
+ back to the document paint when its fade ends snaps off the header's
63
+ half-pixel as it lands. */
62
64
  transform: translateZ(0);
63
65
  }
64
66
  .qm-card-reorder {
65
67
  display: flex;
66
- opacity: 0;
67
- transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
68
68
  }
69
- /* Opacity hides the pair without taking it out of the tab order, so the
70
- component holds its own floor: a chevron that has focus is drawn, and its ring
71
- with it. The card's reveal already covers this; the rule is here because the
72
- hidden state is. */
73
- .qm-card-reorder:focus-within {
69
+ /* Opacity hides the row without taking it out of the tab order, which keeps
70
+ delete the keyboard's way to remove a card, so the component holds its own
71
+ floor: a control that has focus is drawn, and its ring with it. The card's
72
+ reveal already covers this; the rule is here because the hidden state is. */
73
+ .qm-card-controls:focus-within {
74
74
  opacity: 1;
75
75
  }
76
76
  /* Squares: the chip's inline pad is for a label. Width and height are one rung