staffa 0.12.0 → 0.12.1
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/README.md +1 -1
- package/dist/components/main.js +29 -20
- package/dist/components/panels.d.ts +68 -16
- package/dist/components/panels.js +75 -52
- package/dist/staffa.esm.js +1 -1
- package/package.json +3 -3
- package/skill/Panel.md +36 -2
- package/skill/PanelStack.md +5 -0
- package/skill/SKILL.md +1 -1
- package/src/components/main.ts +30 -22
- package/src/components/panels.ts +113 -53
package/src/components/panels.ts
CHANGED
|
@@ -179,13 +179,19 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
179
179
|
* column fits beside it. For lists and detail forms.
|
|
180
180
|
* - `"full"` (the default) — the whole content area, up to ~1100px.
|
|
181
181
|
* - `"screen"` — the whole window, unbounded: boards, wide tables, dense
|
|
182
|
-
* dashboards. While one is open the
|
|
183
|
-
*
|
|
182
|
+
* dashboards. While one is open the columns stretch to the screen edges
|
|
183
|
+
* instead of stopping at the standard 1280px page; the top bar and
|
|
184
|
+
* footer hold the standard width throughout.
|
|
184
185
|
*
|
|
185
186
|
* Below the width two columns need, everything takes the content area
|
|
186
187
|
* whatever it asked for. Widths depend only on the window, never on what
|
|
187
188
|
* else is open, so opening or closing a panel never resizes another.
|
|
188
189
|
*
|
|
190
|
+
* This is a *layout regime*, not a width guarantee: handle whatever width
|
|
191
|
+
* the bucket yields, and ask only for what your content can actually use —
|
|
192
|
+
* a screen that would cap its own content narrower than its ask is holding
|
|
193
|
+
* room that would have let another column fit beside it.
|
|
194
|
+
*
|
|
189
195
|
* Set it at the top of your handler and the panel is already that wide when
|
|
190
196
|
* you draw (see {@link Panel.width}); set it later — when your data tells you
|
|
191
197
|
* — and the panel reflows without being redrawn, keeping its state, while
|
|
@@ -261,6 +267,31 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
261
267
|
* ```
|
|
262
268
|
*/
|
|
263
269
|
close(): Promise<boolean>;
|
|
270
|
+
/**
|
|
271
|
+
* Opens `href` exactly as a click on a link inside this panel does — the
|
|
272
|
+
* shell's own link handling runs through this very call, so the two can't
|
|
273
|
+
* drift apart. By default that is a push: the target opens on top of this
|
|
274
|
+
* panel, closing the panels after it first (pinned ones ride along
|
|
275
|
+
* beneath the new panel, unsaved ones park), and a path that is already
|
|
276
|
+
* open is returned to rather than opened twice. `how` plays the part of a
|
|
277
|
+
* link's `data-panel` attribute: `"replace"` puts the target in this
|
|
278
|
+
* panel's place, `"open"` leaves the panel behind and gives the target
|
|
279
|
+
* its own stack, and omitting it follows the shell's
|
|
280
|
+
* {@link MainOptions.linkNavigation}, like a link without the attribute.
|
|
281
|
+
*
|
|
282
|
+
* This is the one for navigation that can't be a link: a row's click
|
|
283
|
+
* handler, a keyboard shortcut acting on this screen. The stack's
|
|
284
|
+
* {@link PanelStack.pushPanel} builds on the *current* panel instead — a
|
|
285
|
+
* different panel exactly when the interaction happened in a column
|
|
286
|
+
* beside it, where it would pile the new panel on top of the open detail
|
|
287
|
+
* rather than pruning back to this one.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```ts
|
|
291
|
+
* A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
|
|
264
295
|
}
|
|
265
296
|
|
|
266
297
|
// ─── Path matching ───────────────────────────────────────────────────────────
|
|
@@ -374,7 +405,7 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
374
405
|
/**
|
|
375
406
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
376
407
|
* `left` moves of columns shifting sideways, the ensemble-width transition the
|
|
377
|
-
*
|
|
408
|
+
* body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
|
|
378
409
|
* panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
|
|
379
410
|
* and JS can't drift apart.
|
|
380
411
|
*
|
|
@@ -388,9 +419,11 @@ const LOADING_HOLD_MS = 300;
|
|
|
388
419
|
/**
|
|
389
420
|
* The standard page width: sidebar plus content area, capped by the window.
|
|
390
421
|
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
391
|
-
* page makes the shell grow past it.
|
|
422
|
+
* page makes the shell grow past it. The top bar and footer keep to this
|
|
423
|
+
* width even then (see main.ts), so the chrome holds still while the
|
|
424
|
+
* columns stretch.
|
|
392
425
|
*/
|
|
393
|
-
const SHELL_PX = 1280;
|
|
426
|
+
export const SHELL_PX = 1280;
|
|
394
427
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
395
428
|
const PAIR_MIN_PX = 360;
|
|
396
429
|
/**
|
|
@@ -415,8 +448,13 @@ A.insertGlobalCss({
|
|
|
415
448
|
// The region paints the panel's sheen over its own box, and every panel shows
|
|
416
449
|
// a slice of that same gradient (see `.s-panel` below), so the columns and
|
|
417
450
|
// the ground beside them are one continuous surface.
|
|
451
|
+
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
452
|
+
// and anything that ever scrolls it — find-in-page reaching for text in a
|
|
453
|
+
// parked column, an in-page anchor, an extension — shifts every column
|
|
454
|
+
// sideways, permanently, because nothing here would ever scroll it back.
|
|
455
|
+
// `clip` clips without being scrollable at all, closing the whole class.
|
|
418
456
|
".s-panels":
|
|
419
|
-
"flex:1 min-width:0 min-height:0 position:relative overflow:
|
|
457
|
+
"flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
420
458
|
SURFACE_SHEEN,
|
|
421
459
|
".s-panel": {
|
|
422
460
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
@@ -513,14 +551,19 @@ A.insertGlobalCss({
|
|
|
513
551
|
// the weight change alone is ambiguous in a short crumb, the colour alone
|
|
514
552
|
// too subtle. No padding of its own — the first crumb has to start on the
|
|
515
553
|
// same pixel as the app's name above it, and the gap below spaces the row.
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
554
|
+
// The flex is how a tight row is shared out. Every crumb grows from the
|
|
555
|
+
// same 4rem basis in equal shares, freezing at its own text
|
|
556
|
+
// (`max-width:max-content`) — so with room to spare every title shows in
|
|
557
|
+
// full, and under pressure it is the *longest* crumbs that give way
|
|
558
|
+
// first, equalising downward while short ones keep every character. No
|
|
559
|
+
// crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
|
|
560
|
+
// that point the row overflows and the strip scrolls — which is what
|
|
561
|
+
// keeps a deep stack on a phone readable. (Crumbs allowed to shrink
|
|
562
|
+
// would ellipsise to a row of stubs instead, and the stack would never
|
|
563
|
+
// scroll.)
|
|
521
564
|
"&":
|
|
522
|
-
"flex
|
|
523
|
-
"white-space:nowrap max-width:
|
|
565
|
+
"flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
566
|
+
"white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
|
|
524
567
|
"transition: color 0.12s;",
|
|
525
568
|
"&.s-crumb-on": "font-weight:600 fg:$s-text",
|
|
526
569
|
// The same hover treatment as a menu item. The panel you are on is a plain
|
|
@@ -739,6 +782,11 @@ export interface PanelStack {
|
|
|
739
782
|
* than opening it twice, and a panel holding {@link Panel.unsaved} work is
|
|
740
783
|
* never closed, only parked. That's what a plain link does, and what
|
|
741
784
|
* `data-panel=push` says outright.
|
|
785
|
+
*
|
|
786
|
+
* Note that a link builds on the panel it is *drawn in*, which is the
|
|
787
|
+
* current panel only while no column beside it has the focus. Code
|
|
788
|
+
* navigating on behalf of a particular screen — a row's click handler —
|
|
789
|
+
* wants that panel's own {@link Panel.open} instead.
|
|
742
790
|
*/
|
|
743
791
|
pushPanel(path: string): Promise<boolean>;
|
|
744
792
|
/**
|
|
@@ -1117,9 +1165,12 @@ export class PanelStackController implements PanelStack {
|
|
|
1117
1165
|
maxWidth: "full" as const,
|
|
1118
1166
|
width: 0,
|
|
1119
1167
|
} as PanelEntry;
|
|
1120
|
-
// `close` closes *this* panel, current or not
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1168
|
+
// `close` closes *this* panel, current or not, and `open` navigates
|
|
1169
|
+
// *from* it, through the very implementation a link click uses (see
|
|
1170
|
+
// `navigate`). Both resolve the panel's place in the stack at call
|
|
1171
|
+
// time, so they keep working after a splice has moved it; an `open`
|
|
1172
|
+
// from a panel that has since closed falls back to a derived stack,
|
|
1173
|
+
// like a link from nowhere.
|
|
1123
1174
|
//
|
|
1124
1175
|
// `visible` starts at what the panel's place implies: shown when it sits
|
|
1125
1176
|
// at or before the current panel (a pushed panel always does), hidden when
|
|
@@ -1133,6 +1184,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1133
1184
|
visible,
|
|
1134
1185
|
pinned: pinned || undefined,
|
|
1135
1186
|
close: () => this.closePath(entry.path),
|
|
1187
|
+
open: (href: string, how?: "push" | "replace" | "open") => this.navigate(href, { from: entry.path, how }),
|
|
1136
1188
|
}) as PanelState;
|
|
1137
1189
|
return entry;
|
|
1138
1190
|
}
|
|
@@ -1365,17 +1417,31 @@ export class PanelStackController implements PanelStack {
|
|
|
1365
1417
|
}
|
|
1366
1418
|
|
|
1367
1419
|
/**
|
|
1368
|
-
* Navigate to `href
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
* the
|
|
1420
|
+
* Navigate to `href` — the one implementation behind a link click,
|
|
1421
|
+
* {@link Panel.open} and the stack's own methods, so none of them can
|
|
1422
|
+
* behave differently.
|
|
1423
|
+
*
|
|
1424
|
+
* `from` is the path of the panel the navigation starts from — the one
|
|
1425
|
+
* the link lives in — or absent when it has none: a nav item, or a call
|
|
1426
|
+
* that means the whole stack, which is then built instead (see
|
|
1427
|
+
* {@link deriveStack}), or taken outright from `beneath`, for callers
|
|
1428
|
+
* that know it.
|
|
1429
|
+
*
|
|
1430
|
+
* `how` is the link's `data-panel` attribute (or the caller's word for
|
|
1431
|
+
* it): absent — like a link without the attribute — it is the shell's
|
|
1432
|
+
* `linkNavigation` default, an unrecognised value is a push on top of
|
|
1433
|
+
* `from`, `"replace"` swaps `from` out rather than stacking on it, and
|
|
1434
|
+
* `"open"` drops `from` altogether so the target arrives with its own
|
|
1435
|
+
* stack, the way a nav item's link does.
|
|
1373
1436
|
*
|
|
1374
1437
|
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
1375
1438
|
* navigation lands, `false` when it doesn't (already there counts as
|
|
1376
1439
|
* landed).
|
|
1377
1440
|
*/
|
|
1378
|
-
private navigate(href: string,
|
|
1441
|
+
private navigate(href: string, { from, how, beneath }: { from?: string; how?: string; beneath?: readonly string[] } = {}): Promise<boolean> {
|
|
1442
|
+
const mode = how ?? this.opts.linkNavigation;
|
|
1443
|
+
const origin = mode === "open" ? null : from ?? null;
|
|
1444
|
+
const replace = mode === "replace";
|
|
1379
1445
|
return A.peek(() => {
|
|
1380
1446
|
let url: URL;
|
|
1381
1447
|
try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
|
|
@@ -1430,7 +1496,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1430
1496
|
private pushPath(path: string, replace: boolean): Promise<boolean> {
|
|
1431
1497
|
return A.peek(() => {
|
|
1432
1498
|
const arr = this.intended();
|
|
1433
|
-
return this.navigate(path, arr.stack[arr.focus]
|
|
1499
|
+
return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
|
|
1434
1500
|
});
|
|
1435
1501
|
}
|
|
1436
1502
|
|
|
@@ -1438,36 +1504,30 @@ export class PanelStackController implements PanelStack {
|
|
|
1438
1504
|
|
|
1439
1505
|
/**
|
|
1440
1506
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
1441
|
-
* the anchor so we can decide what the click *means
|
|
1442
|
-
* `.s-panel` (which decides what the click truncates), the `data-panel`
|
|
1443
|
-
* attribute, and return-to-an-open-panel semantics. The exclusion rules
|
|
1507
|
+
* the anchor so we can decide what the click *means*. The exclusion rules
|
|
1444
1508
|
* (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
1445
1509
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
1446
1510
|
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
* an unrecognised value is a `push`.
|
|
1511
|
+
* A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
|
|
1512
|
+
* attribute as its `how` (see {@link navigate}, the shared implementation).
|
|
1513
|
+
* A link that isn't inside any panel — a nav item, one in a dialog — has no
|
|
1514
|
+
* panel to build on, so it replaces the stack as a whole, exactly as a cold
|
|
1515
|
+
* link to the same URL would open it.
|
|
1453
1516
|
*/
|
|
1454
1517
|
private interceptLinks(): void {
|
|
1455
1518
|
route.interceptLinks((url, anchor) => {
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
void this.navigate(url.href, origin, mode === "replace");
|
|
1519
|
+
const how = anchor.getAttribute("data-panel") ?? undefined;
|
|
1520
|
+
// The panel the link lives in: the enclosing `.s-panel`, or — for the
|
|
1521
|
+
// current panel's actions, promoted into the top bar on a narrow shell
|
|
1522
|
+
// (see main.ts), outside every `.s-panel` — the current panel, whose
|
|
1523
|
+
// own chrome they remain at every width.
|
|
1524
|
+
const panelEl = anchor.closest<HTMLElement>(".s-panel");
|
|
1525
|
+
const entry = panelEl
|
|
1526
|
+
? this.$state.live.find((e) => e.el === panelEl)
|
|
1527
|
+
: anchor.closest(".s-panel-origin")
|
|
1528
|
+
? this.$state.live[this.$state.focus]
|
|
1529
|
+
: undefined;
|
|
1530
|
+
void this.navigate(url.href, { from: entry?.path, how });
|
|
1471
1531
|
return true;
|
|
1472
1532
|
});
|
|
1473
1533
|
}
|
|
@@ -1499,7 +1559,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1499
1559
|
}
|
|
1500
1560
|
|
|
1501
1561
|
openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean> {
|
|
1502
|
-
return this.navigate(path,
|
|
1562
|
+
return this.navigate(path, { how: "open", beneath });
|
|
1503
1563
|
}
|
|
1504
1564
|
|
|
1505
1565
|
closePanel(path?: string): Promise<boolean> {
|
|
@@ -1961,11 +2021,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1961
2021
|
if (!entry.width) entry.width = width(entry);
|
|
1962
2022
|
}
|
|
1963
2023
|
|
|
1964
|
-
// The
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
1967
|
-
//
|
|
1968
|
-
//
|
|
2024
|
+
// The body row caps itself to the ensemble width, keeping the columns
|
|
2025
|
+
// centred however far the area stretches, and transitions its max-width
|
|
2026
|
+
// (see main.ts) so the recentring plays along with the panel that caused
|
|
2027
|
+
// it. The bars above and below don't follow — they hold at the standard
|
|
2028
|
+
// page width (also main.ts).
|
|
1969
2029
|
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
1970
2030
|
|
|
1971
2031
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|