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.
@@ -180,8 +180,8 @@ export interface MainOptions<R = Routes> {
180
180
  * How many panels are *shown* at a time. `"auto"` (the default) shows as
181
181
  * many columns, side by side, as comfortably fit, ending at the current
182
182
  * panel; `"single"` shows only the current panel, however wide the screen
183
- * — one screen at a time at every size, each still at its asked width,
184
- * centred (the nav sidebar still sits beside it). Only the display
183
+ * — one screen at a time at every size, each still at its asked width
184
+ * (the nav sidebar still sits beside it). Only the display
185
185
  * differs: the stack, the breadcrumbs, the URL, Escape and the back
186
186
  * button behave identically in both. Routed mode only.
187
187
  *
@@ -175,7 +175,7 @@ export interface Panel<P = Record<string, string | number | string[]>> {
175
175
  * Every size is capped at the content area, so on a phone they all come to
176
176
  * the same thing: one screen at a time. And a width depends only on the
177
177
  * window, never on what else is open, so opening or closing a panel never
178
- * resizes another — the run of columns just recentres in the area.
178
+ * resizes another — the run of columns just shifts over in the area.
179
179
  *
180
180
  * The ask is a ceiling only; there is no matching floor, since the window can
181
181
  * be any width. Aim your layout at 360px — about the narrowest phone still in
@@ -193,10 +193,11 @@ export interface Panel<P = Record<string, string | number | string[]>> {
193
193
  maxWidth?: PanelSize;
194
194
  /**
195
195
  * Set this while you're fetching what the panel needs, and back to `false`
196
- * when you're done. A new panel waits a moment before sliding in, so it can
197
- * arrive with real content instead of empty; if the wait drags on it slides
198
- * in anyway and shows a loading indicator until the flag clears. It only
199
- * affects the animation; the stack and the URL never wait for it.
196
+ * when you're done. A new panel slides into place right away but waits a
197
+ * moment before fading in, so it can appear with real content instead of
198
+ * empty; if the wait drags on it fades in anyway and shows a loading
199
+ * indicator until the flag clears. It only affects the animation; the
200
+ * stack and the URL never wait for it.
200
201
  */
201
202
  loading?: boolean;
202
203
  /**
@@ -701,7 +702,9 @@ export declare class PanelStackController implements PanelStack {
701
702
  * The motion between two such arrangements is CSS's job.
702
703
  */
703
704
  private layout;
704
- /** Let a `loading` panel's enter animation wait but not indefinitely. */
705
+ /** Start (or skip) a newcomer's fade-in; any slide is already underway. */
706
+ private releaseEnter;
707
+ /** Let a `loading` panel's fade-in wait — but not indefinitely. */
705
708
  private holdEnter;
706
709
  }
707
710
  export {};
@@ -114,7 +114,7 @@ function matchRoute(r, segments) {
114
114
  * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
115
115
  */
116
116
  const PAGE_MS = 250;
117
- /** How long a freshly pushed `loading` panel holds its enter animation. */
117
+ /** How long a freshly pushed `loading` panel holds its fade-in. */
118
118
  const LOADING_HOLD_MS = 300;
119
119
  /**
120
120
  * The bounds of a column: at most 540px — the width columns aim for, the area
@@ -123,66 +123,67 @@ const LOADING_HOLD_MS = 300;
123
123
  */
124
124
  const SMALL_MIN_PX = 360;
125
125
  export const SMALL_MAX_PX = 540;
126
- /**
127
- * Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
128
- * the stack, a *closing* one on the even layer just below. So a replacement
129
- * comes in over the panel it replaces, while a closing panel fades out over
130
- * whatever it was covering.
131
- */
132
- const LAYER_STEP = 2;
133
126
  // ─── Module-level styling ────────────────────────────────────────────────────
134
127
  A.insertGlobalCss({
135
128
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
136
129
  // The clipping viewport the columns slide through; panels are absolutely
137
130
  // positioned inside it, sized and offset from JS (see `layout()`).
138
- // `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
139
- // own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
140
- // the ground beside them read as one surface.
131
+ // `isolation` keeps their z-index layers below the shell's own chrome. It
132
+ // paints the same PANEL_SHEEN as every panel, so columns and the ground
133
+ // beside them read as one surface.
141
134
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
142
135
  // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
143
136
  // column sideways permanently, with nothing to scroll it back.
144
137
  ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
145
138
  PANEL_SHEEN,
146
139
  ".s-panel": {
147
- // A panel rests at a plain `left` offset with no transform: a transformed
148
- // element is composited, costing it subpixel text antialiasing. Transform
149
- // is used only for the enter/exit slides. No `width` transition either —
150
- // animating one reflows the column's content every frame.
151
- // The fade is deliberately `linear` while the drift eases out: an eased
152
- // opacity spends its last stretch near zero, reading as a vanish.
153
- // Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
154
- // closing panel is no longer in the reactive list, so its element's
155
- // position among the live ones is Aberdeen's business. See LAYER_STEP.
140
+ // The shell knows three motions, and this vocabulary is all of them:
141
+ // a panel that MOVES animates its `left` every mover in a pass shares
142
+ // this one duration and ease-out, so panels travelling the same distance
143
+ // travel as one a CREATED panel joins the strip beside the old position
144
+ // of the panels beneath it and rides their slide while fading in, and a
145
+ // CLOSED one fades out where it stood. Nothing else ever animates.
146
+ // A plain `left`, never a transform: a transformed element is
147
+ // composited, costing it subpixel text antialiasing. No `width`
148
+ // transition either animating one reflows the column every frame. And
149
+ // the fade is `linear` while the moves ease out: an eased opacity spends
150
+ // its last stretch near zero, reading as a vanish.
151
+ // Layering is fixed per state, not per stack depth: live panels never
152
+ // overlap each other (the strip keeps them adjacent, see `layout()`), so
153
+ // only the fading ones need an order — below, in both directions, per
154
+ // the classes underneath.
156
155
  // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
157
156
  // another, and two transparent ones mean text sliding over text).
158
157
  "&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
159
158
  PANEL_SHEEN + " " +
160
- "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
159
+ "z-index:2 transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear;",
161
160
  // The hairline between two columns, fading at both ends (like the sidebar's
162
161
  // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
163
162
  // padding — so this sits exactly on the boundary.
164
163
  "&.s-panel-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
165
164
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
166
- // Enter and exit share one vocabulary: a fade, drifting a short 8cqw
167
- // (`cqw`: `.s-main` is the container) only when the navigation really
168
- // moves a push opening a new column, a back closing one. A panel
169
- // replacing another simply fades in on top of it (see LAYER_STEP), the
170
- // old one fading in place beneath. The enter start state is applied with
171
- // transitions off and then dropped, so the panel settles instead of jumping.
172
- "&.s-panel-enter": "opacity:0 transition:none",
173
- // On its way out; leaves the DOM when the fade ends (see `playExit`).
174
- "&.s-panel-closing": "opacity:0 pointer-events:none",
175
- // The sideways drift, as an enter's start state or a close's destination.
176
- "&.s-panel-drift": "transform: translateX(8cqw);",
177
- // Off screen but open: crowded out at the left edge (`hidden`) or parked
178
- // past the right one (`parked`). Both keep their DOM — and so their scroll
179
- // position and half-typed forms hence `visibility`, not `display:none`.
180
- // Transitioning it counts as *visible* for the whole fade, flipping at the
181
- // very end; the rule above (`visibility 0s`) reveals it again instantly.
182
- "&.s-panel-hidden, &.s-panel-parked": "opacity:0 visibility:hidden " +
183
- "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);",
184
- "&.s-panel-hidden": "transform: translateX(-8cqw);",
185
- "&.s-panel-parked": "transform: translateX(8cqw);",
165
+ // Still owed or playing its entry fade: beneath the settled panels, so
166
+ // whatever slides across its spot passes over it. Dropped once the fade
167
+ // is over (see `releaseEnter`).
168
+ "&.s-panel-new": "z-index:1",
169
+ // On its way out: it fades where it stood, beneath every live panel
170
+ // (declared after `.s-panel-new`, so closing mid-enter drops a panel to
171
+ // the bottom), and leaves the DOM when the fade ends (see `playExit`).
172
+ "&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
173
+ // The fade-in's start state: a newcomer wears it from creation until its
174
+ // content is ready usually the very pass that placed it, later for a
175
+ // `loading` panel holding out for data — sliding invisibly meanwhile.
176
+ // Dropping the class is what starts the fade (see `releaseEnter`).
177
+ "&.s-panel-enter": "opacity:0",
178
+ // Off screen but open: the strip simply continues past the viewport's
179
+ // edges, so these rest at their true positions, clipped being crowded
180
+ // out or revealed is an ordinary move, not a fade. Both keep their DOM —
181
+ // and so their scroll position and half-typed forms hence
182
+ // `visibility`, not `display:none`: it holds through the move out
183
+ // (flipping only at its end) and lifts instantly on the move back in
184
+ // (the base transition above doesn't list it).
185
+ "&.s-panel-hidden, &.s-panel-parked": "visibility:hidden " +
186
+ "transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
186
187
  },
187
188
  // The scroll container, with the column's own padding. Its scrollbar sits
188
189
  // flush against the column edge (unlike content mode's inset one), so it meets
@@ -497,12 +498,6 @@ export class PanelStackController {
497
498
  const pinned = route.current.state.pinned;
498
499
  const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
499
500
  const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
500
- // A replacement — something enters while something closes — just fades:
501
- // the new panel in on top, the old ones out in place beneath it (see the
502
- // `.s-panel-drift` comment). A pure push or a pure close drifts sideways.
503
- const entering = nav !== "load" && nav !== "back";
504
- const drift = !(entering && target.stack.some((p) => !existing.has(p)) &&
505
- this.$state.live.some((e) => !target.stack.includes(e.path)));
506
501
  const next = [];
507
502
  for (const path of target.stack) {
508
503
  const kept = existing.get(path);
@@ -513,17 +508,16 @@ export class PanelStackController {
513
508
  continue;
514
509
  }
515
510
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
516
- // An initial load just appears, and so do panels *revealed* by a back:
517
- // they belong underneath the ones sliding away.
518
- if (entering) {
511
+ // An initial load just appears; any later navigation animates its new
512
+ // panels in beneath what's already there, so a back that re-creates
513
+ // a panel plays out under the one it takes away.
514
+ if (nav !== "load")
519
515
  entry.enter = true;
520
- entry.drift = drift;
521
- }
522
516
  next.push(entry);
523
517
  this.$open[path] = entry;
524
518
  }
525
519
  for (const entry of existing.values())
526
- this.beginClose(entry, drift);
520
+ this.beginClose(entry);
527
521
  this.$state.live = next;
528
522
  this.$state.focus = Math.min(target.focus, next.length - 1);
529
523
  this.scheduleLayout();
@@ -562,15 +556,9 @@ export class PanelStackController {
562
556
  * at the end of the animation. Only the element lingers to play that animation,
563
557
  * which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
564
558
  */
565
- beginClose(entry, drift) {
559
+ beginClose(entry) {
566
560
  entry.closing = true;
567
- entry.drift = drift;
568
561
  entry.$panel.visible = false;
569
- // Frozen one layer below where it was, so it fades out over the panel it
570
- // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
571
- // the element is still ours — a moment later `entry.el` is gone.
572
- if (entry.el)
573
- entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
574
562
  delete this.$open[entry.path];
575
563
  }
576
564
  /**
@@ -588,8 +576,6 @@ export class PanelStackController {
588
576
  return;
589
577
  }
590
578
  el.classList.add("s-panel-closing");
591
- if (entry.drift)
592
- el.classList.add("s-panel-drift");
593
579
  el.setAttribute("inert", "");
594
580
  const drop = () => {
595
581
  clearTimeout(timer);
@@ -1265,9 +1251,11 @@ export class PanelStackController {
1265
1251
  first = i;
1266
1252
  }
1267
1253
  }
1268
- // The content area is a fixed width, so a run that doesn't fill it centres
1269
- // rather than hanging off the left edge the chrome around it never moves.
1270
- const left = (geom.area - runSum) / 2;
1254
+ // A run that doesn't fill the fixed-width area centres in it unless
1255
+ // panels sit crowded out on its left: then it hugs the left edge instead,
1256
+ // so the strip crosses that edge with no gap and a reveal is a plain
1257
+ // slide back in.
1258
+ const left = first > 0 ? 0 : (geom.area - runSum) / 2;
1271
1259
  for (let i = first; i <= cur; i++)
1272
1260
  live[i].width = width(live[i]);
1273
1261
  // Never-visible panels get their would-be width, so a reveal doesn't start
@@ -1276,50 +1264,65 @@ export class PanelStackController {
1276
1264
  if (!entry.width)
1277
1265
  entry.width = width(entry);
1278
1266
  }
1279
- // Phase 1 — every panel's *start* state for this frame. Panels on screen
1280
- // simply move; freshly mounted ones still have transitions off, so what is
1281
- // set here is adopted instantly and becomes the "before" of their enter.
1267
+ // Phase 1 — lay the strip out for this frame: each panel flush against
1268
+ // its neighbours, the visible run [first..cur] in the viewport, earlier
1269
+ // panels continuing off its left edge and parked ones held past its
1270
+ // right. Placed panels get their new positions — their standing
1271
+ // transitions carry them there. Newcomers, transitions still off, get
1272
+ // their *start* state instead: their strip position anchored to the OLD
1273
+ // position of the nearest placed panel beneath them (`delta`), so the
1274
+ // slide in is one motion with the panels making room. With nothing
1275
+ // beneath them to come from — or nothing moving — that start is where
1276
+ // they already stand, and they simply fade in.
1282
1277
  const fresh = [];
1283
1278
  let x = left;
1279
+ let delta = 0;
1280
+ for (let i = 0; i < first; i++)
1281
+ x -= live[i].width;
1284
1282
  for (let i = 0; i < n; i++) {
1285
1283
  const entry = live[i];
1286
1284
  const el = entry.el;
1287
1285
  const shown = i >= first && i <= cur;
1288
- // Visible columns tile the run left to right; crowded-out ones rest at
1289
- // its left edge and parked ones just past its right, both keeping their
1290
- // last width. Deeper panels layer over shallower (see LAYER_STEP).
1291
- place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1286
+ // Parked panels never dip into the viewport, however short the run.
1287
+ if (i === cur + 1)
1288
+ x = Math.max(x, geom.area);
1289
+ // A panel that mounts while still fetching holds its fade for a
1290
+ // moment, so it can appear with real content instead of empty.
1291
+ if (entry.enter || !entry.placed) {
1292
+ if (!entry.$panel.loading || entry.holdDone)
1293
+ entry.$ui.holding = false;
1294
+ else if (!entry.$ui.holding) {
1295
+ entry.$ui.holding = true;
1296
+ this.holdEnter(entry);
1297
+ }
1298
+ }
1299
+ if (entry.placed) {
1300
+ delta = parseFloat(el.style.left) - x;
1301
+ el.style.left = `${x}px`;
1302
+ // A fade held back for content starts the moment its hold lifts.
1303
+ if (entry.enter && !entry.$ui.holding)
1304
+ this.releaseEnter(entry);
1305
+ }
1306
+ else {
1307
+ fresh.push({ entry, x });
1308
+ const entering = entry.enter && shown;
1309
+ el.style.left = `${entering ? x + delta : x}px`;
1310
+ if (entering)
1311
+ el.classList.add("s-panel-enter", "s-panel-new");
1312
+ }
1313
+ el.style.width = `${entry.width}px`;
1314
+ x += entry.width;
1292
1315
  // Written only on a change, so per-panel UI hanging off `visible` or
1293
1316
  // `width` isn't rebuilt by every pass.
1294
1317
  if (entry.$panel.visible !== shown)
1295
1318
  entry.$panel.visible = shown;
1296
1319
  if (entry.$panel.width !== entry.width)
1297
1320
  entry.$panel.width = entry.width;
1298
- if (shown)
1299
- x += entry.width;
1300
1321
  el.classList.toggle("s-panel-sep", shown && i > first);
1301
- // Off-screen panels fade out and stop rendering, keeping their DOM.
1322
+ // Off-screen panels stop rendering, keeping their DOM.
1302
1323
  el.classList.toggle("s-panel-hidden", i < first);
1303
1324
  el.classList.toggle("s-panel-parked", i > cur);
1304
1325
  el.toggleAttribute("inert", !shown);
1305
- if (entry.placed)
1306
- continue;
1307
- fresh.push(entry);
1308
- // A panel that mounts while still fetching holds here for a moment, so
1309
- // it can enter with real content instead of an empty column.
1310
- if (!entry.$panel.loading || entry.holdDone)
1311
- entry.$ui.holding = false;
1312
- else if (!entry.$ui.holding) {
1313
- entry.$ui.holding = true;
1314
- this.holdEnter(entry);
1315
- }
1316
- // Already at its resting place; the enter is the transparency (and,
1317
- // when drifting, the offset one edge to the right) it starts from.
1318
- if (entry.enter && shown) {
1319
- el.classList.add("s-panel-enter");
1320
- if (entry.drift)
1321
- el.classList.add("s-panel-drift");
1322
- }
1323
1326
  }
1324
1327
  // Phase 2 — reading a layout property forces the browser to adopt those
1325
1328
  // start states (and a snap pass's transition-free geometry) to animate from.
@@ -1327,17 +1330,35 @@ export class PanelStackController {
1327
1330
  void container.offsetWidth;
1328
1331
  if (snap)
1329
1332
  shell.classList.remove("s-shell-snap");
1330
- // Phase 3 — transitions back on, start state dropped, and off they go.
1331
- for (const entry of fresh) {
1332
- if (entry.$ui.holding)
1333
- continue;
1334
- entry.el.style.transition = "";
1335
- entry.el.classList.remove("s-panel-enter", "s-panel-drift");
1336
- entry.enter = false;
1333
+ // Phase 3 — newcomers get their transitions and their resting place: the
1334
+ // slide starts now, and the fade with it — unless the panel is holding
1335
+ // for its content, which keeps the fade's start state on until then.
1336
+ for (const { entry, x } of fresh) {
1337
+ const el = entry.el;
1338
+ el.style.transition = "";
1339
+ el.style.left = `${x}px`;
1337
1340
  entry.placed = true;
1341
+ if (!entry.$ui.holding)
1342
+ this.releaseEnter(entry);
1338
1343
  }
1339
1344
  }
1340
- /** Let a `loading` panel's enter animation wait but not indefinitely. */
1345
+ /** Start (or skip) a newcomer's fade-in; any slide is already underway. */
1346
+ releaseEnter(entry) {
1347
+ entry.enter = false;
1348
+ const el = entry.el;
1349
+ if (!el || !el.classList.contains("s-panel-enter"))
1350
+ return;
1351
+ el.classList.remove("s-panel-enter");
1352
+ // Keep it beneath its elders until the fade is over — by timer, a hair
1353
+ // past it, since a resize can snap the fade short without ever firing a
1354
+ // `transitionend`.
1355
+ const timer = setTimeout(() => {
1356
+ this.timers.delete(timer);
1357
+ el.classList.remove("s-panel-new");
1358
+ }, PAGE_MS + 80);
1359
+ this.timers.add(timer);
1360
+ }
1361
+ /** Let a `loading` panel's fade-in wait — but not indefinitely. */
1341
1362
  holdEnter(entry) {
1342
1363
  const timer = setTimeout(() => {
1343
1364
  this.timers.delete(timer);
@@ -1350,12 +1371,6 @@ export class PanelStackController {
1350
1371
  this.timers.add(timer);
1351
1372
  }
1352
1373
  }
1353
- /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
1354
- function place(el, x, width, z) {
1355
- el.style.left = `${x}px`;
1356
- el.style.width = `${width}px`;
1357
- el.style.zIndex = String(z);
1358
- }
1359
1374
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1360
1375
  function sameStack(a, b) {
1361
1376
  return a.length === b.length && a.every((v, i) => v === b[i]);