@gjermundgaraba-designs/pixel-paper 0.23.0 → 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.
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
 
@@ -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
@@ -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
@@ -377,12 +424,19 @@ export type ViewInputs = Readonly<{
377
424
  * and the rail's width follow it (without one, `--rail`). The drawer keeps its own width. Lift
378
425
  * `subscriptions(keyPrefix)` into the application's, or a dragged splitter follows nothing.
379
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.
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,14 +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(
418
- Option.map(maybeWidth, (shown) => h.Style({ "--railed-width": `${shown}px` })),
419
- ),
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
+ )),
420
518
  ],
421
519
  [
422
520
  toDeckView(openButton),
@@ -439,30 +537,10 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
439
537
  ),
440
538
  ],
441
539
  ),
442
- ...toAsideView(),
540
+ ...toAsideView(toggleButton),
443
541
  ],
444
542
  ),
445
- h.div([
446
- h.Class("rail-resize"),
447
- h.Role("separator"),
448
- h.AriaLabel(`Width of ${label}`),
449
- h.AriaControls(model.id),
450
- h.AriaValuemin(WIDTH.least),
451
- h.AriaValuemax(WIDTH.most),
452
- h.AriaValuenow(width),
453
- h.AriaValuetext(`${width} pixels`),
454
- h.Tabindex(0),
455
- ...splitterAttributes(
456
- {
457
- orientation: "vertical",
458
- isIdle: Option.isNone(model.maybeResizing),
459
- onPress: (pointerId, clientX) => Message.PressedSplitter({ pointerId, clientX, width }),
460
- onStep: (direction) =>
461
- Message.PressedSplitterKey({ width: width + direction * WIDTH.step }),
462
- },
463
- h,
464
- ),
465
- ]),
543
+ ...(isHidden ? [] : [splitter]),
466
544
  ],
467
545
  );
468
546
  });
package/src/tips/tips.ts CHANGED
@@ -1,4 +1,15 @@
1
- import { Array, Duration, Effect, Match, Number, Option, Schema, Stream, pipe } from "effect";
1
+ import {
2
+ Array,
3
+ Duration,
4
+ Effect,
5
+ Equal,
6
+ Match,
7
+ Number,
8
+ Option,
9
+ Schema,
10
+ Stream,
11
+ pipe,
12
+ } from "effect";
2
13
  import * as Command from "foldkit/command";
3
14
  import * as Dom from "foldkit/dom";
4
15
  import type { Html } from "foldkit/html";
@@ -23,7 +34,10 @@ type Side = typeof Side.Type;
23
34
  export const Trigger = Schema.Literals(["Pointer", "Focus"]);
24
35
  export type Trigger = typeof Trigger.Type;
25
36
 
26
- const Words = { text: Schema.String, side: Side };
37
+ /** What a tip says: its control's name, and the key that does the same where the control
38
+ * names one (`data-tip-key`). */
39
+ const Words = { text: Schema.String, maybeKey: Schema.Option(Schema.String), side: Side };
40
+ type Words = Readonly<{ text: string; maybeKey: Option.Option<string>; side: Side }>;
27
41
  const ShownWords = { ...Words, trigger: Trigger };
28
42
 
29
43
  /** Where the page's tip is. `Cold`: none lately. `Waiting`: a pointer rests on a control, for
@@ -51,10 +65,10 @@ export type Model = typeof Model.Type;
51
65
 
52
66
  /** Union of all Messages the tip layer can produce. */
53
67
  export const Message = defineMessageUnion({
54
- EnteredTipTarget: { text: Schema.String, side: Side },
68
+ EnteredTipTarget: Words,
55
69
  LeftTipTarget: {},
56
70
  EnteredTip: {},
57
- FocusedTipTarget: { text: Schema.String, side: Side },
71
+ FocusedTipTarget: Words,
58
72
  BlurredTipTarget: {},
59
73
  PressedPointer: {},
60
74
  PressedKey: {},
@@ -103,6 +117,9 @@ const WARMTH = Duration.millis(400);
103
117
  /** The attribute that holds a control's tip: its accessible name. */
104
118
  const TIP_ATTRIBUTE = "data-tip";
105
119
 
120
+ /** The attribute that holds the key a control's tip prints after its name. */
121
+ const TIP_KEY_ATTRIBUTE = "data-tip-key";
122
+
106
123
  const TIP_SELECTOR = `[${TIP_ATTRIBUTE}]`;
107
124
 
108
125
  const hasText = (element: HTMLElement, text: string): boolean =>
@@ -204,9 +221,11 @@ export const WaitBeforeCooling = Command.define("WaitBeforeCooling", {
204
221
 
205
222
  type UpdateReturn = Update.Return<Model, Message>;
206
223
 
207
- const show = (model: Model, text: string, side: Side, trigger: Trigger): UpdateReturn => ({
208
- model: modifyFields(model, { state: () => TipState.Shown({ text, side, trigger }) }),
209
- commands: [ShowTip({ tipId: model.id, text, trigger })],
224
+ const wordsOf = ({ text, maybeKey, side }: Words): Words => ({ text, maybeKey, side });
225
+
226
+ const show = (model: Model, words: Words, trigger: Trigger): UpdateReturn => ({
227
+ model: modifyFields(model, { state: () => TipState.Shown({ ...wordsOf(words), trigger }) }),
228
+ commands: [ShowTip({ tipId: model.id, text: words.text, trigger })],
210
229
  });
211
230
 
212
231
  const hide = (model: Model): UpdateReturn => {
@@ -217,12 +236,12 @@ const hide = (model: Model): UpdateReturn => {
217
236
  };
218
237
  };
219
238
 
220
- const wait = (model: Model, text: string, side: Side): UpdateReturn => {
239
+ const wait = (model: Model, words: Words): UpdateReturn => {
221
240
  const nextVersion = Number.increment(model.version);
222
241
  return {
223
242
  model: modifyFields(model, {
224
243
  version: () => nextVersion,
225
- state: () => TipState.Waiting({ text, side }),
244
+ state: () => TipState.Waiting(wordsOf(words)),
226
245
  }),
227
246
  commands: [WaitBeforeShowing({ version: nextVersion })],
228
247
  };
@@ -233,7 +252,7 @@ const leave = (model: Model, shown: typeof TipState.Shown.Type): UpdateReturn =>
233
252
  return {
234
253
  model: modifyFields(model, {
235
254
  version: () => nextVersion,
236
- state: () => TipState.Leaving({ text: shown.text, side: shown.side, trigger: shown.trigger }),
255
+ state: () => TipState.Leaving({ ...wordsOf(shown), trigger: shown.trigger }),
237
256
  }),
238
257
  commands: [WaitBeforeHiding({ version: nextVersion })],
239
258
  };
@@ -253,26 +272,26 @@ const dismiss = (model: Model): UpdateReturn =>
253
272
  Warm: () => ({ model }),
254
273
  });
255
274
 
256
- const entered = (model: Model, text: string, side: Side): UpdateReturn =>
275
+ const entered = (model: Model, words: Words): UpdateReturn =>
257
276
  TipState.match<UpdateReturn>(model.state, {
258
- Cold: () => wait(model, text, side),
259
- Waiting: (waiting) => (waiting.text === text ? { model } : wait(model, text, side)),
260
- Shown: (shown) => (shown.text === text ? { model } : show(model, text, side, "Pointer")),
277
+ Cold: () => wait(model, words),
278
+ Waiting: (waiting) => (waiting.text === words.text ? { model } : wait(model, words)),
279
+ Shown: (shown) => (shown.text === words.text ? { model } : show(model, words, "Pointer")),
261
280
  Leaving: (leaving) =>
262
- leaving.text === text
281
+ leaving.text === words.text
263
282
  ? {
264
283
  model: modifyFields(model, {
265
- state: () => TipState.Shown({ text, side, trigger: leaving.trigger }),
284
+ state: () => TipState.Shown({ ...wordsOf(words), trigger: leaving.trigger }),
266
285
  }),
267
286
  }
268
- : show(model, text, side, "Pointer"),
269
- Warm: () => show(model, text, side, "Pointer"),
287
+ : show(model, words, "Pointer"),
288
+ Warm: () => show(model, words, "Pointer"),
270
289
  });
271
290
 
272
291
  /** Processes a tip layer Message and returns the next Model and Commands. */
273
292
  export const update = (model: Model, message: Message) =>
274
293
  Message.match<UpdateReturn>(message, {
275
- EnteredTipTarget: ({ text, side }) => entered(model, text, side),
294
+ EnteredTipTarget: (words) => entered(model, wordsOf(words)),
276
295
  LeftTipTarget: () =>
277
296
  TipState.matchOrElse<UpdateReturn>(
278
297
  model.state,
@@ -286,13 +305,15 @@ export const update = (model: Model, message: Message) =>
286
305
  TipState.matchOrElse<UpdateReturn>(
287
306
  model.state,
288
307
  {
289
- Leaving: ({ text, side, trigger }) => ({
290
- model: modifyFields(model, { state: () => TipState.Shown({ text, side, trigger }) }),
308
+ Leaving: (leaving) => ({
309
+ model: modifyFields(model, {
310
+ state: () => TipState.Shown({ ...wordsOf(leaving), trigger: leaving.trigger }),
311
+ }),
291
312
  }),
292
313
  },
293
314
  () => ({ model }),
294
315
  ),
295
- FocusedTipTarget: ({ text, side }) => show(model, text, side, "Focus"),
316
+ FocusedTipTarget: (words) => show(model, wordsOf(words), "Focus"),
296
317
  BlurredTipTarget: () => dismiss(model),
297
318
  PressedPointer: () => dismiss(model),
298
319
  PressedKey: () => dismiss(model),
@@ -300,8 +321,8 @@ export const update = (model: Model, message: Message) =>
300
321
  TipState.matchOrElse<UpdateReturn>(
301
322
  model.state,
302
323
  {
303
- Waiting: ({ text, side }) =>
304
- model.version === version ? show(model, text, side, "Pointer") : { model },
324
+ Waiting: (waiting) =>
325
+ model.version === version ? show(model, wordsOf(waiting), "Pointer") : { model },
305
326
  },
306
327
  () => ({ model }),
307
328
  ),
@@ -328,10 +349,15 @@ type TargetMessage =
328
349
  | typeof Message.LeftTipTarget.Type
329
350
  | typeof Message.EnteredTip.Type;
330
351
 
331
- type TipTarget = Readonly<{ element: HTMLElement; text: string; side: Side }>;
352
+ type TipTarget = Words & Readonly<{ element: HTMLElement }>;
332
353
 
354
+ /** A control in a vertical strip of tools, or set in vertical writing (a folded rail's title
355
+ * bar), takes its tip at its side, where the tip would cover the next tool or the top bar. */
333
356
  const sideOf = (element: Element): Side =>
334
- element.closest('[aria-orientation="vertical"]') === null ? "Top" : "Right";
357
+ element.closest('[aria-orientation="vertical"]') === null &&
358
+ !getComputedStyle(element).writingMode.startsWith("vertical")
359
+ ? "Top"
360
+ : "Right";
335
361
 
336
362
  const tipTargetOf = (target: EventTarget | null): Option.Option<TipTarget> =>
337
363
  pipe(
@@ -341,6 +367,7 @@ const tipTargetOf = (target: EventTarget | null): Option.Option<TipTarget> =>
341
367
  Option.map(Option.fromNullishOr(element.getAttribute(TIP_ATTRIBUTE)), (text) => ({
342
368
  element,
343
369
  text,
370
+ maybeKey: Option.fromNullishOr(element.getAttribute(TIP_KEY_ATTRIBUTE)),
344
371
  side: sideOf(element),
345
372
  })),
346
373
  ),
@@ -366,7 +393,7 @@ export const pointerOverMessage = (
366
393
  return Option.some(
367
394
  Option.match(tipTargetOf(event.target), {
368
395
  onNone: () => Message.LeftTipTarget(),
369
- onSome: ({ text, side }) => Message.EnteredTipTarget({ text, side }),
396
+ onSome: (target) => Message.EnteredTipTarget(wordsOf(target)),
370
397
  }),
371
398
  );
372
399
  };
@@ -376,7 +403,7 @@ export const pointerOverMessage = (
376
403
  const focusInMessage = (event: FocusEvent): Option.Option<Message> =>
377
404
  Option.map(
378
405
  Option.filter(tipTargetOf(event.target), ({ element }) => element.matches(":focus-visible")),
379
- ({ text, side }) => Message.FocusedTipTarget({ text, side }),
406
+ (target) => Message.FocusedTipTarget(wordsOf(target)),
380
407
  );
381
408
 
382
409
  /** Focus leaving a control with a tip, not into a part of itself, closes the tip (and
@@ -421,7 +448,9 @@ const pointerOverChanges = (tipId: string): Stream.Stream<Message> =>
421
448
  const sameWords = (previous: Message, next: Message): boolean =>
422
449
  previous._tag !== "EnteredTipTarget" ||
423
450
  next._tag !== "EnteredTipTarget" ||
424
- (previous.text === next.text && previous.side === next.side);
451
+ (previous.text === next.text &&
452
+ previous.side === next.side &&
453
+ Equal.equals(previous.maybeKey, next.maybeKey));
425
454
 
426
455
  const tipSubscriptions = Subscription.make<Model, Message>()((entry) => ({
427
456
  pointerAndFocus: entry(
@@ -480,16 +509,17 @@ export const subscriptions = <Prefix extends string>(keyPrefix: Prefix) =>
480
509
 
481
510
  // VIEW
482
511
 
483
- const wordsOf = (model: Model): Option.Option<Readonly<{ text: string; side: Side }>> =>
512
+ const maybeWordsOf = (model: Model): Option.Option<Words> =>
484
513
  Match.value(model.state).pipe(
485
- Match.withReturnType<Option.Option<Readonly<{ text: string; side: Side }>>>(),
486
- Match.tag("Waiting", "Shown", "Leaving", ({ text, side }) => Option.some({ text, side })),
514
+ Match.withReturnType<Option.Option<Words>>(),
515
+ Match.tag("Waiting", "Shown", "Leaving", (words) => Option.some(wordsOf(words))),
487
516
  Match.orElse(() => Option.none()),
488
517
  );
489
518
 
490
519
  /**
491
520
  * The page's one tip: `div.tip[popover="manual"][aria-hidden="true"]`, which shows the words
492
- * of any control carrying `data-tip` (IconButton, the glyph switch, a board's grip). Render it
521
+ * of any control carrying `data-tip` (IconButton, the glyph switch, a board's grip), then the
522
+ * key in its `data-tip-key` where it has one (an IconButton's `keyHint`). Render it
493
523
  * once, beside the Announcer, and lift `subscriptions(keyPrefix)`. It opens on a resting
494
524
  * pointer after a moment (at once while another has just closed), never on touch, and on
495
525
  * keyboard focus; it can be hovered; leaving, blur, a press or any key closes it, and Escape
@@ -501,7 +531,7 @@ const wordsOf = (model: Model): Option.Option<Readonly<{ text: string; side: Sid
501
531
  * accessibility tree, and any control takes one by carrying `data-tip`.
502
532
  */
503
533
  export const view = defineView<Model, Message>((model, h): Html => {
504
- const maybeWords = wordsOf(model);
534
+ const maybeWords = maybeWordsOf(model);
505
535
 
506
536
  return h.div(
507
537
  [
@@ -513,6 +543,12 @@ export const view = defineView<Model, Message>((model, h): Html => {
513
543
  ? [h.DataAttribute("side", "right")]
514
544
  : []),
515
545
  ],
516
- Option.isSome(maybeWords) ? [maybeWords.value.text] : [],
546
+ Option.match(maybeWords, {
547
+ onNone: () => [],
548
+ onSome: ({ text, maybeKey }) => [
549
+ text,
550
+ ...Array.fromOption(Option.map(maybeKey, (key) => h.kbd([h.Class("key-hint")], [key]))),
551
+ ],
552
+ }),
517
553
  );
518
554
  });
package/src/tokens.css CHANGED
@@ -127,6 +127,7 @@
127
127
  --deck: 1160px; /* the widest a deck of panels runs */
128
128
  --rail: 272px; /* the column of panels beside a deck, until the reader sizes it */
129
129
  --rail-drawer: 320px; /* the rail as a drawer, whatever width the reader gave it */
130
+ --rail-folded: 64px; /* the rail folded to its first panel's bar, stood on end */
130
131
 
131
132
  /* ---------- space: between components (Stack, Group, Wrap) ---------- */
132
133
  --space-s: 8px; /* things in one line: buttons, chips, a glyph and its word */