@gjermundgaraba-designs/pixel-paper 0.23.0 → 0.25.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 +11 -8
- 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/reel/index.d.ts +1 -1
- package/dist/src/reel/index.d.ts.map +1 -1
- package/dist/src/reel/index.js +1 -1
- package/dist/src/reel/reel.d.ts +85 -7
- package/dist/src/reel/reel.d.ts.map +1 -1
- package/dist/src/reel/reel.js +232 -115
- 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 +167 -4
- package/src/rail/index.ts +2 -0
- package/src/rail/rail.ts +119 -41
- package/src/reel/index.ts +2 -0
- package/src/reel/reel.ts +221 -31
- package/src/tips/tips.ts +71 -35
- package/src/tokens.css +1 -0
package/src/reel/reel.ts
CHANGED
|
@@ -86,6 +86,7 @@ const COPY = {
|
|
|
86
86
|
widthLabel: "Width of column {column}",
|
|
87
87
|
heightLabel: "Height of {pane}",
|
|
88
88
|
focused: "{pane}, {where}.",
|
|
89
|
+
focusedTab: "{pane}, tab {tab} of {tabs}.",
|
|
89
90
|
moved: "{subject} moved to {where}.",
|
|
90
91
|
widened: "Column {column} takes {width}.",
|
|
91
92
|
pickedUp: "Picked up {subject}.",
|
|
@@ -143,6 +144,12 @@ export const Model = Schema.Struct({
|
|
|
143
144
|
});
|
|
144
145
|
export type Model = typeof Model.Type;
|
|
145
146
|
|
|
147
|
+
/** How a reel shows its panes: side by side in their columns (`Strip`), or one at a time at
|
|
148
|
+
* the reel's full size under a tab per pane (`Tabs`). The application holds it and passes it
|
|
149
|
+
* in; the layout is the same in both. */
|
|
150
|
+
export const Arrangement = Schema.Literals(["Strip", "Tabs"]);
|
|
151
|
+
export type Arrangement = typeof Arrangement.Type;
|
|
152
|
+
|
|
146
153
|
// MESSAGE
|
|
147
154
|
|
|
148
155
|
const PointerAt = Schema.Struct({
|
|
@@ -169,6 +176,8 @@ export const Message = defineMessageUnion({
|
|
|
169
176
|
LeftGrip: {},
|
|
170
177
|
PressedEscape: {},
|
|
171
178
|
FocusedPane: { paneId: Schema.String },
|
|
179
|
+
PressedTab: { paneId: Schema.String },
|
|
180
|
+
SteppedTab: { paneId: Schema.String },
|
|
172
181
|
PressedSplitter: {
|
|
173
182
|
splitter: Splitter,
|
|
174
183
|
pointerId: Schema.Number,
|
|
@@ -181,6 +190,7 @@ export const Message = defineMessageUnion({
|
|
|
181
190
|
PressedSplitterKey: { splitter: Splitter, share: Schema.Number },
|
|
182
191
|
CompletedAnnounceReel: {},
|
|
183
192
|
CompletedFocusPane: {},
|
|
193
|
+
CompletedFocusTab: {},
|
|
184
194
|
CompletedRestoreFocus: {},
|
|
185
195
|
CompletedScrollIntoView: {},
|
|
186
196
|
CompletedSizeLiftedPane: {},
|
|
@@ -238,6 +248,8 @@ const paneHook = (model: Model, paneId: string): string => `${model.id}/pane/${p
|
|
|
238
248
|
|
|
239
249
|
const columnHook = (model: Model, columnId: string): string => `${model.id}/column/${columnId}`;
|
|
240
250
|
|
|
251
|
+
const tabHook = (model: Model, paneId: string): string => `${model.id}/tab/${paneId}`;
|
|
252
|
+
|
|
241
253
|
const scrollerOf = (reelHook: string): Option.Option<HTMLElement> =>
|
|
242
254
|
Option.fromNullishOr(
|
|
243
255
|
document.querySelector<HTMLElement>(`[data-reel="${CSS.escape(reelHook)}"] > .reel-view`),
|
|
@@ -286,6 +298,21 @@ export const FocusPane = Command.define("FocusPane", {
|
|
|
286
298
|
}),
|
|
287
299
|
});
|
|
288
300
|
|
|
301
|
+
/** Focuses the tab `tabHook` names, for a tab the arrows step to: the keyboard stays in the
|
|
302
|
+
* tab list until the reader goes into the pane. */
|
|
303
|
+
export const FocusTab = Command.define("FocusTab", {
|
|
304
|
+
args: { tabHook: Schema.String },
|
|
305
|
+
messages: [Message.CompletedFocusTab],
|
|
306
|
+
execute: ({ tabHook: hook }) =>
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
yield* Render.afterCommit;
|
|
309
|
+
yield* Effect.sync(() => {
|
|
310
|
+
document.querySelector<HTMLElement>(`[data-reel-tab="${CSS.escape(hook)}"]`)?.focus();
|
|
311
|
+
});
|
|
312
|
+
return Message.CompletedFocusTab();
|
|
313
|
+
}),
|
|
314
|
+
});
|
|
315
|
+
|
|
289
316
|
/** Focuses the pane `paneHook` names only where focus was lost (it left with the pane that
|
|
290
317
|
* held it), so focus the reader put somewhere else stays there. */
|
|
291
318
|
export const RestoreFocus = Command.define("RestoreFocus", {
|
|
@@ -327,8 +354,9 @@ export const ScrollIntoView = Command.define("ScrollIntoView", {
|
|
|
327
354
|
// UPDATE
|
|
328
355
|
|
|
329
356
|
/** What the reel reads from its parent as each Message arrives: the layout as the
|
|
330
|
-
* application holds it now
|
|
331
|
-
|
|
357
|
+
* application holds it now, and how the view shows it (`Strip` when not given), which
|
|
358
|
+
* `perform` reads. */
|
|
359
|
+
export type UpdateContext = Readonly<{ layout: Layout; arrangement?: Arrangement }>;
|
|
332
360
|
|
|
333
361
|
type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>;
|
|
334
362
|
|
|
@@ -497,6 +525,19 @@ const resizeSplitter = (
|
|
|
497
525
|
},
|
|
498
526
|
});
|
|
499
527
|
|
|
528
|
+
/** Shows the pane `paneId` under its tab and moves the keyboard as `focus` says: into the
|
|
529
|
+
* pane for a press, onto the tab for an arrow. */
|
|
530
|
+
const selectTab = (
|
|
531
|
+
model: Model,
|
|
532
|
+
layout: Layout,
|
|
533
|
+
paneId: string,
|
|
534
|
+
focus: Command.Command<Message>,
|
|
535
|
+
): UpdateReturn =>
|
|
536
|
+
Option.match(placeOf(layout, paneId), {
|
|
537
|
+
onNone: () => ({ model }),
|
|
538
|
+
onSome: (place) => ({ model: focusOn(model, place), commands: [focus] }),
|
|
539
|
+
});
|
|
540
|
+
|
|
500
541
|
/** Processes a reel Message against the layout as the application holds it now, and returns
|
|
501
542
|
* the next Model, Commands and OutMessage. */
|
|
502
543
|
export const update = (model: Model, message: Message, context: UpdateContext): UpdateReturn => {
|
|
@@ -542,6 +583,10 @@ export const update = (model: Model, message: Message, context: UpdateContext):
|
|
|
542
583
|
Option.isSome(model.maybeResizing)
|
|
543
584
|
? { model: withResizing(model, Option.none()) }
|
|
544
585
|
: whileHeld(model, (held) => cancel(model, layout, held)),
|
|
586
|
+
PressedTab: ({ paneId }) =>
|
|
587
|
+
selectTab(model, layout, paneId, FocusPane({ paneHook: paneHook(model, paneId) })),
|
|
588
|
+
SteppedTab: ({ paneId }) =>
|
|
589
|
+
selectTab(model, layout, paneId, FocusTab({ tabHook: tabHook(model, paneId) })),
|
|
545
590
|
FocusedPane: ({ paneId }) =>
|
|
546
591
|
Option.match(
|
|
547
592
|
Option.filter(
|
|
@@ -578,6 +623,7 @@ export const update = (model: Model, message: Message, context: UpdateContext):
|
|
|
578
623
|
}),
|
|
579
624
|
CompletedAnnounceReel: () => ({ model }),
|
|
580
625
|
CompletedFocusPane: () => ({ model }),
|
|
626
|
+
CompletedFocusTab: () => ({ model }),
|
|
581
627
|
CompletedRestoreFocus: () => ({ model }),
|
|
582
628
|
CompletedScrollIntoView: () => ({ model }),
|
|
583
629
|
CompletedSizeLiftedPane: () => ({ model }),
|
|
@@ -700,6 +746,51 @@ const requestStep = (
|
|
|
700
746
|
},
|
|
701
747
|
);
|
|
702
748
|
|
|
749
|
+
/** The panes in the order their tabs print: column by column, and down each column. */
|
|
750
|
+
const readingOrder = (layout: Layout): ReadonlyArray<Pane> =>
|
|
751
|
+
Array.flatMap(layout, (column) => column.panes);
|
|
752
|
+
|
|
753
|
+
/** How far each focus key steps through the tabs: left and up to the one before, right and
|
|
754
|
+
* down to the one after. */
|
|
755
|
+
const TAB_STEPS: Readonly<Partial<Record<Action, number>>> = {
|
|
756
|
+
FocusLeft: -1,
|
|
757
|
+
FocusUp: -1,
|
|
758
|
+
FocusRight: 1,
|
|
759
|
+
FocusDown: 1,
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
/** `perform` while the panes print as tabs: a focus key moves to the tab beside the focused
|
|
763
|
+
* pane's, past neither end; there is no column to move or size. */
|
|
764
|
+
const performTabbed = (model: Model, action: Action, layout: Layout): UpdateReturn => {
|
|
765
|
+
const panes = readingOrder(layout);
|
|
766
|
+
const maybeNext = Option.flatMap(
|
|
767
|
+
Option.all({
|
|
768
|
+
step: Option.fromUndefinedOr(TAB_STEPS[action]),
|
|
769
|
+
index: Option.flatMap(focusedPaneId(model, layout), (paneId) =>
|
|
770
|
+
Array.findFirstIndex(panes, (pane) => pane.id === paneId),
|
|
771
|
+
),
|
|
772
|
+
}),
|
|
773
|
+
({ step, index }) =>
|
|
774
|
+
Option.flatMap(Array.get(panes, index + step), (pane) =>
|
|
775
|
+
Option.map(placeOf(layout, pane.id), (place) => ({ place, tab: index + step + 1 })),
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
return Option.match(
|
|
780
|
+
Option.filter(maybeNext, () => !isHolding(model)),
|
|
781
|
+
{
|
|
782
|
+
onNone: () => ({ model }),
|
|
783
|
+
onSome: ({ place, tab }) => ({
|
|
784
|
+
model: focusOn(model, place),
|
|
785
|
+
commands: [
|
|
786
|
+
FocusPane({ paneHook: paneHook(model, place.pane.id) }),
|
|
787
|
+
announce(fill(COPY.focusedTab, { pane: place.pane.label, tab, tabs: panes.length })),
|
|
788
|
+
],
|
|
789
|
+
}),
|
|
790
|
+
},
|
|
791
|
+
);
|
|
792
|
+
};
|
|
793
|
+
|
|
703
794
|
const columnIdAt = (layout: Layout, index: number): Option.Option<string> =>
|
|
704
795
|
Option.map(Array.get(layout, index), ({ id }) => id);
|
|
705
796
|
|
|
@@ -708,10 +799,15 @@ const columnIdAt = (layout: Layout, index: number): Option.Option<string> =>
|
|
|
708
799
|
* layout as the application holds it: focus moves focus the pane and scroll the reel to it;
|
|
709
800
|
* moves report their request as the OutMessage, which the application applies, with focus
|
|
710
801
|
* kept on the pane. Each is said through the live region. Nothing happens while something is
|
|
711
|
-
* held, or where there is nowhere to go.
|
|
802
|
+
* held, or where there is nowhere to go. Under tabs, only the focus keys act, stepping from tab
|
|
803
|
+
* to tab.
|
|
712
804
|
*/
|
|
713
805
|
export const perform = (model: Model, action: Action, context: UpdateContext): UpdateReturn => {
|
|
714
|
-
const { layout } = context;
|
|
806
|
+
const { layout, arrangement = "Strip" } = context;
|
|
807
|
+
|
|
808
|
+
if (arrangement === "Tabs") {
|
|
809
|
+
return performTabbed(model, action, layout);
|
|
810
|
+
}
|
|
715
811
|
|
|
716
812
|
return Option.match(
|
|
717
813
|
Option.filter(focusedPlace(model, layout), () => !isHolding(model)),
|
|
@@ -1157,11 +1253,12 @@ export const SizeLiftedPane = Mount.define("SizeLiftedPane", {
|
|
|
1157
1253
|
|
|
1158
1254
|
// VIEW
|
|
1159
1255
|
|
|
1160
|
-
/** What the reel hands `toPaneView` for each pane: put `
|
|
1256
|
+
/** What the reel hands `toPaneView` for each pane: put `lead` in the Window's `lead` and give
|
|
1161
1257
|
* it `id`. */
|
|
1162
1258
|
export type PaneRender = Readonly<{
|
|
1163
|
-
/** The
|
|
1164
|
-
|
|
1259
|
+
/** The title bar's `lead`: the pane's grip, a quiet glyph button named "Move <label>", or
|
|
1260
|
+
* nothing under tabs, where a pane does not move. */
|
|
1261
|
+
lead: ReadonlyArray<Html>;
|
|
1165
1262
|
/** The window's id, unique in the document. */
|
|
1166
1263
|
id: string;
|
|
1167
1264
|
}>;
|
|
@@ -1173,15 +1270,21 @@ export type ViewInputs = Readonly<{
|
|
|
1173
1270
|
layout: Layout;
|
|
1174
1271
|
/** Names the reel for screen readers. */
|
|
1175
1272
|
label: string;
|
|
1176
|
-
/** Renders one pane, a `Window.view` with `render.
|
|
1273
|
+
/** Renders one pane, a `Window.view` with `render.lead` as its `lead` and `render.id` as its
|
|
1177
1274
|
* `id`. Mark the control focus should land on in it (a terminal's input) with
|
|
1178
1275
|
* `data-reel-focus`. */
|
|
1179
1276
|
toPaneView: (pane: Pane, render: PaneRender) => Html;
|
|
1277
|
+
/** How the panes print: in their columns (`Strip`, when not given), or one at a time under
|
|
1278
|
+
* their tabs (`Tabs`). Pass the same to `perform`. */
|
|
1279
|
+
arrangement?: Arrangement;
|
|
1280
|
+
/** What a pane's tab prints before its label: a kind glyph. Nothing when not given. */
|
|
1281
|
+
toTabLeadView?: (pane: Pane) => ReadonlyArray<Html>;
|
|
1180
1282
|
}>;
|
|
1181
1283
|
|
|
1182
1284
|
type ReelInfo = Readonly<{
|
|
1183
1285
|
model: Model;
|
|
1184
1286
|
layout: Layout;
|
|
1287
|
+
isTabbed: boolean;
|
|
1185
1288
|
toPaneView: (pane: Pane, render: PaneRender) => Html;
|
|
1186
1289
|
maybeFocusedId: Option.Option<string>;
|
|
1187
1290
|
maybeMark: Option.Option<Target>;
|
|
@@ -1202,6 +1305,9 @@ const HELD_STEPS: Readonly<Record<string, Step>> = {
|
|
|
1202
1305
|
|
|
1203
1306
|
const isDropKey = (key: string): boolean => key === " " || key === "Enter";
|
|
1204
1307
|
|
|
1308
|
+
/** The anchor every pane is pinned to under tabs: the one slot, the reel's full size. */
|
|
1309
|
+
const STAGE_ANCHOR = "--reel-stage";
|
|
1310
|
+
|
|
1205
1311
|
/** The anchor that pins a pane to its slot. Pane ids are the application's, so the name is
|
|
1206
1312
|
* the pane's place in the id order, which the slot and the pane read from one list. */
|
|
1207
1313
|
const anchorName = (index: number): string => `--reel-${index}`;
|
|
@@ -1406,26 +1512,32 @@ const paneLayerView = (
|
|
|
1406
1512
|
): Html => {
|
|
1407
1513
|
const { model } = info;
|
|
1408
1514
|
|
|
1409
|
-
// NOTE: the panes keep one order in the page, by id, whatever the layout
|
|
1410
|
-
// only where each is anchored, so no pane's element is
|
|
1411
|
-
// drop focus, a terminal's scroll and whatever a Mount
|
|
1515
|
+
// NOTE: the panes keep one order in the page, by id, whatever the layout or the arrangement:
|
|
1516
|
+
// a move or a change to tabs changes only where each is anchored, so no pane's element is
|
|
1517
|
+
// taken out and put back, which would drop focus, a terminal's scroll and whatever a Mount
|
|
1518
|
+
// holds inside it. Under tabs the panes behind the focused one are hidden, not removed, and
|
|
1519
|
+
// keep their size, so a terminal sized by its box is not resized as the tabs change.
|
|
1412
1520
|
return h.div(
|
|
1413
1521
|
[h.Class("reel-panes")],
|
|
1414
1522
|
Array.map(panes, (pane) => {
|
|
1415
1523
|
const isFocused = Option.contains(info.maybeFocusedId, pane.id);
|
|
1416
1524
|
const isHeld = Option.contains(info.maybeCarriedId, pane.id);
|
|
1525
|
+
const isBehind = info.isTabbed && !isFocused;
|
|
1417
1526
|
return h.keyed("div")(
|
|
1418
1527
|
pane.id,
|
|
1419
1528
|
[
|
|
1420
|
-
h.Class(
|
|
1529
|
+
h.Class(
|
|
1530
|
+
`reel-pane${isFocused ? " focused" : ""}${isHeld ? " drag-dim" : ""}${isBehind ? " behind" : ""}`,
|
|
1531
|
+
),
|
|
1421
1532
|
h.DataAttribute("reel-pane", paneHook(model, pane.id)),
|
|
1422
1533
|
h.Style({ "--reel-anchor": anchors.get(pane.id) ?? "none" }),
|
|
1423
1534
|
h.Tabindex(-1),
|
|
1535
|
+
...(isBehind ? [h.Inert(true)] : []),
|
|
1424
1536
|
h.OnFocusEnter(Message.FocusedPane({ paneId: pane.id })),
|
|
1425
1537
|
],
|
|
1426
1538
|
[
|
|
1427
1539
|
info.toPaneView(pane, {
|
|
1428
|
-
|
|
1540
|
+
lead: info.isTabbed ? [] : [gripView(model, pane, h)],
|
|
1429
1541
|
id: `${model.id}-${pane.id}`,
|
|
1430
1542
|
}),
|
|
1431
1543
|
],
|
|
@@ -1484,6 +1596,57 @@ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<
|
|
|
1484
1596
|
);
|
|
1485
1597
|
};
|
|
1486
1598
|
|
|
1599
|
+
/** The tab list under tabs: a tab per pane in reading order, the focused pane's selected. A
|
|
1600
|
+
* press shows its pane and puts the keyboard in it; the side arrows (wrapping), Home and End
|
|
1601
|
+
* select as they move and keep the keyboard on the tabs, only the selected tab being in the
|
|
1602
|
+
* Tab order. A modified arrow is the application's. */
|
|
1603
|
+
const tabListView = (
|
|
1604
|
+
info: ReelInfo,
|
|
1605
|
+
label: string,
|
|
1606
|
+
toTabLeadView: (pane: Pane) => ReadonlyArray<Html>,
|
|
1607
|
+
h: HtmlBuilder<Message>,
|
|
1608
|
+
): Html => {
|
|
1609
|
+
const { model, layout } = info;
|
|
1610
|
+
const panes = readingOrder(layout);
|
|
1611
|
+
const stepTo = (index: number): Option.Option<Message> =>
|
|
1612
|
+
Option.map(Array.get(panes, (index + panes.length) % panes.length), ({ id }) =>
|
|
1613
|
+
Message.SteppedTab({ paneId: id }),
|
|
1614
|
+
);
|
|
1615
|
+
const keyOf = (index: number, key: string): Option.Option<Message> =>
|
|
1616
|
+
Match.value(key).pipe(
|
|
1617
|
+
Match.when("ArrowLeft", () => stepTo(index - 1)),
|
|
1618
|
+
Match.when("ArrowRight", () => stepTo(index + 1)),
|
|
1619
|
+
Match.when("Home", () => stepTo(0)),
|
|
1620
|
+
Match.when("End", () => stepTo(panes.length - 1)),
|
|
1621
|
+
Match.orElse(() => Option.none()),
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1624
|
+
return h.div(
|
|
1625
|
+
[h.Class("nav tabs reel-tabs"), h.Role("tablist"), h.AriaLabel(label)],
|
|
1626
|
+
Array.map(panes, (pane, index) => {
|
|
1627
|
+
const isSelected = Option.contains(info.maybeFocusedId, pane.id);
|
|
1628
|
+
return h.keyed("button")(
|
|
1629
|
+
pane.id,
|
|
1630
|
+
[
|
|
1631
|
+
h.Type("button"),
|
|
1632
|
+
h.Role("tab"),
|
|
1633
|
+
h.AriaSelected(isSelected),
|
|
1634
|
+
h.AriaControls(`${model.id}-${pane.id}`),
|
|
1635
|
+
h.Tabindex(isSelected ? 0 : -1),
|
|
1636
|
+
h.DataAttribute("reel-tab", tabHook(model, pane.id)),
|
|
1637
|
+
h.OnClick(Message.PressedTab({ paneId: pane.id })),
|
|
1638
|
+
h.OnKeyDownPreventDefault((key, modifiers) =>
|
|
1639
|
+
isPlain(modifiers) ? keyOf(index, key) : Option.none(),
|
|
1640
|
+
),
|
|
1641
|
+
],
|
|
1642
|
+
[...toTabLeadView(pane), h.span([h.Class("reel-tab-words")], [pane.label])],
|
|
1643
|
+
);
|
|
1644
|
+
}),
|
|
1645
|
+
);
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
const NO_TAB_LEAD = (): ReadonlyArray<Html> => [];
|
|
1649
|
+
|
|
1487
1650
|
/** The layout as it prints, with a dragged splitter's resize shown before it is asked for. */
|
|
1488
1651
|
const previewed = (model: Model, layout: Layout): Layout =>
|
|
1489
1652
|
Option.match(
|
|
@@ -1504,48 +1667,75 @@ const previewed = (model: Model, layout: Layout): Layout =>
|
|
|
1504
1667
|
* panes), Space drops it, and Escape or Tab puts it back. A drop reports what it asks
|
|
1505
1668
|
* of the layout as the OutMessage. Lift `subscriptions(keyPrefix)` into the application's, or
|
|
1506
1669
|
* nothing follows the pointer, and call `reconcile` whenever the layout changes.
|
|
1670
|
+
*
|
|
1671
|
+
* Under tabs (`arrangement: "Tabs"`) the reel is `div.reel.tabbed`: a `div.nav.tabs.reel-tabs`
|
|
1672
|
+
* tab list over the scroller, whose track holds one `div.reel-slot.reel-stage` at the reel's
|
|
1673
|
+
* full size where the columns were. Every pane is pinned to it, the focused one shown and the
|
|
1674
|
+
* others `.behind`, hidden and inert; there are no grips and no splitters.
|
|
1507
1675
|
*/
|
|
1508
1676
|
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
|
|
1509
|
-
const { label, toPaneView } = viewInputs;
|
|
1677
|
+
const { label, toPaneView, arrangement = "Strip", toTabLeadView = NO_TAB_LEAD } = viewInputs;
|
|
1678
|
+
const isTabbed = arrangement === "Tabs";
|
|
1510
1679
|
const layout = previewed(model, viewInputs.layout);
|
|
1511
1680
|
const panes = Array.sort(
|
|
1512
1681
|
Array.flatMap(layout, (column) => column.panes),
|
|
1513
1682
|
byId,
|
|
1514
1683
|
);
|
|
1515
|
-
const anchors = new Map(
|
|
1684
|
+
const anchors = new Map(
|
|
1685
|
+
Array.map(
|
|
1686
|
+
panes,
|
|
1687
|
+
(pane, index) => [pane.id, isTabbed ? STAGE_ANCHOR : anchorName(index)] as const,
|
|
1688
|
+
),
|
|
1689
|
+
);
|
|
1516
1690
|
const info: ReelInfo = {
|
|
1517
1691
|
model,
|
|
1518
1692
|
layout,
|
|
1693
|
+
isTabbed,
|
|
1519
1694
|
toPaneView,
|
|
1520
1695
|
maybeFocusedId: focusedPaneId(model, layout),
|
|
1521
1696
|
maybeMark: markOf(model, layout),
|
|
1522
1697
|
maybeCarriedId: carriedIdOf(model),
|
|
1523
1698
|
};
|
|
1524
1699
|
|
|
1700
|
+
const strip = (): ReadonlyArray<Html> => [
|
|
1701
|
+
h.keyed("div")("gap:start", [
|
|
1702
|
+
h.Class(
|
|
1703
|
+
`reel-gap${
|
|
1704
|
+
isGapMarked(
|
|
1705
|
+
info,
|
|
1706
|
+
Option.map(Array.head(layout), ({ id }) => id),
|
|
1707
|
+
)
|
|
1708
|
+
? " drop-here"
|
|
1709
|
+
: ""
|
|
1710
|
+
}`,
|
|
1711
|
+
),
|
|
1712
|
+
]),
|
|
1713
|
+
...Array.flatMap(layout, (column, index) => columnView(info, anchors, column, index, h)),
|
|
1714
|
+
];
|
|
1715
|
+
|
|
1525
1716
|
return h.div(
|
|
1526
|
-
[h.Class("reel"), h.Role("group"), h.AriaLabel(label), h.DataAttribute("reel", model.id)],
|
|
1527
1717
|
[
|
|
1718
|
+
h.Class(isTabbed ? "reel tabbed" : "reel"),
|
|
1719
|
+
h.Role("group"),
|
|
1720
|
+
h.AriaLabel(label),
|
|
1721
|
+
h.DataAttribute("reel", model.id),
|
|
1722
|
+
],
|
|
1723
|
+
[
|
|
1724
|
+
...(isTabbed ? [tabListView(info, label, toTabLeadView, h)] : []),
|
|
1528
1725
|
h.div(
|
|
1529
1726
|
[h.Class("reel-view")],
|
|
1530
1727
|
[
|
|
1531
1728
|
h.div(
|
|
1532
1729
|
[h.Class("reel-track")],
|
|
1533
1730
|
[
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
: ""
|
|
1543
|
-
}`,
|
|
1544
|
-
),
|
|
1545
|
-
]),
|
|
1546
|
-
...Array.flatMap(layout, (column, index) =>
|
|
1547
|
-
columnView(info, anchors, column, index, h),
|
|
1548
|
-
),
|
|
1731
|
+
...(isTabbed
|
|
1732
|
+
? [
|
|
1733
|
+
h.div([
|
|
1734
|
+
h.Class("reel-slot reel-stage"),
|
|
1735
|
+
h.Style({ "--reel-anchor": STAGE_ANCHOR }),
|
|
1736
|
+
]),
|
|
1737
|
+
]
|
|
1738
|
+
: strip()),
|
|
1549
1739
|
paneLayerView(info, panes, anchors, h),
|
|
1550
1740
|
],
|
|
1551
1741
|
),
|
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
|
});
|