@gjermundgaraba-designs/pixel-paper 0.24.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 +3 -1
- 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/package.json +1 -1
- package/src/pixel-paper.css +48 -0
- package/src/reel/index.ts +2 -0
- package/src/reel/reel.ts +221 -31
package/package.json
CHANGED
package/src/pixel-paper.css
CHANGED
|
@@ -5630,6 +5630,54 @@ dialog.veil:focus {
|
|
|
5630
5630
|
opacity: var(--fade-dragged);
|
|
5631
5631
|
}
|
|
5632
5632
|
|
|
5633
|
+
/* under tabs, the reel is its tab list over the scroller, which takes the height left. The
|
|
5634
|
+
list sits a gutter in from the reel's edges, as the windows do */
|
|
5635
|
+
.reel.tabbed {
|
|
5636
|
+
display: flex;
|
|
5637
|
+
flex-direction: column;
|
|
5638
|
+
}
|
|
5639
|
+
|
|
5640
|
+
.reel.tabbed > .reel-view {
|
|
5641
|
+
flex: 1 1 0;
|
|
5642
|
+
height: auto;
|
|
5643
|
+
min-height: 0;
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5646
|
+
.reel-tabs {
|
|
5647
|
+
flex: none;
|
|
5648
|
+
max-width: calc(100% - 2 * var(--reel-gutter));
|
|
5649
|
+
margin: var(--reel-gutter) var(--reel-gutter) 0;
|
|
5650
|
+
}
|
|
5651
|
+
|
|
5652
|
+
/* a tab holds a kind glyph before its words, and gives up width before the list overflows:
|
|
5653
|
+
its words end in an ellipsis */
|
|
5654
|
+
.reel-tabs button {
|
|
5655
|
+
display: inline-flex;
|
|
5656
|
+
align-items: center;
|
|
5657
|
+
gap: 6px;
|
|
5658
|
+
min-width: 0;
|
|
5659
|
+
}
|
|
5660
|
+
|
|
5661
|
+
.reel-tab-words {
|
|
5662
|
+
min-width: 0;
|
|
5663
|
+
overflow: hidden;
|
|
5664
|
+
text-overflow: ellipsis;
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
/* the one slot under tabs: the view less a gutter at every edge, every pane pinned to it */
|
|
5668
|
+
.reel-stage {
|
|
5669
|
+
flex: 1 1 0;
|
|
5670
|
+
height: calc(100cqh - 2 * var(--reel-gutter));
|
|
5671
|
+
min-height: 0;
|
|
5672
|
+
margin-inline: var(--reel-gutter);
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
/* a pane behind the selected tab keeps its place and its size, so what sizes itself by its
|
|
5676
|
+
box (a terminal) is not resized as the tabs change; it is only not drawn */
|
|
5677
|
+
.reel-pane.behind {
|
|
5678
|
+
visibility: hidden;
|
|
5679
|
+
}
|
|
5680
|
+
|
|
5633
5681
|
/* only the grips lift: a pane's text selects and its terminal takes the pointer */
|
|
5634
5682
|
[data-reel-grip] {
|
|
5635
5683
|
cursor: grab;
|
package/src/reel/index.ts
CHANGED
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
|
),
|