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 CHANGED
@@ -180,17 +180,19 @@ Navigations settle asynchronously (closes travel through the browser's history),
180
180
 
181
181
  Navigating faster than the shell can settle is fine: closing travels through the browser's history, so it takes a moment to land, and anything asked for in the meantime waits for it rather than being dropped. Two quick Escapes (or back gestures) peel two panels, each aimed at the stack the one before it was heading for.
182
182
 
183
- **Every panel must work at 360–540px**, because that is what it gets whenever two columns fit. `$panel.maxWidth` says how much *more* it can usefully take. The content area is the page, at most 1280px wide, minus the nav sidebar:
183
+ **Every panel must work at 360–540px**, because that is what it gets whenever two columns fit. `$panel.maxWidth` says how much *more* it can usefully take. The content area is what `S.main()`'s `fullWidth` says it is 1080px by default:
184
184
 
185
185
  | `maxWidth` | How wide the panel gets | Good for |
186
186
  | --- | --- | --- |
187
187
  | `"half"` | Half the content area: 360 to 540px. | lists, detail forms — anything that reads well at phone width |
188
- | `"full"` (default) | The whole content area: up to ~1100px. | ordinary screens; the safe default |
189
- | `"screen"` | The whole window, no upper limit: ~1750px on a 1920px screen. | boards, wide tables, dense dashboards |
188
+ | `"full"` (default) | The whole content area: up to 1080px. | ordinary screens; the safe default |
189
+ | `"screen"` | The whole window, no upper limit: ~1720px on a 1920px screen. | boards, wide tables, dense dashboards |
190
190
 
191
- Below the width two columns need, everything takes the whole content area whatever it asked for. Those numbers assume a nav sidebar of around 170px; without one, add that back. Nothing fits beside a `"full"` on a standard 1280px page, but on a wide enough window a `"half"` still can, and the page grows past 1280px to hold both.
191
+ Below the width two columns need, everything takes the whole content area whatever it asked for. Nothing fits beside a `"full"` on a standard page, but on a wide enough window a `"half"` still can, and the page grows to hold both.
192
192
 
193
- A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them.
193
+ The standard page is those 1080px plus the nav sidebar's 200 the 1280px an app is usually seen at, though neither figure is fixed: `S.main({ navWidth, fullWidth })` sets both, and everything above follows from them.
194
+
195
+ A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard page holds (three halves, say), the page itself grows, staying centred, to hold them — though the top bar and footer keep to the standard width, so the chrome holds still while the columns come and go.
194
196
 
195
197
  Columns tile that area, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
196
198
 
@@ -217,6 +217,20 @@ export interface MainOptions<R = Routes> {
217
217
  * themselves up with them.
218
218
  */
219
219
  maxWidth?: string;
220
+ /**
221
+ * How wide a `"full"` panel gets, in pixels — and with it the whole content
222
+ * area, since a `"full"` fills it exactly. A `"half"` gets half of this, and
223
+ * a `"screen"` ignores it and takes the window. Defaults to 1080; the window
224
+ * caps it when there is less room than that. Routed mode only.
225
+ *
226
+ * This plus {@link MainOptions.navWidth} is the app's standard page — see
227
+ * there.
228
+ *
229
+ * Live, like {@link MainOptions.columns}: pass a proxied options object (or
230
+ * make this field a getter) and a change is adopted in one layout pass,
231
+ * every panel keeping its state.
232
+ */
233
+ fullWidth?: number;
220
234
  /** Aberdeen attr/style string applied to the content area. */
221
235
  contentAttrs?: Attributes;
222
236
  /** Aberdeen attr/style string applied to the top bar. */
@@ -243,6 +257,17 @@ export interface MainOptions<R = Routes> {
243
257
  * chrome goes assumes they are one.
244
258
  */
245
259
  navPosition?: "left" | "right";
260
+ /**
261
+ * How wide the nav sidebar column is, in pixels — its hairline included.
262
+ * Defaults to 200.
263
+ *
264
+ * Together with {@link MainOptions.fullWidth} this is the app's *standard
265
+ * page*: the width the top bar and footer keep to, and the width the
266
+ * columns settle back to. The defaults come to the familiar 1280px.
267
+ *
268
+ * Live, like {@link MainOptions.fullWidth}.
269
+ */
270
+ navWidth?: number;
246
271
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
247
272
  navAttrs?: Attributes;
248
273
  /** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
@@ -9,6 +9,15 @@ import { menu as menuIcon, x as closeIcon } from "../icons.js";
9
9
  import { iconButton } from "./button.js";
10
10
  import { isDialogOpen } from "./dialog.js";
11
11
  import { PanelStackController } from "./panels.js";
12
+ /**
13
+ * The default nav column (hairline included) and the default width of a
14
+ * `"full"` panel — see {@link MainOptions.navWidth} and
15
+ * {@link MainOptions.fullWidth}. Side by side they come to the 1280px page the
16
+ * shell is usually seen as, but that figure lives nowhere: the browser adds
17
+ * these two up, and an app that changes either simply gets a different page.
18
+ */
19
+ const NAV_W = 200;
20
+ const FULL_W = 1080;
12
21
  A.insertGlobalCss({
13
22
  ".s-main": {
14
23
  // container-type so @container queries below can respond to shell width.
@@ -26,8 +35,10 @@ A.insertGlobalCss({
26
35
  // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
27
36
  // trailing slot's own growth: it takes the free space and right-aligns
28
37
  // itself in it, which is what lets a search box live there. When the two
29
- // compete, the title truncates first but only down to a floor, past
30
- // which the trailing slot shrinks instead: a wide search box must not
38
+ // compete, the titles give way first: the trailing slot's near-zero
39
+ // shrink factor keeps a row of actions at its natural width while the
40
+ // crumbs absorb the squeeze — but only down to the titles' floor, past
41
+ // which the trailing slot shrinks after all: a wide search box must not
31
42
  // starve the titles to nothing (the crumb strip's overlay buttons would
32
43
  // escape their zero-width strip, over the ☰ beside it).
33
44
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
@@ -48,13 +59,16 @@ A.insertGlobalCss({
48
59
  // which their classes then provide. (`filter:none` keeps the global
49
60
  // `a:hover` brighten off the gradient text.)
50
61
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
51
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
62
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
52
63
  // Body always wraps <main> (with or without a sidebar) so max-width centering
53
64
  // and scrollbar alignment work identically in both cases.
54
65
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
55
66
  // It's also the positioning + clipping context for the narrow-screen nav panel,
56
- // which slides in and out across its left edge.
57
- ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
67
+ // which slides in and out across its left edge. `overflow:clip` rather than
68
+ // `hidden` for the same reason as `.s-panels`: a hidden box can still be
69
+ // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
70
+ // would shove the whole row — sidebar and columns — out of place for good.
71
+ ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
58
72
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
59
73
  // Put the sidebar on the right (content fills the left) for right-hand navs.
60
74
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
@@ -84,21 +98,25 @@ A.insertGlobalCss({
84
98
  ".s-body main.s-scroll-y": "margin-right:$3",
85
99
  // Routed mode takes its width from the stack instead of from
86
100
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
87
- // separator + content area) as --s-shell-w — the standard 1280px page
88
- // normally, the window's edges while a "screen" page is up — and the body
89
- // row and the bars cap themselves to it. So the chrome lines up with the
90
- // columns and the lot stays centred in the shell.
91
- "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
92
- "&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
93
- "&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
94
- // Changing the custom property animates the max-widths consuming it, with no
95
- // JS in the loop: the chrome recentres in step with the panel whose arrival
96
- // or departure moved it, over the same --s-panel-ms (see panels.ts). During
97
- // a window resize (and the very first pass) the layout engine raises
98
- // `.s-shell-snap` so the new width is adopted instantly instead of chasing
99
- // the window through a transition.
100
- "&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "transition: max-width var(--s-panel-ms) ease;",
101
- "&.s-routed.s-shell-snap > .s-body > .s-body-inner, &.s-routed.s-shell-snap > header > .s-bar, &.s-routed.s-shell-snap > footer > .s-bar": "transition:none",
101
+ // separator + content area) as --s-shell-w — the standard page
102
+ // normally, wider while the columns outgrow it (a "screen" page, or
103
+ // extra columns fitting a wide window) and the body row caps itself
104
+ // to it, staying centred around the columns. Changing the custom
105
+ // property animates the max-width consuming it, with no JS in the loop:
106
+ // the body recentres in step with the panel whose arrival or departure
107
+ // moved it, over the same --s-panel-ms (see panels.ts). During a window
108
+ // resize (and the very first pass) the layout engine raises
109
+ // `.s-shell-snap` so the new width is adopted instantly instead of
110
+ // chasing the window through a transition.
111
+ "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
112
+ "&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
113
+ // The bars don't follow the ensemble past the standard page: a header
114
+ // stretching to the window's edges and back with every "screen" panel
115
+ // reads as the whole app flexing, so the chrome holds still and only
116
+ // the columns grow. (Below the standard width the ensemble is simply
117
+ // the window, which only a resize changes — so the bars never animate,
118
+ // and take no part in the transition above.)
119
+ "&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "max-width: calc(var(--s-nav-w) + var(--s-full-w))",
102
120
  },
103
121
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
104
122
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -109,7 +127,10 @@ A.insertGlobalCss({
109
127
  // The generous horizontal padding is what keeps the rows clear of the content
110
128
  // separator on one side and the shell edge on the other; the vertical scroll
111
129
  // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
112
- "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
130
+ // `--s-nav-w` measures the whole column, hairline included, so the panel
131
+ // itself gives that 1px back — and the app's two widths then add up to
132
+ // exactly the page the bars above and below keep to.
133
+ "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
113
134
  },
114
135
  // The narrow-screen nav: a full "panel" that slides in over the content from the
115
136
  // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
@@ -187,6 +208,9 @@ export function main(opts = {}) {
187
208
  notFound: opts.notFound,
188
209
  ancestors: opts.ancestors,
189
210
  title: opts.title,
211
+ // Corrected below, and on every change, from the app's own option:
212
+ // read here it would subscribe the whole shell to it.
213
+ fullWidth: FULL_W,
190
214
  $shell,
191
215
  })
192
216
  : null;
@@ -198,6 +222,7 @@ export function main(opts = {}) {
198
222
  // the new link default. Nothing else of the shell is touched.
199
223
  A(() => ctl.setColumns(opts.columns));
200
224
  A(() => ctl.setLinkNavigation(opts.linkNavigation));
225
+ A(() => ctl.setFullWidth(opts.fullWidth ?? FULL_W));
201
226
  }
202
227
  // Where the brand mark and the app's name link — or nowhere, when the app
203
228
  // said `home: null` (a title slot holding a control of its own, say).
@@ -206,13 +231,22 @@ export function main(opts = {}) {
206
231
  // rather than to `maxWidth`.
207
232
  const capWidth = ctl ? null : opts.maxWidth;
208
233
  const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
209
- // Which side the sidebar is on, as a class on the shell for the CSS above to
210
- // hang off. Its own scope (see `nav` above), so a nav appearing or emptying
211
- // out only retags the shell rather than redrawing it.
234
+ // The two widths the CSS above works from, and with them the standard page
235
+ // the bars keep to. Each sits in a scope of its own one that draws
236
+ // nothing, so re-running it is a single style write: an app that changes
237
+ // either on a proxied options object resizes the shell in place, panels
238
+ // and their state untouched.
239
+ A(() => A(`--s-full-w: ${opts.fullWidth ?? FULL_W}px`));
240
+ // `--s-nav-w` is the sidebar's whole column, and nothing at all when there
241
+ // is no sidebar to give it to — a shell without one lines its bars up with
242
+ // the content. This scope also tags the shell with the side the sidebar is
243
+ // on, for the CSS above to hang off (see `nav` above: reading `nav.items`
244
+ // here subscribes this scope alone, never the shell entire).
212
245
  A(() => {
213
246
  if (nav == null || !nav.items.length)
214
- return;
215
- A(`.s-nav-${navPos}`);
247
+ A("--s-nav-w: 0px");
248
+ else
249
+ A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
216
250
  });
217
251
  // Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
218
252
  // contents depend on how much room the shell has and — in routed mode — on
@@ -157,17 +157,25 @@ export interface Panel<P = Record<string, string | number | string[]>> {
157
157
  * because that is what it gets when two columns fit; this says how much
158
158
  * *more* it can take.
159
159
  *
160
- * - `"half"` — nothing more. Half the content area (360–540px), so a second
161
- * column fits beside it. For lists and detail forms.
162
- * - `"full"` (the default) — the whole content area, up to ~1100px.
160
+ * - `"half"` — nothing more. Half the content area (360px up to half of
161
+ * {@link MainOptions.fullWidth}), so a second column fits beside it. For
162
+ * lists and detail forms.
163
+ * - `"full"` (the default) — the whole content area, which is exactly
164
+ * {@link MainOptions.fullWidth}: 1080px unless the app says otherwise.
163
165
  * - `"screen"` — the whole window, unbounded: boards, wide tables, dense
164
- * dashboards. While one is open the shell itself stretches to the screen
165
- * edges instead of stopping at the standard 1280px page.
166
+ * dashboards. While one is open the columns stretch to the screen edges
167
+ * instead of stopping at the standard page; the top bar and footer hold
168
+ * the standard width throughout.
166
169
  *
167
170
  * Below the width two columns need, everything takes the content area
168
171
  * whatever it asked for. Widths depend only on the window, never on what
169
172
  * else is open, so opening or closing a panel never resizes another.
170
173
  *
174
+ * This is a *layout regime*, not a width guarantee: handle whatever width
175
+ * the bucket yields, and ask only for what your content can actually use —
176
+ * a screen that would cap its own content narrower than its ask is holding
177
+ * room that would have let another column fit beside it.
178
+ *
171
179
  * Set it at the top of your handler and the panel is already that wide when
172
180
  * you draw (see {@link Panel.width}); set it later — when your data tells you
173
181
  * — and the panel reflows without being redrawn, keeping its state, while
@@ -243,6 +251,31 @@ export interface Panel<P = Record<string, string | number | string[]>> {
243
251
  * ```
244
252
  */
245
253
  close(): Promise<boolean>;
254
+ /**
255
+ * Opens `href` exactly as a click on a link inside this panel does — the
256
+ * shell's own link handling runs through this very call, so the two can't
257
+ * drift apart. By default that is a push: the target opens on top of this
258
+ * panel, closing the panels after it first (pinned ones ride along
259
+ * beneath the new panel, unsaved ones park), and a path that is already
260
+ * open is returned to rather than opened twice. `how` plays the part of a
261
+ * link's `data-panel` attribute: `"replace"` puts the target in this
262
+ * panel's place, `"open"` leaves the panel behind and gives the target
263
+ * its own stack, and omitting it follows the shell's
264
+ * {@link MainOptions.linkNavigation}, like a link without the attribute.
265
+ *
266
+ * This is the one for navigation that can't be a link: a row's click
267
+ * handler, a keyboard shortcut acting on this screen. The stack's
268
+ * {@link PanelStack.pushPanel} builds on the *current* panel instead — a
269
+ * different panel exactly when the interaction happened in a column
270
+ * beside it, where it would pile the new panel on top of the open detail
271
+ * rather than pruning back to this one.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
276
+ * ```
277
+ */
278
+ open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
246
279
  }
247
280
  /** Options the stack needs from its shell. */
248
281
  export interface PanelStackOptions {
@@ -254,6 +287,8 @@ export interface PanelStackOptions {
254
287
  columns?: "auto" | "single";
255
288
  /** What a bare link does. See {@link MainOptions.linkNavigation}. */
256
289
  linkNavigation?: "push" | "replace" | "open";
290
+ /** How wide a `"full"` panel gets, in px. See {@link MainOptions.fullWidth}. */
291
+ fullWidth: number;
257
292
  /** The shell's own title, used as the suffix of `document.title`. */
258
293
  title?: unknown;
259
294
  /**
@@ -315,6 +350,11 @@ export interface PanelStack {
315
350
  * than opening it twice, and a panel holding {@link Panel.unsaved} work is
316
351
  * never closed, only parked. That's what a plain link does, and what
317
352
  * `data-panel=push` says outright.
353
+ *
354
+ * Note that a link builds on the panel it is *drawn in*, which is the
355
+ * current panel only while no column beside it has the focus. Code
356
+ * navigating on behalf of a particular screen — a row's click handler —
357
+ * wants that panel's own {@link Panel.open} instead.
318
358
  */
319
359
  pushPanel(path: string): Promise<boolean>;
320
360
  /**
@@ -411,8 +451,8 @@ export declare class PanelStackController implements PanelStack {
411
451
  private containerEl?;
412
452
  /** The shell's measurements, shared by everything drawn since they were taken. */
413
453
  private geom?;
414
- /** The body width at the last layout; a change means a window resize → snap. */
415
- private lastBodyW;
454
+ /** The measurements the last layout ran on; a change in them → snap. */
455
+ private lastGeom?;
416
456
  private layoutQueued;
417
457
  private timers;
418
458
  /** The arrangement the navigation in flight is heading for; see {@link intended}. */
@@ -581,11 +621,22 @@ export declare class PanelStackController implements PanelStack {
581
621
  */
582
622
  private closePath;
583
623
  /**
584
- * Navigate to `href`. `origin` is the path of the panel the link lives in, or
585
- * `null` when it has none a nav item, or a programmatic call, which builds
586
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
587
- * originating panel rather than stacking on top of it, and `beneath` says what
588
- * the stack under the target is outright, for callers that know.
624
+ * Navigate to `href` the one implementation behind a link click,
625
+ * {@link Panel.open} and the stack's own methods, so none of them can
626
+ * behave differently.
627
+ *
628
+ * `from` is the path of the panel the navigation starts from the one
629
+ * the link lives in — or absent when it has none: a nav item, or a call
630
+ * that means the whole stack, which is then built instead (see
631
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
632
+ * that know it.
633
+ *
634
+ * `how` is the link's `data-panel` attribute (or the caller's word for
635
+ * it): absent — like a link without the attribute — it is the shell's
636
+ * `linkNavigation` default, an unrecognised value is a push on top of
637
+ * `from`, `"replace"` swaps `from` out rather than stacking on it, and
638
+ * `"open"` drops `from` altogether so the target arrives with its own
639
+ * stack, the way a nav item's link does.
589
640
  *
590
641
  * Resolves the way every {@link PanelStack} method does: `true` once the
591
642
  * navigation lands, `false` when it doesn't (already there counts as
@@ -596,18 +647,15 @@ export declare class PanelStackController implements PanelStack {
596
647
  private pushPath;
597
648
  /**
598
649
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
599
- * the anchor so we can decide what the click *means*: the originating
600
- * `.s-panel` (which decides what the click truncates), the `data-panel`
601
- * attribute, and return-to-an-open-panel semantics. The exclusion rules
650
+ * the anchor so we can decide what the click *means*. The exclusion rules
602
651
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
603
652
  * close guards run in `checkChange` when our navigation reaches the router.
604
653
  *
605
- * `data-panel` names which of the three {@link PanelStack} navigations the
606
- * click is: `push`, `replace`, or `open`, which drops the
607
- * originating panel so the target arrives with its own stack beneath it,
608
- * exactly as a nav item's link does. A link that doesn't say gets the
609
- * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
610
- * an unrecognised value is a `push`.
654
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
655
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
656
+ * A link that isn't inside any panel a nav item, one in a dialog has no
657
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
658
+ * link to the same URL would open it.
611
659
  */
612
660
  private interceptLinks;
613
661
  get currentPanel(): Panel | undefined;
@@ -621,6 +669,12 @@ export declare class PanelStackController implements PanelStack {
621
669
  setColumns(columns: "auto" | "single" | undefined): void;
622
670
  /** Adopt a changed `linkNavigation` default; the next click reads it. */
623
671
  setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void;
672
+ /**
673
+ * Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
674
+ * `navWidth` needs no counterpart — resizing the sidebar resizes the column
675
+ * region, which the layout engine is already observing.
676
+ */
677
+ setFullWidth(px: number): void;
624
678
  /**
625
679
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
626
680
  * panel, oldest first, the ones on screen right now in bold, pinned ones