@gjermundgaraba-designs/pixel-paper 0.22.2 → 0.24.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 (176) hide show
  1. package/DESIGN.md +18 -17
  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/iconButton/iconButton.d.ts +6 -1
  44. package/dist/src/iconButton/iconButton.d.ts.map +1 -1
  45. package/dist/src/iconButton/iconButton.js +7 -2
  46. package/dist/src/inlineInput/inlineInput.d.ts +4 -0
  47. package/dist/src/inlineInput/inlineInput.d.ts.map +1 -1
  48. package/dist/src/inlineInput/inlineInput.js +9 -6
  49. package/dist/src/keyHelp/index.d.ts +1 -1
  50. package/dist/src/keyHelp/index.d.ts.map +1 -1
  51. package/dist/src/keyHelp/index.js +1 -1
  52. package/dist/src/keyHelp/keyHelp.d.ts +1 -3
  53. package/dist/src/keyHelp/keyHelp.d.ts.map +1 -1
  54. package/dist/src/keyHelp/keyHelp.js +3 -6
  55. package/dist/src/keyHint/keyLabel.d.ts +2 -0
  56. package/dist/src/keyHint/keyLabel.d.ts.map +1 -1
  57. package/dist/src/keyHint/keyLabel.js +3 -2
  58. package/dist/src/lineDiff/diff.d.ts +6 -3
  59. package/dist/src/lineDiff/diff.d.ts.map +1 -1
  60. package/dist/src/lineDiff/diff.js +40 -28
  61. package/dist/src/lineDiff/patch.d.ts.map +1 -1
  62. package/dist/src/lineDiff/patch.js +18 -13
  63. package/dist/src/linkState/linkState.d.ts.map +1 -1
  64. package/dist/src/linkState/linkState.js +1 -7
  65. package/dist/src/menu/slip.js +2 -2
  66. package/dist/src/meter/meter.d.ts.map +1 -1
  67. package/dist/src/meter/meter.js +22 -17
  68. package/dist/src/notice/notice.d.ts +2 -0
  69. package/dist/src/notice/notice.d.ts.map +1 -1
  70. package/dist/src/notice/notice.js +2 -1
  71. package/dist/src/panel/panel.d.ts +3 -0
  72. package/dist/src/panel/panel.d.ts.map +1 -1
  73. package/dist/src/panel/panel.js +2 -1
  74. package/dist/src/passwordField/passwordField.d.ts.map +1 -1
  75. package/dist/src/passwordField/passwordField.js +3 -7
  76. package/dist/src/rail/index.d.ts +1 -1
  77. package/dist/src/rail/index.d.ts.map +1 -1
  78. package/dist/src/rail/index.js +1 -1
  79. package/dist/src/rail/rail.d.ts +95 -20
  80. package/dist/src/rail/rail.d.ts.map +1 -1
  81. package/dist/src/rail/rail.js +103 -46
  82. package/dist/src/reel/layout.d.ts +9 -5
  83. package/dist/src/reel/layout.d.ts.map +1 -1
  84. package/dist/src/reel/layout.js +11 -5
  85. package/dist/src/reel/reel.d.ts.map +1 -1
  86. package/dist/src/reel/reel.js +122 -68
  87. package/dist/src/scheme/scheme.d.ts +1 -1
  88. package/dist/src/scheme/scheme.d.ts.map +1 -1
  89. package/dist/src/segmented/segmented.d.ts +0 -3
  90. package/dist/src/segmented/segmented.d.ts.map +1 -1
  91. package/dist/src/segmented/segmented.js +1 -6
  92. package/dist/src/selectField/selectField.d.ts.map +1 -1
  93. package/dist/src/selectField/selectField.js +3 -10
  94. package/dist/src/sortable/index.d.ts +1 -1
  95. package/dist/src/sortable/index.d.ts.map +1 -1
  96. package/dist/src/sortable/index.js +1 -1
  97. package/dist/src/sortable/sortable.d.ts +39 -40
  98. package/dist/src/sortable/sortable.d.ts.map +1 -1
  99. package/dist/src/sortable/sortable.js +14 -31
  100. package/dist/src/stack/stack.d.ts +1 -1
  101. package/dist/src/stack/stack.d.ts.map +1 -1
  102. package/dist/src/stack/stack.js +1 -1
  103. package/dist/src/tabs/tabs.d.ts +1 -1
  104. package/dist/src/tabs/tabs.d.ts.map +1 -1
  105. package/dist/src/tabs/tabs.js +1 -1
  106. package/dist/src/textareaField/textareaField.d.ts.map +1 -1
  107. package/dist/src/textareaField/textareaField.js +3 -7
  108. package/dist/src/time/time.d.ts.map +1 -1
  109. package/dist/src/time/time.js +4 -2
  110. package/dist/src/tips/index.d.ts +1 -1
  111. package/dist/src/tips/index.d.ts.map +1 -1
  112. package/dist/src/tips/tips.d.ts +30 -1
  113. package/dist/src/tips/tips.d.ts.map +1 -1
  114. package/dist/src/tips/tips.js +53 -31
  115. package/dist/src/toast/index.d.ts +1 -1
  116. package/dist/src/toast/index.d.ts.map +1 -1
  117. package/dist/src/toast/toast.d.ts +5 -5
  118. package/dist/src/toast/toast.d.ts.map +1 -1
  119. package/dist/src/toast/toast.js +1 -6
  120. package/dist/src/toolbar/toolbar.d.ts +0 -3
  121. package/dist/src/toolbar/toolbar.d.ts.map +1 -1
  122. package/dist/src/toolbar/toolbar.js +1 -2
  123. package/package.json +1 -1
  124. package/src/adapters/mermaid.ts +0 -24
  125. package/src/announcer/announcer.ts +13 -17
  126. package/src/board/board.ts +32 -80
  127. package/src/board/index.ts +1 -1
  128. package/src/button/button.ts +1 -1
  129. package/src/commandPalette/commandPalette.ts +21 -38
  130. package/src/commandPalette/keys.ts +2 -4
  131. package/src/commandPalette/match.ts +3 -3
  132. package/src/confirmDialog/confirmDialog.ts +5 -3
  133. package/src/copyButton/copyButton.ts +2 -5
  134. package/src/crumbs/crumbs.ts +2 -4
  135. package/src/dialog/dialog.ts +3 -2
  136. package/src/drag/drag.ts +54 -5
  137. package/src/field/field.ts +29 -7
  138. package/src/fieldset/fieldset.ts +2 -4
  139. package/src/fileTree/fileTree.ts +10 -12
  140. package/src/iconButton/iconButton.ts +19 -2
  141. package/src/inlineInput/inlineInput.ts +11 -2
  142. package/src/keyHelp/index.ts +0 -1
  143. package/src/keyHelp/keyHelp.ts +3 -9
  144. package/src/keyHint/keyLabel.ts +5 -2
  145. package/src/lineDiff/diff.ts +70 -54
  146. package/src/lineDiff/patch.ts +27 -14
  147. package/src/linkState/linkState.ts +1 -8
  148. package/src/menu/slip.ts +2 -2
  149. package/src/meter/meter.ts +25 -15
  150. package/src/notice/notice.ts +2 -1
  151. package/src/panel/panel.ts +5 -0
  152. package/src/passwordField/passwordField.ts +3 -7
  153. package/src/pixel-paper.css +200 -134
  154. package/src/rail/index.ts +2 -0
  155. package/src/rail/rail.ts +124 -44
  156. package/src/reel/layout.ts +14 -7
  157. package/src/reel/reel.ts +132 -63
  158. package/src/scheme/scheme.ts +1 -1
  159. package/src/segmented/segmented.ts +1 -9
  160. package/src/selectField/selectField.ts +3 -10
  161. package/src/sortable/index.ts +0 -1
  162. package/src/sortable/sortable.ts +35 -54
  163. package/src/stack/stack.ts +1 -1
  164. package/src/tabs/tabs.ts +1 -1
  165. package/src/textareaField/textareaField.ts +3 -7
  166. package/src/time/time.ts +6 -7
  167. package/src/tips/index.ts +1 -1
  168. package/src/tips/tips.ts +71 -35
  169. package/src/toast/index.ts +1 -1
  170. package/src/toast/toast.ts +2 -8
  171. package/src/tokens.css +7 -0
  172. package/src/toolbar/toolbar.ts +0 -5
  173. package/dist/src/inlineInput/inline.d.ts +0 -6
  174. package/dist/src/inlineInput/inline.d.ts.map +0 -1
  175. package/dist/src/inlineInput/inline.js +0 -7
  176. package/src/inlineInput/inline.ts +0 -13
@@ -35,7 +35,7 @@ export const view = <Message>(h: HtmlBuilder<Message>): Html =>
35
35
  h.Class("sr-only"),
36
36
  h.Role("status"),
37
37
  h.AriaLive("polite"),
38
- h.DataAttribute("pixel-paper-announcer", ""),
38
+ h.Attribute(ANNOUNCER_ATTRIBUTE, ""),
39
39
  ]);
40
40
 
41
41
  // ANNOUNCE
@@ -97,28 +97,21 @@ export const announce = (text: string): Effect.Effect<void> =>
97
97
  // FLASH
98
98
 
99
99
  const FLASH_ID = "pixel-paper-flash";
100
- const DEFAULT_BEAT = Duration.millis(700);
101
100
 
102
- const toMillis = (time: string): Option.Option<number> =>
103
- pipe(
101
+ /** The beat in milliseconds, from the stylesheet's `--motion-flash`: none on a page that has
102
+ * not loaded the stylesheet, which has nothing to flash with. */
103
+ const beatOf = (element: Element): Option.Option<number> => {
104
+ const time = getComputedStyle(element).getPropertyValue("--motion-flash").trim();
105
+ return pipe(
104
106
  Option.liftPredicate(Number.parseFloat(time), Number.isFinite),
105
107
  Option.map((value) => (time.endsWith("ms") ? value : value * 1000)),
106
108
  );
107
-
108
- const beatOf = (element: Element): number =>
109
- Option.getOrElse(
110
- toMillis(getComputedStyle(element).getPropertyValue("--motion-flash").trim()),
111
- () => Duration.toMillis(DEFAULT_BEAT),
112
- );
109
+ };
113
110
 
114
111
  const prefersReducedMotion = (): boolean =>
115
112
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
116
113
 
117
- const runFlash = (element: Element): void => {
118
- if (!("animate" in element)) {
119
- return;
120
- }
121
- const beat = beatOf(element);
114
+ const runFlash = (element: Element, beat: number): void => {
122
115
  Array.forEach(
123
116
  Array.filter(element.getAnimations(), (animation) => animation.id === FLASH_ID),
124
117
  (animation) => animation.cancel(),
@@ -141,8 +134,11 @@ const runFlash = (element: Element): void => {
141
134
  export const flash = (selector: string): Effect.Effect<void> =>
142
135
  Effect.sync(() => {
143
136
  const maybeElement = Option.fromNullishOr(document.querySelector(selector));
144
- if (Option.isSome(maybeElement)) {
145
- runFlash(maybeElement.value);
137
+ const maybeFlash = Option.flatMap(maybeElement, (element) =>
138
+ Option.map(beatOf(element), (beat) => ({ element, beat })),
139
+ );
140
+ if (Option.isSome(maybeFlash)) {
141
+ runFlash(maybeFlash.value.element, maybeFlash.value.beat);
146
142
  }
147
143
  });
148
144
 
@@ -1,5 +1,5 @@
1
1
  import { Button as UiButton, DragAndDrop as UiDragAndDrop } from "@foldkit/ui";
2
- import { Array, Effect, Equal, Match, Option, Schema, Stream, pipe } from "effect";
2
+ import { Array, Effect, Equal, Match, Option, Schema, pipe } from "effect";
3
3
  import * as Command from "foldkit/command";
4
4
  import {
5
5
  type Attribute,
@@ -26,13 +26,15 @@ import { prefixKeys } from "../subscription/prefixKeys.ts";
26
26
  import {
27
27
  LEFT_BUTTON,
28
28
  activatedKeyboardDrag,
29
+ canLift,
29
30
  cancelledDrag,
30
- escapeKeys,
31
+ escapeSubscriptions,
31
32
  innerControls,
32
33
  isCarrying,
33
34
  measureLift,
34
35
  phaseOf,
35
36
  pressedDraggable,
37
+ settle as settleDrag,
36
38
  } from "../drag/drag.ts";
37
39
 
38
40
  // MODEL
@@ -50,19 +52,20 @@ export const Column = Schema.Struct({
50
52
  });
51
53
  export type Column = typeof Column.Type;
52
54
 
55
+ /** How the keyboard carries what it holds: said on the grip and once it is picked up. */
56
+ const KEYBOARD_MOVES = "Arrow keys move it to another column, space drops it, escape cancels.";
57
+
53
58
  /** The words a board says. `{card}` stands for the card's label and `{column}` for a
54
59
  * column's title. */
55
60
  const COPY = {
56
61
  /** The grip's name. */
57
62
  gripLabel: "Move {card}",
58
63
  /** What the grip describes: how to move a card from the keyboard. */
59
- gripHelp:
60
- "Press space to pick the card up. Arrow keys move it to another column, space drops it, escape cancels.",
64
+ gripHelp: `Press space to pick the card up. ${KEYBOARD_MOVES}`,
61
65
  /** A pointer has lifted the card. */
62
66
  pickedUp: "Picked up {card}.",
63
67
  /** The keyboard has lifted the card: say how to go on. */
64
- pickedUpByKeyboard:
65
- "Picked up {card}. Arrow keys move it to another column, space drops it, escape cancels.",
68
+ pickedUpByKeyboard: `Picked up {card}. ${KEYBOARD_MOVES}`,
66
69
  /** The held card is over another column. */
67
70
  over: "{card} is over {column}.",
68
71
  /** The card was dropped in a column. */
@@ -177,12 +180,6 @@ export const id = (model: Model): string => model.dragAndDrop.id;
177
180
  /** Whether a card is held by a pointer or the keyboard. */
178
181
  export const isDragging = (model: Model): boolean => UiDragAndDrop.isDragging(model.dragAndDrop);
179
182
 
180
- /** Whether a card is pressed or held. While it is, the arrow keys, Space and Escape belong to
181
- * the board: pause the page's own bindings for them. The board takes Escape before anything
182
- * else sees it. */
183
- export const isHolding = (model: Model): boolean =>
184
- Option.isSome(UiDragAndDrop.maybeDraggedItemId(model.dragAndDrop));
185
-
186
183
  // DOM HOOKS
187
184
 
188
185
  const hookId = (boardId: string, localId: string): string => `${boardId}/${localId}`;
@@ -234,9 +231,6 @@ const overColumn = (model: Model): Option.Option<Column> =>
234
231
  ),
235
232
  );
236
233
 
237
- const announceMove = (maybeText: Option.Option<string>): ReadonlyArray<Command.Command<Message>> =>
238
- Array.fromOption(Option.map(maybeText, (text) => AnnounceMove({ text })));
239
-
240
234
  const placementOf = (
241
235
  model: Model,
242
236
  held: Held,
@@ -263,10 +257,7 @@ const placementOf = (
263
257
  fromIndex: from.index,
264
258
  toColumnId: to.id,
265
259
  toIndex,
266
- maybeAfterCardId: Option.map(
267
- toIndex > 0 ? Array.get(others, toIndex - 1) : Option.none(),
268
- (card) => card.id,
269
- ),
260
+ maybeAfterCardId: Option.map(Array.get(others, toIndex - 1), (card) => card.id),
270
261
  maybeBeforeCardId: Option.map(Array.get(others, toIndex), (card) => card.id),
271
262
  });
272
263
  });
@@ -349,35 +340,26 @@ const moveText = (
349
340
  * A card set down without a move says `putBack`. */
350
341
  const settle =
351
342
  (previous: Model, putBack = COPY.putBack) =>
352
- (dragAndDropUpdate: UpdateReturn): UpdateReturn => {
353
- const nextModel =
354
- phaseOf(dragAndDropUpdate.model.dragAndDrop) === "Idle"
355
- ? modifyFields(dragAndDropUpdate.model, { maybeHeld: () => Option.none() })
356
- : dragAndDropUpdate.model;
357
- const maybeText = Option.flatMap(previous.maybeHeld, (held) =>
358
- moveText(
359
- held,
360
- previous,
361
- dragAndDropUpdate.model,
362
- Option.fromNullishOr(dragAndDropUpdate.outMessage),
363
- putBack,
343
+ (dragAndDropUpdate: UpdateReturn): UpdateReturn =>
344
+ settleDrag(
345
+ dragAndDropUpdate,
346
+ Option.map(
347
+ Option.flatMap(previous.maybeHeld, (held) =>
348
+ moveText(
349
+ held,
350
+ previous,
351
+ dragAndDropUpdate.model,
352
+ Option.fromNullishOr(dragAndDropUpdate.outMessage),
353
+ putBack,
354
+ ),
355
+ ),
356
+ (text) => AnnounceMove({ text }),
364
357
  ),
365
358
  );
366
359
 
367
- return Update.withOutMessage(
368
- {
369
- model: nextModel,
370
- commands: [...(dragAndDropUpdate.commands ?? []), ...announceMove(maybeText)],
371
- },
372
- dragAndDropUpdate.outMessage,
373
- );
374
- };
375
-
376
360
  const holding = (model: Model, held: Held): Model =>
377
361
  modifyFields(model, { maybeHeld: () => Option.some(held) });
378
362
 
379
- const canLift = (model: Model): boolean => model.isMovable && phaseOf(model.dragAndDrop) === "Idle";
380
-
381
363
  const liftByKeyboard = (model: Model, place: Place, columns: ReadonlyArray<Column>) => {
382
364
  const pressed = holding(model, { card: place.card, columns, maybePressPoint: Option.none() });
383
365
  return settle(pressed)(
@@ -545,17 +527,6 @@ export const setMovable = (model: Model, isMovable: boolean): UpdateReturn => {
545
527
 
546
528
  // SUBSCRIPTION
547
529
 
548
- const holdingSubscriptions = Subscription.make<Model, Message>()((entry) => ({
549
- escape: entry(
550
- { isHolding: Schema.Boolean },
551
- {
552
- modelToDependencies: (model) => ({ isHolding: isHolding(model) }),
553
- dependenciesToStream: ({ isHolding: isHoldingNow }) =>
554
- isHoldingNow ? escapeKeys(Message.PressedEscape()) : Stream.empty,
555
- },
556
- ),
557
- }));
558
-
559
530
  // NOTE: @foldkit/ui gap: DragAndDrop's Subscription keys are fixed, so two of its users on a
560
531
  // page would share them. The board lifts them under its own names, behind the parent's prefix.
561
532
  const dragSubscriptions = Subscription.lift({
@@ -567,7 +538,10 @@ const dragSubscriptions = Subscription.lift({
567
538
  toParentMessage: toDragAndDropMessage,
568
539
  });
569
540
 
570
- const boardSubscriptions = Subscription.aggregate(holdingSubscriptions, dragSubscriptions);
541
+ const boardSubscriptions = Subscription.aggregate(
542
+ escapeSubscriptions<Model, Message>(Message.PressedEscape()),
543
+ dragSubscriptions,
544
+ );
571
545
 
572
546
  /** A held card follows the pointer, the arrows and Space move and drop it, Escape puts it
573
547
  * back and the page scrolls near its edges, all from listeners that run only while a card is
@@ -581,9 +555,6 @@ export const subscriptions = <Prefix extends string>(keyPrefix: Prefix) =>
581
555
 
582
556
  // LIFTED CARD
583
557
 
584
- const pressPointOf = (model: Model): Option.Option<typeof PressPoint.Type> =>
585
- Option.flatMap(model.maybeHeld, (held) => held.maybePressPoint);
586
-
587
558
  /** Sizes the copy that follows the pointer to the card it lifts, and keeps the pointer where
588
559
  * it pressed the card. Measures the card once, when the copy appears. */
589
560
  export const SizeLiftedCard = Mount.define("SizeLiftedCard", {
@@ -627,9 +598,6 @@ export type ViewInputs = Readonly<{
627
598
  toEmptyView?: (column: Column) => ReadonlyArray<Html>;
628
599
  /** Controls at the right of a column's head, after its count ("capture here"). */
629
600
  toColumnActions?: (column: Column) => ReadonlyArray<Html>;
630
- /** Further attributes for a column's `div.board-col` (`data-status`), never a class. Wrap
631
- * them with `childAttributes` so their handlers dispatch the parent's Messages. */
632
- toColumnAttributes?: (column: Column) => ReadonlyArray<ChildAttribute>;
633
601
  }>;
634
602
 
635
603
  const INNER_CONTROL = innerControls(`[data-${TITLE_ATTRIBUTE}], [data-${GRIP_ATTRIBUTE}]`);
@@ -664,7 +632,6 @@ type PlaceInfo = Readonly<{
664
632
  toCardView: (card: Card, render: CardRender) => Html;
665
633
  toEmptyView: (column: Column) => ReadonlyArray<Html>;
666
634
  toColumnActions: (column: Column) => ReadonlyArray<Html>;
667
- toColumnAttributes: (column: Column) => ReadonlyArray<ChildAttribute>;
668
635
  }>;
669
636
 
670
637
  const gripHelpId = (model: Model): string => `${id(model)}-grip-help`;
@@ -872,7 +839,6 @@ const columnView = (info: PlaceInfo, column: Column, h: HtmlBuilder<Message>): H
872
839
  return h.keyed("div")(
873
840
  column.id,
874
841
  [
875
- ...info.toColumnAttributes(column),
876
842
  h.DataAttribute("droppable-id", columnHook(model, column.id)),
877
843
  h.Class(isTarget ? "board-col drop-target" : "board-col"),
878
844
  ],
@@ -905,12 +871,11 @@ const liftedCopyView = (info: PlaceInfo, h: HtmlBuilder<Message>): ReadonlyArray
905
871
  const maybeLifted = Option.all({
906
872
  style: UiDragAndDrop.ghostStyle(model.dragAndDrop),
907
873
  held: model.maybeHeld,
874
+ pressPoint: Option.flatMap(model.maybeHeld, (held) => held.maybePressPoint),
908
875
  });
909
876
 
910
- const pressPoint = Option.getOrElse(pressPointOf(model), () => ({ clientX: 0, clientY: 0 }));
911
-
912
877
  return Array.fromOption(
913
- Option.map(maybeLifted, ({ style, held }) =>
878
+ Option.map(maybeLifted, ({ style, held, pressPoint }) =>
914
879
  h.div(
915
880
  [
916
881
  h.Class("board-lift"),
@@ -946,21 +911,8 @@ const liftedCopyView = (info: PlaceInfo, h: HtmlBuilder<Message>): ReadonlyArray
946
911
  * follows the pointer or the keys.
947
912
  */
948
913
  export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
949
- const {
950
- columns,
951
- toCardView,
952
- toEmptyView = () => [],
953
- toColumnActions = () => [],
954
- toColumnAttributes = () => [],
955
- } = viewInputs;
956
- const info: PlaceInfo = {
957
- model,
958
- columns,
959
- toCardView,
960
- toEmptyView,
961
- toColumnActions,
962
- toColumnAttributes,
963
- };
914
+ const { columns, toCardView, toEmptyView = () => [], toColumnActions = () => [] } = viewInputs;
915
+ const info: PlaceInfo = { model, columns, toCardView, toEmptyView, toColumnActions };
964
916
 
965
917
  return h.div(
966
918
  [h.Class("board"), h.Style({ "--cols": `${columns.length}` })],
@@ -10,10 +10,10 @@ export {
10
10
  cardView,
11
11
  id,
12
12
  init,
13
- isHolding,
14
13
  setMovable,
15
14
  subscriptions,
16
15
  update,
17
16
  view,
18
17
  } from "./board.ts";
18
+ export { isHolding } from "../drag/drag.ts";
19
19
  export type { CardRender, CardViewConfig, InitConfig, ViewInputs } from "./board.ts";
@@ -76,7 +76,7 @@ export type ViewConfig<Message> = Look &
76
76
  isDisabled?: boolean;
77
77
  keyHint?: KeyHintConfig;
78
78
  /** `submit` makes it its form's submit button while it is not held. */
79
- type?: "button" | "submit" | "reset";
79
+ type?: "button" | "submit";
80
80
  /** Further attributes for the `<button>`, such as an id or a Submodel's attribute group. */
81
81
  attributes?: Attributes<Message>;
82
82
  }> &
@@ -1,5 +1,5 @@
1
1
  import { Input as UiInput } from "@foldkit/ui";
2
- import { Array, Effect, HashSet, Match, Option, Schema, Stream, String, pipe } from "effect";
2
+ import { Array, Effect, Equal, HashSet, Match, Option, Schema, Stream, String, pipe } from "effect";
3
3
  import * as FoldkitCommand from "foldkit/command";
4
4
  import * as Dom from "foldkit/dom";
5
5
  import type { Attribute, Html, HtmlBuilder, KeyboardModifiers } from "foldkit/html";
@@ -230,19 +230,10 @@ const fresh = (model: Model, maybePage: Option.Option<OpenPage>): Model =>
230
230
  }),
231
231
  );
232
232
 
233
- const isSamePage = (
234
- maybeFirst: Option.Option<string>,
235
- maybeSecond: Option.Option<string>,
236
- ): boolean =>
237
- Option.match(maybeFirst, {
238
- onNone: () => Option.isNone(maybeSecond),
239
- onSome: (commandId) => Option.contains(maybeSecond, commandId),
240
- });
241
-
242
233
  /** Makes the Model follow what the view showed: a page whose command is gone is dropped (with
243
234
  * a pick out on it), so the commands' query and cursor take the reader's input again. */
244
235
  const followShown = (model: Model, maybeShownPage: Option.Option<string>): Model =>
245
- isSamePage(maybePageId(model), maybeShownPage)
236
+ Equal.equals(maybePageId(model), maybeShownPage)
246
237
  ? model
247
238
  : clearPendingKeys(
248
239
  modifyFields(model, {
@@ -265,13 +256,12 @@ const leavePage = (model: Model): UpdateReturn =>
265
256
  commands: [focusField(model), scrollPaletteToCursor(model)],
266
257
  };
267
258
 
268
- const openPage = (model: Model, commandId: string): UpdateReturn =>
269
- unlessPicking(model, (current) => ({
270
- model: clearPendingKeys(
271
- modifyFields(current, { maybePage: () => Option.some(freshPage(commandId)) }),
272
- ),
273
- commands: [focusField(current), scrollPaletteToCursor(current)],
274
- }));
259
+ const openPage = (model: Model, commandId: string): UpdateReturn => ({
260
+ model: clearPendingKeys(
261
+ modifyFields(model, { maybePage: () => Option.some(freshPage(commandId)) }),
262
+ ),
263
+ commands: [focusField(model), scrollPaletteToCursor(model)],
264
+ });
275
265
 
276
266
  const selectCommand = (model: Model, commandId: string): UpdateReturn =>
277
267
  pipe(
@@ -292,18 +282,16 @@ const moveCursor = (model: Model, cursorId: string): Model =>
292
282
  );
293
283
 
294
284
  // A pick drops the keys waiting for the next render: the row picked is the one acted on.
295
- const pick = (model: Model, commandId: string, choiceId: string): UpdateReturn =>
296
- isPicking(model)
297
- ? { model }
298
- : {
299
- model: clearPendingKeys(
300
- modifyFields(moveCursor(model, choiceId), { maybePicking: () => Option.some(choiceId) }),
301
- ),
302
- outMessage: OutMessage.SelectedChoice({ commandId, choiceId }),
303
- };
285
+ const pick = (model: Model, commandId: string, choiceId: string): UpdateReturn => ({
286
+ model: clearPendingKeys(
287
+ modifyFields(moveCursor(model, choiceId), { maybePicking: () => Option.some(choiceId) }),
288
+ ),
289
+ outMessage: OutMessage.SelectedChoice({ commandId, choiceId }),
290
+ });
304
291
 
305
292
  // What a click on a row or Enter on the cursor's does: nothing when it is held, else pick it
306
- // on a page, open its page, or select it.
293
+ // on a page, open its page, or select it. Never while a pick is out: the click and the keys
294
+ // come through `unlessPicking`, and a pick clears the waiting Enter `settlePending` runs.
307
295
  const runRow = (model: Model, row: ShownRow): UpdateReturn => {
308
296
  if (row.isHeld) {
309
297
  return { model };
@@ -322,12 +310,7 @@ const maybeCurrentCursor = (model: Model): Option.Option<string> =>
322
310
 
323
311
  const ARROW_STEP: Readonly<Record<ArrowKey, number>> = { ArrowUp: -1, ArrowDown: 1 };
324
312
 
325
- type ShownList = Readonly<{
326
- rows: ReadonlyArray<ShownRow>;
327
- maybeShownCursor: Option.Option<string>;
328
- shownQuery: string;
329
- shownShift: number;
330
- }>;
313
+ type ShownList = Omit<typeof Message.PressedEnter.Type, "_tag" | "maybeShownPage">;
331
314
 
332
315
  const isListed = (rows: ReadonlyArray<Readonly<{ id: string }>>, rowId: string): boolean =>
333
316
  Array.some(rows, (row) => row.id === rowId);
@@ -435,7 +418,7 @@ type PendingReport = typeof Message.CompletedReportPendingKeys.Type;
435
418
  const settlePending = (model: Model, report: PendingReport): UpdateReturn => {
436
419
  const { maybeShownPage, shownQuery, shownShift, maybeRow } = report;
437
420
  const isCurrent =
438
- isSamePage(maybePageId(model), maybeShownPage) &&
421
+ Equal.equals(maybePageId(model), maybeShownPage) &&
439
422
  shownShift === model.cursorShift &&
440
423
  shownQuery === query(model);
441
424
  if (!isPending(model) || !isCurrent) {
@@ -566,7 +549,7 @@ export type ViewInputs = Readonly<{
566
549
  toLiveRegionView?: (() => Html) | undefined;
567
550
  }>;
568
551
 
569
- type ShownPageState = Readonly<{ commandId: string; page: Page; state: OpenPage }>;
552
+ type ShownPageState = Readonly<{ page: Page; state: OpenPage }>;
570
553
 
571
554
  type Shown = Readonly<{
572
555
  maybePage: Option.Option<ShownPageState>;
@@ -580,7 +563,7 @@ const pageOf = (commands: ReadonlyArray<Command>, state: OpenPage): Option.Optio
580
563
  pipe(
581
564
  Array.findFirst(commands, (command) => command.id === state.commandId),
582
565
  Option.flatMap((command) => Option.fromNullishOr(command.page)),
583
- Option.map((page) => ({ commandId: state.commandId, page, state })),
566
+ Option.map((page) => ({ page, state })),
584
567
  );
585
568
 
586
569
  const firstListed = (
@@ -686,7 +669,7 @@ const shownQueryOf = (model: Model, shown: Shown): string =>
686
669
 
687
670
  // The command whose page the view shows.
688
671
  const shownPageIdOf = (shown: Shown): Option.Option<string> =>
689
- Option.map(shown.maybePage, ({ commandId }) => commandId);
672
+ Option.map(shown.maybePage, ({ state }) => state.commandId);
690
673
 
691
674
  // NOTE: the field's keys that act on the rows are taken here, where the rows are known, as a
692
675
  // Mount on the document cannot know them: ↑, ↓ and Enter carry what this render shows (see
@@ -24,14 +24,13 @@ const isEmptyFieldOnPage = (root: Element, field: Element): boolean =>
24
24
 
25
25
  /** What a Backspace in the palette's field does: in an empty field on a page it goes back.
26
26
  * It reads the field as it is at the press, which a render cannot know. A key from elsewhere,
27
- * a key an IME is composing, a modified key, a key someone already handled, and every key
28
- * while a pick is out are left alone. */
27
+ * a key an IME is composing, a modified key and a key someone already handled are left
28
+ * alone; while a pick is out the update refuses it. */
29
29
  export const fieldKey = (
30
30
  root: Element,
31
31
  event: KeyboardEvent,
32
32
  ): Option.Option<typeof Message.PressedBackspace.Type> => {
33
33
  const field = root.querySelector(`[data-${FIELD_ATTRIBUTE}]`);
34
- const isWaiting = root.querySelector('[aria-busy="true"]') !== null;
35
34
  if (
36
35
  field === null ||
37
36
  event.target !== field ||
@@ -39,7 +38,6 @@ export const fieldKey = (
39
38
  event.isComposing ||
40
39
  event.defaultPrevented ||
41
40
  !isPlain(event) ||
42
- isWaiting ||
43
41
  !isEmptyFieldOnPage(root, field)
44
42
  ) {
45
43
  return Option.none();
@@ -56,13 +56,15 @@ export const matches = (query: string, label: string): boolean => {
56
56
  return isSubsequence(needle, String.toLowerCase(label));
57
57
  };
58
58
 
59
+ const isBlank = (query: string): boolean => String.isEmpty(String.trim(query));
60
+
59
61
  /** The commands the query finds, under their groups in the order the groups first appear,
60
62
  * authored titles after the commands. With no query, authored titles wait. */
61
63
  export const findCommands = (
62
64
  commands: ReadonlyArray<Command>,
63
65
  query: string,
64
66
  ): ReadonlyArray<Command> => {
65
- const hits = String.isEmpty(String.trim(query))
67
+ const hits = isBlank(query)
66
68
  ? Array.filter(commands, (command) => command.isAuthored !== true)
67
69
  : Array.filter(commands, (command) => matches(query, command.label));
68
70
  const ordered = [
@@ -78,8 +80,6 @@ export const findCommands = (
78
80
  return Array.sort(ordered, Order.mapInput(Order.Number, groupIndex));
79
81
  };
80
82
 
81
- const isBlank = (query: string): boolean => String.isEmpty(String.trim(query));
82
-
83
83
  /** The choices a page's query finds. */
84
84
  export const findChoices = (page: Page, query: string): ReadonlyArray<Choice> =>
85
85
  isBlank(query)
@@ -65,11 +65,13 @@ const dialogFold = foldDialog(Dialog, {
65
65
  toParentOutMessage: toCancelled,
66
66
  });
67
67
 
68
- const FREE: Dialog.UpdateContext = { isCloseHeld: false };
69
-
70
68
  /** Processes a confirm dialog Message and returns the next Model, Commands and OutMessage.
71
69
  * While `context.isCloseHeld`, cancel, Escape and the veil change nothing. */
72
- export const update = (model: Model, message: Message, context: Dialog.UpdateContext = FREE) =>
70
+ export const update = (
71
+ model: Model,
72
+ message: Message,
73
+ context: Dialog.UpdateContext = Dialog.FREE,
74
+ ) =>
73
75
  Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(message, {
74
76
  GotDialogMessage: ({ message: dialogMessage }) =>
75
77
  dialogFold.updateWith(context)(model, dialogMessage),
@@ -10,7 +10,6 @@ import type * as Update from "foldkit/update";
10
10
 
11
11
  import * as Announcer from "../announcer/announcer.ts";
12
12
  import * as Button from "../button/button.ts";
13
- import type { Attributes } from "../attributes/attributes.ts";
14
13
  import * as IconButton from "../iconButton/iconButton.ts";
15
14
 
16
15
  // MODEL
@@ -139,8 +138,6 @@ export type ViewInputs = Readonly<{
139
138
  * word (`"copy"`). */
140
139
  label: string;
141
140
  variant?: Variant;
142
- /** Further attributes for the `<button>`, never a class. */
143
- attributes?: Attributes<Message>;
144
141
  }>;
145
142
 
146
143
  /**
@@ -151,9 +148,9 @@ export type ViewInputs = Readonly<{
151
148
  * resources.
152
149
  */
153
150
  export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
154
- const { text, label, variant = "Icon", attributes = [] } = viewInputs;
151
+ const { text, label, variant = "Icon" } = viewInputs;
155
152
  const isDone = isCopied(model);
156
- const buttonAttributes = [...attributes, h.Id(id(model))];
153
+ const buttonAttributes = [h.Id(id(model))];
157
154
  const onClick = Message.ClickedCopy({ text });
158
155
 
159
156
  return variant === "Icon"
@@ -14,8 +14,6 @@ export type Crumb = Readonly<{
14
14
  /** Configuration for rendering crumbs with `view`. */
15
15
  export type ViewConfig = Readonly<{
16
16
  crumbs: ReadonlyArray<Crumb>;
17
- /** Names the navigation for screen readers. */
18
- label?: string;
19
17
  }>;
20
18
 
21
19
  const crumbView =
@@ -34,10 +32,10 @@ const crumbView =
34
32
  /** The path to where you are, in the pixel face, split by muted slashes: a navigation
35
33
  * landmark named "Breadcrumb", its steps an ordered list, the last `aria-current="page"`. */
36
34
  export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
37
- const { crumbs, label = "Breadcrumb" } = config;
35
+ const { crumbs } = config;
38
36
 
39
37
  return h.nav(
40
- [h.AriaLabel(label), h.Class("crumbs")],
38
+ [h.AriaLabel("Breadcrumb"), h.Class("crumbs")],
41
39
  [h.ol([], Array.map(crumbs, crumbView(crumbs.length - 1, h)))],
42
40
  );
43
41
  };
@@ -89,7 +89,8 @@ export type UpdateContext = Readonly<{
89
89
  isCloseHeld: boolean;
90
90
  }>;
91
91
 
92
- const FREE: UpdateContext = { isCloseHeld: false };
92
+ /** The context of a dialog whose close is not held, the default. */
93
+ export const FREE: UpdateContext = { isCloseHeld: false };
93
94
 
94
95
  // NOTE: @foldkit/ui gap: Dialog has no way to hold a close. Escape, a press on the veil and a
95
96
  // control given the `closeButton` group all send `RequestedClose`, so a held dialog reads it
@@ -248,7 +249,7 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
248
249
  } = viewInputs;
249
250
 
250
251
  const actionBar = childAttributes([
251
- h.DataAttribute("pixel-paper-action-bar", ""),
252
+ h.Attribute(ACTION_BAR_ATTRIBUTE, ""),
252
253
  h.OnMount(BindActionKeys()),
253
254
  ]);
254
255
 
package/src/drag/drag.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  // Shared by Board and Sortable, the two users of @foldkit/ui's DragAndDrop, and Reel, which
2
- // holds its own: the phase a drag is in, the DragAndDrop Messages both build, what a press
3
- // lifts, Escape taken before anything else, the pressing pointer's events, the lifted copy's
4
- // measure, and the closest-centre drop target a sortable resolves for a pointer. A splitter's
5
- // handlers are the Reel's and the Rail's.
2
+ // holds its own: the phase a drag is in, what a hold keeps and how a step settles it, the
3
+ // DragAndDrop Messages both build, what a press lifts, Escape taken before anything else, the
4
+ // pressing pointer's events, the lifted copy's measure, and the closest-centre drop target a
5
+ // sortable resolves for a pointer. A splitter's handlers are the Reel's and the Rail's.
6
6
 
7
7
  import { DragAndDrop as UiDragAndDrop } from "@foldkit/ui";
8
- import { Array, Effect, Option, Order, Stream, pipe } from "effect";
8
+ import { Array, Effect, Option, Order, Schema, Stream, pipe } from "effect";
9
+ import type * as Command from "foldkit/command";
9
10
  import * as Dom from "foldkit/dom";
10
11
  import type { Attribute, HtmlBuilder } from "foldkit/html";
12
+ import * as Subscription from "foldkit/subscription";
13
+ import * as Update from "foldkit/update";
11
14
 
12
15
  import { isPlain } from "../keys/keys.ts";
13
16
 
@@ -36,6 +39,38 @@ export const phaseOf = (dragAndDrop: UiDragAndDrop.Model): Phase => {
36
39
  export const isCarrying = (phase: Phase): boolean =>
37
40
  phase === "PointerDragging" || phase === "KeyboardDragging";
38
41
 
42
+ // HOLD
43
+
44
+ /** What a Board's and a Sortable's Model share: the DragAndDrop, whether items may move, and
45
+ * what the list keeps while an item is pressed or held. */
46
+ type Holder = Readonly<{
47
+ dragAndDrop: UiDragAndDrop.Model;
48
+ isMovable: boolean;
49
+ maybeHeld: Option.Option<unknown>;
50
+ }>;
51
+
52
+ /** Whether an item is pressed or held. While it is, the arrow keys, Space and Escape belong to
53
+ * the list: pause the page's own bindings for them. The list takes Escape before anything
54
+ * else sees it. */
55
+ export const isHolding = (model: Holder): boolean => phaseOf(model.dragAndDrop) !== "Idle";
56
+
57
+ /** Whether a press may lift an item now: items may move and none is pressed or held. */
58
+ export const canLift = (model: Holder): boolean => model.isMovable && !isHolding(model);
59
+
60
+ /** Settles a DragAndDrop step: forgets the held item once it is down, and runs what the step
61
+ * says (`maybeAnnouncement`) after the step's own Commands. */
62
+ export const settle = <Model extends Holder, Message, OutMessage>(
63
+ step: Update.ReturnWithOutMessage<Model, Message, OutMessage>,
64
+ maybeAnnouncement: Option.Option<Command.Command<Message>>,
65
+ ): Update.ReturnWithOutMessage<Model, Message, OutMessage> =>
66
+ Update.withOutMessage(
67
+ {
68
+ model: isHolding(step.model) ? step.model : { ...step.model, maybeHeld: Option.none() },
69
+ commands: [...(step.commands ?? []), ...Array.fromOption(maybeAnnouncement)],
70
+ },
71
+ step.outMessage,
72
+ );
73
+
39
74
  // CHILD MESSAGES
40
75
 
41
76
  // NOTE: @foldkit/ui gap: DragAndDrop exposes no update capability for a press, a keyboard
@@ -114,6 +149,20 @@ export const escapeKeys = <Message>(message: Message): Stream.Stream<Message> =>
114
149
  options: { capture: true },
115
150
  });
116
151
 
152
+ /** A list's `escape` Subscription: `escapeKeys` as `message` while an item is pressed or
153
+ * held. */
154
+ export const escapeSubscriptions = <Model extends Holder, Message>(message: Message) =>
155
+ Subscription.make<Model, Message>()((entry) => ({
156
+ escape: entry(
157
+ { isHolding: Schema.Boolean },
158
+ {
159
+ modelToDependencies: (model) => ({ isHolding: isHolding(model) }),
160
+ dependenciesToStream: ({ isHolding: isHoldingNow }) =>
161
+ isHoldingNow ? escapeKeys(message) : Stream.empty,
162
+ },
163
+ ),
164
+ }));
165
+
117
166
  // POINTER
118
167
 
119
168
  /** The moves, release and cancel of the pointer `pointerId`, on the document; another