staffa 0.12.0 → 0.13.0
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/README.md +7 -5
- package/dist/components/main.d.ts +25 -0
- package/dist/components/main.js +60 -26
- package/dist/components/panels.d.ts +75 -21
- package/dist/components/panels.js +102 -77
- package/dist/staffa.esm.js +1 -1
- package/package.json +3 -3
- package/skill/MainOptions.md +29 -0
- package/skill/Panel.md +41 -5
- package/skill/PanelStack.md +5 -0
- package/skill/SKILL.md +7 -5
- package/src/components/main.ts +86 -28
- package/src/components/panels.ts +148 -82
package/src/components/main.ts
CHANGED
|
@@ -229,6 +229,20 @@ export interface MainOptions<R = Routes> {
|
|
|
229
229
|
* themselves up with them.
|
|
230
230
|
*/
|
|
231
231
|
maxWidth?: string;
|
|
232
|
+
/**
|
|
233
|
+
* How wide a `"full"` panel gets, in pixels — and with it the whole content
|
|
234
|
+
* area, since a `"full"` fills it exactly. A `"half"` gets half of this, and
|
|
235
|
+
* a `"screen"` ignores it and takes the window. Defaults to 1080; the window
|
|
236
|
+
* caps it when there is less room than that. Routed mode only.
|
|
237
|
+
*
|
|
238
|
+
* This plus {@link MainOptions.navWidth} is the app's standard page — see
|
|
239
|
+
* there.
|
|
240
|
+
*
|
|
241
|
+
* Live, like {@link MainOptions.columns}: pass a proxied options object (or
|
|
242
|
+
* make this field a getter) and a change is adopted in one layout pass,
|
|
243
|
+
* every panel keeping its state.
|
|
244
|
+
*/
|
|
245
|
+
fullWidth?: number;
|
|
232
246
|
/** Aberdeen attr/style string applied to the content area. */
|
|
233
247
|
contentAttrs?: Attributes;
|
|
234
248
|
/** Aberdeen attr/style string applied to the top bar. */
|
|
@@ -255,12 +269,33 @@ export interface MainOptions<R = Routes> {
|
|
|
255
269
|
* chrome goes assumes they are one.
|
|
256
270
|
*/
|
|
257
271
|
navPosition?: "left" | "right";
|
|
272
|
+
/**
|
|
273
|
+
* How wide the nav sidebar column is, in pixels — its hairline included.
|
|
274
|
+
* Defaults to 200.
|
|
275
|
+
*
|
|
276
|
+
* Together with {@link MainOptions.fullWidth} this is the app's *standard
|
|
277
|
+
* page*: the width the top bar and footer keep to, and the width the
|
|
278
|
+
* columns settle back to. The defaults come to the familiar 1280px.
|
|
279
|
+
*
|
|
280
|
+
* Live, like {@link MainOptions.fullWidth}.
|
|
281
|
+
*/
|
|
282
|
+
navWidth?: number;
|
|
258
283
|
/** Aberdeen attr/style string applied to the sidebar nav panel. */
|
|
259
284
|
navAttrs?: Attributes;
|
|
260
285
|
/** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
|
|
261
286
|
navPageAttrs?: Attributes;
|
|
262
287
|
}
|
|
263
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The default nav column (hairline included) and the default width of a
|
|
291
|
+
* `"full"` panel — see {@link MainOptions.navWidth} and
|
|
292
|
+
* {@link MainOptions.fullWidth}. Side by side they come to the 1280px page the
|
|
293
|
+
* shell is usually seen as, but that figure lives nowhere: the browser adds
|
|
294
|
+
* these two up, and an app that changes either simply gets a different page.
|
|
295
|
+
*/
|
|
296
|
+
const NAV_W = 200;
|
|
297
|
+
const FULL_W = 1080;
|
|
298
|
+
|
|
264
299
|
A.insertGlobalCss({
|
|
265
300
|
".s-main": {
|
|
266
301
|
// container-type so @container queries below can respond to shell width.
|
|
@@ -278,8 +313,10 @@ A.insertGlobalCss({
|
|
|
278
313
|
// The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
|
|
279
314
|
// trailing slot's own growth: it takes the free space and right-aligns
|
|
280
315
|
// itself in it, which is what lets a search box live there. When the two
|
|
281
|
-
// compete, the
|
|
282
|
-
//
|
|
316
|
+
// compete, the titles give way first: the trailing slot's near-zero
|
|
317
|
+
// shrink factor keeps a row of actions at its natural width while the
|
|
318
|
+
// crumbs absorb the squeeze — but only down to the titles' floor, past
|
|
319
|
+
// which the trailing slot shrinks after all: a wide search box must not
|
|
283
320
|
// starve the titles to nothing (the crumb strip's overlay buttons would
|
|
284
321
|
// escape their zero-width strip, over the ☰ beside it).
|
|
285
322
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
@@ -300,13 +337,16 @@ A.insertGlobalCss({
|
|
|
300
337
|
// which their classes then provide. (`filter:none` keeps the global
|
|
301
338
|
// `a:hover` brighten off the gradient text.)
|
|
302
339
|
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
303
|
-
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
|
|
340
|
+
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
|
|
304
341
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
305
342
|
// and scrollbar alignment work identically in both cases.
|
|
306
343
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
307
344
|
// It's also the positioning + clipping context for the narrow-screen nav panel,
|
|
308
|
-
// which slides in and out across its left edge.
|
|
309
|
-
|
|
345
|
+
// which slides in and out across its left edge. `overflow:clip` rather than
|
|
346
|
+
// `hidden` for the same reason as `.s-panels`: a hidden box can still be
|
|
347
|
+
// scrolled (find-in-page, an anchor, an extension), and a stray scroll here
|
|
348
|
+
// would shove the whole row — sidebar and columns — out of place for good.
|
|
349
|
+
".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
310
350
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
311
351
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
312
352
|
"&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
|
|
@@ -337,23 +377,26 @@ A.insertGlobalCss({
|
|
|
337
377
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
338
378
|
// Routed mode takes its width from the stack instead of from
|
|
339
379
|
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
340
|
-
// separator + content area) as --s-shell-w — the standard
|
|
341
|
-
// normally, the
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
380
|
+
// separator + content area) as --s-shell-w — the standard page
|
|
381
|
+
// normally, wider while the columns outgrow it (a "screen" page, or
|
|
382
|
+
// extra columns fitting a wide window) — and the body row caps itself
|
|
383
|
+
// to it, staying centred around the columns. Changing the custom
|
|
384
|
+
// property animates the max-width consuming it, with no JS in the loop:
|
|
385
|
+
// the body recentres in step with the panel whose arrival or departure
|
|
386
|
+
// moved it, over the same --s-panel-ms (see panels.ts). During a window
|
|
387
|
+
// resize (and the very first pass) the layout engine raises
|
|
388
|
+
// `.s-shell-snap` so the new width is adopted instantly instead of
|
|
389
|
+
// chasing the window through a transition.
|
|
390
|
+
"&.s-routed > .s-body > .s-body-inner":
|
|
391
|
+
"max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
|
|
392
|
+
"&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
|
|
393
|
+
// The bars don't follow the ensemble past the standard page: a header
|
|
394
|
+
// stretching to the window's edges and back with every "screen" panel
|
|
395
|
+
// reads as the whole app flexing, so the chrome holds still and only
|
|
396
|
+
// the columns grow. (Below the standard width the ensemble is simply
|
|
397
|
+
// the window, which only a resize changes — so the bars never animate,
|
|
398
|
+
// and take no part in the transition above.)
|
|
399
|
+
"&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "max-width: calc(var(--s-nav-w) + var(--s-full-w))",
|
|
357
400
|
},
|
|
358
401
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
359
402
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
@@ -364,7 +407,10 @@ A.insertGlobalCss({
|
|
|
364
407
|
// The generous horizontal padding is what keeps the rows clear of the content
|
|
365
408
|
// separator on one side and the shell edge on the other; the vertical scroll
|
|
366
409
|
// (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
|
|
367
|
-
|
|
410
|
+
// `--s-nav-w` measures the whole column, hairline included, so the panel
|
|
411
|
+
// itself gives that 1px back — and the app's two widths then add up to
|
|
412
|
+
// exactly the page the bars above and below keep to.
|
|
413
|
+
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
|
|
368
414
|
},
|
|
369
415
|
// The narrow-screen nav: a full "panel" that slides in over the content from the
|
|
370
416
|
// left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
|
|
@@ -493,6 +539,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
493
539
|
notFound: opts.notFound,
|
|
494
540
|
ancestors: opts.ancestors,
|
|
495
541
|
title: opts.title,
|
|
542
|
+
// Corrected below, and on every change, from the app's own option:
|
|
543
|
+
// read here it would subscribe the whole shell to it.
|
|
544
|
+
fullWidth: FULL_W,
|
|
496
545
|
$shell,
|
|
497
546
|
})
|
|
498
547
|
: null;
|
|
@@ -504,6 +553,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
504
553
|
// the new link default. Nothing else of the shell is touched.
|
|
505
554
|
A(() => ctl.setColumns(opts.columns));
|
|
506
555
|
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
556
|
+
A(() => ctl.setFullWidth(opts.fullWidth ?? FULL_W));
|
|
507
557
|
}
|
|
508
558
|
// Where the brand mark and the app's name link — or nowhere, when the app
|
|
509
559
|
// said `home: null` (a title slot holding a control of its own, say).
|
|
@@ -513,12 +563,20 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
513
563
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
514
564
|
|
|
515
565
|
const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
566
|
+
// The two widths the CSS above works from, and with them the standard page
|
|
567
|
+
// the bars keep to. Each sits in a scope of its own — one that draws
|
|
568
|
+
// nothing, so re-running it is a single style write: an app that changes
|
|
569
|
+
// either on a proxied options object resizes the shell in place, panels
|
|
570
|
+
// and their state untouched.
|
|
571
|
+
A(() => A(`--s-full-w: ${opts.fullWidth ?? FULL_W}px`));
|
|
572
|
+
// `--s-nav-w` is the sidebar's whole column, and nothing at all when there
|
|
573
|
+
// is no sidebar to give it to — a shell without one lines its bars up with
|
|
574
|
+
// the content. This scope also tags the shell with the side the sidebar is
|
|
575
|
+
// on, for the CSS above to hang off (see `nav` above: reading `nav.items`
|
|
576
|
+
// here subscribes this scope alone, never the shell entire).
|
|
519
577
|
A(() => {
|
|
520
|
-
if (nav == null || !nav.items.length)
|
|
521
|
-
A(`.s-nav-${navPos}`);
|
|
578
|
+
if (nav == null || !nav.items.length) A("--s-nav-w: 0px");
|
|
579
|
+
else A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
|
|
522
580
|
});
|
|
523
581
|
|
|
524
582
|
// Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
|
package/src/components/panels.ts
CHANGED
|
@@ -175,17 +175,25 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
175
175
|
* because that is what it gets when two columns fit; this says how much
|
|
176
176
|
* *more* it can take.
|
|
177
177
|
*
|
|
178
|
-
* - `"half"` — nothing more. Half the content area (
|
|
179
|
-
* column fits beside it. For
|
|
180
|
-
*
|
|
178
|
+
* - `"half"` — nothing more. Half the content area (360px up to half of
|
|
179
|
+
* {@link MainOptions.fullWidth}), so a second column fits beside it. For
|
|
180
|
+
* lists and detail forms.
|
|
181
|
+
* - `"full"` (the default) — the whole content area, which is exactly
|
|
182
|
+
* {@link MainOptions.fullWidth}: 1080px unless the app says otherwise.
|
|
181
183
|
* - `"screen"` — the whole window, unbounded: boards, wide tables, dense
|
|
182
|
-
* dashboards. While one is open the
|
|
183
|
-
*
|
|
184
|
+
* dashboards. While one is open the columns stretch to the screen edges
|
|
185
|
+
* instead of stopping at the standard page; the top bar and footer hold
|
|
186
|
+
* the standard width throughout.
|
|
184
187
|
*
|
|
185
188
|
* Below the width two columns need, everything takes the content area
|
|
186
189
|
* whatever it asked for. Widths depend only on the window, never on what
|
|
187
190
|
* else is open, so opening or closing a panel never resizes another.
|
|
188
191
|
*
|
|
192
|
+
* This is a *layout regime*, not a width guarantee: handle whatever width
|
|
193
|
+
* the bucket yields, and ask only for what your content can actually use —
|
|
194
|
+
* a screen that would cap its own content narrower than its ask is holding
|
|
195
|
+
* room that would have let another column fit beside it.
|
|
196
|
+
*
|
|
189
197
|
* Set it at the top of your handler and the panel is already that wide when
|
|
190
198
|
* you draw (see {@link Panel.width}); set it later — when your data tells you
|
|
191
199
|
* — and the panel reflows without being redrawn, keeping its state, while
|
|
@@ -261,6 +269,31 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
261
269
|
* ```
|
|
262
270
|
*/
|
|
263
271
|
close(): Promise<boolean>;
|
|
272
|
+
/**
|
|
273
|
+
* Opens `href` exactly as a click on a link inside this panel does — the
|
|
274
|
+
* shell's own link handling runs through this very call, so the two can't
|
|
275
|
+
* drift apart. By default that is a push: the target opens on top of this
|
|
276
|
+
* panel, closing the panels after it first (pinned ones ride along
|
|
277
|
+
* beneath the new panel, unsaved ones park), and a path that is already
|
|
278
|
+
* open is returned to rather than opened twice. `how` plays the part of a
|
|
279
|
+
* link's `data-panel` attribute: `"replace"` puts the target in this
|
|
280
|
+
* panel's place, `"open"` leaves the panel behind and gives the target
|
|
281
|
+
* its own stack, and omitting it follows the shell's
|
|
282
|
+
* {@link MainOptions.linkNavigation}, like a link without the attribute.
|
|
283
|
+
*
|
|
284
|
+
* This is the one for navigation that can't be a link: a row's click
|
|
285
|
+
* handler, a keyboard shortcut acting on this screen. The stack's
|
|
286
|
+
* {@link PanelStack.pushPanel} builds on the *current* panel instead — a
|
|
287
|
+
* different panel exactly when the interaction happened in a column
|
|
288
|
+
* beside it, where it would pile the new panel on top of the open detail
|
|
289
|
+
* rather than pruning back to this one.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
296
|
+
open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
|
|
264
297
|
}
|
|
265
298
|
|
|
266
299
|
// ─── Path matching ───────────────────────────────────────────────────────────
|
|
@@ -374,7 +407,7 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
374
407
|
/**
|
|
375
408
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
376
409
|
* `left` moves of columns shifting sideways, the ensemble-width transition the
|
|
377
|
-
*
|
|
410
|
+
* body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
|
|
378
411
|
* panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
|
|
379
412
|
* and JS can't drift apart.
|
|
380
413
|
*
|
|
@@ -385,12 +418,6 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
385
418
|
const PAGE_MS = 250;
|
|
386
419
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
387
420
|
const LOADING_HOLD_MS = 300;
|
|
388
|
-
/**
|
|
389
|
-
* The standard page width: sidebar plus content area, capped by the window.
|
|
390
|
-
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
391
|
-
* page makes the shell grow past it.
|
|
392
|
-
*/
|
|
393
|
-
const SHELL_PX = 1280;
|
|
394
421
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
395
422
|
const PAIR_MIN_PX = 360;
|
|
396
423
|
/**
|
|
@@ -415,8 +442,13 @@ A.insertGlobalCss({
|
|
|
415
442
|
// The region paints the panel's sheen over its own box, and every panel shows
|
|
416
443
|
// a slice of that same gradient (see `.s-panel` below), so the columns and
|
|
417
444
|
// the ground beside them are one continuous surface.
|
|
445
|
+
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
446
|
+
// and anything that ever scrolls it — find-in-page reaching for text in a
|
|
447
|
+
// parked column, an in-page anchor, an extension — shifts every column
|
|
448
|
+
// sideways, permanently, because nothing here would ever scroll it back.
|
|
449
|
+
// `clip` clips without being scrollable at all, closing the whole class.
|
|
418
450
|
".s-panels":
|
|
419
|
-
"flex:1 min-width:0 min-height:0 position:relative overflow:
|
|
451
|
+
"flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
420
452
|
SURFACE_SHEEN,
|
|
421
453
|
".s-panel": {
|
|
422
454
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
@@ -513,14 +545,19 @@ A.insertGlobalCss({
|
|
|
513
545
|
// the weight change alone is ambiguous in a short crumb, the colour alone
|
|
514
546
|
// too subtle. No padding of its own — the first crumb has to start on the
|
|
515
547
|
// same pixel as the app's name above it, and the gap below spaces the row.
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
548
|
+
// The flex is how a tight row is shared out. Every crumb grows from the
|
|
549
|
+
// same 4rem basis in equal shares, freezing at its own text
|
|
550
|
+
// (`max-width:max-content`) — so with room to spare every title shows in
|
|
551
|
+
// full, and under pressure it is the *longest* crumbs that give way
|
|
552
|
+
// first, equalising downward while short ones keep every character. No
|
|
553
|
+
// crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
|
|
554
|
+
// that point the row overflows and the strip scrolls — which is what
|
|
555
|
+
// keeps a deep stack on a phone readable. (Crumbs allowed to shrink
|
|
556
|
+
// would ellipsise to a row of stubs instead, and the stack would never
|
|
557
|
+
// scroll.)
|
|
521
558
|
"&":
|
|
522
|
-
"flex
|
|
523
|
-
"white-space:nowrap max-width:
|
|
559
|
+
"flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
560
|
+
"white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
|
|
524
561
|
"transition: color 0.12s;",
|
|
525
562
|
"&.s-crumb-on": "font-weight:600 fg:$s-text",
|
|
526
563
|
// The same hover treatment as a menu item. The panel you are on is a plain
|
|
@@ -642,7 +679,7 @@ interface Geometry {
|
|
|
642
679
|
chrome: number;
|
|
643
680
|
/** Half the standard content area, or all of it when a half would be too narrow. */
|
|
644
681
|
half: number;
|
|
645
|
-
/** The standard content area: the
|
|
682
|
+
/** The standard content area: what the app asked a `"full"` panel to be. */
|
|
646
683
|
full: number;
|
|
647
684
|
/** Everything the window has beside the chrome, with no upper limit. */
|
|
648
685
|
screen: number;
|
|
@@ -672,6 +709,8 @@ export interface PanelStackOptions {
|
|
|
672
709
|
columns?: "auto" | "single";
|
|
673
710
|
/** What a bare link does. See {@link MainOptions.linkNavigation}. */
|
|
674
711
|
linkNavigation?: "push" | "replace" | "open";
|
|
712
|
+
/** How wide a `"full"` panel gets, in px. See {@link MainOptions.fullWidth}. */
|
|
713
|
+
fullWidth: number;
|
|
675
714
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
676
715
|
title?: unknown;
|
|
677
716
|
/**
|
|
@@ -739,6 +778,11 @@ export interface PanelStack {
|
|
|
739
778
|
* than opening it twice, and a panel holding {@link Panel.unsaved} work is
|
|
740
779
|
* never closed, only parked. That's what a plain link does, and what
|
|
741
780
|
* `data-panel=push` says outright.
|
|
781
|
+
*
|
|
782
|
+
* Note that a link builds on the panel it is *drawn in*, which is the
|
|
783
|
+
* current panel only while no column beside it has the focus. Code
|
|
784
|
+
* navigating on behalf of a particular screen — a row's click handler —
|
|
785
|
+
* wants that panel's own {@link Panel.open} instead.
|
|
742
786
|
*/
|
|
743
787
|
pushPanel(path: string): Promise<boolean>;
|
|
744
788
|
/**
|
|
@@ -836,8 +880,8 @@ export class PanelStackController implements PanelStack {
|
|
|
836
880
|
private containerEl?: HTMLElement;
|
|
837
881
|
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
838
882
|
private geom?: Geometry;
|
|
839
|
-
/** The
|
|
840
|
-
private
|
|
883
|
+
/** The measurements the last layout ran on; a change in them → snap. */
|
|
884
|
+
private lastGeom?: Geometry;
|
|
841
885
|
private layoutQueued = false;
|
|
842
886
|
private timers = new Set<ReturnType<typeof setTimeout>>();
|
|
843
887
|
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
@@ -1117,9 +1161,12 @@ export class PanelStackController implements PanelStack {
|
|
|
1117
1161
|
maxWidth: "full" as const,
|
|
1118
1162
|
width: 0,
|
|
1119
1163
|
} as PanelEntry;
|
|
1120
|
-
// `close` closes *this* panel, current or not
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1164
|
+
// `close` closes *this* panel, current or not, and `open` navigates
|
|
1165
|
+
// *from* it, through the very implementation a link click uses (see
|
|
1166
|
+
// `navigate`). Both resolve the panel's place in the stack at call
|
|
1167
|
+
// time, so they keep working after a splice has moved it; an `open`
|
|
1168
|
+
// from a panel that has since closed falls back to a derived stack,
|
|
1169
|
+
// like a link from nowhere.
|
|
1123
1170
|
//
|
|
1124
1171
|
// `visible` starts at what the panel's place implies: shown when it sits
|
|
1125
1172
|
// at or before the current panel (a pushed panel always does), hidden when
|
|
@@ -1133,6 +1180,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1133
1180
|
visible,
|
|
1134
1181
|
pinned: pinned || undefined,
|
|
1135
1182
|
close: () => this.closePath(entry.path),
|
|
1183
|
+
open: (href: string, how?: "push" | "replace" | "open") => this.navigate(href, { from: entry.path, how }),
|
|
1136
1184
|
}) as PanelState;
|
|
1137
1185
|
return entry;
|
|
1138
1186
|
}
|
|
@@ -1365,17 +1413,31 @@ export class PanelStackController implements PanelStack {
|
|
|
1365
1413
|
}
|
|
1366
1414
|
|
|
1367
1415
|
/**
|
|
1368
|
-
* Navigate to `href
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
* the
|
|
1416
|
+
* Navigate to `href` — the one implementation behind a link click,
|
|
1417
|
+
* {@link Panel.open} and the stack's own methods, so none of them can
|
|
1418
|
+
* behave differently.
|
|
1419
|
+
*
|
|
1420
|
+
* `from` is the path of the panel the navigation starts from — the one
|
|
1421
|
+
* the link lives in — or absent when it has none: a nav item, or a call
|
|
1422
|
+
* that means the whole stack, which is then built instead (see
|
|
1423
|
+
* {@link deriveStack}), or taken outright from `beneath`, for callers
|
|
1424
|
+
* that know it.
|
|
1425
|
+
*
|
|
1426
|
+
* `how` is the link's `data-panel` attribute (or the caller's word for
|
|
1427
|
+
* it): absent — like a link without the attribute — it is the shell's
|
|
1428
|
+
* `linkNavigation` default, an unrecognised value is a push on top of
|
|
1429
|
+
* `from`, `"replace"` swaps `from` out rather than stacking on it, and
|
|
1430
|
+
* `"open"` drops `from` altogether so the target arrives with its own
|
|
1431
|
+
* stack, the way a nav item's link does.
|
|
1373
1432
|
*
|
|
1374
1433
|
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
1375
1434
|
* navigation lands, `false` when it doesn't (already there counts as
|
|
1376
1435
|
* landed).
|
|
1377
1436
|
*/
|
|
1378
|
-
private navigate(href: string,
|
|
1437
|
+
private navigate(href: string, { from, how, beneath }: { from?: string; how?: string; beneath?: readonly string[] } = {}): Promise<boolean> {
|
|
1438
|
+
const mode = how ?? this.opts.linkNavigation;
|
|
1439
|
+
const origin = mode === "open" ? null : from ?? null;
|
|
1440
|
+
const replace = mode === "replace";
|
|
1379
1441
|
return A.peek(() => {
|
|
1380
1442
|
let url: URL;
|
|
1381
1443
|
try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
|
|
@@ -1430,7 +1492,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1430
1492
|
private pushPath(path: string, replace: boolean): Promise<boolean> {
|
|
1431
1493
|
return A.peek(() => {
|
|
1432
1494
|
const arr = this.intended();
|
|
1433
|
-
return this.navigate(path, arr.stack[arr.focus]
|
|
1495
|
+
return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
|
|
1434
1496
|
});
|
|
1435
1497
|
}
|
|
1436
1498
|
|
|
@@ -1438,36 +1500,30 @@ export class PanelStackController implements PanelStack {
|
|
|
1438
1500
|
|
|
1439
1501
|
/**
|
|
1440
1502
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
1441
|
-
* the anchor so we can decide what the click *means
|
|
1442
|
-
* `.s-panel` (which decides what the click truncates), the `data-panel`
|
|
1443
|
-
* attribute, and return-to-an-open-panel semantics. The exclusion rules
|
|
1503
|
+
* the anchor so we can decide what the click *means*. The exclusion rules
|
|
1444
1504
|
* (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
1445
1505
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
1446
1506
|
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
* an unrecognised value is a `push`.
|
|
1507
|
+
* A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
|
|
1508
|
+
* attribute as its `how` (see {@link navigate}, the shared implementation).
|
|
1509
|
+
* A link that isn't inside any panel — a nav item, one in a dialog — has no
|
|
1510
|
+
* panel to build on, so it replaces the stack as a whole, exactly as a cold
|
|
1511
|
+
* link to the same URL would open it.
|
|
1453
1512
|
*/
|
|
1454
1513
|
private interceptLinks(): void {
|
|
1455
1514
|
route.interceptLinks((url, anchor) => {
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
void this.navigate(url.href, origin, mode === "replace");
|
|
1515
|
+
const how = anchor.getAttribute("data-panel") ?? undefined;
|
|
1516
|
+
// The panel the link lives in: the enclosing `.s-panel`, or — for the
|
|
1517
|
+
// current panel's actions, promoted into the top bar on a narrow shell
|
|
1518
|
+
// (see main.ts), outside every `.s-panel` — the current panel, whose
|
|
1519
|
+
// own chrome they remain at every width.
|
|
1520
|
+
const panelEl = anchor.closest<HTMLElement>(".s-panel");
|
|
1521
|
+
const entry = panelEl
|
|
1522
|
+
? this.$state.live.find((e) => e.el === panelEl)
|
|
1523
|
+
: anchor.closest(".s-panel-origin")
|
|
1524
|
+
? this.$state.live[this.$state.focus]
|
|
1525
|
+
: undefined;
|
|
1526
|
+
void this.navigate(url.href, { from: entry?.path, how });
|
|
1471
1527
|
return true;
|
|
1472
1528
|
});
|
|
1473
1529
|
}
|
|
@@ -1499,7 +1555,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1499
1555
|
}
|
|
1500
1556
|
|
|
1501
1557
|
openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean> {
|
|
1502
|
-
return this.navigate(path,
|
|
1558
|
+
return this.navigate(path, { how: "open", beneath });
|
|
1503
1559
|
}
|
|
1504
1560
|
|
|
1505
1561
|
closePanel(path?: string): Promise<boolean> {
|
|
@@ -1528,6 +1584,17 @@ export class PanelStackController implements PanelStack {
|
|
|
1528
1584
|
this.opts.linkNavigation = mode;
|
|
1529
1585
|
}
|
|
1530
1586
|
|
|
1587
|
+
/**
|
|
1588
|
+
* Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
|
|
1589
|
+
* `navWidth` needs no counterpart — resizing the sidebar resizes the column
|
|
1590
|
+
* region, which the layout engine is already observing.
|
|
1591
|
+
*/
|
|
1592
|
+
setFullWidth(px: number): void {
|
|
1593
|
+
if (this.opts.fullWidth === px) return;
|
|
1594
|
+
this.opts.fullWidth = px;
|
|
1595
|
+
this.scheduleLayout();
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1531
1598
|
/**
|
|
1532
1599
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1533
1600
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
@@ -1854,25 +1921,21 @@ export class PanelStackController implements PanelStack {
|
|
|
1854
1921
|
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
1855
1922
|
}
|
|
1856
1923
|
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
//
|
|
1861
|
-
// snap pass in
|
|
1924
|
+
// What the window has beside the sidebar, and within that the *standard*
|
|
1925
|
+
// content area: the width the app gave a "full" panel, or all there is
|
|
1926
|
+
// when the window has less. Widths are a pure function of the window —
|
|
1927
|
+
// never of what else is open — so a panel NEVER resizes because a
|
|
1928
|
+
// neighbour came or went; only a window resize (the snap pass in
|
|
1929
|
+
// `layout`) changes them:
|
|
1862
1930
|
// - "full" fills the standard content area exactly;
|
|
1863
1931
|
// - "half" is half of it whenever that half is still a usable column, and
|
|
1864
1932
|
// the whole of it on narrower screens;
|
|
1865
1933
|
// - "screen" ignores the standard width and takes everything the window
|
|
1866
1934
|
// has — which also means nothing ever fits beside it.
|
|
1867
|
-
const
|
|
1935
|
+
const screen = Math.max(0, total - chrome);
|
|
1936
|
+
const full = Math.min(this.opts.fullWidth, screen);
|
|
1868
1937
|
const halved = full / 2;
|
|
1869
|
-
return {
|
|
1870
|
-
total,
|
|
1871
|
-
chrome,
|
|
1872
|
-
half: halved >= PAIR_MIN_PX ? halved : full,
|
|
1873
|
-
full,
|
|
1874
|
-
screen: Math.max(0, total - chrome),
|
|
1875
|
-
};
|
|
1938
|
+
return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
|
|
1876
1939
|
}
|
|
1877
1940
|
|
|
1878
1941
|
/**
|
|
@@ -1919,13 +1982,16 @@ export class PanelStackController implements PanelStack {
|
|
|
1919
1982
|
|
|
1920
1983
|
const stacking = this.opts.columns !== "single";
|
|
1921
1984
|
|
|
1922
|
-
// A window resize
|
|
1923
|
-
//
|
|
1924
|
-
//
|
|
1985
|
+
// A window resize — or the app resizing the shell itself, by changing
|
|
1986
|
+
// `navWidth` or `fullWidth` — must be adopted instantly: geometry tracking
|
|
1987
|
+
// the window through a 450ms transition reads as lag, and a shell
|
|
1988
|
+
// animating itself into place on its first pass reads as a glitch. Only
|
|
1989
|
+
// what a *panel* did is worth animating, and none of those three are.
|
|
1925
1990
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
1926
|
-
const
|
|
1991
|
+
const was = this.lastGeom;
|
|
1992
|
+
const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
|
|
1927
1993
|
if (snap) {
|
|
1928
|
-
this.
|
|
1994
|
+
this.lastGeom = geom;
|
|
1929
1995
|
shell.classList.add("s-shell-snap");
|
|
1930
1996
|
}
|
|
1931
1997
|
|
|
@@ -1949,7 +2015,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1949
2015
|
// The content area holds the run, but is never smaller than the standard
|
|
1950
2016
|
// panel (a lone small leaves its other half open — which is exactly where
|
|
1951
2017
|
// the next small lands, without anything on screen moving) and never
|
|
1952
|
-
// wider than the window. So the
|
|
2018
|
+
// wider than the window. So the page holds its standard width until extra
|
|
1953
2019
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
1954
2020
|
// do; with a "screen" up that's the window's edges.
|
|
1955
2021
|
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
@@ -1961,11 +2027,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1961
2027
|
if (!entry.width) entry.width = width(entry);
|
|
1962
2028
|
}
|
|
1963
2029
|
|
|
1964
|
-
// The
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
1967
|
-
//
|
|
1968
|
-
//
|
|
2030
|
+
// The body row caps itself to the ensemble width, keeping the columns
|
|
2031
|
+
// centred however far the area stretches, and transitions its max-width
|
|
2032
|
+
// (see main.ts) so the recentring plays along with the panel that caused
|
|
2033
|
+
// it. The bars above and below don't follow — they hold at the standard
|
|
2034
|
+
// page width (also main.ts).
|
|
1969
2035
|
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
1970
2036
|
|
|
1971
2037
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|