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.
- package/dist/components/main.d.ts +2 -2
- package/dist/components/main.js +10 -4
- package/dist/components/panels.d.ts +9 -6
- package/dist/components/panels.js +120 -86
- package/dist/staffa.esm.js +1 -1
- package/package.json +1 -1
- package/skill/MainOptions.md +2 -2
- package/skill/Panel.md +6 -5
- package/src/components/main.ts +12 -6
- package/src/components/panels.ts +118 -85
package/src/components/panels.ts
CHANGED
|
@@ -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
|
|
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
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
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
|
|
446
|
-
//
|
|
447
|
-
//
|
|
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,43 +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
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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
|
-
"
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
"&.s-panel-
|
|
480
|
-
// On its way out
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
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).
|
|
487
496
|
"&.s-panel-hidden, &.s-panel-parked":
|
|
488
|
-
"
|
|
489
|
-
"transition: left var(--s-panel-ms) ease
|
|
490
|
-
"&.s-panel-hidden": "transform: translateX(-8cqw);",
|
|
491
|
-
"&.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);",
|
|
492
499
|
},
|
|
493
500
|
// The scroll container, with the column's own padding. Its scrollbar sits
|
|
494
501
|
// flush against the column edge (unlike content mode's inset one), so it meets
|
|
@@ -594,7 +601,7 @@ interface PanelEntry {
|
|
|
594
601
|
hash?: string;
|
|
595
602
|
/** Set once the panel is on its way out, playing its exit animation. */
|
|
596
603
|
closing?: boolean;
|
|
597
|
-
/** Set while
|
|
604
|
+
/** Set while the fade-in is still owed (a `loading` hold can owe it past placement). */
|
|
598
605
|
enter?: boolean;
|
|
599
606
|
/** Whether the panel has been through a full layout pass (and so may animate). */
|
|
600
607
|
placed?: boolean;
|
|
@@ -1035,9 +1042,10 @@ export class PanelStackController implements PanelStack {
|
|
|
1035
1042
|
continue;
|
|
1036
1043
|
}
|
|
1037
1044
|
const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
|
|
1038
|
-
// An initial load just appears
|
|
1039
|
-
//
|
|
1040
|
-
|
|
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;
|
|
1041
1049
|
next.push(entry);
|
|
1042
1050
|
this.$open[path] = entry;
|
|
1043
1051
|
}
|
|
@@ -1085,10 +1093,6 @@ export class PanelStackController implements PanelStack {
|
|
|
1085
1093
|
private beginClose(entry: PanelEntry): void {
|
|
1086
1094
|
entry.closing = true;
|
|
1087
1095
|
entry.$panel.visible = false;
|
|
1088
|
-
// Frozen one layer below where it was, so it fades out over the panel it
|
|
1089
|
-
// uncovers and under the one replacing it (see LAYER_STEP). Set here, while
|
|
1090
|
-
// the element is still ours — a moment later `entry.el` is gone.
|
|
1091
|
-
if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
|
|
1092
1096
|
delete this.$open[entry.path];
|
|
1093
1097
|
}
|
|
1094
1098
|
|
|
@@ -1801,9 +1805,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1801
1805
|
}
|
|
1802
1806
|
}
|
|
1803
1807
|
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
|
|
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;
|
|
1807
1813
|
|
|
1808
1814
|
for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
|
|
1809
1815
|
// Never-visible panels get their would-be width, so a reveal doesn't start
|
|
@@ -1812,55 +1818,89 @@ export class PanelStackController implements PanelStack {
|
|
|
1812
1818
|
if (!entry.width) entry.width = width(entry);
|
|
1813
1819
|
}
|
|
1814
1820
|
|
|
1815
|
-
// Phase 1 —
|
|
1816
|
-
//
|
|
1817
|
-
//
|
|
1818
|
-
|
|
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 }[] = [];
|
|
1819
1832
|
let x = left;
|
|
1833
|
+
let delta = 0;
|
|
1834
|
+
for (let i = 0; i < first; i++) x -= live[i].width;
|
|
1820
1835
|
for (let i = 0; i < n; i++) {
|
|
1821
1836
|
const entry = live[i];
|
|
1822
1837
|
const el = entry.el!;
|
|
1823
1838
|
const shown = i >= first && i <= cur;
|
|
1824
|
-
//
|
|
1825
|
-
|
|
1826
|
-
//
|
|
1827
|
-
|
|
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;
|
|
1828
1860
|
// Written only on a change, so per-panel UI hanging off `visible` or
|
|
1829
1861
|
// `width` isn't rebuilt by every pass.
|
|
1830
1862
|
if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
|
|
1831
1863
|
if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
|
|
1832
|
-
if (shown) x += entry.width;
|
|
1833
1864
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
1834
|
-
// Off-screen panels
|
|
1865
|
+
// Off-screen panels stop rendering, keeping their DOM.
|
|
1835
1866
|
el.classList.toggle("s-panel-hidden", i < first);
|
|
1836
1867
|
el.classList.toggle("s-panel-parked", i > cur);
|
|
1837
1868
|
el.toggleAttribute("inert", !shown);
|
|
1838
|
-
if (entry.placed) continue;
|
|
1839
|
-
fresh.push(entry);
|
|
1840
|
-
// A panel that mounts while still fetching holds here for a moment, so
|
|
1841
|
-
// it can enter with real content instead of an empty column.
|
|
1842
|
-
if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
|
|
1843
|
-
else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
|
|
1844
|
-
// Already at its resting place; the enter is the offset and transparency
|
|
1845
|
-
// it starts from, one edge to the right.
|
|
1846
|
-
if (entry.enter && shown) el.classList.add("s-panel-enter");
|
|
1847
1869
|
}
|
|
1848
1870
|
|
|
1849
1871
|
// Phase 2 — reading a layout property forces the browser to adopt those
|
|
1850
1872
|
// start states (and a snap pass's transition-free geometry) to animate from.
|
|
1851
1873
|
if (fresh.length || snap) void container.offsetWidth;
|
|
1852
1874
|
if (snap) shell.classList.remove("s-shell-snap");
|
|
1853
|
-
// Phase 3 —
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
entry.el
|
|
1858
|
-
|
|
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`;
|
|
1859
1882
|
entry.placed = true;
|
|
1883
|
+
if (!entry.$ui.holding) this.releaseEnter(entry);
|
|
1860
1884
|
}
|
|
1861
1885
|
}
|
|
1862
1886
|
|
|
1863
|
-
/**
|
|
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. */
|
|
1864
1904
|
private holdEnter(entry: PanelEntry): void {
|
|
1865
1905
|
const timer = setTimeout(() => {
|
|
1866
1906
|
this.timers.delete(timer);
|
|
@@ -1874,13 +1914,6 @@ export class PanelStackController implements PanelStack {
|
|
|
1874
1914
|
}
|
|
1875
1915
|
}
|
|
1876
1916
|
|
|
1877
|
-
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
1878
|
-
function place(el: HTMLElement, x: number, width: number, z: number): void {
|
|
1879
|
-
el.style.left = `${x}px`;
|
|
1880
|
-
el.style.width = `${width}px`;
|
|
1881
|
-
el.style.zIndex = String(z);
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
1917
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
1885
1918
|
|
|
1886
1919
|
function sameStack(a: string[], b: string[]): boolean {
|