staffa 0.8.1 → 0.10.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 +130 -64
- package/dist/components/autocomplete.js +1 -1
- package/dist/components/box.d.ts +8 -16
- package/dist/components/box.js +21 -27
- package/dist/components/button.d.ts +40 -0
- package/dist/components/button.js +85 -12
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/checkbox.js +3 -3
- package/dist/components/field.js +3 -3
- package/dist/components/main.d.ts +187 -64
- package/dist/components/main.js +284 -158
- package/dist/components/menu.d.ts +72 -14
- package/dist/components/menu.js +235 -31
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +512 -206
- package/dist/components/panels.js +926 -414
- package/dist/components/tabs.d.ts +37 -0
- package/dist/components/tabs.js +128 -69
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/glyphs.d.ts +24 -0
- package/dist/glyphs.js +25 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +4 -5
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +67 -0
- package/dist/theme.js +12 -2
- package/package.json +2 -2
- package/skill/AncestorTable.md +10 -0
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +143 -54
- package/skill/MenuItem.md +16 -1
- package/skill/MenuListOptions.md +24 -0
- package/skill/MenuOptions.md +3 -2
- package/skill/Panel.md +190 -0
- package/skill/PanelStack.md +106 -0
- package/skill/SKILL.md +214 -77
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +23 -0
- package/skill/iconButton.md +27 -0
- package/skill/main.md +13 -9
- package/skill/menu.md +29 -0
- package/skill/scrollStrip.md +28 -0
- package/src/components/autocomplete.ts +1 -1
- package/src/components/box.ts +29 -39
- package/src/components/button.ts +109 -8
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/checkbox.ts +3 -3
- package/src/components/field.ts +3 -3
- package/src/components/main.ts +459 -167
- package/src/components/menu.ts +268 -34
- package/src/components/panels.ts +1254 -497
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +5 -5
- package/src/theme.ts +14 -3
- package/skill/Page.md +0 -119
- package/skill/panels.md +0 -10
package/skill/SKILL.md
CHANGED
|
@@ -118,12 +118,12 @@ S.setDarkMode(undefined); // follow OS
|
|
|
118
118
|
|
|
119
119
|
### Panel-stack navigation
|
|
120
120
|
|
|
121
|
-
Give `S.main()` a `routes` table instead of a `content` slot, and it takes over navigation for you. Each route draws one screen of your app
|
|
121
|
+
Give `S.main()` a `routes` table instead of a `content` slot, and it takes over navigation for you. Each route draws one screen of your app — Staffa calls those **panels** — and the shell shows as many of them at a time as comfortably fit, each in its own **column**.
|
|
122
122
|
|
|
123
123
|
On a phone that means one panel at a time: a link opens a new panel on top of it, and closing that one brings the previous back, the way most mobile apps work. On a wider screen, panels that would have covered each other sit side by side instead. Pick a project from a list and it opens *beside* the list; pick another and it takes the first one's place. Your code doesn't know the difference.
|
|
124
124
|
|
|
125
125
|
```ts
|
|
126
|
-
S.main({
|
|
126
|
+
const shell = S.main({
|
|
127
127
|
title: "Trackle",
|
|
128
128
|
nav: { items: [{ label: "Projects", href: "/projects" }] },
|
|
129
129
|
routes: {
|
|
@@ -131,18 +131,19 @@ S.main({
|
|
|
131
131
|
"/projects/[projectId]": drawProject,
|
|
132
132
|
"/projects/[projectId]/tasks/[taskId=integer]": drawProjectTask,
|
|
133
133
|
},
|
|
134
|
-
notFound: ($
|
|
134
|
+
notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
function drawProject($
|
|
138
|
-
const { projectId } = $
|
|
137
|
+
function drawProject($panel: S.Panel<{ projectId: string }>) {
|
|
138
|
+
const { projectId } = $panel.params; // typed from the route key
|
|
139
|
+
$panel.title = `Project ${projectId}`; // the shell puts it wherever it fits
|
|
139
140
|
A(`a href=/projects/${projectId}/tasks/1 #Open the first task`);
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
// Etc..
|
|
143
144
|
```
|
|
144
145
|
|
|
145
|
-
Each handler gets a `$
|
|
146
|
+
Each handler gets a `$panel` object holding the params from its route, along with the things Staffa needs to know about the panel: what it's called, what it can do, how much room it wants, whether it's still loading. It's an Aberdeen proxy, so you can set those later (when your data arrives, say) and the shell keeps up.
|
|
146
147
|
|
|
147
148
|
**Route keys.** A segment wrapped in brackets is a param:
|
|
148
149
|
|
|
@@ -150,78 +151,154 @@ Each handler gets a `$page` object holding the params from its route, along with
|
|
|
150
151
|
- `[name=integer]` matches one segment, as a number.
|
|
151
152
|
- `[...name]` matches the rest of the path, as a string. It has to be the last thing in the key, and it needs at least one segment to match.
|
|
152
153
|
|
|
153
|
-
The first key that matches wins, and a segment a param refuses simply doesn't match, so it falls through to a later route, or to `notFound`. TypeScript reads each key and types that handler's `$
|
|
154
|
+
The first key that matches wins, and a segment a param refuses simply doesn't match, so it falls through to a later route, or to `notFound`. TypeScript reads each key and types that handler's `$panel.params` from it, so `params.taskId` above really is a `number`.
|
|
154
155
|
|
|
155
|
-
`integer` only accepts spellings that survive a round trip back to the same URL: `42` and `-7` and `0`, but not `007`, `1.5`, `0x10`, `-0` or anything past `Number.MAX_SAFE_INTEGER`. Otherwise `/tasks/42` and `/tasks/0042` would be two different paths for one record, and could sit open in two
|
|
156
|
+
`integer` only accepts spellings that survive a round trip back to the same URL: `42` and `-7` and `0`, but not `007`, `1.5`, `0x10`, `-0` or anything past `Number.MAX_SAFE_INTEGER`. Otherwise `/tasks/42` and `/tasks/0042` would be two different paths for one record, and could sit open in two columns at once. For ids that aren't safe integers, such as snowflakes, use a plain `[id]` and keep them as strings.
|
|
156
157
|
|
|
157
158
|
`[...name]` hands you the remaining path exactly as it appears in the URL, still percent-encoded. Decoding it for you would be lossy: an encoded slash inside a segment would come back looking just like a separator. When you want the pieces, `name.split("/").map(decodeURIComponent)` gives them to you. (Single-segment params have no such ambiguity, so those *are* decoded.)
|
|
158
159
|
|
|
159
160
|
**Navigating is just links.** Write ordinary `<a href="/...">` links; Staffa handles the clicks (so don't also call Aberdeen's `interceptLinks()`).
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- A link
|
|
164
|
-
- A
|
|
162
|
+
The open panels form a **stack**, and one of them is the **current** panel: the one the URL names, and the rightmost column on screen. Usually that's the newest panel — but going back along the stack moves the cursor without closing anything (see the breadcrumbs below), so panels can sit *after* the current one too, parked just past the viewport's right edge.
|
|
163
|
+
|
|
164
|
+
- A link inside a panel opens its target on top of that panel, closing everything after it first. That's why clicking a second project replaces the open project instead of adding a third column — and why the panels you'd browsed past don't pile up.
|
|
165
|
+
- A `data-panel` attribute on the link picks a different one of the three navigations. `push` is the default just described; `replace` puts the target in place of the link's own panel rather than on top of it, which is what prev/next buttons want; and `open` leaves that panel behind altogether and gives the target its own stack, exactly as a nav item would — for a link that points somewhere else in the app, a search hit or a mention, where the panel you clicked from isn't the context you want to keep.
|
|
166
|
+
- A link to something that's already open goes back to it instead of opening it twice — a move along the stack, closing nothing. The same path is never in the stack twice.
|
|
167
|
+
- A link that isn't inside a panel (a nav item, or one in a dialog) has no panel to build on, so it replaces the stack as a whole: the panel you asked for, with its ancestor panels opened beneath it (see [below](#ancestors)). Panels that the new stack also contains stay as they are, so clicking the nav item for the section you're already in won't reset it. Clicking a nav item and opening that same URL in a fresh tab therefore give you the same columns.
|
|
168
|
+
|
|
169
|
+
**The stack is an object, not a global.** In routed mode `S.main()` hands back the panel stack, and every panel gets the same object as `$panel.stack` — which is what a route handler uses, since it runs while the `S.main()` call is still going and can't see its return value yet.
|
|
165
170
|
|
|
166
|
-
|
|
171
|
+
```ts
|
|
172
|
+
shell.pushPanel(path); // on top of the current panel
|
|
173
|
+
shell.replacePanel(path); // in its place
|
|
174
|
+
shell.openPanelStack(path, beneath?); // a whole arrangement, the way a nav item does
|
|
175
|
+
shell.closePanel(path?); // the current panel, or a named one
|
|
176
|
+
|
|
177
|
+
shell.panels; // the open panels, oldest first — the Panel objects themselves
|
|
178
|
+
shell.currentPanelIndex; // which of them the URL is on
|
|
179
|
+
shell.currentPanel; // shorthand for panels[currentPanelIndex]
|
|
180
|
+
```
|
|
167
181
|
|
|
168
|
-
|
|
182
|
+
Navigations settle asynchronously (closes travel through the browser's history), so each of the four methods returns a `Promise<boolean>`: `true` once it lands, `false` when it doesn't — an unsaved panel refused to close, a route guard said no, or another navigation superseded it. Ignore it unless you care.
|
|
169
183
|
|
|
170
|
-
|
|
184
|
+
`panels` is a live view rather than a copy, so writing through it works — `shell.panels[0].pinned = true` is the only way to pin a panel from outside its own handler. All three are reactive on the stack's shape: read one in a scope and it re-runs when panels open, close or the cursor moves. Don't hold a `Panel` across a navigation; read it fresh.
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
188
|
+
**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:
|
|
189
|
+
|
|
190
|
+
| `maxWidth` | How wide the panel gets | Good for |
|
|
171
191
|
| --- | --- | --- |
|
|
172
|
-
| `"
|
|
173
|
-
| `"
|
|
174
|
-
| `"
|
|
192
|
+
| `"half"` | Half the content area: 360 to 540px. | lists, detail forms — anything that reads well at phone width |
|
|
193
|
+
| `"full"` (default) | The whole content area: up to ~1100px. | ordinary screens; the safe default |
|
|
194
|
+
| `"screen"` | The whole window, no upper limit: ~1750px on a 1920px screen. | boards, wide tables, dense dashboards |
|
|
195
|
+
|
|
196
|
+
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.
|
|
175
197
|
|
|
176
|
-
|
|
198
|
+
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.
|
|
199
|
+
|
|
200
|
+
Columns tile that area, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
|
|
201
|
+
|
|
202
|
+
The panel is sized before your handler runs, and `$panel.width` is the resolved figure in pixels — so a chart, a virtualised list or a column count has the real width from the first frame, with nothing to measure. Set `maxWidth` at the top of your handler and you draw at the new width; set it later and the panel reflows without being redrawn, so nothing in it is rebuilt or loses its state.
|
|
203
|
+
|
|
204
|
+
<a id="chrome"></a>
|
|
205
|
+
|
|
206
|
+
**A panel declares its chrome; the shell places it.** A screen says what it is called and what it can do; everything else in its column — headings, cards, boxes — is the screen's own content, drawn like any other. Where the chrome ends up depends on how many columns are showing and how wide the shell is, so the shell decides:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
function drawTask($panel: S.Panel<{ taskId: number }>) {
|
|
210
|
+
$panel.title = "Task 42";
|
|
211
|
+
$panel.actions = () => S.button({ content: "Save", attrs: ".small", click: save });
|
|
212
|
+
S.box({ header: "Task 42", content: drawTaskForm }); // ordinary content
|
|
213
|
+
}
|
|
214
|
+
```
|
|
177
215
|
|
|
178
|
-
|
|
216
|
+
On a wide screen the title becomes the stack's last crumb and the Save button sits in a quiet strip at the top of the column. On a phone the crumb is still there and Save moves into the top bar, where the app menu was. Nothing in your code measures the viewport, and no screen is written twice.
|
|
179
217
|
|
|
180
|
-
The
|
|
218
|
+
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. Clicking an earlier crumb goes back to it *without closing anything*: the panels right of it stay open, parked just past the viewport's right edge, and clicking their crumbs brings them back. Browsing the stack is free — it's opening a *new* panel that closes the panels after the one it came from. The app's name and logo link to the app's home (the `home` option, `/` by default), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
|
|
181
219
|
|
|
182
|
-
|
|
220
|
+
That line is the `subtitle`'s while the stack has nothing to add: one panel open, reachable from a nav item that is already highlighted in a visible sidebar. Otherwise the stack takes it, since it is then the only thing naming the screen.
|
|
221
|
+
|
|
222
|
+
Right-click (or long-press) a crumb for **Close** — which takes just that panel out, wherever it sits in the stack — and **Pin**. A pinned panel — its crumb wears a pin — never closes as a side effect of navigation elsewhere: where opening a new panel would prune it, it rides along beneath the new panel instead, one crumb click away. Pin the reference you keep coming back to, then navigate freely. An *explicit* close (Escape, `close()`, the crumb menu, `data-panel=replace`) still closes it, and it's yours from code as `$panel.pinned`. Because a crumb is a real link whose right-click the menu takes over, the menu also offers **Open in new tab** and **Copy link**.
|
|
223
|
+
|
|
224
|
+
A crumb can also wear a **●**: the panel holds unsaved work, and nothing will close it (see `$panel.unsaved` below).
|
|
225
|
+
|
|
226
|
+
| `$panel` | what it does |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `title` | Names the screen: its breadcrumb, and `document.title` while it's the current panel. A panel that sets none borrows the first line of text in its own body — good enough for a crumb, but say it yourself. |
|
|
229
|
+
| `actions` | The screen's buttons or menu. In the column's chrome while several columns fit; in the top bar (taking the app `menu`'s place) once the shell is narrow. |
|
|
230
|
+
|
|
231
|
+
Two deliberate rules there. `actions` are the screen's *verbs* — Save, Delete, Share, a menu — not a second way out: going back is the crumbs' job, at every width, and there is no back button even on a phone. And **`title` names the screen; it does not draw a heading** — a screen that wants its name in its own body writes it there, where it owns the typography.
|
|
232
|
+
|
|
233
|
+
A column's body keeps a comfortable `$3` of padding; a screen that wants edge-to-edge rows just writes `A("p:0")`, since the draw function's current element *is* the body.
|
|
234
|
+
|
|
235
|
+
**The rest of `$panel`:**
|
|
183
236
|
|
|
184
237
|
- `params` and `path`: read-only.
|
|
185
|
-
- `
|
|
186
|
-
- `layout`: as above, and live — set it whenever you like and the panel reflows.
|
|
238
|
+
- `maxWidth`: as above, and live — set it whenever you like and the panel reflows.
|
|
187
239
|
- `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.
|
|
188
|
-
- `
|
|
189
|
-
- `
|
|
240
|
+
- `width` and `visible`: read-only and reactive. `width` is this column's width in pixels, for the rare content that genuinely differs by width. `visible` says whether this panel is on screen — not crowded out, not parked, not closing — which is the right question for per-panel floating UI like a FAB, since "am I the current panel?" answers wrongly when two columns are up.
|
|
241
|
+
- `pinned`: the crumb menu's Pin, from code.
|
|
242
|
+
- `unsaved`: set it while the panel holds work that must not be lost — a dirty form, an upload in flight. An unsaved panel **cannot be closed, by anything**: navigation and the back button park it instead (wearing a ● in its crumb), `close()` and the crumb menu's Close refuse, Escape steps left, and closing the browser tab runs into the browser's own are-you-sure. The tab title carries a leading `•` while *any* open panel is unsaved. Only the app clears the flag, which is its explicit "this is now discardable":
|
|
190
243
|
|
|
191
244
|
```ts
|
|
192
|
-
|
|
245
|
+
A(() => { $panel.unsaved = $form.dirty || undefined; }); // the whole dirty check
|
|
246
|
+
|
|
247
|
+
S.button({ content: "Discard", attrs: ".neutral", click: () => {
|
|
248
|
+
$panel.unsaved = false; // explicitly: the reactive scope above reruns too late
|
|
249
|
+
void $panel.close();
|
|
250
|
+
}});
|
|
193
251
|
```
|
|
194
252
|
|
|
195
|
-
|
|
253
|
+
So a panel that can *be* unsaved needs its own way out — a Save or Discard among its `actions`. There is no "discard changes?" dialog anywhere: leaving is never blocked, the work just waits, parked, one crumb away.
|
|
254
|
+
|
|
255
|
+
- `close()`: closes this panel, wherever it sits in the stack (refused while it's `unsaved`). Behind a Cancel button, or a Save that closes:
|
|
196
256
|
|
|
197
257
|
```ts
|
|
198
|
-
S.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
S.panels.close("/projects/7"); // that panel, wherever it is
|
|
258
|
+
S.button({ content: "Cancel", attrs: ".neutral", click: () => $panel.close() });
|
|
259
|
+
$panel.stack.closePanel(); // the current panel
|
|
260
|
+
$panel.stack.closePanel("/projects/7"); // that panel, wherever it is
|
|
202
261
|
```
|
|
203
262
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
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.
|
|
263
|
+
Closing the current panel hands the focus to the panel on its left. Closing one that *isn't* current takes just that one away: the columns around it stay where they are and keep their state, and the URL doesn't change, because the current panel didn't move. Either way it becomes a history entry, so the browser's back button brings the panel back.
|
|
207
264
|
|
|
208
265
|
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.
|
|
209
266
|
|
|
210
|
-
|
|
267
|
+
Escape steps one panel back along the stack: at the stack's end that closes the current panel, mid-stack it just moves left and parks the panel you leave, and at the stack's start it jumps to the navigation. The browser's back button replays whole arrangements — it re-opens what a navigation closed and re-parks what a crumb click brought back.
|
|
211
268
|
|
|
212
269
|
<a id="ancestors"></a>
|
|
213
270
|
|
|
214
|
-
**The back button, and links from elsewhere.** The URL holds the
|
|
271
|
+
**The back button, and links from elsewhere.** The URL holds the current panel; the rest of the stack — the panels before it, any parked after it, and which are pinned — is stored beside it in the browser's history entry. So back and forward step through whole arrangements of columns, and a reload brings the same columns back.
|
|
215
272
|
|
|
216
|
-
A URL that arrives without any of that (a shared link, a bookmark, a new tab) has nothing to restore, so Staffa builds the stack from the path: it walks the parent paths and opens each one you have a route for. With the routes above, `/projects/7/tasks/42` opens as three
|
|
273
|
+
A URL that arrives without any of that (a shared link, a bookmark, a new tab) has nothing to restore, so Staffa builds the stack from the path: it walks the parent paths and opens each one you have a route for. With the routes above, `/projects/7/tasks/42` opens as three columns: the project list, project 7, and task 42. A parent path you have no route for is skipped, so if you don't want one screen appearing under another, just don't give it a route.
|
|
217
274
|
|
|
218
|
-
|
|
275
|
+
That only works for URLs that spell their own context out. A flat one — `/thread/[id]`, where a push notification lands — has no parent path to walk, so it would open as a lone column with nothing beneath it and nothing for Escape to do. `ancestors` is where you say what belongs under it. It's keyed by the same path templates as `routes`, so each entry gets that key's params, matched and typed:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
S.main({
|
|
279
|
+
routes: {
|
|
280
|
+
"/mailbox/[id]": drawMailbox,
|
|
281
|
+
"/thread/[id=integer]": drawThread,
|
|
282
|
+
},
|
|
283
|
+
ancestors: {
|
|
284
|
+
"/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id is a number
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Return the paths shallowest first, or nothing to leave that path to the parent-path walk — which is also what a route you don't list gets, so you only name the ones whose URL doesn't say where they belong. It's asked for every navigation that has no panel to build on, so a nav item and a fresh tab still agree.
|
|
290
|
+
|
|
291
|
+
It has to answer without drawing anything, which is why it lives here rather than on `$panel`: it's consulted while the navigation is still being worked out, before any route handler has run.
|
|
292
|
+
|
|
293
|
+
From code, `openPanelStack(path, beneath?)` opens the same kind of arrangement, either asking `ancestors` for the panels beneath or taking the ones you hand it.
|
|
294
|
+
|
|
295
|
+
Search params and the `#hash` belong to the current panel only. Anything another panel in the stack needs in order to redraw itself has to live in its path. (A panel you browse away from does get its search and hash back when a crumb makes it current again.)
|
|
219
296
|
|
|
220
297
|
**A few more things.**
|
|
221
298
|
|
|
222
|
-
- `stacking: false` shows only the
|
|
223
|
-
- Only one routed `S.main()` can be mounted at a time; a second one throws
|
|
224
|
-
- Navigating with `aberdeen/route`'s own `go()` works
|
|
299
|
+
- `stacking: false` shows only the current panel, however wide the screen. Everything else behaves the same: the URL, the back button, unsaved panels, and the panels' own close buttons.
|
|
300
|
+
- Only one routed `S.main()` can be mounted at a time; a second one throws — the URL is global, so two of them would fight over it. Nothing else is global: the stack belongs to its shell, and each handler gets its own `$panel`, since several panels are alive at once.
|
|
301
|
+
- Navigating with `aberdeen/route`'s own `go()` works — an unsaved panel survives it too — but, like a link from outside a panel, it builds the whole stack from the path. So prefer the stack's own methods. A navigation guard your app registered with `route.setGuard` (an auth redirect, say) keeps working untouched: Staffa registers none of its own.
|
|
225
302
|
- Deep links need your static server to serve the app for unknown paths (the usual SPA fallback). For `http-server` that's `-P`, as in the demo command below.
|
|
226
303
|
|
|
227
304
|
### CSS reset
|
|
@@ -276,9 +353,10 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
276
353
|
|
|
277
354
|
### Layout & containers
|
|
278
355
|
|
|
279
|
-
- **`S.main(opts)`**: app shell, a sticky header with `
|
|
280
|
-
- **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close:
|
|
281
|
-
- **`S.tabs(opts)`**: tablist with live panels and keyboard navigation. More tabs than fit make the strip scroll
|
|
356
|
+
- **`S.main(opts)`**: app shell, a sticky header with `logo`, `title`, `subtitle`, `menu` — plus, in routed mode, the breadcrumbs of the open panels; 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. An item with `items` of its own becomes a collapsible submenu: only the branch holding the current page stays unfolded, and clicking a branch selects its first leaf (expanding a branch doesn't dismiss the phone's full-page nav — only picking a leaf does). A sidebar taller than the window scrolls, and follows the highlighted item: navigating to a page whose item sits past the fold scrolls it back into view. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
|
|
357
|
+
- **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: fn` adds a ✕ that runs your dismissal — in the header row, or floating over the body when there is no header. (It is plain furniture: a routed screen gets its own way out from the shell, see [Panel-declared chrome](#chrome).)
|
|
358
|
+
- **`S.tabs(opts)`**: tablist with live tab panels and keyboard navigation. More tabs than fit make the strip scroll (see `S.scrollStrip`); selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
|
|
359
|
+
- **`S.scrollStrip(opts)`**: a horizontal row that scrolls once its content outgrows it, with a ‹ / › button appearing over whichever end still has something to reach — so it isn't just a swipe target. Its own scrollbar is hidden. `S.tabs` and the routed shell's breadcrumbs are built on it; reach for it for any row of chrome that can outgrow its space. `S.revealInStrip(el)` scrolls one of its children into view.
|
|
282
360
|
- **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
|
|
283
361
|
|
|
284
362
|
### Form fields
|
|
@@ -297,6 +375,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
297
375
|
### Actions
|
|
298
376
|
|
|
299
377
|
- **`S.button(opts | text)`**: button surface; restyle via `attrs` (e.g. `.danger`, `.outlined`), plus `size`, `disabled`, `icon`, `href` (renders `<a role=button>`). Defaults to filled `.primary`.
|
|
378
|
+
- **`S.iconButton(opts)`**: a bare glyph in a square hit area — no fill, no border, ink that lifts on hover. For chrome that mustn't compete with what it sits beside: the app shell's ✕ and ☰ are made of it, and it's usually what a page's `actions` want.
|
|
300
379
|
- **`S.buttonGroup(opts)`**: groups buttons, `attached` (segmented) or `spaced`.
|
|
301
380
|
- **`S.buttonChooser(opts)`**: single-select segmented control bound to a value.
|
|
302
381
|
|
|
@@ -318,7 +397,9 @@ Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `jo
|
|
|
318
397
|
|
|
319
398
|
### Other
|
|
320
399
|
|
|
321
|
-
- **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation.
|
|
400
|
+
- **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation. A menu closes itself when the page navigates.
|
|
401
|
+
- **`S.menu(opts)`**: the same menu rows drawn in place — for a nav or settings column of your own. Items with nested `items` form a collapsible tree; `onLeafSelect` fires only when a leaf is picked, never for a branch unfolding.
|
|
402
|
+
- **`S.closeNav()`**: dismisses `S.main`'s navigation when it's showing as an overlay (the full page on a phone, the dropdown on a wider screen). For custom nav rows that act without navigating.
|
|
322
403
|
- **`S.toast(opts)`**: transient notification at the bottom of the viewport.
|
|
323
404
|
- **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
|
|
324
405
|
|
|
@@ -410,26 +491,11 @@ mkdir -p .claude/skills
|
|
|
410
491
|
ln -s ../../node_modules/staffa/skill .claude/skills/staffa
|
|
411
492
|
```
|
|
412
493
|
|
|
413
|
-
##
|
|
414
|
-
|
|
415
|
-
- **0.7** — the surface model was reduced to two families: **neutral** (`.neutral`) and **accent** (`.primary`/`.danger`/`.success`/`.warning`/`.link`). Apps that only use the high-level `S.*` components need no changes. Code that uses surface classes or tokens directly must update:
|
|
416
|
-
- **Surface levels gone.** Replace `.base`/`.panel`/`.raised`/`.neutral`/`.nest` with the single `.neutral` class.
|
|
417
|
-
- **`.secondary` and `.gradient` gone.** Drop any `s-secondary` colour override; there's no `s-secondary` anymore. The default button is now `.primary`.
|
|
418
|
-
- **Tokens renamed.** `--s-fg`→`--s-text`, `--s-fg-muted`→`--s-muted`, `--s-border`→`--s-faint`. Removed: `--s-fg-faint`, `--s-border-strong`, `--s-ink`, `--s-on-accent`, `--s-page`/`--s-panel`/`--s-raised`, `--s-neutral`, `--s-tint`, `--s-glow`, `--s-shadow`, `--s-gradient-surface`. A custom surface now sets `--s-bg`/`--s-text` (was the `--s-a`/`--s-b` anchors).
|
|
419
|
-
- **Borders/shadows moved onto surfaces.** Components no longer draw their own border/shadow. If you relied on `S.box`/`S.dialog`/etc. elevation, it now comes from the surface; pass `.no-shadow` to drop it, or `.shadow`/`.extra-shadow` to add it on any surface.
|
|
494
|
+
## Changelog
|
|
420
495
|
|
|
421
|
-
|
|
496
|
+
What changed in each release, and what to do about the breaking ones, is in [CHANGELOG.md](CHANGELOG.md).
|
|
422
497
|
|
|
423
|
-
|
|
424
|
-
- Surfaces (`.s-s`) now apply `border-radius` and — for `.tonal` and `.outlined` variants — `border` automatically. Custom surfaces or components that previously set these manually may see doubled or conflicting styles; remove the manual declarations.
|
|
425
|
-
- `border:0` is now applied to `.s-btn` by default (overriding the browser's 2px button border). Custom button-like components built on `.s-btn` that relied on the browser default border should add an explicit border.
|
|
426
|
-
|
|
427
|
-
- **0.4**
|
|
428
|
-
- There is no default export anymore: replace `import S from "staffa"` with `import * as S from "staffa"`.
|
|
429
|
-
- `S.button` no longer has a `text` option: use `content` instead (it accepts a string or a draw function).
|
|
430
|
-
- The `Content` type is gone: use `Slot` instead. The `Styling` type alias is now exported as `Attributes`.
|
|
431
|
-
- `S.buttonChooser` uses `undefined` instead of `null` for "nothing selected" (in `bind` and with `allowDeselect`).
|
|
432
|
-
|
|
498
|
+
*Hint:* the recommended update strategy for a library this young is: don't. Pin it, and read the changelog before you move.
|
|
433
499
|
|
|
434
500
|
# API Reference
|
|
435
501
|
|
|
@@ -479,10 +545,22 @@ Options for `box`.
|
|
|
479
545
|
A button. Tonal and outlined variants show a border; filled variants rely on
|
|
480
546
|
their solid background for affordance.
|
|
481
547
|
|
|
548
|
+
## [iconButton](iconButton.md) · function
|
|
549
|
+
|
|
550
|
+
A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
551
|
+
hover. The quiet end of the button family, for chrome that has to sit beside
|
|
552
|
+
something more important without competing with it: a ✕ on a box, the ☰ a
|
|
553
|
+
routed `S.main()` puts in its top bar, the verbs in a
|
|
554
|
+
| page's actions.
|
|
555
|
+
|
|
482
556
|
## [ButtonOptions](ButtonOptions.md) · interface
|
|
483
557
|
|
|
484
558
|
Options for `button`.
|
|
485
559
|
|
|
560
|
+
## [IconButtonOptions](IconButtonOptions.md) · interface
|
|
561
|
+
|
|
562
|
+
Options for `iconButton`.
|
|
563
|
+
|
|
486
564
|
## [buttonChooser](buttonChooser.md) · function
|
|
487
565
|
|
|
488
566
|
A single-selection segmented control: an attached button group where exactly
|
|
@@ -524,24 +602,30 @@ Options for `form`.
|
|
|
524
602
|
## [main](main.md) · function
|
|
525
603
|
|
|
526
604
|
An application shell that wires up the things almost every app needs: a sticky
|
|
527
|
-
top bar (
|
|
605
|
+
top bar (logo, title, action menu), a scrollable content area, and a
|
|
528
606
|
footer. With `MainOptions.maxWidth` the content area is centred and its
|
|
529
|
-
width capped. Add a `nav` to get a
|
|
530
|
-
|
|
531
|
-
Below 640 px that button opens the nav as a full page sliding in from the
|
|
607
|
+
width capped. Add a `nav` to get a sidebar that collapses to a ☰ in the top bar
|
|
608
|
+
below 640 px, which there opens the nav as a full panel sliding in from the
|
|
532
609
|
left; picking an item slides it away as the chosen screen enters from the
|
|
533
610
|
right.
|
|
534
611
|
|
|
612
|
+
## [closeNav](closeNav.md) · function
|
|
613
|
+
|
|
614
|
+
Close the navigation, if it's showing as an overlay — the full panel it becomes
|
|
615
|
+
on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
|
|
616
|
+
on a wider shell this does nothing.
|
|
617
|
+
|
|
535
618
|
## [MainOptions](MainOptions.md) · interface
|
|
536
619
|
|
|
537
620
|
Options for `main`.
|
|
538
621
|
|
|
539
|
-
## [
|
|
622
|
+
## [PanelStack](PanelStack.md) · interface
|
|
540
623
|
|
|
541
|
-
|
|
542
|
-
|
|
624
|
+
The panel stack behind a routed `S.main()`, and what that call hands back:
|
|
625
|
+
the open `Panel`s, which of them is current, and the four ways to
|
|
626
|
+
change that. Everything on it is scoped to its own shell.
|
|
543
627
|
|
|
544
|
-
## [
|
|
628
|
+
## [Panel](Panel.md) · interface
|
|
545
629
|
|
|
546
630
|
What a route handler gets: the params from its route, plus everything the
|
|
547
631
|
shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
|
|
@@ -552,23 +636,38 @@ you can set things later, such as a `title` that arrives with your data or
|
|
|
552
636
|
|
|
553
637
|
A route table: path templates mapped to panel draw functions. Used as the
|
|
554
638
|
loose (non-inferred) type; `S.main()` infers a more precise type from the
|
|
555
|
-
literal you pass, so each handler's `$
|
|
639
|
+
literal you pass, so each handler's `$panel.params` is typed per its key.
|
|
556
640
|
|
|
557
641
|
**Type:** `Record<string, RouteHandler>`
|
|
558
642
|
|
|
559
643
|
## RouteHandler · type
|
|
560
644
|
|
|
561
|
-
A panel draw function: it receives the panel's `
|
|
645
|
+
A panel draw function: it receives the panel's `Panel` and draws into the current scope.
|
|
562
646
|
|
|
563
|
-
**Type:** `(
|
|
647
|
+
**Type:** `(panel: Panel<P>) => void`
|
|
564
648
|
|
|
565
649
|
## RouteTable · type
|
|
566
650
|
|
|
567
651
|
The shape `S.main()`'s `routes` option is checked against: every key types its
|
|
568
652
|
own handler's `params`. Used as a self-referential generic constraint, which
|
|
569
|
-
is what makes `$
|
|
653
|
+
is what makes `$panel.params` infer from the route key.
|
|
654
|
+
|
|
655
|
+
**Type:** `{ [K in keyof R & string]: (panel: Panel<Prettify<PathParams<K>>>) => void }`
|
|
656
|
+
|
|
657
|
+
## AncestorsHandler · type
|
|
658
|
+
|
|
659
|
+
What belongs beneath a path that arrives cold, worked out from the params of
|
|
660
|
+
the path itself. Return the paths shallowest first, or nothing to leave this
|
|
661
|
+
one to the parent-path derivation.
|
|
570
662
|
|
|
571
|
-
**Type:** `
|
|
663
|
+
**Type:** `(params: P, path: string) => readonly string[] | undefined | void`
|
|
664
|
+
|
|
665
|
+
## [AncestorTable](AncestorTable.md) · type
|
|
666
|
+
|
|
667
|
+
A table of `AncestorsHandler`s keyed by path template, the same way
|
|
668
|
+
`routes` is — so each one's `params` are matched and typed from its own key
|
|
669
|
+
rather than parsed out of the path a second time. The keys are checked
|
|
670
|
+
against the route table, so a stale one is a type error.
|
|
572
671
|
|
|
573
672
|
## [PathParams](PathParams.md) · type
|
|
574
673
|
|
|
@@ -581,6 +680,16 @@ The params object described by a path template, e.g.
|
|
|
581
680
|
The params contributed by a single path-template segment: `[x]` a string,
|
|
582
681
|
`[x=integer]` a number, `[...x]` the rest of the path as one raw string.
|
|
583
682
|
|
|
683
|
+
## [menu](menu.md) · function
|
|
684
|
+
|
|
685
|
+
A menu drawn in place: the same list of rows the floating dropdown and
|
|
686
|
+
`S.main()`'s sidebar are made of, as a plain component — for a nav of your
|
|
687
|
+
own, a settings column, a sidebar the shell doesn't draw for you. Items are
|
|
688
|
+
real links/buttons with arrow-key navigation, `href` items highlight
|
|
689
|
+
themselves on the current page, and an item with `items` of its own becomes
|
|
690
|
+
a collapsible branch (see `MenuItem.items`): only the branch holding
|
|
691
|
+
the current page stays unfolded.
|
|
692
|
+
|
|
584
693
|
## [menuButton](menuButton.md) · function
|
|
585
694
|
|
|
586
695
|
A button that opens a | floating dropdown menu on
|
|
@@ -619,6 +728,10 @@ menu can't steal someone else's.
|
|
|
619
728
|
|
|
620
729
|
- `anchor?: HTMLElement`
|
|
621
730
|
|
|
731
|
+
## [MenuListOptions](MenuListOptions.md) · interface
|
|
732
|
+
|
|
733
|
+
Options for `menu`.
|
|
734
|
+
|
|
622
735
|
## [MenuOptions](MenuOptions.md) · interface
|
|
623
736
|
|
|
624
737
|
Options for `menuButton` and `MainOptions.nav`.
|
|
@@ -709,6 +822,26 @@ A selectable option: a bare string, or a `{ value, label }` pair.
|
|
|
709
822
|
A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
|
|
710
823
|
for the selected tab. Supports keyboard navigation (left/right/home/end).
|
|
711
824
|
|
|
825
|
+
## [scrollStrip](scrollStrip.md) · function
|
|
826
|
+
|
|
827
|
+
A horizontal row that scrolls when its content outgrows it, with a ‹ / ›
|
|
828
|
+
button appearing over whichever end still has something left to reach — a
|
|
829
|
+
bare scroll area says nothing about itself to a mouse, and a scrollbar under
|
|
830
|
+
a row of chrome reads as a mistake. The row's own scrollbar is hidden, and
|
|
831
|
+
the buttons scroll it by most of a width at a time.
|
|
832
|
+
|
|
833
|
+
## revealInStrip · function
|
|
834
|
+
|
|
835
|
+
Scroll `el`'s `scrollStrip` just far enough to bring it into view,
|
|
836
|
+
clearing the buttons that overlay the row's ends. Does nothing when `el`
|
|
837
|
+
isn't in a strip, or is already comfortably visible.
|
|
838
|
+
|
|
839
|
+
**Signature:** `(el: HTMLElement) => void`
|
|
840
|
+
|
|
841
|
+
**Parameters:**
|
|
842
|
+
|
|
843
|
+
- `el: HTMLElement`
|
|
844
|
+
|
|
712
845
|
## [Tab](Tab.md) · interface
|
|
713
846
|
|
|
714
847
|
A single tab definition.
|
|
@@ -717,6 +850,10 @@ A single tab definition.
|
|
|
717
850
|
|
|
718
851
|
Options for `tabs`.
|
|
719
852
|
|
|
853
|
+
## [ScrollStripOptions](ScrollStripOptions.md) · interface
|
|
854
|
+
|
|
855
|
+
Options for `scrollStrip`.
|
|
856
|
+
|
|
720
857
|
## [textarea](textarea.md) · function
|
|
721
858
|
|
|
722
859
|
A multi-line text input. Shares the field chrome and styling of
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
## ScrollStripOptions · interface
|
|
2
|
+
|
|
3
|
+
Options for `scrollStrip`.
|
|
4
|
+
|
|
5
|
+
### scrollStripOptions.content · member
|
|
6
|
+
|
|
7
|
+
The row's content, laid out left to right.
|
|
8
|
+
|
|
9
|
+
**Type:** `Slot`
|
|
10
|
+
|
|
11
|
+
### scrollStripOptions.attrs · member
|
|
12
|
+
|
|
13
|
+
Aberdeen attr/style string applied to the outer element.
|
|
14
|
+
|
|
15
|
+
**Type:** `string`
|
|
16
|
+
|
|
17
|
+
### scrollStripOptions.stripAttrs · member
|
|
18
|
+
|
|
19
|
+
Aberdeen attr/style string applied to the scrolling row itself.
|
|
20
|
+
|
|
21
|
+
**Type:** `string`
|
package/skill/box.md
CHANGED
|
@@ -9,9 +9,6 @@ out as a flex container.
|
|
|
9
9
|
|
|
10
10
|
Shortcut: pass a function to use it directly as the body content.
|
|
11
11
|
|
|
12
|
-
| `close: true` adds a ✕ that closes the panel the box
|
|
13
|
-
is drawn in: the usual way back out of a screen in a routed `S.main()`.
|
|
14
|
-
|
|
15
12
|
**Signature:** `(opts?: BoxOptions | Slot) => void`
|
|
16
13
|
|
|
17
14
|
**Parameters:**
|
|
@@ -26,5 +23,5 @@ S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", c
|
|
|
26
23
|
S.textline({ label: "Name", bind: A.ref($user, "name") });
|
|
27
24
|
}});
|
|
28
25
|
S.box(() => A("p#Just some content")); // shorthand
|
|
29
|
-
S.box({ header: "
|
|
26
|
+
S.box({ header: "Draft", close: () => discard(), content: drawDraft }); // ✕ runs discard()
|
|
30
27
|
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
## closeNav · function
|
|
2
|
+
|
|
3
|
+
Close the navigation, if it's showing as an overlay — the full panel it becomes
|
|
4
|
+
on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
|
|
5
|
+
on a wider shell this does nothing.
|
|
6
|
+
|
|
7
|
+
A navigation closes the nav by itself, links in your own custom rows included,
|
|
8
|
+
so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
9
|
+
flips a setting, and should still get the nav out of the way.
|
|
10
|
+
|
|
11
|
+
**Signature:** `() => void`
|
|
12
|
+
|
|
13
|
+
**Examples:**
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
S.main({
|
|
17
|
+
nav: { items: [
|
|
18
|
+
{ label: "Inbox", href: "/inbox" },
|
|
19
|
+
() => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
20
|
+
]},
|
|
21
|
+
routes: { ... },
|
|
22
|
+
});
|
|
23
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## iconButton · function
|
|
2
|
+
|
|
3
|
+
A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
4
|
+
hover. The quiet end of the button family, for chrome that has to sit beside
|
|
5
|
+
something more important without competing with it: a ✕ on a box, the ☰ a
|
|
6
|
+
routed `S.main()` puts in its top bar, the verbs in a
|
|
7
|
+
| page's actions.
|
|
8
|
+
|
|
9
|
+
Reach for `button` instead whenever the thing has a name worth reading;
|
|
10
|
+
an icon alone is only unambiguous for a handful of universal actions.
|
|
11
|
+
|
|
12
|
+
**Signature:** `(opts: IconButtonOptions) => void`
|
|
13
|
+
|
|
14
|
+
**Parameters:**
|
|
15
|
+
|
|
16
|
+
- `opts: IconButtonOptions`
|
|
17
|
+
|
|
18
|
+
**Examples:**
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { trash2, share2 } from "staffa/icons";
|
|
22
|
+
|
|
23
|
+
$panel.actions = () => {
|
|
24
|
+
S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
|
|
25
|
+
S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
|
|
26
|
+
};
|
|
27
|
+
```
|