staffa 0.18.0 → 0.18.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.
@@ -193,7 +193,7 @@ export interface Panel<P = Record<string, string | number | string[]>> {
193
193
  * Every size is capped at the content area, so on a phone they all come to
194
194
  * the same thing: one screen at a time. And a width depends only on the
195
195
  * window, never on what else is open, so opening or closing a panel never
196
- * resizes another — the run of columns just recentres in the area.
196
+ * resizes another — the run of columns just shifts over in the area.
197
197
  *
198
198
  * The ask is a ceiling only; there is no matching floor, since the window can
199
199
  * be any width. Aim your layout at 360px — about the narrowest phone still in
@@ -211,10 +211,11 @@ export interface Panel<P = Record<string, string | number | string[]>> {
211
211
  maxWidth?: PanelSize;
212
212
  /**
213
213
  * Set this while you're fetching what the panel needs, and back to `false`
214
- * when you're done. A new panel waits a moment before sliding in, so it can
215
- * arrive with real content instead of empty; if the wait drags on it slides
216
- * in anyway and shows a loading indicator until the flag clears. It only
217
- * affects the animation; the stack and the URL never wait for it.
214
+ * when you're done. A new panel slides into place right away but waits a
215
+ * moment before fading in, so it can appear with real content instead of
216
+ * empty; if the wait drags on it fades in anyway and shows a loading
217
+ * indicator until the flag clears. It only affects the animation; the
218
+ * stack and the URL never wait for it.
218
219
  */
219
220
  loading?: boolean;
220
221
  /**
@@ -419,7 +420,7 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
419
420
  * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
420
421
  */
421
422
  const PAGE_MS = 250;
422
- /** How long a freshly pushed `loading` panel holds its enter animation. */
423
+ /** How long a freshly pushed `loading` panel holds its fade-in. */
423
424
  const LOADING_HOLD_MS = 300;
424
425
  /**
425
426
  * The bounds of a column: at most 540px — the width columns aim for, the area
@@ -428,13 +429,6 @@ const LOADING_HOLD_MS = 300;
428
429
  */
429
430
  const SMALL_MIN_PX = 360;
430
431
  export const SMALL_MAX_PX = 540;
431
- /**
432
- * Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
433
- * the stack, a *closing* one on the even layer just below. So a replacement
434
- * comes in over the panel it replaces, while a closing panel fades out over
435
- * whatever it was covering.
436
- */
437
- const LAYER_STEP = 2;
438
432
 
439
433
  // ─── Module-level styling ────────────────────────────────────────────────────
440
434
 
@@ -442,9 +436,9 @@ A.insertGlobalCss({
442
436
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
443
437
  // The clipping viewport the columns slide through; panels are absolutely
444
438
  // positioned inside it, sized and offset from JS (see `layout()`).
445
- // `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
446
- // own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
447
- // the ground beside them read as one surface.
439
+ // `isolation` keeps their z-index layers below the shell's own chrome. It
440
+ // paints the same PANEL_SHEEN as every panel, so columns and the ground
441
+ // beside them read as one surface.
448
442
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
449
443
  // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
450
444
  // column sideways permanently, with nothing to scroll it back.
@@ -452,48 +446,56 @@ A.insertGlobalCss({
452
446
  "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
453
447
  PANEL_SHEEN,
454
448
  ".s-panel": {
455
- // A panel rests at a plain `left` offset with no transform: a transformed
456
- // element is composited, costing it subpixel text antialiasing. Transform
457
- // is used only for the enter/exit slides. No `width` transition either —
458
- // animating one reflows the column's content every frame.
459
- // The fade is deliberately `linear` while the drift eases out: an eased
460
- // opacity spends its last stretch near zero, reading as a vanish.
461
- // Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
462
- // closing panel is no longer in the reactive list, so its element's
463
- // position among the live ones is Aberdeen's business. See LAYER_STEP.
449
+ // The shell knows three motions, and this vocabulary is all of them:
450
+ // a panel that MOVES animates its `left` — every mover in a pass shares
451
+ // this one duration and ease-out, so panels travelling the same distance
452
+ // travel as one — a CREATED panel joins the strip beside the old position
453
+ // of the panels beneath it and rides their slide while fading in, and a
454
+ // CLOSED one fades out where it stood. Nothing else ever animates.
455
+ // A plain `left`, never a transform: a transformed element is
456
+ // composited, costing it subpixel text antialiasing. No `width`
457
+ // transition either — animating one reflows the column every frame. And
458
+ // the fade is `linear` while the moves ease out: an eased opacity spends
459
+ // its last stretch near zero, reading as a vanish.
460
+ // Layering is fixed per state, not per stack depth: live panels never
461
+ // overlap each other (the strip keeps them adjacent, see `layout()`), so
462
+ // only the fading ones need an order — below, in both directions, per
463
+ // the classes underneath.
464
464
  // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
465
465
  // another, and two transparent ones mean text sliding over text).
466
466
  "&":
467
467
  "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
468
468
  PANEL_SHEEN + " " +
469
- "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
469
+ "z-index:2 transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear;",
470
470
  // The hairline between two columns, fading at both ends (like the sidebar's
471
471
  // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
472
472
  // padding — so this sits exactly on the boundary.
473
473
  "&.s-panel-sep::before":
474
474
  "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
475
475
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
476
- // Enter and exit share one vocabulary: a fade, drifting a short 8cqw
477
- // (`cqw`: `.s-main` is the container) only when the navigation really
478
- // moves — a push opening a new column, a back closing one. A panel
479
- // replacing another simply fades in on top of it (see LAYER_STEP), the
480
- // old one fading in place beneath. The enter start state is applied with
481
- // transitions off and then dropped, so the panel settles instead of jumping.
482
- "&.s-panel-enter": "opacity:0 transition:none",
483
- // On its way out; leaves the DOM when the fade ends (see `playExit`).
484
- "&.s-panel-closing": "opacity:0 pointer-events:none",
485
- // The sideways drift, as an enter's start state or a close's destination.
486
- "&.s-panel-drift": "transform: translateX(8cqw);",
487
- // Off screen but open: crowded out at the left edge (`hidden`) or parked
488
- // past the right one (`parked`). Both keep their DOM — and so their scroll
489
- // position and half-typed forms — hence `visibility`, not `display:none`.
490
- // Transitioning it counts as *visible* for the whole fade, flipping at the
491
- // very end; the rule above (`visibility 0s`) reveals it again instantly.
476
+ // Still owed or playing its entry fade: beneath the settled panels, so
477
+ // whatever slides across its spot passes over it. Dropped once the fade
478
+ // is over (see `releaseEnter`).
479
+ "&.s-panel-new": "z-index:1",
480
+ // On its way out: it fades where it stood, beneath every live panel
481
+ // (declared after `.s-panel-new`, so closing mid-enter drops a panel to
482
+ // the bottom), and leaves the DOM when the fade ends (see `playExit`).
483
+ "&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
484
+ // The fade-in's start state: a newcomer wears it from creation until its
485
+ // content is ready — usually the very pass that placed it, later for a
486
+ // `loading` panel holding out for data — sliding invisibly meanwhile.
487
+ // Dropping the class is what starts the fade (see `releaseEnter`).
488
+ "&.s-panel-enter": "opacity:0",
489
+ // Off screen but open: the strip simply continues past the viewport's
490
+ // edges, so these rest at their true positions, clipped — being crowded
491
+ // out or revealed is an ordinary move, not a fade. Both keep their DOM —
492
+ // and so their scroll position and half-typed forms — hence
493
+ // `visibility`, not `display:none`: it holds through the move out
494
+ // (flipping only at its end) and lifts instantly on the move back in
495
+ // (the base transition above doesn't list it).
492
496
  "&.s-panel-hidden, &.s-panel-parked":
493
- "opacity:0 visibility:hidden " +
494
- "transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
495
- "&.s-panel-hidden": "transform: translateX(-8cqw);",
496
- "&.s-panel-parked": "transform: translateX(8cqw);",
497
+ "visibility:hidden " +
498
+ "transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
497
499
  },
498
500
  // The scroll container, with the column's own padding. Its scrollbar sits
499
501
  // flush against the column edge (unlike content mode's inset one), so it meets
@@ -599,10 +601,8 @@ interface PanelEntry {
599
601
  hash?: string;
600
602
  /** Set once the panel is on its way out, playing its exit animation. */
601
603
  closing?: boolean;
602
- /** Set while an enter animation is still to be played. */
604
+ /** Set while the fade-in is still owed (a `loading` hold can owe it past placement). */
603
605
  enter?: boolean;
604
- /** Whether that enter — or the exit, once `closing` — carries the sideways drift. */
605
- drift?: boolean;
606
606
  /** Whether the panel has been through a full layout pass (and so may animate). */
607
607
  placed?: boolean;
608
608
  /** Whether its `loading` hold has already expired, so it can't hold again. */
@@ -1032,12 +1032,6 @@ export class PanelStackController implements PanelStack {
1032
1032
  const pinned = route.current.state.pinned;
1033
1033
  const seedPins = new Set<string>(Array.isArray(pinned) ? pinned.map(String) : []);
1034
1034
  const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
1035
- // A replacement — something enters while something closes — just fades:
1036
- // the new panel in on top, the old ones out in place beneath it (see the
1037
- // `.s-panel-drift` comment). A pure push or a pure close drifts sideways.
1038
- const entering = nav !== "load" && nav !== "back";
1039
- const drift = !(entering && target.stack.some((p) => !existing.has(p)) &&
1040
- this.$state.live.some((e) => !target.stack.includes(e.path)));
1041
1035
  const next: PanelEntry[] = [];
1042
1036
  for (const path of target.stack) {
1043
1037
  const kept = existing.get(path);
@@ -1048,16 +1042,14 @@ export class PanelStackController implements PanelStack {
1048
1042
  continue;
1049
1043
  }
1050
1044
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
1051
- // An initial load just appears, and so do panels *revealed* by a back:
1052
- // they belong underneath the ones sliding away.
1053
- if (entering) {
1054
- entry.enter = true;
1055
- entry.drift = drift;
1056
- }
1045
+ // An initial load just appears; any later navigation animates its new
1046
+ // panels in — beneath what's already there, so a back that re-creates
1047
+ // a panel plays out under the one it takes away.
1048
+ if (nav !== "load") entry.enter = true;
1057
1049
  next.push(entry);
1058
1050
  this.$open[path] = entry;
1059
1051
  }
1060
- for (const entry of existing.values()) this.beginClose(entry, drift);
1052
+ for (const entry of existing.values()) this.beginClose(entry);
1061
1053
  this.$state.live = next;
1062
1054
  this.$state.focus = Math.min(target.focus, next.length - 1);
1063
1055
  this.scheduleLayout();
@@ -1098,14 +1090,9 @@ export class PanelStackController implements PanelStack {
1098
1090
  * at the end of the animation. Only the element lingers to play that animation,
1099
1091
  * which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
1100
1092
  */
1101
- private beginClose(entry: PanelEntry, drift: boolean): void {
1093
+ private beginClose(entry: PanelEntry): void {
1102
1094
  entry.closing = true;
1103
- entry.drift = drift;
1104
1095
  entry.$panel.visible = false;
1105
- // Frozen one layer below where it was, so it fades out over the panel it
1106
- // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
1107
- // the element is still ours — a moment later `entry.el` is gone.
1108
- if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
1109
1096
  delete this.$open[entry.path];
1110
1097
  }
1111
1098
 
@@ -1121,7 +1108,6 @@ export class PanelStackController implements PanelStack {
1121
1108
  // simply goes, so the new one isn't drawn over a ghost of the old.
1122
1109
  if (!entry.closing) { el.remove(); return; }
1123
1110
  el.classList.add("s-panel-closing");
1124
- if (entry.drift) el.classList.add("s-panel-drift");
1125
1111
  el.setAttribute("inert", "");
1126
1112
  const drop = () => {
1127
1113
  clearTimeout(timer);
@@ -1819,9 +1805,11 @@ export class PanelStackController implements PanelStack {
1819
1805
  }
1820
1806
  }
1821
1807
 
1822
- // The content area is a fixed width, so a run that doesn't fill it centres
1823
- // rather than hanging off the left edge — the chrome around it never moves.
1824
- const left = (geom.area - runSum) / 2;
1808
+ // A run that doesn't fill the fixed-width area centres in it — unless
1809
+ // panels sit crowded out on its left: then it hugs the left edge instead,
1810
+ // so the strip crosses that edge with no gap and a reveal is a plain
1811
+ // slide back in.
1812
+ const left = first > 0 ? 0 : (geom.area - runSum) / 2;
1825
1813
 
1826
1814
  for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
1827
1815
  // Never-visible panels get their would-be width, so a reveal doesn't start
@@ -1830,58 +1818,89 @@ export class PanelStackController implements PanelStack {
1830
1818
  if (!entry.width) entry.width = width(entry);
1831
1819
  }
1832
1820
 
1833
- // Phase 1 — every panel's *start* state for this frame. Panels on screen
1834
- // simply move; freshly mounted ones still have transitions off, so what is
1835
- // set here is adopted instantly and becomes the "before" of their enter.
1836
- const fresh: PanelEntry[] = [];
1821
+ // Phase 1 — lay the strip out for this frame: each panel flush against
1822
+ // its neighbours, the visible run [first..cur] in the viewport, earlier
1823
+ // panels continuing off its left edge and parked ones held past its
1824
+ // right. Placed panels get their new positions — their standing
1825
+ // transitions carry them there. Newcomers, transitions still off, get
1826
+ // their *start* state instead: their strip position anchored to the OLD
1827
+ // position of the nearest placed panel beneath them (`delta`), so the
1828
+ // slide in is one motion with the panels making room. With nothing
1829
+ // beneath them to come from — or nothing moving — that start is where
1830
+ // they already stand, and they simply fade in.
1831
+ const fresh: { entry: PanelEntry; x: number }[] = [];
1837
1832
  let x = left;
1833
+ let delta = 0;
1834
+ for (let i = 0; i < first; i++) x -= live[i].width;
1838
1835
  for (let i = 0; i < n; i++) {
1839
1836
  const entry = live[i];
1840
1837
  const el = entry.el!;
1841
1838
  const shown = i >= first && i <= cur;
1842
- // Visible columns tile the run left to right; crowded-out ones rest at
1843
- // its left edge and parked ones just past its right, both keeping their
1844
- // last width. Deeper panels layer over shallower (see LAYER_STEP).
1845
- place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1839
+ // Parked panels never dip into the viewport, however short the run.
1840
+ if (i === cur + 1) x = Math.max(x, geom.area);
1841
+ // A panel that mounts while still fetching holds its fade for a
1842
+ // moment, so it can appear with real content instead of empty.
1843
+ if (entry.enter || !entry.placed) {
1844
+ if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1845
+ else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1846
+ }
1847
+ if (entry.placed) {
1848
+ delta = parseFloat(el.style.left) - x;
1849
+ el.style.left = `${x}px`;
1850
+ // A fade held back for content starts the moment its hold lifts.
1851
+ if (entry.enter && !entry.$ui.holding) this.releaseEnter(entry);
1852
+ } else {
1853
+ fresh.push({ entry, x });
1854
+ const entering = entry.enter && shown;
1855
+ el.style.left = `${entering ? x + delta : x}px`;
1856
+ if (entering) el.classList.add("s-panel-enter", "s-panel-new");
1857
+ }
1858
+ el.style.width = `${entry.width}px`;
1859
+ x += entry.width;
1846
1860
  // Written only on a change, so per-panel UI hanging off `visible` or
1847
1861
  // `width` isn't rebuilt by every pass.
1848
1862
  if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
1849
1863
  if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
1850
- if (shown) x += entry.width;
1851
1864
  el.classList.toggle("s-panel-sep", shown && i > first);
1852
- // Off-screen panels fade out and stop rendering, keeping their DOM.
1865
+ // Off-screen panels stop rendering, keeping their DOM.
1853
1866
  el.classList.toggle("s-panel-hidden", i < first);
1854
1867
  el.classList.toggle("s-panel-parked", i > cur);
1855
1868
  el.toggleAttribute("inert", !shown);
1856
- if (entry.placed) continue;
1857
- fresh.push(entry);
1858
- // A panel that mounts while still fetching holds here for a moment, so
1859
- // it can enter with real content instead of an empty column.
1860
- if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1861
- else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1862
- // Already at its resting place; the enter is the transparency (and,
1863
- // when drifting, the offset one edge to the right) it starts from.
1864
- if (entry.enter && shown) {
1865
- el.classList.add("s-panel-enter");
1866
- if (entry.drift) el.classList.add("s-panel-drift");
1867
- }
1868
1869
  }
1869
1870
 
1870
1871
  // Phase 2 — reading a layout property forces the browser to adopt those
1871
1872
  // start states (and a snap pass's transition-free geometry) to animate from.
1872
1873
  if (fresh.length || snap) void container.offsetWidth;
1873
1874
  if (snap) shell.classList.remove("s-shell-snap");
1874
- // Phase 3 — transitions back on, start state dropped, and off they go.
1875
- for (const entry of fresh) {
1876
- if (entry.$ui.holding) continue;
1877
- entry.el!.style.transition = "";
1878
- entry.el!.classList.remove("s-panel-enter", "s-panel-drift");
1879
- entry.enter = false;
1875
+ // Phase 3 — newcomers get their transitions and their resting place: the
1876
+ // slide starts now, and the fade with it — unless the panel is holding
1877
+ // for its content, which keeps the fade's start state on until then.
1878
+ for (const { entry, x } of fresh) {
1879
+ const el = entry.el!;
1880
+ el.style.transition = "";
1881
+ el.style.left = `${x}px`;
1880
1882
  entry.placed = true;
1883
+ if (!entry.$ui.holding) this.releaseEnter(entry);
1881
1884
  }
1882
1885
  }
1883
1886
 
1884
- /** Let a `loading` panel's enter animation wait — but not indefinitely. */
1887
+ /** Start (or skip) a newcomer's fade-in; any slide is already underway. */
1888
+ private releaseEnter(entry: PanelEntry): void {
1889
+ entry.enter = false;
1890
+ const el = entry.el;
1891
+ if (!el || !el.classList.contains("s-panel-enter")) return;
1892
+ el.classList.remove("s-panel-enter");
1893
+ // Keep it beneath its elders until the fade is over — by timer, a hair
1894
+ // past it, since a resize can snap the fade short without ever firing a
1895
+ // `transitionend`.
1896
+ const timer = setTimeout(() => {
1897
+ this.timers.delete(timer);
1898
+ el.classList.remove("s-panel-new");
1899
+ }, PAGE_MS + 80);
1900
+ this.timers.add(timer);
1901
+ }
1902
+
1903
+ /** Let a `loading` panel's fade-in wait — but not indefinitely. */
1885
1904
  private holdEnter(entry: PanelEntry): void {
1886
1905
  const timer = setTimeout(() => {
1887
1906
  this.timers.delete(timer);
@@ -1895,13 +1914,6 @@ export class PanelStackController implements PanelStack {
1895
1914
  }
1896
1915
  }
1897
1916
 
1898
- /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
1899
- function place(el: HTMLElement, x: number, width: number, z: number): void {
1900
- el.style.left = `${x}px`;
1901
- el.style.width = `${width}px`;
1902
- el.style.zIndex = String(z);
1903
- }
1904
-
1905
1917
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1906
1918
 
1907
1919
  function sameStack(a: string[], b: string[]): boolean {