staffa 0.11.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.
@@ -112,7 +112,7 @@ function matchRoute(r, segments) {
112
112
  /**
113
113
  * The one duration every bit of shell motion shares: the enter/exit fades, the
114
114
  * `left` moves of columns shifting sideways, the ensemble-width transition the
115
- * chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
115
+ * body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
116
116
  * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
117
117
  * and JS can't drift apart.
118
118
  *
@@ -126,9 +126,11 @@ const LOADING_HOLD_MS = 300;
126
126
  /**
127
127
  * The standard page width: sidebar plus content area, capped by the window.
128
128
  * `"full"` fills the content-area part of this exactly; only a `"screen"`
129
- * page makes the shell grow past it.
129
+ * page makes the shell grow past it. The top bar and footer keep to this
130
+ * width even then (see main.ts), so the chrome holds still while the
131
+ * columns stretch.
130
132
  */
131
- const SHELL_PX = 1280;
133
+ export const SHELL_PX = 1280;
132
134
  /** Don't pair smalls when half the content area would be narrower than this. */
133
135
  const PAIR_MIN_PX = 360;
134
136
  /**
@@ -151,7 +153,12 @@ A.insertGlobalCss({
151
153
  // The region paints the panel's sheen over its own box, and every panel shows
152
154
  // a slice of that same gradient (see `.s-panel` below), so the columns and
153
155
  // the ground beside them are one continuous surface.
154
- ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
156
+ // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
157
+ // and anything that ever scrolls it — find-in-page reaching for text in a
158
+ // parked column, an in-page anchor, an extension — shifts every column
159
+ // sideways, permanently, because nothing here would ever scroll it back.
160
+ // `clip` clips without being scrollable at all, closing the whole class.
161
+ ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
155
162
  SURFACE_SHEEN,
156
163
  ".s-panel": {
157
164
  // A panel rests at a plain `left` offset and carries no transform: a
@@ -245,13 +252,18 @@ A.insertGlobalCss({
245
252
  // the weight change alone is ambiguous in a short crumb, the colour alone
246
253
  // too subtle. No padding of its own — the first crumb has to start on the
247
254
  // same pixel as the app's name above it, and the gap below spaces the row.
248
- // `flex-shrink:0` because the crumbs are the strip's flex items and carry
249
- // `overflow:hidden`, which resolves their automatic minimum size to zero:
250
- // left to shrink they would ellipsise themselves down to stubs rather than
251
- // overflow, and the stack would never scroll. One long title still caps at
252
- // 14rem — that is this crumb's own business, not the row running out.
253
- "&": "flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
254
- "white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
255
+ // The flex is how a tight row is shared out. Every crumb grows from the
256
+ // same 4rem basis in equal shares, freezing at its own text
257
+ // (`max-width:max-content`) — so with room to spare every title shows in
258
+ // full, and under pressure it is the *longest* crumbs that give way
259
+ // first, equalising downward while short ones keep every character. No
260
+ // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
261
+ // that point the row overflows and the strip scrolls — which is what
262
+ // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
263
+ // would ellipsise to a row of stubs instead, and the stack would never
264
+ // scroll.)
265
+ "&": "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
266
+ "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
255
267
  "transition: color 0.12s;",
256
268
  "&.s-crumb-on": "font-weight:600 fg:$s-text",
257
269
  // The same hover treatment as a menu item. The panel you are on is a plain
@@ -620,9 +632,12 @@ export class PanelStackController {
620
632
  maxWidth: "full",
621
633
  width: 0,
622
634
  };
623
- // `close` closes *this* panel, current or not. It resolves the panel's
624
- // place in the stack at call time, so it keeps working after a splice has
625
- // moved it — and quietly resolves false once the panel is gone.
635
+ // `close` closes *this* panel, current or not, and `open` navigates
636
+ // *from* it, through the very implementation a link click uses (see
637
+ // `navigate`). Both resolve the panel's place in the stack at call
638
+ // time, so they keep working after a splice has moved it; an `open`
639
+ // from a panel that has since closed falls back to a derived stack,
640
+ // like a link from nowhere.
626
641
  //
627
642
  // `visible` starts at what the panel's place implies: shown when it sits
628
643
  // at or before the current panel (a pushed panel always does), hidden when
@@ -636,6 +651,7 @@ export class PanelStackController {
636
651
  visible,
637
652
  pinned: pinned || undefined,
638
653
  close: () => this.closePath(entry.path),
654
+ open: (href, how) => this.navigate(href, { from: entry.path, how }),
639
655
  });
640
656
  return entry;
641
657
  }
@@ -865,17 +881,31 @@ export class PanelStackController {
865
881
  });
866
882
  }
867
883
  /**
868
- * Navigate to `href`. `origin` is the path of the panel the link lives in, or
869
- * `null` when it has none — a nav item, or a programmatic call, which builds
870
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
871
- * originating panel rather than stacking on top of it, and `beneath` says what
872
- * the stack under the target is outright, for callers that know.
884
+ * Navigate to `href` — the one implementation behind a link click,
885
+ * {@link Panel.open} and the stack's own methods, so none of them can
886
+ * behave differently.
887
+ *
888
+ * `from` is the path of the panel the navigation starts from — the one
889
+ * the link lives in — or absent when it has none: a nav item, or a call
890
+ * that means the whole stack, which is then built instead (see
891
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
892
+ * that know it.
893
+ *
894
+ * `how` is the link's `data-panel` attribute (or the caller's word for
895
+ * it): absent — like a link without the attribute — it is the shell's
896
+ * `linkNavigation` default, an unrecognised value is a push on top of
897
+ * `from`, `"replace"` swaps `from` out rather than stacking on it, and
898
+ * `"open"` drops `from` altogether so the target arrives with its own
899
+ * stack, the way a nav item's link does.
873
900
  *
874
901
  * Resolves the way every {@link PanelStack} method does: `true` once the
875
902
  * navigation lands, `false` when it doesn't (already there counts as
876
903
  * landed).
877
904
  */
878
- navigate(href, origin, replace = false, beneath) {
905
+ navigate(href, { from, how, beneath } = {}) {
906
+ const mode = how ?? this.opts.linkNavigation;
907
+ const origin = mode === "open" ? null : from ?? null;
908
+ const replace = mode === "replace";
879
909
  return A.peek(() => {
880
910
  let url;
881
911
  try {
@@ -933,43 +963,36 @@ export class PanelStackController {
933
963
  pushPath(path, replace) {
934
964
  return A.peek(() => {
935
965
  const arr = this.intended();
936
- return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
966
+ return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
937
967
  });
938
968
  }
939
969
  // ── Link interception ──────────────────────────────────────────────────
940
970
  /**
941
971
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
942
- * the anchor so we can decide what the click *means*: the originating
943
- * `.s-panel` (which decides what the click truncates), the `data-panel`
944
- * attribute, and return-to-an-open-panel semantics. The exclusion rules
972
+ * the anchor so we can decide what the click *means*. The exclusion rules
945
973
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
946
974
  * close guards run in `checkChange` when our navigation reaches the router.
947
975
  *
948
- * `data-panel` names which of the three {@link PanelStack} navigations the
949
- * click is: `push`, `replace`, or `open`, which drops the
950
- * originating panel so the target arrives with its own stack beneath it,
951
- * exactly as a nav item's link does. A link that doesn't say gets the
952
- * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
953
- * an unrecognised value is a `push`.
976
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
977
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
978
+ * A link that isn't inside any panel — a nav item, one in a dialog — has no
979
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
980
+ * link to the same URL would open it.
954
981
  */
955
982
  interceptLinks() {
956
983
  route.interceptLinks((url, anchor) => {
957
- const mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
958
- let origin = null;
959
- if (mode !== "open") {
960
- const panel = anchor.closest(".s-panel");
961
- if (panel) {
962
- origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
963
- }
964
- else if (anchor.closest(".s-panel-origin")) {
965
- // The current panel's actions, promoted into the top bar on a
966
- // narrow shell (see main.ts), sit outside every `.s-panel` — but
967
- // they are still the current panel's own chrome, so a link among
968
- // them builds on that panel, exactly as it does at full width.
969
- origin = this.$state.live[this.$state.focus]?.path ?? null;
970
- }
971
- }
972
- void this.navigate(url.href, origin, mode === "replace");
984
+ const how = anchor.getAttribute("data-panel") ?? undefined;
985
+ // The panel the link lives in: the enclosing `.s-panel`, or — for the
986
+ // current panel's actions, promoted into the top bar on a narrow shell
987
+ // (see main.ts), outside every `.s-panel` — the current panel, whose
988
+ // own chrome they remain at every width.
989
+ const panelEl = anchor.closest(".s-panel");
990
+ const entry = panelEl
991
+ ? this.$state.live.find((e) => e.el === panelEl)
992
+ : anchor.closest(".s-panel-origin")
993
+ ? this.$state.live[this.$state.focus]
994
+ : undefined;
995
+ void this.navigate(url.href, { from: entry?.path, how });
973
996
  return true;
974
997
  });
975
998
  }
@@ -993,7 +1016,7 @@ export class PanelStackController {
993
1016
  return this.pushPath(path, true);
994
1017
  }
995
1018
  openPanelStack(path, beneath) {
996
- return this.navigate(path, null, false, beneath);
1019
+ return this.navigate(path, { how: "open", beneath });
997
1020
  }
998
1021
  closePanel(path) {
999
1022
  return A.peek(() => {
@@ -1001,6 +1024,23 @@ export class PanelStackController {
1001
1024
  return this.closePath(path ?? arr.stack[arr.focus] ?? "");
1002
1025
  });
1003
1026
  }
1027
+ // ── Live settings ──────────────────────────────────────────────────────
1028
+ // `main()` keeps these fed from small reactive scopes of their own, so an
1029
+ // app that reads them off a proxy (or through a getter) can change them at
1030
+ // runtime and the shell adapts in place — nothing is redrawn, no panel
1031
+ // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1032
+ // options; these are how `main()` talks to the stack.
1033
+ /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1034
+ setColumns(columns) {
1035
+ if (this.opts.columns === columns)
1036
+ return;
1037
+ this.opts.columns = columns;
1038
+ this.scheduleLayout();
1039
+ }
1040
+ /** Adopt a changed `linkNavigation` default; the next click reads it. */
1041
+ setLinkNavigation(mode) {
1042
+ this.opts.linkNavigation = mode;
1043
+ }
1004
1044
  /**
1005
1045
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1006
1046
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -1430,11 +1470,11 @@ export class PanelStackController {
1430
1470
  if (!entry.width)
1431
1471
  entry.width = width(entry);
1432
1472
  }
1433
- // The chrome above and below the body caps itself to the ensemble width,
1434
- // keeping everything centred and aligned however far the area stretches.
1435
- // The consumers transition their max-width (see main.ts), so the
1436
- // recentring plays along with the panel that caused it instead of
1437
- // snapping.
1473
+ // The body row caps itself to the ensemble width, keeping the columns
1474
+ // centred however far the area stretches, and transitions its max-width
1475
+ // (see main.ts) so the recentring plays along with the panel that caused
1476
+ // it. The bars above and below don't follow — they hold at the standard
1477
+ // page width (also main.ts).
1438
1478
  shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
1439
1479
  // Phase 1 — every panel's *start* state for this frame. Panels already on
1440
1480
  // screen simply move (their standing transition animates it); freshly