@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
package/src/rail/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {
2
+ FocusRailToggle,
2
3
  FollowRailDrawer,
3
4
  HideRailDrawer,
4
5
  Message,
@@ -10,6 +11,7 @@ export {
10
11
  init,
11
12
  isOpen,
12
13
  subscriptions,
14
+ toggle,
13
15
  update,
14
16
  view,
15
17
  } from "./rail.ts";
package/src/rail/rail.ts CHANGED
@@ -27,8 +27,8 @@ const Resizing = Schema.Struct({
27
27
  });
28
28
 
29
29
  /** Schema for a rail: whether it is open as a drawer (at 900px and under; wider, it is always
30
- * in the page and this stays false), and its splitter while a pointer drags it. Its width is
31
- * the parent's. */
30
+ * in the page and this stays false), and its splitter while a pointer drags it. Its width,
31
+ * and whether the reader hid it, are the parent's. */
32
32
  export const Model = Schema.Struct({
33
33
  id: Schema.String,
34
34
  isOpen: Schema.Boolean,
@@ -43,6 +43,7 @@ export type Model = typeof Model.Type;
43
43
  * the window growing past 900px while it is open, where the rail is back in the page. */
44
44
  export const Message = defineMessageUnion({
45
45
  ClickedOpen: {},
46
+ ClickedToggle: {},
46
47
  PressedSplitter: { pointerId: Schema.Number, clientX: Schema.Number, width: Schema.Number },
47
48
  MovedSplitter: { clientX: Schema.Number },
48
49
  ReleasedSplitter: { clientX: Schema.Number },
@@ -55,14 +56,18 @@ export const Message = defineMessageUnion({
55
56
  WidenedPastDrawer: {},
56
57
  CompletedShowRailDrawer: {},
57
58
  CompletedHideRailDrawer: {},
59
+ CompletedFocusRailToggle: {},
58
60
  });
59
61
  export type Message = typeof Message.Type;
60
62
 
61
63
  /** What a rail asks of its parent. `ResizedRail`: its splitter was let go, or stepped by a
62
64
  * key, at `width` pixels, within the rail's bounds; the parent keeps it (per client) and
63
- * passes it back as the view's `maybeWidth`. */
65
+ * passes it back as the view's `maybeWidth`. `ToggledRail`: its toggle was pressed; the
66
+ * parent flips whether it keeps the rail hidden (per client) and passes it back as the view's
67
+ * `isHidden`. */
64
68
  export const OutMessage = defineMessageUnion({
65
69
  ResizedRail: { width: Schema.Number },
70
+ ToggledRail: {},
66
71
  });
67
72
  export type OutMessage = typeof OutMessage.Type;
68
73
 
@@ -87,7 +92,7 @@ export const init = (config: InitConfig): Model => ({
87
92
  * (which leaves the deck at least 421px of a 901px window, the narrowest one the rail stands
88
93
  * in, more than a phone has), and what an arrow on its splitter moves it. The bounds and the
89
94
  * 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 };
95
+ export const WIDTH = { start: 272, least: 220, most: 480, step: 16 };
91
96
 
92
97
  const bounded = (width: number): number =>
93
98
  Math.round(Number.clamp(width, { minimum: WIDTH.least, maximum: WIDTH.most }));
@@ -102,6 +107,8 @@ export const isOpen = (model: Model): boolean => model.isOpen;
102
107
 
103
108
  const openButtonId = (model: Model): string => `${model.id}-open`;
104
109
 
110
+ const toggleId = (model: Model): string => `${model.id}-toggle`;
111
+
105
112
  // COMMAND
106
113
 
107
114
  const FOCUSABLE =
@@ -113,11 +120,13 @@ const findRail = (railId: string): Effect.Effect<HTMLElement, Cause.NoSuchElemen
113
120
  );
114
121
 
115
122
  /** The drawer's first Tab stop past its head: a control the keyboard reaches, so a tree's
116
- * items other than its one stop (`tabindex="-1"`) are passed over. */
123
+ * items other than its one stop (`tabindex="-1"`) are passed over, and so is the toggle,
124
+ * which the drawer does not print. */
117
125
  export const firstTabStop = (rail: Element): Option.Option<HTMLElement> =>
118
126
  Array.findFirst(
119
127
  Array.fromIterable(rail.querySelectorAll<HTMLElement>(`.rail-head ~ * :is(${FOCUSABLE})`)),
120
- (control) => control.tabIndex >= 0 && control.closest("[inert], [hidden]") === null,
128
+ (control) =>
129
+ control.tabIndex >= 0 && control.closest("[inert], [hidden], .rail-toggle") === null,
121
130
  );
122
131
 
123
132
  /** Opens the rail as a drawer over the page and puts focus on its first control past its
@@ -157,6 +166,25 @@ export const HideRailDrawer = Command.define("HideRailDrawer", {
157
166
  ),
158
167
  });
159
168
 
169
+ /** Puts focus on the rail's toggle once the rail has folded or unfolded, if focus was in the
170
+ * rail or on its splitter: folding takes away every control in it but the toggle, which
171
+ * brings it back, and the splitter with them. Focus anywhere else stays where it is. */
172
+ export const FocusRailToggle = Command.define("FocusRailToggle", {
173
+ args: { railId: Schema.String, toggleId: Schema.String },
174
+ messages: [Message.CompletedFocusRailToggle],
175
+ execute: ({ railId, toggleId: buttonId }) =>
176
+ Effect.gen(function* () {
177
+ // NOTE: read before the fold renders, which takes away the control focus is on
178
+ const rail = yield* findRail(railId);
179
+ const active = document.activeElement;
180
+ const isOnSplitter =
181
+ active?.matches(`.rail-resize[aria-controls="${CSS.escape(railId)}"]`) ?? false;
182
+ if (rail.contains(active) || isOnSplitter) {
183
+ yield* Dom.focus(`#${CSS.escape(buttonId)}`);
184
+ }
185
+ }).pipe(Effect.ignore, Effect.as(Message.CompletedFocusRailToggle())),
186
+ });
187
+
160
188
  // UPDATE
161
189
 
162
190
  type UpdateReturn = Update.Return<Model, Message>;
@@ -180,6 +208,14 @@ const open = (model: Model): UpdateReturn => ({
180
208
  /** Closes the rail's drawer, focus back on its button. */
181
209
  export const close = (model: Model): UpdateReturn => closeDrawer(model, true);
182
210
 
211
+ /** Folds or unfolds the rail from a key of the parent's: the parent flips whether it keeps the
212
+ * rail hidden, as it does for `ToggledRail`, and folds this in, which moves focus in the rail
213
+ * onto the toggle. */
214
+ export const toggle = (model: Model): UpdateReturn => ({
215
+ model,
216
+ commands: [FocusRailToggle({ railId: model.id, toggleId: toggleId(model) })],
217
+ });
218
+
183
219
  const withResizing = (model: Model, maybeResizing: Option.Option<typeof Resizing.Type>): Model =>
184
220
  modifyFields(model, { maybeResizing: () => maybeResizing });
185
221
 
@@ -194,10 +230,12 @@ const dragTo = (model: Model, clientX: number): Model =>
194
230
  );
195
231
 
196
232
  /** Processes a rail Message and returns the next Model, Commands and OutMessage: a splitter let
197
- * go where it changes the width, or stepped by a key, asks for the width. */
233
+ * go where it changes the width, or stepped by a key, asks for the width, and the toggle asks
234
+ * to fold or unfold the rail. */
198
235
  export const update = (model: Model, message: Message) =>
199
236
  Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(message, {
200
237
  ClickedOpen: () => open(model),
238
+ ClickedToggle: () => ({ ...toggle(model), outMessage: OutMessage.ToggledRail() }),
201
239
  PressedSplitter: ({ pointerId, clientX, width }) =>
202
240
  Option.isSome(model.maybeResizing)
203
241
  ? { model }
@@ -228,6 +266,7 @@ export const update = (model: Model, message: Message) =>
228
266
  WidenedPastDrawer: () => closeDrawer(model, false),
229
267
  CompletedShowRailDrawer: () => ({ model }),
230
268
  CompletedHideRailDrawer: () => ({ model }),
269
+ CompletedFocusRailToggle: () => ({ model }),
231
270
  });
232
271
 
233
272
  // DRAWER EVENTS
@@ -238,7 +277,7 @@ type DrawerMessage =
238
277
  | typeof Message.WidenedPastDrawer.Type;
239
278
 
240
279
  /** The width at and under which the rail is a drawer, as the stylesheet has it. */
241
- const DRAWER_QUERY = "(max-width: 900px)";
280
+ export const DRAWER_QUERY = "(max-width: 900px)";
242
281
 
243
282
  /** The window crossing the drawer's width: growing past it while the drawer is open closes
244
283
  * the drawer, since the rail is back in the page there. */
@@ -351,10 +390,17 @@ export type ViewInputs = Readonly<{
351
390
  * (for this client: another reader's window is not this one's); none for the stylesheet's.
352
391
  * A width out of the rail's bounds prints at the nearer bound. */
353
392
  maybeWidth: Option.Option<number>;
354
- /** The rail's panels: a tree, places, filters. A function because a Submodel's viewInputs
355
- * may hold functions only at the top level, and Foldkit runs this one in the parent's
356
- * boundary, so the handlers in what it returns dispatch the parent's Messages. */
357
- toAsideView: () => ReadonlyArray<Html>;
393
+ /** Whether the reader folded the rail to its first panel's title bar, as the parent keeps it
394
+ * from `ToggledRail` (for this client, as it keeps the width). The drawer ignores it. */
395
+ isHidden: boolean;
396
+ /** The parent's key for the toggle, which its tip prints after its label; the parent binds
397
+ * it to `toggle`. */
398
+ maybeToggleKeyHint: Option.Option<Button.KeyHintConfig>;
399
+ /** The rail's panels: a tree, places, filters, given the toggle that folds the rail to put in
400
+ * its first panel's `lead`. A function because a Submodel's viewInputs may hold functions
401
+ * only at the top level, and Foldkit runs this one in the parent's boundary, so the
402
+ * handlers in what it returns dispatch the parent's Messages. */
403
+ toAsideView: (toggle: Html) => ReadonlyArray<Html>;
358
404
  /** The page beside the rail, `main.deck#main` or `main.bench#main`, given the button that
359
405
  * opens the drawer to print first in it. A function because it receives that button, and
360
406
  * runs in the parent's boundary like `toAsideView`. */
@@ -362,10 +408,11 @@ export type ViewInputs = Readonly<{
362
408
  }>;
363
409
 
364
410
  /**
365
- * A column of panels beside the deck, fixed under the top bar and scrolling itself:
366
- * `div.railed >` the deck, then `aside.rail[popover="auto"][aria-label]`, then its splitter,
367
- * `div.rail-resize[role="separator"]`, on its right edge. One element serves both layouts, so
368
- * nothing in it renders twice or loses its state. At 900px and under it leaves the page, and
411
+ * A column of panels beside the deck, fixed under the top bar: `div.railed >` the deck, then
412
+ * `aside.rail[popover="auto"][aria-label]`, then its splitter, `div.rail-resize[role="separator"]`,
413
+ * on its right edge. What comes before its last panel keeps its height, and the last panel
414
+ * shrinks to the room left, its bar and foot staying while its body scrolls. One element
415
+ * serves both layouts, so nothing in it renders twice or loses its state. At 900px and under it leaves the page, and
369
416
  * `span.rail-open > button.btn.small` at the top of the deck opens the same element as a drawer
370
417
  * over a veil, its head (the name and a close glyph) showing. Opening focuses its first
371
418
  * control; Escape, a press on the veil, the close glyph, focus leaving or the window growing
@@ -373,16 +420,23 @@ export type ViewInputs = Readonly<{
373
420
  *
374
421
  * Its splitter sizes it, by a pointer (previewed as it moves) or the side arrows, within its
375
422
  * 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.
423
+ * `maybeWidth`, which the view sets as `--railed-width` on `div.railed`, so the page's padding
424
+ * and the rail's width follow it (without one, `--rail`). The drawer keeps its own width. Lift
425
+ * `subscriptions(keyPrefix)` into the application's, or a dragged splitter follows nothing.
426
+ *
427
+ * Its toggle, `span.rail-toggle > button.iconbtn.quiet[aria-expanded]` ("Hide <label>"), comes
428
+ * to `toAsideView` to print as the first panel's `lead`. Pressed, it reports `ToggledRail` for
429
+ * the parent to keep and pass back as `isHidden`: `div.railed.folded` then stands the first
430
+ * panel's title bar on end on the ground (the toggle at its top, "Show <label>"), the rest of
431
+ * the rail and its splitter gone, and the deck takes the width. Focus in the rail lands on the
432
+ * toggle. The drawer, at 900px and under, prints neither the toggle nor the fold.
379
433
  *
380
434
  * Its state is its own, not `@foldkit/ui`'s Popover: that panel is placed by Floating UI and
381
435
  * rendered only while open, and this one is always in the page, placed by the stylesheet, and
382
436
  * opened as a native popover so the browser keeps it in the top layer.
383
437
  */
384
438
  export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
385
- const { label, toAsideView, toDeckView } = viewInputs;
439
+ const { label, isHidden, maybeToggleKeyHint, toAsideView, toDeckView } = viewInputs;
386
440
  const maybeWidth = Option.orElse(
387
441
  Option.map(model.maybeResizing, ({ width }) => width),
388
442
  () => Option.map(viewInputs.maybeWidth, bounded),
@@ -409,12 +463,58 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
409
463
  ],
410
464
  );
411
465
 
466
+ const toggleButton = h.span(
467
+ [h.Class("rail-toggle")],
468
+ [
469
+ IconButton.view(
470
+ {
471
+ glyph: "sidebar",
472
+ label: `${isHidden ? "Show" : "Hide"} ${label}`,
473
+ isQuiet: true,
474
+ onClick: Message.ClickedToggle(),
475
+ ...Option.match(maybeToggleKeyHint, {
476
+ onNone: () => ({}),
477
+ onSome: (keyHint) => ({ keyHint }),
478
+ }),
479
+ attributes: [h.Id(toggleId(model)), h.AriaExpanded(!isHidden), h.AriaControls(model.id)],
480
+ },
481
+ h,
482
+ ),
483
+ ],
484
+ );
485
+
486
+ const splitter = h.div([
487
+ h.Class("rail-resize"),
488
+ h.Role("separator"),
489
+ h.AriaLabel(`Width of ${label}`),
490
+ h.AriaControls(model.id),
491
+ h.AriaValuemin(WIDTH.least),
492
+ h.AriaValuemax(WIDTH.most),
493
+ h.AriaValuenow(width),
494
+ h.AriaValuetext(`${width} pixels`),
495
+ h.Tabindex(0),
496
+ ...splitterAttributes(
497
+ {
498
+ orientation: "vertical",
499
+ isIdle: Option.isNone(model.maybeResizing),
500
+ onPress: (pointerId, clientX) => Message.PressedSplitter({ pointerId, clientX, width }),
501
+ onStep: (direction) =>
502
+ Message.PressedSplitterKey({ width: width + direction * WIDTH.step }),
503
+ },
504
+ h,
505
+ ),
506
+ ]);
507
+
412
508
  // NOTE: the deck comes first so the page's main content, and its h1, precede the rail's
413
509
  // panels in reading order; the stylesheet places the rail, fixed, at the left.
414
510
  return h.div(
415
511
  [
416
- h.Class("railed"),
417
- ...Array.fromOption(Option.map(maybeWidth, (shown) => h.Style({ "--rail": `${shown}px` }))),
512
+ h.Class(isHidden ? "railed folded" : "railed"),
513
+ ...(isHidden
514
+ ? []
515
+ : Array.fromOption(
516
+ Option.map(maybeWidth, (shown) => h.Style({ "--railed-width": `${shown}px` })),
517
+ )),
418
518
  ],
419
519
  [
420
520
  toDeckView(openButton),
@@ -437,30 +537,10 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
437
537
  ),
438
538
  ],
439
539
  ),
440
- ...toAsideView(),
540
+ ...toAsideView(toggleButton),
441
541
  ],
442
542
  ),
443
- h.div([
444
- h.Class("rail-resize"),
445
- h.Role("separator"),
446
- h.AriaLabel(`Width of ${label}`),
447
- h.AriaControls(model.id),
448
- h.AriaValuemin(WIDTH.least),
449
- h.AriaValuemax(WIDTH.most),
450
- h.AriaValuenow(width),
451
- h.AriaValuetext(`${width} pixels`),
452
- h.Tabindex(0),
453
- ...splitterAttributes(
454
- {
455
- orientation: "vertical",
456
- isIdle: Option.isNone(model.maybeResizing),
457
- onPress: (pointerId, clientX) => Message.PressedSplitter({ pointerId, clientX, width }),
458
- onStep: (direction) =>
459
- Message.PressedSplitterKey({ width: width + direction * WIDTH.step }),
460
- },
461
- h,
462
- ),
463
- ]),
543
+ ...(isHidden ? [] : [splitter]),
464
544
  ],
465
545
  );
466
546
  });
@@ -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
  },