staffa 0.8.0 → 0.8.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/README.md CHANGED
@@ -172,11 +172,13 @@ Those numbers assume a nav sidebar of around 170px; without a sidebar, add that
172
172
 
173
173
  A panel'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 small leaves its other half empty, and that is exactly where the next small lands. When more columns fit than the standard 1280px page holds (three smalls, say), the page itself grows, staying centred, to hold them.
174
174
 
175
+ The panel is sized before your handler runs, so anything inside it that measures its own box — a chart, a virtualised list, a column count — gets a real one from the first frame rather than a zero-width one. A new panel starts at whatever `layout` says at that point, which is the default, so a handler that *assigns* `layout` is drawn at the medium width and reflowed immediately after. Assigning it later works too: the panel reflows to its new width without being redrawn, so nothing in it is rebuilt or loses its state, and the columns beside it move over.
176
+
175
177
  **The rest of `$page`:**
176
178
 
177
179
  - `params` and `path`: read-only.
178
180
  - `title`: shown in `document.title` while this panel is the top one.
179
- - `layout`: as above. It's read once, right after your handler runs, so set it there.
181
+ - `layout`: as above, and live set it whenever you like and the panel reflows.
180
182
  - `loading`: set it while you're fetching. A new panel waits a moment before sliding in, so it can arrive with real content instead of empty, and shows a loading indicator if the wait drags on.
181
183
  - `close()`: closes this panel, wherever it sits in the stack.
182
184
  - `requestClose`: your chance to say no. Everything that would close the panel waits for it: Escape, the panel's own ✕ or Cancel button, the browser's back button, a link that would close it, `S.panels.close()`. Return `false` to keep the panel open.
@@ -198,6 +200,8 @@ S.panels.close("/projects/7"); // that panel, wherever it is
198
200
 
199
201
  Closing the top panel goes back to whatever was underneath it. Closing one that *isn't* on top takes just that one away: the columns to its right stay where they are and keep their state, and the URL doesn't change, because the top panel didn't move. Either way it becomes a history entry, so the browser's back button brings the panel back.
200
202
 
203
+ A closed panel is torn down at once: its `A.clean()` hooks run the moment it closes, so subscriptions, timers and requests stop there and then. Only its element hangs around, inert and frozen, for the length of the exit animation.
204
+
201
205
  Staffa itself contributes two things: the Escape key, which closes the top panel (and jumps to the navigation once you're at the bottom of the stack), and making the browser's back button do the right thing. Both ask `requestClose` first.
202
206
 
203
207
  <a id="ancestors"></a>
@@ -267,7 +271,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
267
271
 
268
272
  ### Layout & containers
269
273
 
270
- - **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
274
+ - **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
271
275
  - **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: true` adds a ✕ that closes the panel the box is in (see [Panel-stack navigation](#panel-stack-navigation)); `close: fn` runs your own dismissal.
272
276
  - **`S.tabs(opts)`**: tablist with live panels and keyboard navigation. More tabs than fit make the strip scroll, with a ‹ / › button appearing at whichever end still has something to reach — so it's not just a swipe target. Selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
273
277
  - **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
@@ -111,6 +111,11 @@ export interface MainOptions<R = Routes> {
111
111
  * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
112
112
  * collapses to a button when the shell is too narrow — which there opens the
113
113
  * nav as a full page sliding in from the left, not as a dropdown.
114
+ *
115
+ * `items` may be a reactive array: the shell reads it inside the sidebar's own
116
+ * scope, so an item arriving or leaving redraws the sidebar and nothing else.
117
+ * The content beside it — in routed mode, the whole panel stack — is left
118
+ * alone.
114
119
  */
115
120
  nav?: MenuOptions;
116
121
  /**
@@ -169,10 +169,14 @@ A.insertGlobalCss({
169
169
  // silently degrades to `any`. Callers that pass no `routes` are unaffected —
170
170
  // `MainOptions`'s own default kicks in there.
171
171
  export function main(opts = {}) {
172
+ // Whether there is a nav to show is deliberately NOT worked out here: `items`
173
+ // may well be a reactive array, and reading it in the shell's own scope would
174
+ // subscribe *the whole shell* to it — an item arriving later would redraw the
175
+ // lot, and in routed mode that means tearing the panel stack down and building
176
+ // it again from the URL. So every use below reads `nav.items` inside its own
177
+ // scope, and only that scope redraws.
172
178
  const nav = opts.nav;
173
179
  const navPos = opts.navPosition ?? "left";
174
- const hasNav = nav != null && nav.items.length > 0;
175
- const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
176
180
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
177
181
  // sibling `main()`s can't fight over it.
178
182
  const $nav = A.proxy({ open: false });
@@ -182,19 +186,32 @@ export function main(opts = {}) {
182
186
  }
183
187
  // The panel stack owns the routing, so it starts observing (and building its
184
188
  // stack from) the URL before any of the shell is drawn — the top bar's back
185
- // button already needs to know how deep we are.
186
- const ctl = routes ? new PanelController({ ...opts, routes, title: opts.title }) : null;
189
+ // button already needs to know how deep we are. Its options are listed one by
190
+ // one rather than spread from `opts`: a spread reads every key, which on a
191
+ // proxied options object subscribes this scope to all of them.
192
+ const ctl = routes
193
+ ? new PanelController({ routes, notFound: opts.notFound, stacking: opts.stacking, title: opts.title })
194
+ : null;
187
195
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
188
196
  // rather than to `maxWidth`.
189
197
  const capWidth = ctl ? null : opts.maxWidth;
190
- const root = A(`div.s-main${navCls}${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
198
+ const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
199
+ // Which nav mode the shell is in — sidebar or button — as a class on the
200
+ // shell, for the CSS below to hang the responsive collapse off. Its own
201
+ // scope (see `nav` above), so a nav appearing or emptying out only retags
202
+ // the shell rather than redrawing it.
203
+ A(() => {
204
+ if (nav == null || !nav.items.length)
205
+ return;
206
+ A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
207
+ });
191
208
  // Top bar.
192
209
  A(() => {
193
210
  const hasBar = opts.title != null ||
194
211
  opts.subtitle != null ||
195
212
  opts.icon != null ||
196
213
  opts.menu != null ||
197
- hasNav;
214
+ (nav != null && nav.items.length > 0);
198
215
  if (!hasBar)
199
216
  return;
200
217
  A("header.s-s.neutral", opts.topbarAttrs, () => {
@@ -206,7 +223,7 @@ export function main(opts = {}) {
206
223
  });
207
224
  // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
208
225
  A(() => {
209
- if (!hasNav)
226
+ if (nav == null || !nav.items.length)
210
227
  return;
211
228
  // .s-nav-trigger: CSS toggles display based on sidebar visibility.
212
229
  A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
@@ -240,17 +257,21 @@ export function main(opts = {}) {
240
257
  if (capWidth != null)
241
258
  A("max-width:", capWidth);
242
259
  });
243
- if (hasNav && navPos !== "button") {
260
+ // The sidebar, in its own scope so a changing item list redraws just
261
+ // it — never the content area beside it (see `nav` above).
262
+ A(() => {
263
+ if (nav == null || !nav.items.length || navPos === "button")
264
+ return;
244
265
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
245
266
  drawMenu(nav.items);
246
267
  });
247
268
  A("div.s-nav-sep aria-hidden=true");
248
- }
269
+ });
249
270
  drawMainContent(opts, ctl);
250
271
  });
251
272
  // The narrow-screen nav page, laid over the body it slides across.
252
273
  A(() => {
253
- if (hasNav && $nav.open)
274
+ if (nav != null && nav.items.length && $nav.open)
254
275
  drawNavPage(nav, opts.navPageAttrs, $nav);
255
276
  });
256
277
  });
@@ -275,7 +296,7 @@ export function main(opts = {}) {
275
296
  // shell width calls for), which focuses its current item. Listens on
276
297
  // `document` so it works wherever focus is, but bows out while another overlay
277
298
  // (a dialog, or an already-open menu) is up — those handle Escape themselves.
278
- if (hasNav || ctl) {
299
+ if (nav != null || ctl) {
279
300
  const onKey = (e) => {
280
301
  if (e.key !== "Escape" || e.defaultPrevented)
281
302
  return;
@@ -298,8 +319,9 @@ export function main(opts = {}) {
298
319
  void ctl.closeTop();
299
320
  return;
300
321
  }
301
- if (!hasNav)
302
- return;
322
+ // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
323
+ // a subscription here would be one on the shell's own scope again, and
324
+ // an empty nav simply has neither of the two elements below.
303
325
  // `offsetParent` is null when the sidebar is hidden (display:none).
304
326
  const panel = root.querySelector(".s-nav-panel");
305
327
  if (panel?.offsetParent != null) {
@@ -101,8 +101,19 @@ export interface Page<P = Record<string, string | number | string[]>> {
101
101
  *
102
102
  * A panel's width depends only on the size of the window, never on what else
103
103
  * is open, so opening or closing a panel never resizes the ones already on
104
- * screen. This is read **once**, right after your handler runs, so set it
105
- * there; later changes are ignored.
104
+ * screen.
105
+ *
106
+ * The panel is sized from this **before** your handler runs, so anything that
107
+ * measures its own box has a real one from the first frame. What it is sized
108
+ * at is whatever this says at that moment, which for a brand-new panel is the
109
+ * default: a handler that *assigns* `layout` is drawn at the medium width and
110
+ * reflowed immediately after — in time for the frame, but not for a
111
+ * measurement taken in the same breath.
112
+ *
113
+ * Assigning it later works just as well. When your data arrives and you find
114
+ * you want the wide one, the panel reflows to its new width without being
115
+ * redrawn — so nothing in it is rebuilt or loses its state — and the columns
116
+ * beside it move over.
106
117
  */
107
118
  layout?: "small" | "medium" | "large";
108
119
  /**
@@ -169,6 +180,8 @@ export declare class PanelController {
169
180
  topId: number;
170
181
  };
171
182
  private containerEl?;
183
+ /** The shell's measurements, shared by everything drawn since they were taken. */
184
+ private geom?;
172
185
  /** The body width at the last layout; a change means a window resize → snap. */
173
186
  private lastBodyW;
174
187
  private layoutQueued;
@@ -220,14 +233,24 @@ export declare class PanelController {
220
233
  private commit;
221
234
  private createEntry;
222
235
  /**
223
- * Start a panel's exit: it lingers in the DOM, inert and fading, and is dropped
224
- * only when the fade itself ends. Removing it on a fixed timer instead would
225
- * race the transitionpull the element a frame early and the panel appears to
226
- * fade half-way and then vanish. The timeout is just a fallback for when no
227
- * `transitionend` is coming at all (transitions off, or an element that never
228
- * got placed).
236
+ * Take a panel out of the shell. The *scope* goes now: its cleaners run this
237
+ * tick, so whatever the panel registered with `A.clean` subscriptions,
238
+ * timers, an open portal is torn down when the panel closes, not when its
239
+ * animation is over. Only the element lingers, to play that animation, which
240
+ * is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
241
+ * element to {@link playExit} instead of removing it.
229
242
  */
230
243
  private beginClose;
244
+ /**
245
+ * A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
246
+ * the content it shows is frozen, which is exactly what a departing column
247
+ * should be): it fades where it stands, inert, and leaves the DOM when the fade
248
+ * itself ends. Removing it on a fixed timer instead would race the transition —
249
+ * pull the element a frame early and the panel appears to fade half-way and
250
+ * then vanish. The timeout is just a fallback for when no `transitionend` is
251
+ * coming at all (transitions off, or an element that never got placed).
252
+ */
253
+ private playExit;
231
254
  /**
232
255
  * Navigate back to a stack that is a truncation of the current one — the shared
233
256
  * implementation of Escape, a page closing itself, return-links and
@@ -289,6 +312,26 @@ export declare class PanelController {
289
312
  drawStack(): void;
290
313
  private drawPanel;
291
314
  scheduleLayout(): void;
315
+ /**
316
+ * Measure the shell, and with it the width the window gives a panel of each
317
+ * layout. Measured on the *shell*, not on the panel region: the region's width
318
+ * is the layout engine's own output, so reading it back would nail the layout
319
+ * to whatever it happened to be a frame ago. Fractional widths throughout — a
320
+ * rounded column edge would drift a pixel away from the chrome above it.
321
+ *
322
+ * `undefined` while the shell has no width to speak of (it isn't in a document
323
+ * yet, or it's `display:none`); the next pass tries again.
324
+ */
325
+ private measure;
326
+ /**
327
+ * The measurements this pass runs on. Taken once per layout pass and per
328
+ * commit, and shared with the panels drawn in between — they all size
329
+ * themselves against the same shell, and a `getBoundingClientRect()` each
330
+ * would be a forced reflow each, in the middle of building their DOM.
331
+ */
332
+ private geometry;
333
+ /** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
334
+ private roomFor;
292
335
  /**
293
336
  * Size and position every panel, and publish the width of the whole ensemble
294
337
  * (sidebar + separator + columns) for the shell to centre itself on.