@gjermundgaraba-designs/pixel-paper 0.22.2 → 0.23.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 (160) hide show
  1. package/DESIGN.md +13 -13
  2. package/dist/src/adapters/mermaid.d.ts.map +1 -1
  3. package/dist/src/adapters/mermaid.js +0 -24
  4. package/dist/src/announcer/announcer.d.ts.map +1 -1
  5. package/dist/src/announcer/announcer.js +12 -12
  6. package/dist/src/board/board.d.ts +0 -10
  7. package/dist/src/board/board.d.ts.map +1 -1
  8. package/dist/src/board/board.js +36 -64
  9. package/dist/src/board/index.d.ts +2 -1
  10. package/dist/src/board/index.d.ts.map +1 -1
  11. package/dist/src/board/index.js +2 -1
  12. package/dist/src/button/button.d.ts +1 -1
  13. package/dist/src/button/button.d.ts.map +1 -1
  14. package/dist/src/commandPalette/commandPalette.d.ts.map +1 -1
  15. package/dist/src/commandPalette/commandPalette.js +78 -83
  16. package/dist/src/commandPalette/keys.d.ts +2 -2
  17. package/dist/src/commandPalette/keys.d.ts.map +1 -1
  18. package/dist/src/commandPalette/keys.js +2 -4
  19. package/dist/src/commandPalette/match.d.ts.map +1 -1
  20. package/dist/src/commandPalette/match.js +2 -2
  21. package/dist/src/confirmDialog/confirmDialog.d.ts.map +1 -1
  22. package/dist/src/confirmDialog/confirmDialog.js +1 -2
  23. package/dist/src/copyButton/copyButton.d.ts +0 -5
  24. package/dist/src/copyButton/copyButton.d.ts.map +1 -1
  25. package/dist/src/copyButton/copyButton.js +2 -2
  26. package/dist/src/crumbs/crumbs.d.ts +0 -2
  27. package/dist/src/crumbs/crumbs.d.ts.map +1 -1
  28. package/dist/src/crumbs/crumbs.js +2 -2
  29. package/dist/src/dialog/dialog.d.ts +2 -0
  30. package/dist/src/dialog/dialog.d.ts.map +1 -1
  31. package/dist/src/dialog/dialog.js +3 -2
  32. package/dist/src/drag/drag.d.ts +28 -0
  33. package/dist/src/drag/drag.d.ts.map +1 -1
  34. package/dist/src/drag/drag.js +39 -17
  35. package/dist/src/editableTitle/editableTitle.d.ts +4 -4
  36. package/dist/src/field/field.d.ts +7 -1
  37. package/dist/src/field/field.d.ts.map +1 -1
  38. package/dist/src/field/field.js +14 -6
  39. package/dist/src/fieldset/fieldset.d.ts.map +1 -1
  40. package/dist/src/fieldset/fieldset.js +2 -4
  41. package/dist/src/fileTree/fileTree.d.ts.map +1 -1
  42. package/dist/src/fileTree/fileTree.js +16 -16
  43. package/dist/src/inlineInput/inlineInput.d.ts +4 -0
  44. package/dist/src/inlineInput/inlineInput.d.ts.map +1 -1
  45. package/dist/src/inlineInput/inlineInput.js +9 -6
  46. package/dist/src/keyHelp/index.d.ts +1 -1
  47. package/dist/src/keyHelp/index.d.ts.map +1 -1
  48. package/dist/src/keyHelp/index.js +1 -1
  49. package/dist/src/keyHelp/keyHelp.d.ts +1 -3
  50. package/dist/src/keyHelp/keyHelp.d.ts.map +1 -1
  51. package/dist/src/keyHelp/keyHelp.js +3 -6
  52. package/dist/src/keyHint/keyLabel.d.ts +2 -0
  53. package/dist/src/keyHint/keyLabel.d.ts.map +1 -1
  54. package/dist/src/keyHint/keyLabel.js +3 -2
  55. package/dist/src/lineDiff/diff.d.ts +6 -3
  56. package/dist/src/lineDiff/diff.d.ts.map +1 -1
  57. package/dist/src/lineDiff/diff.js +40 -28
  58. package/dist/src/lineDiff/patch.d.ts.map +1 -1
  59. package/dist/src/lineDiff/patch.js +18 -13
  60. package/dist/src/linkState/linkState.d.ts.map +1 -1
  61. package/dist/src/linkState/linkState.js +1 -7
  62. package/dist/src/menu/slip.js +2 -2
  63. package/dist/src/meter/meter.d.ts.map +1 -1
  64. package/dist/src/meter/meter.js +22 -17
  65. package/dist/src/notice/notice.d.ts +2 -0
  66. package/dist/src/notice/notice.d.ts.map +1 -1
  67. package/dist/src/notice/notice.js +2 -1
  68. package/dist/src/passwordField/passwordField.d.ts.map +1 -1
  69. package/dist/src/passwordField/passwordField.js +3 -7
  70. package/dist/src/rail/rail.d.ts +17 -3
  71. package/dist/src/rail/rail.d.ts.map +1 -1
  72. package/dist/src/rail/rail.js +6 -6
  73. package/dist/src/reel/layout.d.ts +9 -5
  74. package/dist/src/reel/layout.d.ts.map +1 -1
  75. package/dist/src/reel/layout.js +11 -5
  76. package/dist/src/reel/reel.d.ts.map +1 -1
  77. package/dist/src/reel/reel.js +122 -68
  78. package/dist/src/scheme/scheme.d.ts +1 -1
  79. package/dist/src/scheme/scheme.d.ts.map +1 -1
  80. package/dist/src/segmented/segmented.d.ts +0 -3
  81. package/dist/src/segmented/segmented.d.ts.map +1 -1
  82. package/dist/src/segmented/segmented.js +1 -6
  83. package/dist/src/selectField/selectField.d.ts.map +1 -1
  84. package/dist/src/selectField/selectField.js +3 -10
  85. package/dist/src/sortable/index.d.ts +1 -1
  86. package/dist/src/sortable/index.d.ts.map +1 -1
  87. package/dist/src/sortable/index.js +1 -1
  88. package/dist/src/sortable/sortable.d.ts +39 -40
  89. package/dist/src/sortable/sortable.d.ts.map +1 -1
  90. package/dist/src/sortable/sortable.js +14 -31
  91. package/dist/src/stack/stack.d.ts +1 -1
  92. package/dist/src/stack/stack.d.ts.map +1 -1
  93. package/dist/src/stack/stack.js +1 -1
  94. package/dist/src/tabs/tabs.d.ts +1 -1
  95. package/dist/src/tabs/tabs.d.ts.map +1 -1
  96. package/dist/src/tabs/tabs.js +1 -1
  97. package/dist/src/textareaField/textareaField.d.ts.map +1 -1
  98. package/dist/src/textareaField/textareaField.js +3 -7
  99. package/dist/src/time/time.d.ts.map +1 -1
  100. package/dist/src/time/time.js +4 -2
  101. package/dist/src/tips/index.d.ts +1 -1
  102. package/dist/src/tips/index.d.ts.map +1 -1
  103. package/dist/src/toast/index.d.ts +1 -1
  104. package/dist/src/toast/index.d.ts.map +1 -1
  105. package/dist/src/toast/toast.d.ts +5 -5
  106. package/dist/src/toast/toast.d.ts.map +1 -1
  107. package/dist/src/toast/toast.js +1 -6
  108. package/dist/src/toolbar/toolbar.d.ts +0 -3
  109. package/dist/src/toolbar/toolbar.d.ts.map +1 -1
  110. package/dist/src/toolbar/toolbar.js +1 -2
  111. package/package.json +1 -1
  112. package/src/adapters/mermaid.ts +0 -24
  113. package/src/announcer/announcer.ts +13 -17
  114. package/src/board/board.ts +32 -80
  115. package/src/board/index.ts +1 -1
  116. package/src/button/button.ts +1 -1
  117. package/src/commandPalette/commandPalette.ts +21 -38
  118. package/src/commandPalette/keys.ts +2 -4
  119. package/src/commandPalette/match.ts +3 -3
  120. package/src/confirmDialog/confirmDialog.ts +5 -3
  121. package/src/copyButton/copyButton.ts +2 -5
  122. package/src/crumbs/crumbs.ts +2 -4
  123. package/src/dialog/dialog.ts +3 -2
  124. package/src/drag/drag.ts +54 -5
  125. package/src/field/field.ts +29 -7
  126. package/src/fieldset/fieldset.ts +2 -4
  127. package/src/fileTree/fileTree.ts +10 -12
  128. package/src/inlineInput/inlineInput.ts +11 -2
  129. package/src/keyHelp/index.ts +0 -1
  130. package/src/keyHelp/keyHelp.ts +3 -9
  131. package/src/keyHint/keyLabel.ts +5 -2
  132. package/src/lineDiff/diff.ts +70 -54
  133. package/src/lineDiff/patch.ts +27 -14
  134. package/src/linkState/linkState.ts +1 -8
  135. package/src/menu/slip.ts +2 -2
  136. package/src/meter/meter.ts +25 -15
  137. package/src/notice/notice.ts +2 -1
  138. package/src/passwordField/passwordField.ts +3 -7
  139. package/src/pixel-paper.css +82 -131
  140. package/src/rail/rail.ts +8 -6
  141. package/src/reel/layout.ts +14 -7
  142. package/src/reel/reel.ts +132 -63
  143. package/src/scheme/scheme.ts +1 -1
  144. package/src/segmented/segmented.ts +1 -9
  145. package/src/selectField/selectField.ts +3 -10
  146. package/src/sortable/index.ts +0 -1
  147. package/src/sortable/sortable.ts +35 -54
  148. package/src/stack/stack.ts +1 -1
  149. package/src/tabs/tabs.ts +1 -1
  150. package/src/textareaField/textareaField.ts +3 -7
  151. package/src/time/time.ts +6 -7
  152. package/src/tips/index.ts +1 -1
  153. package/src/toast/index.ts +1 -1
  154. package/src/toast/toast.ts +2 -8
  155. package/src/tokens.css +6 -0
  156. package/src/toolbar/toolbar.ts +0 -5
  157. package/dist/src/inlineInput/inline.d.ts +0 -6
  158. package/dist/src/inlineInput/inline.d.ts.map +0 -1
  159. package/dist/src/inlineInput/inline.js +0 -7
  160. package/src/inlineInput/inline.ts +0 -13
package/src/rail/rail.ts CHANGED
@@ -87,7 +87,7 @@ export const init = (config: InitConfig): Model => ({
87
87
  * (which leaves the deck at least 421px of a 901px window, the narrowest one the rail stands
88
88
  * in, more than a phone has), and what an arrow on its splitter moves it. The bounds and the
89
89
  * step are the component's, not tokens: the splitter says them as its values. */
90
- const WIDTH = { start: 272, least: 220, most: 480, step: 16 };
90
+ export const WIDTH = { start: 272, least: 220, most: 480, step: 16 };
91
91
 
92
92
  const bounded = (width: number): number =>
93
93
  Math.round(Number.clamp(width, { minimum: WIDTH.least, maximum: WIDTH.most }));
@@ -238,7 +238,7 @@ type DrawerMessage =
238
238
  | typeof Message.WidenedPastDrawer.Type;
239
239
 
240
240
  /** The width at and under which the rail is a drawer, as the stylesheet has it. */
241
- const DRAWER_QUERY = "(max-width: 900px)";
241
+ export const DRAWER_QUERY = "(max-width: 900px)";
242
242
 
243
243
  /** The window crossing the drawer's width: growing past it while the drawer is open closes
244
244
  * the drawer, since the rail is back in the page there. */
@@ -373,9 +373,9 @@ export type ViewInputs = Readonly<{
373
373
  *
374
374
  * Its splitter sizes it, by a pointer (previewed as it moves) or the side arrows, within its
375
375
  * bounds, and reports the width as `ResizedRail` for the parent to keep and pass back as
376
- * `maybeWidth`, which the view sets as `--rail` on `div.railed`, so the page's padding and the
377
- * rail's width follow it. The drawer keeps its own width. Lift `subscriptions(keyPrefix)` into
378
- * the application's, or a dragged splitter follows nothing.
376
+ * `maybeWidth`, which the view sets as `--railed-width` on `div.railed`, so the page's padding
377
+ * and the rail's width follow it (without one, `--rail`). The drawer keeps its own width. Lift
378
+ * `subscriptions(keyPrefix)` into the application's, or a dragged splitter follows nothing.
379
379
  *
380
380
  * Its state is its own, not `@foldkit/ui`'s Popover: that panel is placed by Floating UI and
381
381
  * rendered only while open, and this one is always in the page, placed by the stylesheet, and
@@ -414,7 +414,9 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
414
414
  return h.div(
415
415
  [
416
416
  h.Class("railed"),
417
- ...Array.fromOption(Option.map(maybeWidth, (shown) => h.Style({ "--rail": `${shown}px` }))),
417
+ ...Array.fromOption(
418
+ Option.map(maybeWidth, (shown) => h.Style({ "--railed-width": `${shown}px` })),
419
+ ),
418
420
  ],
419
421
  [
420
422
  toDeckView(openButton),
@@ -247,6 +247,11 @@ export const apply = (layout: Layout, request: OutMessage): Layout =>
247
247
  })),
248
248
  });
249
249
 
250
+ /** Whether `request` changes `layout`: a move, a drop or a stop that would leave it as it is
251
+ * is passed over. */
252
+ export const isChange = (layout: Layout, request: OutMessage): boolean =>
253
+ !Equal.equals(apply(layout, request), layout);
254
+
250
255
  // WIDTHS
251
256
 
252
257
  /** How close a width is to a preset to count as it. */
@@ -349,7 +354,7 @@ export const requestOf = (
349
354
  OutMessage.MovedPane({ paneId, toColumnId: columnId, toIndex: index }),
350
355
  Gap: ({ maybeBeforeColumnId }) => OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId }),
351
356
  }),
352
- (request) => !Equal.equals(apply(layout, request), layout),
357
+ (request) => isChange(layout, request),
353
358
  );
354
359
 
355
360
  /** Where the pane `paneId` would be once dropped at `target`, in words. */
@@ -469,13 +474,11 @@ export const steppedTarget = (
469
474
  const EDGE_SHARE = 0.2;
470
475
  const EDGE_MAX = 48;
471
476
 
472
- /** A column as a pointer reads it: its id, its left and right edges, and the vertical middles
473
- * of its slots, the held pane's left out. */
477
+ /** A column as a pointer reads it: its id and its left and right edges. */
474
478
  export type ColumnBox = Readonly<{
475
479
  id: string;
476
480
  left: number;
477
481
  right: number;
478
- slotMiddles: ReadonlyArray<number>;
479
482
  }>;
480
483
 
481
484
  const gapBefore = (columns: ReadonlyArray<ColumnBox>, gap: number): Target =>
@@ -484,18 +487,22 @@ const gapBefore = (columns: ReadonlyArray<ColumnBox>, gap: number): Target =>
484
487
  /** Where a pointer at (`clientX`, `clientY`) over a reel whose columns sit at `columns`, left
485
488
  * to right, would drop a pane. Over a column's edge band (or over a gap, or past the last
486
489
  * column) it lands in that gap, as a column of its own; over the rest of a column it lands
487
- * among that column's other panes, after each whose middle is above the pointer. */
490
+ * among that column's other panes, after each whose middle is above the pointer.
491
+ * `slotMiddlesOf` gives the vertical middles of a column's slots, by its index, the held
492
+ * pane's left out: it is asked for the column the pointer is over alone, and only when the
493
+ * pointer is among its panes. */
488
494
  export const targetAtPoint = (
489
495
  columns: ReadonlyArray<ColumnBox>,
490
496
  clientX: number,
491
497
  clientY: number,
498
+ slotMiddlesOf: (index: number) => ReadonlyArray<number>,
492
499
  ): Target =>
493
500
  pipe(
494
501
  Array.findFirstIndex(columns, ({ right }) => clientX <= right),
495
502
  Option.flatMap((index) => Option.map(Array.get(columns, index), (box) => ({ index, box }))),
496
503
  Option.match({
497
504
  onNone: () => gapBefore(columns, columns.length),
498
- onSome: ({ index, box: { id, left, right, slotMiddles } }) => {
505
+ onSome: ({ index, box: { id, left, right } }) => {
499
506
  const band = Math.min((right - left) * EDGE_SHARE, EDGE_MAX);
500
507
  if (clientX < left + band) {
501
508
  return gapBefore(columns, index);
@@ -504,7 +511,7 @@ export const targetAtPoint = (
504
511
  } else {
505
512
  return Target.Into({
506
513
  columnId: id,
507
- index: Array.filter(slotMiddles, (middle) => middle < clientY).length,
514
+ index: Array.filter(slotMiddlesOf(index), (middle) => middle < clientY).length,
508
515
  });
509
516
  }
510
517
  },
package/src/reel/reel.ts CHANGED
@@ -1,4 +1,16 @@
1
- import { Array, Effect, Equal, Match, Number, Option, Order, Schema, Stream, String } from "effect";
1
+ import {
2
+ Array,
3
+ Effect,
4
+ Equal,
5
+ Match,
6
+ Number,
7
+ Option,
8
+ Order,
9
+ Queue,
10
+ Schema,
11
+ Stream,
12
+ String,
13
+ } from "effect";
2
14
  import * as Command from "foldkit/command";
3
15
  import type { Attribute, Html, HtmlBuilder } from "foldkit/html";
4
16
  import { defineMessageUnion } from "foldkit/message";
@@ -42,6 +54,7 @@ import {
42
54
  columnOfTarget,
43
55
  fill,
44
56
  firstPlace,
57
+ isChange,
45
58
  isSplitterPresent,
46
59
  isTargetPresent,
47
60
  landingWords,
@@ -61,21 +74,22 @@ import {
61
74
 
62
75
  // MODEL
63
76
 
77
+ /** How the keyboard carries what it holds: said on the grip and once it is picked up. */
78
+ const KEYBOARD_MOVES = "Arrow keys move it, space drops it, escape or tab puts it back.";
79
+
64
80
  /** The words a reel says. `{pane}` is a pane's label, `{subject}` what moves (the held pane,
65
81
  * or a column a key moves, by its first pane), `{where}` a place and `{column}` a column's
66
82
  * number. */
67
83
  const COPY = {
68
84
  paneGrip: "Move {pane}",
69
- gripHelp:
70
- "Press space to pick it up. Arrow keys move it, space drops it, escape or tab puts it back.",
85
+ gripHelp: `Press space to pick it up. ${KEYBOARD_MOVES}`,
71
86
  widthLabel: "Width of column {column}",
72
87
  heightLabel: "Height of {pane}",
73
88
  focused: "{pane}, {where}.",
74
89
  moved: "{subject} moved to {where}.",
75
90
  widened: "Column {column} takes {width}.",
76
91
  pickedUp: "Picked up {subject}.",
77
- pickedUpByKeyboard:
78
- "Picked up {subject}. Arrow keys move it, space drops it, escape or tab puts it back.",
92
+ pickedUpByKeyboard: `Picked up {subject}. ${KEYBOARD_MOVES}`,
79
93
  over: "{subject}, {where}.",
80
94
  dropped: "{subject} dropped at {where}.",
81
95
  putBack: "{subject} put back where it was.",
@@ -665,7 +679,7 @@ const requestStep = (
665
679
  maybeRequest: Option.Option<OutMessage>,
666
680
  ): UpdateReturn =>
667
681
  Option.match(
668
- Option.filter(maybeRequest, (request) => !Equal.equals(apply(layout, request), layout)),
682
+ Option.filter(maybeRequest, (request) => isChange(layout, request)),
669
683
  {
670
684
  onNone: () => ({ model }),
671
685
  onSome: (request) => {
@@ -823,29 +837,32 @@ const EDGE_SCROLL = 40;
823
837
  const EDGE_SCROLL_PIXELS_PER_MILLI = 0.8;
824
838
  const LONGEST_FRAME_MILLIS = 50;
825
839
 
826
- /** A column as the pointer reads it from the page: its edges, and the middles of its slots
827
- * but the held pane's. */
828
- const columnBoxOf = (element: Element, heldId: string): ColumnBox => {
840
+ /** A column as the pointer reads it from the page: its edges. */
841
+ const columnBoxOf = (element: Element): ColumnBox => {
829
842
  const rect = element.getBoundingClientRect();
830
843
  return {
831
844
  id: element.getAttribute("data-reel-column") ?? "",
832
845
  left: rect.left,
833
846
  right: rect.right,
834
- slotMiddles: Array.map(
835
- Array.filter(
836
- Array.fromIterable(element.querySelectorAll("[data-reel-slot]")),
837
- (slot) => slot.getAttribute("data-reel-slot") !== heldId,
838
- ),
839
- (slot) => {
840
- const slotRect = slot.getBoundingClientRect();
841
- return slotRect.top + slotRect.height / 2;
842
- },
843
- ),
844
847
  };
845
848
  };
846
849
 
850
+ /** The middles of a column's slots but the held pane's. */
851
+ const slotMiddlesOf = (element: Element, heldId: string): ReadonlyArray<number> =>
852
+ Array.map(
853
+ Array.filter(
854
+ Array.fromIterable(element.querySelectorAll("[data-reel-slot]")),
855
+ (slot) => slot.getAttribute("data-reel-slot") !== heldId,
856
+ ),
857
+ (slot) => {
858
+ const slotRect = slot.getBoundingClientRect();
859
+ return slotRect.top + slotRect.height / 2;
860
+ },
861
+ );
862
+
847
863
  /** Where a pointer at (`clientX`, `clientY`) would drop the pane `paneId` in the reel
848
- * `reelHook` names, read from where its columns and their slots sit: none outside the reel. */
864
+ * `reelHook` names, read from where its columns sit and the slots of the one it is over: none
865
+ * outside the reel. */
849
866
  const pointerTarget = (
850
867
  reelHook: string,
851
868
  paneId: string,
@@ -862,14 +879,15 @@ const pointerTarget = (
862
879
  clientY <= rect.bottom
863
880
  );
864
881
  }),
865
- (view) =>
866
- targetAtPoint(
867
- Array.map(Array.fromIterable(view.querySelectorAll("[data-reel-column]")), (element) =>
868
- columnBoxOf(element, paneId),
869
- ),
870
- clientX,
871
- clientY,
872
- ),
882
+ (view) => {
883
+ const columns = Array.fromIterable(view.querySelectorAll("[data-reel-column]"));
884
+ return targetAtPoint(Array.map(columns, columnBoxOf), clientX, clientY, (index) =>
885
+ Option.match(Array.get(columns, index), {
886
+ onNone: () => [],
887
+ onSome: (column) => slotMiddlesOf(column, paneId),
888
+ }),
889
+ );
890
+ },
873
891
  );
874
892
 
875
893
  const PointerHold = Schema.Struct({
@@ -885,13 +903,60 @@ const pointerAt = (hold: PointerHold, event: PointerEvent): PointerAt => ({
885
903
  maybeTarget: pointerTarget(hold.reelHook, hold.paneId, event.clientX, event.clientY),
886
904
  });
887
905
 
888
- /** The pressing pointer's moves, release and cancel; another pointer's are not the reel's. */
906
+ /** The pressing pointer's moves, release and cancel; another pointer's are not the reel's. A
907
+ * pointer reports moves faster than the page draws, and each reads the reel's layout, so its
908
+ * moves are read once a frame, at the latest; a release first reads the move not read yet,
909
+ * so the pane is carried as far as the pointer went, and a cancel drops it. */
889
910
  const pointerMessages = (hold: PointerHold): Stream.Stream<Message> =>
890
- dragMessages<Message>(hold.pointerId, {
891
- moved: (event) => Message.MovedPointer(pointerAt(hold, event)),
892
- released: (event) => Message.ReleasedPointer(pointerAt(hold, event)),
893
- cancelled: () => Message.CancelledPointer(),
894
- });
911
+ Stream.callback<Message>((queue) =>
912
+ Effect.acquireRelease(
913
+ Effect.sync(() => {
914
+ const state = { maybeMove: Option.none<PointerEvent>(), frame: 0 };
915
+ const offer = (message: Message): void => {
916
+ Queue.offerUnsafe(queue, message);
917
+ };
918
+ const readMove = (): void => {
919
+ cancelAnimationFrame(state.frame);
920
+ Option.map(state.maybeMove, (event) =>
921
+ offer(Message.MovedPointer(pointerAt(hold, event))),
922
+ );
923
+ state.maybeMove = Option.none();
924
+ };
925
+ const onMove = (event: PointerEvent): void => {
926
+ if (event.pointerId === hold.pointerId) {
927
+ if (Option.isNone(state.maybeMove)) {
928
+ state.frame = requestAnimationFrame(readMove);
929
+ }
930
+ state.maybeMove = Option.some(event);
931
+ }
932
+ };
933
+ const onUp = (event: PointerEvent): void => {
934
+ if (event.pointerId === hold.pointerId) {
935
+ readMove();
936
+ offer(Message.ReleasedPointer(pointerAt(hold, event)));
937
+ }
938
+ };
939
+ const onCancel = (event: PointerEvent): void => {
940
+ if (event.pointerId === hold.pointerId) {
941
+ cancelAnimationFrame(state.frame);
942
+ state.maybeMove = Option.none();
943
+ offer(Message.CancelledPointer());
944
+ }
945
+ };
946
+ document.addEventListener("pointermove", onMove);
947
+ document.addEventListener("pointerup", onUp);
948
+ document.addEventListener("pointercancel", onCancel);
949
+ return { state, onMove, onUp, onCancel };
950
+ }),
951
+ ({ state, onMove, onUp, onCancel }) =>
952
+ Effect.sync(() => {
953
+ cancelAnimationFrame(state.frame);
954
+ document.removeEventListener("pointermove", onMove);
955
+ document.removeEventListener("pointerup", onUp);
956
+ document.removeEventListener("pointercancel", onCancel);
957
+ }),
958
+ ),
959
+ );
895
960
 
896
961
  /** Which way a pointer at `at` scrolls a box from `low` to `high`: towards the edge it is near. */
897
962
  const towards = (at: number, low: number, high: number): number => {
@@ -1224,33 +1289,32 @@ const reelSplitterAttributes = (
1224
1289
  h,
1225
1290
  );
1226
1291
 
1227
- const dropClass = (info: ReelInfo, column: Column, paneId: string): string =>
1228
- Option.match(
1229
- Option.flatMap(info.maybeMark, (mark) => {
1230
- if (mark._tag !== "Into" || mark.columnId !== column.id) {
1231
- return Option.none();
1232
- }
1233
- const others = Array.filter(
1234
- column.panes,
1235
- (pane) => !Option.contains(info.maybeCarriedId, pane.id),
1236
- );
1237
- return Option.orElse(
1292
+ /** The pane a drop into `column` is marked on, and the class that marks it: the pane it would
1293
+ * land before, or the last one when it would land at the end, the carried pane left out. */
1294
+ const dropMarkOf = (
1295
+ info: ReelInfo,
1296
+ column: Column,
1297
+ ): Option.Option<Readonly<{ paneId: string; markClass: string }>> =>
1298
+ Option.flatMap(info.maybeMark, (mark) => {
1299
+ if (mark._tag !== "Into" || mark.columnId !== column.id) {
1300
+ return Option.none();
1301
+ }
1302
+ const others = Array.filter(
1303
+ column.panes,
1304
+ (pane) => !Option.contains(info.maybeCarriedId, pane.id),
1305
+ );
1306
+ return Option.orElse(
1307
+ Option.map(Array.get(others, mark.index), ({ id }) => ({
1308
+ paneId: id,
1309
+ markClass: " drop-before",
1310
+ })),
1311
+ () =>
1238
1312
  Option.map(
1239
- Option.filter(Array.get(others, mark.index), (pane) => pane.id === paneId),
1240
- () => " drop-before",
1313
+ Option.filter(Array.last(others), () => mark.index >= others.length),
1314
+ ({ id }) => ({ paneId: id, markClass: " drop-after" }),
1241
1315
  ),
1242
- () =>
1243
- Option.map(
1244
- Option.filter(
1245
- Array.last(others),
1246
- (pane) => mark.index >= others.length && pane.id === paneId,
1247
- ),
1248
- () => " drop-after",
1249
- ),
1250
- );
1251
- }),
1252
- { onNone: () => "", onSome: (mark) => mark },
1253
- );
1316
+ );
1317
+ });
1254
1318
 
1255
1319
  const isGapMarked = (info: ReelInfo, maybeBeforeColumnId: Option.Option<string>): boolean =>
1256
1320
  Option.exists(
@@ -1259,14 +1323,18 @@ const isGapMarked = (info: ReelInfo, maybeBeforeColumnId: Option.Option<string>)
1259
1323
  );
1260
1324
 
1261
1325
  const slotView = (
1262
- info: ReelInfo,
1263
1326
  anchors: ReadonlyMap<string, string>,
1264
- column: Column,
1327
+ maybeDropMark: Option.Option<Readonly<{ paneId: string; markClass: string }>>,
1265
1328
  pane: Pane,
1266
1329
  h: HtmlBuilder<Message>,
1267
1330
  ): Html =>
1268
1331
  h.keyed("div")(pane.id, [
1269
- h.Class(`reel-slot${dropClass(info, column, pane.id)}`),
1332
+ h.Class(
1333
+ `reel-slot${Option.match(
1334
+ Option.filter(maybeDropMark, ({ paneId }) => paneId === pane.id),
1335
+ { onNone: () => "", onSome: ({ markClass }) => markClass },
1336
+ )}`,
1337
+ ),
1270
1338
  h.DataAttribute("reel-slot", pane.id),
1271
1339
  h.Style({ "--reel-weight": `${pane.weight}`, "--reel-anchor": anchors.get(pane.id) ?? "none" }),
1272
1340
  ]);
@@ -1293,13 +1361,14 @@ const columnView = (
1293
1361
  ): ReadonlyArray<Html> => {
1294
1362
  const { model, layout } = info;
1295
1363
  const total = Number.sumAll(Array.map(column.panes, (pane) => pane.weight));
1364
+ const maybeDropMark = dropMarkOf(info, column);
1296
1365
  const stack = Array.flatMap(column.panes, (pane, row) => [
1297
1366
  ...Array.fromOption(
1298
1367
  Option.map(Array.get(column.panes, row - 1), (above) =>
1299
1368
  splitView(model, above, total > 0 ? above.weight / total : 0, h),
1300
1369
  ),
1301
1370
  ),
1302
- slotView(info, anchors, column, pane, h),
1371
+ slotView(anchors, maybeDropMark, pane, h),
1303
1372
  ]);
1304
1373
  const maybeNextId = Option.map(Array.get(layout, index + 1), ({ id }) => id);
1305
1374
 
@@ -21,7 +21,7 @@ export const toPinnedValue = (scheme: Scheme): Option.Option<string> =>
21
21
  );
22
22
 
23
23
  /** The scheme a `data-scheme` value pins. Anything else, or nothing, is `System`. */
24
- export const fromPinnedValue = (value: string | null | undefined): Scheme =>
24
+ export const fromPinnedValue = (value: string | null): Scheme =>
25
25
  Match.value(value).pipe(
26
26
  Match.withReturnType<Scheme>(),
27
27
  Match.when("light", () => "Paper"),
@@ -2,7 +2,6 @@ import { Button as UiButton } from "@foldkit/ui";
2
2
  import { Array } from "effect";
3
3
  import type { Html, HtmlBuilder } from "foldkit/html";
4
4
 
5
- import type { Attributes } from "../attributes/attributes.ts";
6
5
  import { pressedAttribute } from "../button/button.ts";
7
6
  import * as Glyph from "../glyph/glyph.ts";
8
7
  import type { GlyphName } from "../glyph/glyphs.ts";
@@ -24,8 +23,6 @@ export type ViewConfig<Value extends string, Message> = Readonly<{
24
23
  options: ReadonlyArray<Segment<Value>>;
25
24
  value: Value;
26
25
  onSelect: (value: Value) => Message;
27
- /** Further attributes for the group, never a class. */
28
- attributes?: Attributes<Message>;
29
26
  }>;
30
27
 
31
28
  const segmentView = <Value extends string, Message>(
@@ -62,12 +59,7 @@ export const view = <Value extends string, Message>(
62
59
  const isGlyphs = Array.every(config.options, (option) => option.glyph !== undefined);
63
60
 
64
61
  return h.div(
65
- [
66
- ...(config.attributes ?? []),
67
- h.Role("group"),
68
- h.AriaLabel(config.label),
69
- h.Class(isGlyphs ? "switch" : "nav"),
70
- ],
62
+ [h.Role("group"), h.AriaLabel(config.label), h.Class(isGlyphs ? "switch" : "nav")],
71
63
  Array.map(config.options, (option) => segmentView(config, option, h)),
72
64
  );
73
65
  };
@@ -2,7 +2,7 @@ import { Select as UiSelect } from "@foldkit/ui";
2
2
  import { Array } from "effect";
3
3
  import type { Html, HtmlBuilder } from "foldkit/html";
4
4
 
5
- import { describedByError, errorView } from "../field/field.ts";
5
+ import { controlAttributes, errorView, helpView } from "../field/field.ts";
6
6
 
7
7
  // VIEW
8
8
 
@@ -78,19 +78,12 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
78
78
  [h.Class("field")],
79
79
  [
80
80
  h.label(groups.label, [label]),
81
- // NOTE: @foldkit/ui gap: Select has no required option, so the wrapper says it.
82
81
  h.select(
83
- [
84
- ...groups.select,
85
- ...describedByError(id, error, help !== undefined, h),
86
- ...(isRequired ? [h.AriaRequired(true)] : []),
87
- ],
82
+ [...groups.select, ...controlAttributes(id, error, help, isRequired, h)],
88
83
  Array.map(options, entryView(h)),
89
84
  ),
90
85
  ...errorView(id, error, h),
91
- ...(help === undefined
92
- ? []
93
- : [h.p([...groups.description, h.Class("field-help")], [help])]),
86
+ ...helpView(help, groups.description, h),
94
87
  ],
95
88
  ),
96
89
  },
@@ -6,7 +6,6 @@ export {
6
6
  OutMessage,
7
7
  SizeLiftedItem,
8
8
  init,
9
- isHolding,
10
9
  setMovable,
11
10
  subscriptions,
12
11
  update,
@@ -14,8 +14,9 @@ import * as Button from "../button/button.ts";
14
14
  import {
15
15
  LEFT_BUTTON,
16
16
  activatedKeyboardDrag,
17
+ canLift,
17
18
  cancelledDrag,
18
- escapeKeys,
19
+ escapeSubscriptions,
19
20
  insertionIndex,
20
21
  isCarrying,
21
22
  markDragging,
@@ -25,6 +26,7 @@ import {
25
26
  pointerMessages as dragMessages,
26
27
  pressedDraggable,
27
28
  releasedPointer,
29
+ settle as settleDrag,
28
30
  } from "../drag/drag.ts";
29
31
  import * as Glyph from "../glyph/glyph.ts";
30
32
  import * as IconButton from "../iconButton/iconButton.ts";
@@ -38,15 +40,16 @@ import { prefixKeys } from "../subscription/prefixKeys.ts";
38
40
  export const Item = Schema.Struct({ id: Schema.String, label: Schema.String });
39
41
  export type Item = typeof Item.Type;
40
42
 
43
+ /** How the keyboard carries what it holds: said on the grip and once it is picked up. */
44
+ const KEYBOARD_MOVES = "Arrow keys move it one place, space drops it, escape cancels.";
45
+
41
46
  /** The words a sortable says. `{item}` stands for the item's label, `{position}` and `{total}`
42
47
  * for where it is, counted from one. */
43
48
  const COPY = {
44
49
  gripLabel: "Move {item}",
45
- gripHelp:
46
- "Press space to pick the item up. Arrow keys move it one place, space drops it, escape cancels.",
50
+ gripHelp: `Press space to pick the item up. ${KEYBOARD_MOVES}`,
47
51
  pickedUp: "Picked up {item}.",
48
- pickedUpByKeyboard:
49
- "Picked up {item}, {position} of {total}. Arrow keys move it one place, space drops it, escape cancels.",
52
+ pickedUpByKeyboard: `Picked up {item}, {position} of {total}. ${KEYBOARD_MOVES}`,
50
53
  over: "{item}, {position} of {total}.",
51
54
  dropped: "{item} dropped at {position} of {total}.",
52
55
  putBack: "{item} put back where it was.",
@@ -150,10 +153,6 @@ export const init = (config: InitConfig): Model => ({
150
153
  /** The list's id, unique in the document. */
151
154
  const id = (model: Model): string => model.dragAndDrop.id;
152
155
 
153
- /** Whether an item is pressed or held. */
154
- export const isHolding = (model: Model): boolean =>
155
- Option.isSome(UiDragAndDrop.maybeDraggedItemId(model.dragAndDrop));
156
-
157
156
  // DOM HOOKS
158
157
 
159
158
  const itemHook = (model: Model, itemId: string): string => `${id(model)}/${itemId}`;
@@ -204,10 +203,7 @@ const placementOf = (model: Model, held: Held, rawIndex: number): Option.Option<
204
203
  itemId: held.item.id,
205
204
  fromIndex,
206
205
  toIndex,
207
- maybeAfterItemId: Option.map(
208
- toIndex > 0 ? Array.get(others, toIndex - 1) : Option.none(),
209
- (item) => item.id,
210
- ),
206
+ maybeAfterItemId: Option.map(Array.get(others, toIndex - 1), (item) => item.id),
211
207
  maybeBeforeItemId: Option.map(Array.get(others, toIndex), (item) => item.id),
212
208
  });
213
209
  });
@@ -283,38 +279,23 @@ const moveText = (
283
279
  /** Announces the step a DragAndDrop update took and forgets the held item once it is down. */
284
280
  const settle =
285
281
  (previous: Model, putBack = COPY.putBack) =>
286
- (dragAndDropUpdate: UpdateReturn): UpdateReturn => {
287
- const nextModel =
288
- phaseOf(dragAndDropUpdate.model.dragAndDrop) === "Idle"
289
- ? modifyFields(dragAndDropUpdate.model, { maybeHeld: () => Option.none() })
290
- : dragAndDropUpdate.model;
291
- const maybeText = Option.flatMap(previous.maybeHeld, (held) =>
292
- moveText(
293
- held,
294
- previous,
295
- dragAndDropUpdate.model,
296
- Option.fromNullishOr(dragAndDropUpdate.outMessage),
297
- putBack,
282
+ (dragAndDropUpdate: UpdateReturn): UpdateReturn =>
283
+ settleDrag(
284
+ dragAndDropUpdate,
285
+ Option.map(
286
+ Option.flatMap(previous.maybeHeld, (held) =>
287
+ moveText(
288
+ held,
289
+ previous,
290
+ dragAndDropUpdate.model,
291
+ Option.fromNullishOr(dragAndDropUpdate.outMessage),
292
+ putBack,
293
+ ),
294
+ ),
295
+ (text) => AnnounceReorder({ text }),
298
296
  ),
299
297
  );
300
298
 
301
- return Update.withOutMessage(
302
- {
303
- model: nextModel,
304
- commands: [
305
- ...(dragAndDropUpdate.commands ?? []),
306
- ...Array.fromOption(Option.map(maybeText, (text) => AnnounceReorder({ text }))),
307
- ],
308
- },
309
- dragAndDropUpdate.outMessage,
310
- );
311
- };
312
-
313
- // NOTE: @foldkit/ui gap: DragAndDrop's pointer resolves a drop along one axis, which a
314
- // wrapping grid cannot use, so the list reads the pointer itself and hands DragAndDrop its
315
- // moves and release.
316
- const canLift = (model: Model): boolean => model.isMovable && phaseOf(model.dragAndDrop) === "Idle";
317
-
318
299
  /** The item `itemId` and its place, when the list may lift it now. */
319
300
  const liftable = (
320
301
  model: Model,
@@ -322,8 +303,9 @@ const liftable = (
322
303
  itemId: string,
323
304
  ): Option.Option<Readonly<{ index: number; item: Item }>> =>
324
305
  Option.filter(
325
- Option.flatMap(indexOf(items, itemId), (index) =>
326
- Option.map(Array.get(items, index), (item) => ({ index, item })),
306
+ Option.map(
307
+ Array.findFirstWithIndex(items, (item) => item.id === itemId),
308
+ ([item, index]) => ({ index, item }),
327
309
  ),
328
310
  () => canLift(model),
329
311
  );
@@ -441,6 +423,9 @@ export const setMovable = (model: Model, isMovable: boolean): UpdateReturn => {
441
423
 
442
424
  // SUBSCRIPTION
443
425
 
426
+ // NOTE: @foldkit/ui gap: DragAndDrop's pointer resolves a drop along one axis, which a
427
+ // wrapping grid cannot use, so the list reads the pointer itself and hands DragAndDrop its
428
+ // moves and release.
444
429
  /** Where a pointer at this point would drop the held item in the list `listId`: none when the
445
430
  * pointer is not over it. */
446
431
  const pointerIndex = (
@@ -480,7 +465,7 @@ const pointerMessages = (hold: PointerHold): Stream.Stream<Message> =>
480
465
  cancelled: () => Message.CancelledPointer(),
481
466
  });
482
467
 
483
- const ownSubscriptions = Subscription.make<Model, Message>()((entry) => ({
468
+ const pointerSubscriptions = Subscription.make<Model, Message>()((entry) => ({
484
469
  pointer: entry(
485
470
  { maybeHold: Schema.Option(PointerHold) },
486
471
  {
@@ -506,14 +491,6 @@ const ownSubscriptions = Subscription.make<Model, Message>()((entry) => ({
506
491
  }),
507
492
  },
508
493
  ),
509
- escape: entry(
510
- { isHolding: Schema.Boolean },
511
- {
512
- modelToDependencies: (model) => ({ isHolding: isHolding(model) }),
513
- dependenciesToStream: ({ isHolding: isHoldingNow }) =>
514
- isHoldingNow ? escapeKeys(Message.PressedEscape()) : Stream.empty,
515
- },
516
- ),
517
494
  }));
518
495
 
519
496
  // NOTE: @foldkit/ui gap: DragAndDrop's Subscription keys are fixed, so two of its users on a
@@ -527,7 +504,11 @@ const dragSubscriptions = Subscription.lift({
527
504
  toParentMessage: toDragAndDropMessage,
528
505
  });
529
506
 
530
- const sortableSubscriptions = Subscription.aggregate(ownSubscriptions, dragSubscriptions);
507
+ const sortableSubscriptions = Subscription.aggregate(
508
+ pointerSubscriptions,
509
+ escapeSubscriptions<Model, Message>(Message.PressedEscape()),
510
+ dragSubscriptions,
511
+ );
531
512
 
532
513
  /** A held item follows the pointer, the arrows and Space move and drop it, Escape puts it back
533
514
  * and the page scrolls near its edges, from listeners that run only while an item is pressed
@@ -4,7 +4,7 @@ import type { Html, HtmlBuilder } from "foldkit/html";
4
4
  // SPACE
5
5
 
6
6
  /** `S`: things in one line (8px). `M`: the parts of one body (14px). `L`: whole surfaces,
7
- * panel to panel (24px). Shared by Stack, Group and Wrap. */
7
+ * panel to panel (24px). */
8
8
  export const Space = Schema.Literals(["S", "M", "L"]);
9
9
  export type Space = typeof Space.Type;
10
10