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.
@@ -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 shell itself stretches to the screen
183
- * edges instead of stopping at the standard 1280px page.
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
- * chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
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:hidden isolation:isolate " +
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
- // `flex-shrink:0` because the crumbs are the strip's flex items and carry
517
- // `overflow:hidden`, which resolves their automatic minimum size to zero:
518
- // left to shrink they would ellipsise themselves down to stubs rather than
519
- // overflow, and the stack would never scroll. One long title still caps at
520
- // 14rem — that is this crumb's own business, not the row running out.
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-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
523
- "white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
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. It resolves the panel's
1121
- // place in the stack at call time, so it keeps working after a splice has
1122
- // moved it — and quietly resolves false once the panel is gone.
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`. `origin` is the path of the panel the link lives in, or
1369
- * `null` when it has none — a nav item, or a programmatic call, which builds
1370
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
1371
- * originating panel rather than stacking on top of it, and `beneath` says what
1372
- * the stack under the target is outright, for callers that know.
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, origin: string | null, replace = false, beneath?: readonly string[]): Promise<boolean> {
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] ?? null, replace);
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*: the originating
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
- * `data-panel` names which of the three {@link PanelStack} navigations the
1448
- * click is: `push`, `replace`, or `open`, which drops the
1449
- * originating panel so the target arrives with its own stack beneath it,
1450
- * exactly as a nav item's link does. A link that doesn't say gets the
1451
- * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
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 mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
1457
- let origin: string | null = null;
1458
- if (mode !== "open") {
1459
- const panel = anchor.closest<HTMLElement>(".s-panel");
1460
- if (panel) {
1461
- origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
1462
- } else if (anchor.closest(".s-panel-origin")) {
1463
- // The current panel's actions, promoted into the top bar on a
1464
- // narrow shell (see main.ts), sit outside every `.s-panel` — but
1465
- // they are still the current panel's own chrome, so a link among
1466
- // them builds on that panel, exactly as it does at full width.
1467
- origin = this.$state.live[this.$state.focus]?.path ?? null;
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, null, false, beneath);
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 chrome above and below the body caps itself to the ensemble width,
1965
- // keeping everything centred and aligned however far the area stretches.
1966
- // The consumers transition their max-width (see main.ts), so the
1967
- // recentring plays along with the panel that caused it instead of
1968
- // snapping.
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