staffa 0.7.3 → 0.8.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.
Files changed (41) hide show
  1. package/README.md +113 -7
  2. package/dist/components/autocomplete.js +9 -3
  3. package/dist/components/box.d.ts +20 -0
  4. package/dist/components/box.js +43 -3
  5. package/dist/components/dialog.js +17 -10
  6. package/dist/components/layers.d.ts +330 -0
  7. package/dist/components/layers.js +888 -0
  8. package/dist/components/main.d.ts +98 -6
  9. package/dist/components/main.js +222 -37
  10. package/dist/components/menu.d.ts +14 -1
  11. package/dist/components/menu.js +32 -4
  12. package/dist/components/panels.d.ts +349 -0
  13. package/dist/components/panels.js +933 -0
  14. package/dist/components/tabs.d.ts +5 -0
  15. package/dist/components/tabs.js +125 -19
  16. package/dist/core.d.ts +7 -0
  17. package/dist/core.js +7 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +3 -2
  20. package/dist/staffa.esm.js +1 -1
  21. package/package.json +7 -5
  22. package/skill/BoxOptions.md +18 -0
  23. package/skill/MainOptions.md +99 -3
  24. package/skill/Page.md +108 -0
  25. package/skill/PathParams.md +7 -0
  26. package/skill/SKILL.md +185 -7
  27. package/skill/SegParams.md +8 -0
  28. package/skill/box.md +4 -0
  29. package/skill/isFloatingMenuOpen.md +12 -0
  30. package/skill/main.md +14 -2
  31. package/skill/panels.md +10 -0
  32. package/skill/tabs.md +5 -0
  33. package/src/components/autocomplete.ts +8 -2
  34. package/src/components/box.ts +57 -2
  35. package/src/components/dialog.ts +16 -10
  36. package/src/components/main.ts +314 -40
  37. package/src/components/menu.ts +33 -5
  38. package/src/components/panels.ts +1167 -0
  39. package/src/components/tabs.ts +126 -19
  40. package/src/core.ts +8 -0
  41. package/src/index.ts +3 -2
@@ -2,6 +2,10 @@
2
2
 
3
3
  Options for `main`.
4
4
 
5
+ **Type Parameters:**
6
+
7
+ - `R = Routes`
8
+
5
9
  ### mainOptions.attrs · member
6
10
 
7
11
  Aberdeen attr/style string applied to the outermost shell element.
@@ -35,9 +39,86 @@ Action area on the right of the top bar (buttons, menu, ...).
35
39
  ### mainOptions.content · member
36
40
 
37
41
  The scrollable page content. A string is rendered as rich text.
42
+ Mutually exclusive with `MainOptions.routes`.
38
43
 
39
44
  **Type:** `Slot`
40
45
 
46
+ ### mainOptions.routes · member
47
+
48
+ Paths mapped to the functions that draw them, which hands navigation over
49
+ to the shell. Each route draws one screen of your app, called a panel, and
50
+ as many panels as fit are shown at a time: one at a time on a phone,
51
+ several side by side on a wider screen. Mutually exclusive with
52
+ `MainOptions.content`.
53
+
54
+ A segment wrapped in brackets is a param: `[name]` matches one segment as
55
+ a string, `[name=integer]` matches one segment as a number, and a trailing
56
+ `[...name]` matches the rest of the path as one raw (still percent-encoded)
57
+ string, so it has to come last and needs at least one segment to match.
58
+ The first key that matches wins, a segment a param refuses falls through
59
+ to a later route (or to `MainOptions.notFound`), and each handler's
60
+ `$page.params` is typed from its own key.
61
+
62
+ `integer` accepts only spellings that survive a round trip back to the
63
+ same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
64
+ aren't safe integers, such as snowflakes, want a plain `[id]`.
65
+
66
+ Navigating is just links: the shell handles the clicks itself, so do *not*
67
+ also call Aberdeen's `interceptLinks()`. A link opens its target on top of
68
+ the panel it sits in, closing anything that was above it first, unless it
69
+ carries `data-panel=replace`, which replaces its own panel instead. A link
70
+ to something already open goes back to it rather than opening it twice.
71
+ From code, use `panels` (`S.panels.push()` and friends): navigating
72
+ with `aberdeen/route`'s own `go()` works and still asks the panels'
73
+ `Page.requestClose`, but builds the whole stack from the path. A
74
+ navigation guard the app registered before mounting (an auth redirect,
75
+ say) keeps working: the shell asks it first, and puts it back when the
76
+ shell goes away.
77
+
78
+ The shell draws no back arrows and no ✕ of its own: **every panel provides
79
+ its own way out**, with `S.box`'s `close` option for a ✕, or
80
+ `Page.close` behind a Cancel button. Escape and the browser's back
81
+ button are the shell's contribution.
82
+
83
+ Only one routed shell can be mounted at a time (a second one throws),
84
+ which is what lets `panels` be a plain module-level object. Each
85
+ handler still gets its own `$page` rather than there being one global
86
+ "current page", since several panels are alive at once. It's that argument
87
+ that carries the per-route typing of `params`.
88
+
89
+ **Type:** `R`
90
+
91
+ **Examples:**
92
+
93
+ ```ts
94
+ S.main({
95
+ title: "Trackle",
96
+ nav: { items: [{ label: "Projects", href: "/projects" }] },
97
+ routes: {
98
+ "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
99
+ "/projects/[id]": ($page) => drawProject($page.params.id), // typed string
100
+ },
101
+ notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
102
+ });
103
+ ```
104
+
105
+ ### mainOptions.notFound · member
106
+
107
+ Draws the panel for a path none of the routes match. There are no params
108
+ to go with it, so `$page.params` is empty; the path itself is in
109
+ `$page.path`.
110
+
111
+ **Type:** `RouteHandler<{}>`
112
+
113
+ ### mainOptions.stacking · member
114
+
115
+ Set `false` to show only the top panel, however wide the screen (the nav
116
+ sidebar still sits beside it). Everything else behaves the same: the URL,
117
+ the back button, `requestClose`, and the panels' own close buttons. This
118
+ only changes how many you see. Defaults to `true`.
119
+
120
+ **Type:** `boolean`
121
+
41
122
  ### mainOptions.footer · member
42
123
 
43
124
  Footer content, pinned below the scroll area.
@@ -53,6 +134,10 @@ sidebar) — cap to this width and centre horizontally. When unset, everything
53
134
  fills the available width. Either way the content shares the page surface —
54
135
  it is not boxed.
55
136
 
137
+ Ignored when you pass `MainOptions.routes`: there the open panels
138
+ decide the width (see `Page.layout`), and the header and footer line
139
+ themselves up with them.
140
+
56
141
  **Type:** `string`
57
142
 
58
143
  ### mainOptions.contentAttrs · member
@@ -71,7 +156,8 @@ Aberdeen attr/style string applied to the top bar.
71
156
 
72
157
  Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
73
158
  mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
74
- collapses to button mode when the shell is too narrow.
159
+ collapses to a button when the shell is too narrow — which there opens the
160
+ nav as a full page sliding in from the left, not as a dropdown.
75
161
 
76
162
  **Type:** `MenuOptions`
77
163
 
@@ -79,8 +165,12 @@ collapses to button mode when the shell is too narrow.
79
165
 
80
166
  Where to render the nav. Defaults to `"left"`.
81
167
  - `"left"` / `"right"`: sidebar next to the content area; collapses to a
82
- button+dropdown in the top bar when the shell width drops below 640 px.
83
- - `"button"`: always a button+dropdown, never a sidebar.
168
+ button in the top bar when the shell width drops below 640 px.
169
+ - `"button"`: always a button, never a sidebar.
170
+
171
+ The button opens a dropdown on a wide shell, and — below 640 px — a
172
+ full-page nav that slides in from the left, handing over to the chosen
173
+ screen with a matching slide in from the right.
84
174
 
85
175
  **Type:** `"button" | "left" | "right"`
86
176
 
@@ -89,3 +179,9 @@ Where to render the nav. Defaults to `"left"`.
89
179
  Aberdeen attr/style string applied to the sidebar nav panel.
90
180
 
91
181
  **Type:** `string`
182
+
183
+ ### mainOptions.navPageAttrs · member
184
+
185
+ Aberdeen attr/style string applied to the narrow-screen full-page nav.
186
+
187
+ **Type:** `string`
package/skill/Page.md ADDED
@@ -0,0 +1,108 @@
1
+ ## Page · interface
2
+
3
+ What a route handler gets: the params from its route, plus everything the
4
+ shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
5
+ you can set things later, such as a `title` that arrives with your data or
6
+ `loading` going back to `false`, and the shell keeps up.
7
+
8
+ Search params and the `#hash` belong to the top panel only. A panel with
9
+ another one on top of it keeps just its path, so anything a panel needs in
10
+ order to redraw itself has to live in that path.
11
+
12
+ **Type Parameters:**
13
+
14
+ - `P = Record<string, string | number | string[]>`
15
+
16
+ ### page.params · member
17
+
18
+ The params matched from this panel's path, typed per its route key:
19
+ `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
20
+ Read-only.
21
+
22
+ **Type:** `P`
23
+
24
+ ### page.path · member
25
+
26
+ This panel's path, e.g. `"/projects/7"`. Read-only.
27
+
28
+ **Type:** `string`
29
+
30
+ ### page.title · member
31
+
32
+ Shown in `document.title` while this panel is top-most.
33
+
34
+ **Type:** `string`
35
+
36
+ ### page.layout · member
37
+
38
+ How much room this panel takes. The content area is the page, at most
39
+ 1280px wide, minus the nav sidebar; the widths below assume a sidebar of
40
+ around 170px, so without one add that back.
41
+
42
+ - `"small"` is 360 to 540px once two panels fit side by side, which is
43
+ what makes it right for lists, detail forms, and anything else that
44
+ reads well at phone width. Below that it takes the whole content area
45
+ (so up to ~730px), like a medium does. A lone small leaves its other
46
+ half empty, and that is exactly where the next small lands, without
47
+ anything on screen moving.
48
+ - `"medium"` (the default) takes the whole content area: up to ~1100px,
49
+ and the screen width on a phone. The safe default for ordinary screens.
50
+ Nothing fits beside a medium on a standard 1280px page, though on a wide
51
+ enough window a small still can.
52
+ - `"large"` takes the whole window, with no upper limit (~1750px on a
53
+ 1920px screen): for boards, wide tables and dense dashboards. While it's
54
+ open the whole shell (top bar, content and footer) stretches to the
55
+ screen edges rather than stopping at 1280px.
56
+
57
+ When more columns fit than the standard page holds (three smalls, or a
58
+ medium and a small) the page itself grows, staying centred, to hold them.
59
+
60
+ A panel's width depends only on the size of the window, never on what else
61
+ is open, so opening or closing a panel never resizes the ones already on
62
+ screen. This is read **once**, right after your handler runs, so set it
63
+ there; later changes are ignored.
64
+
65
+ **Type:** `"small" | "medium" | "large"`
66
+
67
+ ### page.loading · member
68
+
69
+ Set this while you're fetching what the panel needs, and back to `false`
70
+ when you're done. A new panel waits a moment before sliding in, so it can
71
+ arrive with real content instead of empty; if the wait drags on it slides
72
+ in anyway and shows a loading indicator until the flag clears. It only
73
+ affects the animation; the stack, the URL and `requestClose` never wait
74
+ for it.
75
+
76
+ **Type:** `boolean`
77
+
78
+ ### page.requestClose · member
79
+
80
+ Your chance to say no. Everything that would close this panel waits for
81
+ it: Escape, the panel's own ✕ or Cancel button (`Page.close`, or a
82
+ box with `close: true`), the browser's back button, a link that would
83
+ close it, and `panels`.`close()`. Return `false` to keep the panel
84
+ open, usually after a dirty check and a `confirm`.
85
+
86
+ **Type:** `() => boolean | Promise<boolean>`
87
+
88
+ ### page.close · member
89
+
90
+ Closes **this** panel, wherever it sits in the stack. The top panel goes
91
+ back to whatever was underneath it; any other panel is taken out on its
92
+ own, leaving the columns to its right where they are, with their state,
93
+ and the URL alone, since the top panel didn't move. Either way it
94
+ becomes a history entry, so the browser's back button brings it back.
95
+
96
+ Resolves `false` if the panel didn't close: `Page.requestClose` said
97
+ no, it was the only panel on the stack (so there's nothing to go back to),
98
+ or another navigation got there first. The shell draws no back arrows or
99
+ ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
100
+ the user a way out.
101
+
102
+ **Type:** `() => Promise<boolean>`
103
+
104
+ **Examples:**
105
+
106
+ ```ts
107
+ S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
108
+ ```
@@ -0,0 +1,7 @@
1
+ ## PathParams · type
2
+
3
+ The params object described by a path template, e.g.
4
+ `PathParams<"/projects/[id]/tasks/[taskId=integer]">` is
5
+ `{ id: string; taskId: number }`.
6
+
7
+ **Type:** `P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>`
package/skill/SKILL.md CHANGED
@@ -82,7 +82,7 @@ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
82
82
 
83
83
  Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. There are two families:
84
84
 
85
- - **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth (page panel raised, capped). Use them for cards, bars, popovers — anything that just holds content. No variants.
85
+ - **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth (each level a step away from the page colour, up to a cap). Use them for cards, bars, popovers — anything that just holds content. No variants.
86
86
  - **Accent surfaces** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare `.s-s` defaults to primary). A bright fill with white ink, painted as a subtle single-colour gradient. They take a **variant**: `.filled` (default), `.tonal`, or `.outlined`. A surface nested *inside* an accent surface is always rendered filled, so it can't bleed into the vivid parent.
87
87
 
88
88
  Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.neutral`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
@@ -116,6 +116,110 @@ S.setDarkMode(undefined); // follow OS
116
116
 
117
117
  *Hint:* A `buttonChooser` is probably the right component for a color scheme selector.
118
118
 
119
+ ### Panel-stack navigation
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. Staffa calls those screens **panels**, and it shows as many of them at a time as comfortably fit.
122
+
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
+
125
+ ```ts
126
+ S.main({
127
+ title: "Trackle",
128
+ nav: { items: [{ label: "Projects", href: "/projects" }] },
129
+ routes: {
130
+ "/projects": drawProjectList,
131
+ "/projects/[projectId]": drawProject,
132
+ "/projects/[projectId]/tasks/[taskId=integer]": drawProjectTask,
133
+ },
134
+ notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
135
+ });
136
+
137
+ function drawProject($page: S.Page<{ projectId: string }>) {
138
+ const { projectId } = $page.params; // typed from the route key
139
+ A(`a href=/projects/${projectId}/tasks/1 #Open the first task`);
140
+ }
141
+
142
+ // Etc..
143
+ ```
144
+
145
+ Each handler gets a `$page` object holding the params from its route, along with the things Staffa needs to know about the panel: its title, 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
+ **Route keys.** A segment wrapped in brackets is a param:
148
+
149
+ - `[name]` matches one segment, as a string.
150
+ - `[name=integer]` matches one segment, as a number.
151
+ - `[...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
+ 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 `$page.params` from it, so `params.taskId` above really is a `number`.
154
+
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 panels at once. For ids that aren't safe integers, such as snowflakes, use a plain `[id]` and keep them as strings.
156
+
157
+ `[...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
+ **Navigating is just links.** Write ordinary `<a href="/...">` links; Staffa handles the clicks (so don't also call Aberdeen's `interceptLinks()`).
160
+
161
+ - A link inside a panel opens its target on top of that panel, closing anything that was above it first. That's why clicking a second project replaces the open project instead of adding a third column.
162
+ - Add `data-panel=replace` and the link replaces the panel it sits in, rather than opening on top of it. That's what you want for prev/next buttons.
163
+ - A link to something that's already open goes back to it instead of opening it twice. The same path is never in the stack twice.
164
+ - 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 page you asked for, with its ancestor pages 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.
165
+
166
+ From code, `S.panels.push(path)` opens a panel on top of the top one, `.replace(path)` opens one in place of the top one, and `.close(path?)` closes the top panel (or a named one). `S.panels.stack` is the list of open paths.
167
+
168
+ **How much room a panel takes** is up to `$page.layout`. The content area is the page, at most 1280px wide, minus the nav sidebar:
169
+
170
+ | `layout` | How wide the panel gets | Good for |
171
+ | --- | --- | --- |
172
+ | `"small"` | 360 to 540px once two fit side by side. Below that, the whole content area (so up to ~730px). | lists, detail forms, anything that reads well at phone width |
173
+ | `"medium"` (default) | The whole content area: up to ~1100px, and the screen width on a phone. | ordinary screens; the safe default |
174
+ | `"large"` | The whole window, with no upper limit: ~1750px on a 1920px screen. | boards, wide tables, dense dashboards |
175
+
176
+ Those numbers assume a nav sidebar of around 170px; without a sidebar, add that back (a medium then reaches the full 1280px). Nothing fits beside a medium on a standard 1280px page, but on a wide enough window a small still can, and the page grows past 1280px to hold both.
177
+
178
+ 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.
179
+
180
+ **The rest of `$page`:**
181
+
182
+ - `params` and `path`: read-only.
183
+ - `title`: shown in `document.title` while this panel is the top one.
184
+ - `layout`: as above. It's read once, right after your handler runs, so set it there.
185
+ - `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.
186
+ - `close()`: closes this panel, wherever it sits in the stack.
187
+ - `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.
188
+
189
+ ```ts
190
+ $page.requestClose = async () => !$task.dirty || await S.confirm("Discard unsaved changes?");
191
+ ```
192
+
193
+ **Every panel provides its own way out.** Staffa draws no back arrows and no ✕ of its own, because a panel knows better than the shell does what leaving it should look like: Cancel and Save buttons, or a ✕ in the corner of a box. So say it yourself:
194
+
195
+ ```ts
196
+ S.box({ header: "Task 42", close: true, content: drawTask }); // a ✕ in the box's corner
197
+ S.button({ content: "Cancel", attrs: ".neutral", click: () => $page.close() });
198
+ S.panels.close(); // the top panel
199
+ S.panels.close("/projects/7"); // that panel, wherever it is
200
+ ```
201
+
202
+ `S.box`'s `close: true` works out for itself which panel it's in, so the same code closes the right thing whether it's one column of several or a whole phone screen. (Pass a function instead if you'd rather do something else.)
203
+
204
+ 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.
205
+
206
+ 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.
207
+
208
+ <a id="ancestors"></a>
209
+
210
+ **The back button, and links from elsewhere.** The URL holds the top panel; the rest of the stack 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.
211
+
212
+ 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 panels: 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.
213
+
214
+ Search params and the `#hash` belong to the top panel only. Anything a panel deeper in the stack needs in order to redraw itself has to live in its path.
215
+
216
+ **A few more things.**
217
+
218
+ - `stacking: false` shows only the top panel, however wide the screen. Everything else behaves the same: the URL, the back button, `requestClose`, and the panels' own close buttons.
219
+ - Only one routed `S.main()` can be mounted at a time; a second one throws. That's what lets `S.panels` be a plain module-level object. Each handler still gets its own `$page` rather than there being one global "current page", since several panels are alive at once.
220
+ - Navigating with `aberdeen/route`'s own `go()` works and still asks `requestClose`, but, like a link from outside a panel, it builds the whole stack from the path. So prefer `S.panels`. If your app registered its own navigation guard before mounting (an auth redirect, say), it keeps working: Staffa asks it first, and puts it back when the shell goes away.
221
+ - 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.
222
+
119
223
  ### CSS reset
120
224
 
121
225
  Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit better but unsurprising without additional styling.
@@ -168,9 +272,9 @@ Components share naming conventions for options: `attrs` (outermost element), `c
168
272
 
169
273
  ### Layout & containers
170
274
 
171
- - **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content.
172
- - **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`.
173
- - **`S.tabs(opts)`**: tablist with live panels and keyboard navigation.
275
+ - **`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).
276
+ - **`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.
277
+ - **`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.
174
278
  - **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
175
279
 
176
280
  ### Form fields
@@ -225,6 +329,8 @@ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form f
225
329
  {
226
330
  "imports": {
227
331
  "aberdeen": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/aberdeen.js",
332
+ "aberdeen/route": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/route.js",
333
+ "aberdeen/transitions": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/transitions.js",
228
334
  "staffa/all.js": "https://cdn.jsdelivr.net/npm/staffa/dist/staffa.esm.js"
229
335
  }
230
336
  }
@@ -272,7 +378,7 @@ The previous section is good advice for any project-specific custom, but should
272
378
  2. Define `<Name>Options` extending `ContentOptions`, `FieldOptions`, or a plain interface. Add TSDoc on every option.
273
379
  3. Add a TSDoc `@example` on the function.
274
380
  4. Register in `src/index.ts` (the `S` object + type re-export).
275
- 5. Extend `smoke.mjs` to render it. Run `npm run smoke` and `npm run build`.
381
+ 5. Add it to the demo, cover it in the visual tests (`tests/*.spec.ts`), and run `npm run build` and `npm run typecheck`.
276
382
 
277
383
  See `src/components/button.ts` and `src/components/dialog.ts` for examples.
278
384
 
@@ -281,8 +387,8 @@ See `src/components/button.ts` and `src/components/dialog.ts` for examples.
281
387
  ```sh
282
388
  npm run build # compile TypeScript to dist/
283
389
  npm run typecheck # check types
284
- npm run smoke # render every component in jsdom
285
- npx http-server # allows demo to be viewed at http://localhost:8080/demo
390
+ npx http-server -P "http://localhost:8080/demo/index.html?" # demo at http://localhost:8080/demo
391
+ # (-P is the SPA fallback the demo's routed URLs need)
286
392
  npx shotest test # visual tests: click through the demo, screenshotting every step
287
393
  npx shotest review # review/accept the visual changes against the baseline
288
394
  ```
@@ -418,11 +524,59 @@ top bar (icon, title, subtitle, action menu), a scrollable content area, and a
418
524
  footer. With `MainOptions.maxWidth` the content area is centred and its
419
525
  width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
420
526
  menu button below 640 px, or always a button with `navPosition: "button"`).
527
+ Below 640 px that button opens the nav as a full page sliding in from the
528
+ left; picking an item slides it away as the chosen screen enters from the
529
+ right.
421
530
 
422
531
  ## [MainOptions](MainOptions.md) · interface
423
532
 
424
533
  Options for `main`.
425
534
 
535
+ ## [panels](panels.md) · constant
536
+
537
+ Navigating the routed `S.main()` shell from code, for the times it isn't a
538
+ link click, such as opening the screen for a record you just created.
539
+
540
+ ## [Page](Page.md) · interface
541
+
542
+ What a route handler gets: the params from its route, plus everything the
543
+ shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
544
+ you can set things later, such as a `title` that arrives with your data or
545
+ `loading` going back to `false`, and the shell keeps up.
546
+
547
+ ## Routes · type
548
+
549
+ A route table: path templates mapped to panel draw functions. Used as the
550
+ loose (non-inferred) type; `S.main()` infers a more precise type from the
551
+ literal you pass, so each handler's `$page.params` is typed per its key.
552
+
553
+ **Type:** `Record<string, RouteHandler>`
554
+
555
+ ## RouteHandler · type
556
+
557
+ A panel draw function: it receives the panel's `Page` and draws into the current scope.
558
+
559
+ **Type:** `(page: Page<P>) => void`
560
+
561
+ ## RouteTable · type
562
+
563
+ The shape `S.main()`'s `routes` option is checked against: every key types its
564
+ own handler's `params`. Used as a self-referential generic constraint, which
565
+ is what makes `$page.params` infer from the route key.
566
+
567
+ **Type:** `{ [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void }`
568
+
569
+ ## [PathParams](PathParams.md) · type
570
+
571
+ The params object described by a path template, e.g.
572
+ `PathParams<"/projects/[id]/tasks/[taskId=integer]">` is
573
+ `{ id: string; taskId: number }`.
574
+
575
+ ## [SegParams](SegParams.md) · type
576
+
577
+ The params contributed by a single path-template segment: `[x]` a string,
578
+ `[x=integer]` a number, `[...x]` the rest of the path as one raw string.
579
+
426
580
  ## [menuButton](menuButton.md) · function
427
581
 
428
582
  A button that opens a | floating dropdown menu on
@@ -443,6 +597,24 @@ via `A` so a | floating menu opens (instead of
443
597
  the browser's own menu) on right-click or long-press. The menu is anchored to
444
598
  the element and closes on Escape, Tab, item selection, or any click outside.
445
599
 
600
+ ## [isFloatingMenuOpen](isFloatingMenuOpen.md) · function
601
+
602
+ Whether a floating menu is currently open. Reflects live state (cleared the
603
+ instant it closes), unlike the DOM — the panel lingers briefly while its
604
+ `destroy=` transition plays out.
605
+
606
+ ## closeFloatingMenu · function
607
+
608
+ Close the open floating menu (if any), returning focus to its anchor. With an
609
+ `anchor`, only closes when the open menu belongs to it, so dismissing your own
610
+ menu can't steal someone else's.
611
+
612
+ **Signature:** `(anchor?: HTMLElement) => void`
613
+
614
+ **Parameters:**
615
+
616
+ - `anchor?: HTMLElement`
617
+
446
618
  ## [MenuOptions](MenuOptions.md) · interface
447
619
 
448
620
  Options for `menuButton` and `MainOptions.nav`.
@@ -502,6 +674,12 @@ Shows a confirmation dialog with Cancel and OK buttons. Returns a
502
674
  Shows a prompt dialog with a text input. Returns a `Promise<string | null>` —
503
675
  the entered string if the user confirmed, or `null` if cancelled.
504
676
 
677
+ ## isDialogOpen · function
678
+
679
+ Whether any dialog is currently open (live state, not the lingering DOM).
680
+
681
+ **Signature:** `() => boolean`
682
+
505
683
  ## [DialogOptions](DialogOptions.md) · interface
506
684
 
507
685
  Options for `dialog`.
@@ -0,0 +1,8 @@
1
+ ## SegParams · type
2
+
3
+ The params contributed by a single path-template segment: `[x]` a string,
4
+ `[x=integer]` a number, `[...x]` the rest of the path as one raw string.
5
+
6
+ **Type:** `S extends `[...${infer Name}]` ? { [K in Name]: string } :
7
+ S extends `[${infer Name}=${infer Matcher}]` ? { [K in Name]: MatcherType<Matcher> } :
8
+ S extends `[${infer Name}]` ? { [K in Name]: string } : {}`
package/skill/box.md CHANGED
@@ -9,6 +9,9 @@ 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
+
12
15
  **Signature:** `(opts?: BoxOptions | Slot) => void`
13
16
 
14
17
  **Parameters:**
@@ -23,4 +26,5 @@ S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", c
23
26
  S.textline({ label: "Name", bind: A.ref($user, "name") });
24
27
  }});
25
28
  S.box(() => A("p#Just some content")); // shorthand
29
+ S.box({ header: "Task 42", close: true, content: drawTask }); // ✕ closes this panel
26
30
  ```
@@ -0,0 +1,12 @@
1
+ ## isFloatingMenuOpen · function
2
+
3
+ Whether a floating menu is currently open. Reflects live state (cleared the
4
+ instant it closes), unlike the DOM — the panel lingers briefly while its
5
+ `destroy=` transition plays out.
6
+
7
+ **Signature:** `(anchor?: HTMLElement) => boolean`
8
+
9
+ **Parameters:**
10
+
11
+ - `anchor?: HTMLElement` - When given, only reports `true` for a menu opened from *this*
12
+ anchor — so a component can ask about its own menu rather than any menu.
package/skill/main.md CHANGED
@@ -5,12 +5,24 @@ top bar (icon, title, subtitle, action menu), a scrollable content area, and a
5
5
  footer. With `MainOptions.maxWidth` the content area is centred and its
6
6
  width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
7
7
  menu button below 640 px, or always a button with `navPosition: "button"`).
8
+ Below 640 px that button opens the nav as a full page sliding in from the
9
+ left; picking an item slides it away as the chosen screen enters from the
10
+ right.
8
11
 
9
- **Signature:** `(opts?: MainOptions) => void`
12
+ Instead of a single `content` slot, pass `MainOptions.routes` and the
13
+ shell takes over navigation: each route draws one screen, called a panel,
14
+ and as many panels as fit are shown at a time, side by side on a wide screen
15
+ and one at a time on a phone. See `MainOptions.routes` and `Page`.
16
+
17
+ **Signature:** `<R extends RouteTable<R>>(opts?: MainOptions<R>) => void`
18
+
19
+ **Type Parameters:**
20
+
21
+ - `R extends RouteTable<R>`
10
22
 
11
23
  **Parameters:**
12
24
 
13
- - `opts: MainOptions` (optional)
25
+ - `opts: MainOptions<R>` (optional)
14
26
 
15
27
  **Examples:**
16
28
 
@@ -0,0 +1,10 @@
1
+ ## panels · constant
2
+
3
+ Navigating the routed `S.main()` shell from code, for the times it isn't a
4
+ link click, such as opening the screen for a record you just created.
5
+
6
+ The same rules as a link click apply: pushing a path that is already open
7
+ goes back to it rather than opening it twice, and anything that would close a
8
+ panel asks its `Page.requestClose` first.
9
+
10
+ **Value:** `{ push(path: string): void; replace(path: string): void; close(path?: string): Promise<boolean>; readonly stack: readonly string[]; }`
package/skill/tabs.md CHANGED
@@ -3,6 +3,11 @@
3
3
  A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
4
4
  for the selected tab. Supports keyboard navigation (left/right/home/end).
5
5
 
6
+ More tabs than fit make the strip scroll sideways, with a ‹ / › button
7
+ appearing over whichever end still has something to reach — a bare scroll area
8
+ says nothing about itself to a mouse. Selecting a tab that's out of view
9
+ (arrow keys, or a `bind` written from elsewhere) scrolls it back in.
10
+
6
11
  **Signature:** `(opts: TabsOptions) => void`
7
12
 
8
13
  **Parameters:**
@@ -261,8 +261,14 @@ export function autocomplete(opts: AutocompleteOptions): void {
261
261
  $st.open = false;
262
262
  }
263
263
  } else if (e.key === "Escape") {
264
- $st.open = false;
265
- if (!opts.multi) $st.query = labelFor(selectedValues()[0] ?? "");
264
+ // Only consume Escape while the list is showing: it dismisses the
265
+ // innermost layer, so a surrounding dialog must not also close. With the
266
+ // list already closed, let it pass through to the dialog/nav handlers.
267
+ if ($st.open) {
268
+ e.preventDefault();
269
+ $st.open = false;
270
+ if (!opts.multi) $st.query = labelFor(selectedValues()[0] ?? "");
271
+ }
266
272
  } else if (e.key === "Backspace" && opts.multi && $st.query === "") {
267
273
  const sel = selectedValues();
268
274
  if (sel.length) remove(sel[sel.length - 1]!);