@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/DESIGN.md +8 -7
- package/dist/src/iconButton/iconButton.d.ts +6 -1
- package/dist/src/iconButton/iconButton.d.ts.map +1 -1
- package/dist/src/iconButton/iconButton.js +7 -2
- package/dist/src/panel/panel.d.ts +3 -0
- package/dist/src/panel/panel.d.ts.map +1 -1
- package/dist/src/panel/panel.js +2 -1
- package/dist/src/rail/index.d.ts +1 -1
- package/dist/src/rail/index.d.ts.map +1 -1
- package/dist/src/rail/index.js +1 -1
- package/dist/src/rail/rail.d.ts +78 -17
- package/dist/src/rail/rail.d.ts.map +1 -1
- package/dist/src/rail/rail.js +98 -41
- package/dist/src/tips/tips.d.ts +30 -1
- package/dist/src/tips/tips.d.ts.map +1 -1
- package/dist/src/tips/tips.js +53 -31
- package/package.json +1 -1
- package/src/iconButton/iconButton.ts +19 -2
- package/src/panel/panel.ts +5 -0
- package/src/pixel-paper.css +119 -4
- package/src/rail/index.ts +2 -0
- package/src/rail/rail.ts +119 -41
- package/src/tips/tips.ts +71 -35
- package/src/tokens.css +1 -0
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
|
|
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) =>
|
|
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
|
-
/**
|
|
355
|
-
*
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
366
|
-
* `
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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
|
-
...
|
|
418
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
68
|
+
EnteredTipTarget: Words,
|
|
55
69
|
LeftTipTarget: {},
|
|
56
70
|
EnteredTip: {},
|
|
57
|
-
FocusedTipTarget:
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
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(
|
|
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({
|
|
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,
|
|
275
|
+
const entered = (model: Model, words: Words): UpdateReturn =>
|
|
257
276
|
TipState.match<UpdateReturn>(model.state, {
|
|
258
|
-
Cold: () => wait(model,
|
|
259
|
-
Waiting: (waiting) => (waiting.text === text ? { model } : wait(model,
|
|
260
|
-
Shown: (shown) => (shown.text === text ? { model } : show(model,
|
|
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({
|
|
284
|
+
state: () => TipState.Shown({ ...wordsOf(words), trigger: leaving.trigger }),
|
|
266
285
|
}),
|
|
267
286
|
}
|
|
268
|
-
: show(model,
|
|
269
|
-
Warm: () => show(model,
|
|
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: (
|
|
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: (
|
|
290
|
-
model: modifyFields(model, {
|
|
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: (
|
|
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: (
|
|
304
|
-
model.version === version ? show(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
|
|
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
|
|
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: (
|
|
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
|
-
(
|
|
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 &&
|
|
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
|
|
512
|
+
const maybeWordsOf = (model: Model): Option.Option<Words> =>
|
|
484
513
|
Match.value(model.state).pipe(
|
|
485
|
-
Match.withReturnType<Option.Option<
|
|
486
|
-
Match.tag("Waiting", "Shown", "Leaving", (
|
|
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)
|
|
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 =
|
|
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.
|
|
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 */
|