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 +6 -2
- package/dist/components/main.d.ts +5 -0
- package/dist/components/main.js +35 -13
- package/dist/components/panels.d.ts +51 -8
- package/dist/components/panels.js +181 -83
- package/dist/staffa.esm.js +1 -1
- package/package.json +1 -1
- package/skill/MainOptions.md +5 -0
- package/skill/Page.md +13 -2
- package/skill/SKILL.md +6 -2
- package/src/components/main.ts +39 -12
- package/src/components/panels.ts +214 -89
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
|
|
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
|
/**
|
package/dist/components/main.js
CHANGED
|
@@ -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
|
-
|
|
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${
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
302
|
-
|
|
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.
|
|
105
|
-
*
|
|
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
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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.
|