staffa 0.17.2 → 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
  *
@@ -24,9 +24,15 @@ A.insertGlobalCss({
24
24
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
25
25
  // The bar reads `[leading] [title] …spacer… [trailing]`, the spacer being the
26
26
  // trailing slot's own growth (which is what lets a search box live there).
27
- // Its near-zero shrink factor makes the titles give way first, but only to
28
- // their floor past that the trailing slot shrinks after all, since a
29
- // zero-width crumb strip would spill its overlay buttons over the ☰.
27
+ // When the bar runs short, titles and trailing slot shrink in proportion —
28
+ // an honest factor of 1: a fractional one is not a slower shrink but a
29
+ // partial one, handing back that fraction of the shortfall and overflowing
30
+ // the rest. What keeps the slot's chrome from being crushed is its floor:
31
+ // no `min-width:0` here, so it stops at its content's own minimum — verbs
32
+ // promoted into the bar can never be squeezed into painting over the
33
+ // crumbs beside them, while a search box that declares `min-width:0` of
34
+ // its own still gives everything it has. Past the floor the titles do all
35
+ // the giving, down to their own 5rem.
30
36
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
31
37
  "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
32
38
  // Pull back the ~6px of padding in the ☰'s 2rem hit area, so the glyph — not
@@ -42,7 +48,7 @@ A.insertGlobalCss({
42
48
  // to what their own classes provide (`filter:none` keeps the global
43
49
  // `a:hover` brighten off the gradient text).
44
50
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
45
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
51
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto;",
46
52
  // Body always wraps <main>, sidebar or not, so max-width centring and
47
53
  // scrollbar alignment work the same either way. It is also the positioning
48
54
  // and clipping context for the narrow-screen nav panel. `overflow:clip`, not
@@ -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,61 +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 over a short 8cqw drift
167
- // (`cqw`: `.s-main` is the container). This start state is applied with
168
- // transitions off and then dropped, so the panel settles instead of jumping.
169
- "&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
170
- // On its way out; leaves the DOM when the fade ends (see `playExit`).
171
- "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
172
- // Off screen but open: crowded out at the left edge (`hidden`) or parked
173
- // past the right one (`parked`). Both keep their DOM — and so their scroll
174
- // position and half-typed forms hence `visibility`, not `display:none`.
175
- // Transitioning it counts as *visible* for the whole fade, flipping at the
176
- // very end; the rule above (`visibility 0s`) reveals it again instantly.
177
- "&.s-panel-hidden, &.s-panel-parked": "opacity:0 visibility:hidden " +
178
- "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);",
179
- "&.s-panel-hidden": "transform: translateX(-8cqw);",
180
- "&.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);",
181
187
  },
182
188
  // The scroll container, with the column's own padding. Its scrollbar sits
183
189
  // flush against the column edge (unlike content mode's inset one), so it meets
@@ -502,9 +508,10 @@ export class PanelStackController {
502
508
  continue;
503
509
  }
504
510
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
505
- // An initial load just appears, and so do panels *revealed* by a back:
506
- // they belong underneath the ones sliding away.
507
- if (nav !== "load" && nav !== "back")
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")
508
515
  entry.enter = true;
509
516
  next.push(entry);
510
517
  this.$open[path] = entry;
@@ -552,11 +559,6 @@ export class PanelStackController {
552
559
  beginClose(entry) {
553
560
  entry.closing = true;
554
561
  entry.$panel.visible = false;
555
- // Frozen one layer below where it was, so it fades out over the panel it
556
- // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
557
- // the element is still ours — a moment later `entry.el` is gone.
558
- if (entry.el)
559
- entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
560
562
  delete this.$open[entry.path];
561
563
  }
562
564
  /**
@@ -1249,9 +1251,11 @@ export class PanelStackController {
1249
1251
  first = i;
1250
1252
  }
1251
1253
  }
1252
- // The content area is a fixed width, so a run that doesn't fill it centres
1253
- // rather than hanging off the left edge the chrome around it never moves.
1254
- 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;
1255
1259
  for (let i = first; i <= cur; i++)
1256
1260
  live[i].width = width(live[i]);
1257
1261
  // Never-visible panels get their would-be width, so a reveal doesn't start
@@ -1260,47 +1264,65 @@ export class PanelStackController {
1260
1264
  if (!entry.width)
1261
1265
  entry.width = width(entry);
1262
1266
  }
1263
- // Phase 1 — every panel's *start* state for this frame. Panels on screen
1264
- // simply move; freshly mounted ones still have transitions off, so what is
1265
- // 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.
1266
1277
  const fresh = [];
1267
1278
  let x = left;
1279
+ let delta = 0;
1280
+ for (let i = 0; i < first; i++)
1281
+ x -= live[i].width;
1268
1282
  for (let i = 0; i < n; i++) {
1269
1283
  const entry = live[i];
1270
1284
  const el = entry.el;
1271
1285
  const shown = i >= first && i <= cur;
1272
- // Visible columns tile the run left to right; crowded-out ones rest at
1273
- // its left edge and parked ones just past its right, both keeping their
1274
- // last width. Deeper panels layer over shallower (see LAYER_STEP).
1275
- 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;
1276
1315
  // Written only on a change, so per-panel UI hanging off `visible` or
1277
1316
  // `width` isn't rebuilt by every pass.
1278
1317
  if (entry.$panel.visible !== shown)
1279
1318
  entry.$panel.visible = shown;
1280
1319
  if (entry.$panel.width !== entry.width)
1281
1320
  entry.$panel.width = entry.width;
1282
- if (shown)
1283
- x += entry.width;
1284
1321
  el.classList.toggle("s-panel-sep", shown && i > first);
1285
- // Off-screen panels fade out and stop rendering, keeping their DOM.
1322
+ // Off-screen panels stop rendering, keeping their DOM.
1286
1323
  el.classList.toggle("s-panel-hidden", i < first);
1287
1324
  el.classList.toggle("s-panel-parked", i > cur);
1288
1325
  el.toggleAttribute("inert", !shown);
1289
- if (entry.placed)
1290
- continue;
1291
- fresh.push(entry);
1292
- // A panel that mounts while still fetching holds here for a moment, so
1293
- // it can enter with real content instead of an empty column.
1294
- if (!entry.$panel.loading || entry.holdDone)
1295
- entry.$ui.holding = false;
1296
- else if (!entry.$ui.holding) {
1297
- entry.$ui.holding = true;
1298
- this.holdEnter(entry);
1299
- }
1300
- // Already at its resting place; the enter is the offset and transparency
1301
- // it starts from, one edge to the right.
1302
- if (entry.enter && shown)
1303
- el.classList.add("s-panel-enter");
1304
1326
  }
1305
1327
  // Phase 2 — reading a layout property forces the browser to adopt those
1306
1328
  // start states (and a snap pass's transition-free geometry) to animate from.
@@ -1308,17 +1330,35 @@ export class PanelStackController {
1308
1330
  void container.offsetWidth;
1309
1331
  if (snap)
1310
1332
  shell.classList.remove("s-shell-snap");
1311
- // Phase 3 — transitions back on, start state dropped, and off they go.
1312
- for (const entry of fresh) {
1313
- if (entry.$ui.holding)
1314
- continue;
1315
- entry.el.style.transition = "";
1316
- entry.el.classList.remove("s-panel-enter");
1317
- 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`;
1318
1340
  entry.placed = true;
1341
+ if (!entry.$ui.holding)
1342
+ this.releaseEnter(entry);
1319
1343
  }
1320
1344
  }
1321
- /** 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. */
1322
1362
  holdEnter(entry) {
1323
1363
  const timer = setTimeout(() => {
1324
1364
  this.timers.delete(timer);
@@ -1331,12 +1371,6 @@ export class PanelStackController {
1331
1371
  this.timers.add(timer);
1332
1372
  }
1333
1373
  }
1334
- /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
1335
- function place(el, x, width, z) {
1336
- el.style.left = `${x}px`;
1337
- el.style.width = `${width}px`;
1338
- el.style.zIndex = String(z);
1339
- }
1340
1374
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1341
1375
  function sameStack(a, b) {
1342
1376
  return a.length === b.length && a.every((v, i) => v === b[i]);