staffa 0.9.0 → 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.
Files changed (60) hide show
  1. package/README.md +106 -48
  2. package/dist/components/autocomplete.js +1 -1
  3. package/dist/components/box.d.ts +8 -16
  4. package/dist/components/box.js +21 -27
  5. package/dist/components/button.d.ts +40 -0
  6. package/dist/components/button.js +85 -12
  7. package/dist/components/buttonChooser.js +1 -1
  8. package/dist/components/checkbox.js +3 -3
  9. package/dist/components/field.js +3 -3
  10. package/dist/components/main.d.ts +134 -71
  11. package/dist/components/main.js +245 -174
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +231 -34
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +448 -225
  17. package/dist/components/panels.js +819 -435
  18. package/dist/components/tabs.d.ts +37 -0
  19. package/dist/components/tabs.js +128 -69
  20. package/dist/core.d.ts +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/glyphs.d.ts +24 -0
  23. package/dist/glyphs.js +25 -0
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +3 -4
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +67 -0
  28. package/dist/theme.js +12 -2
  29. package/package.json +2 -2
  30. package/skill/BoxOptions.md +7 -12
  31. package/skill/IconButtonOptions.md +41 -0
  32. package/skill/MainOptions.md +106 -58
  33. package/skill/MenuItem.md +16 -1
  34. package/skill/MenuListOptions.md +24 -0
  35. package/skill/MenuOptions.md +3 -2
  36. package/skill/Panel.md +190 -0
  37. package/skill/PanelStack.md +106 -0
  38. package/skill/SKILL.md +172 -64
  39. package/skill/ScrollStripOptions.md +21 -0
  40. package/skill/box.md +1 -4
  41. package/skill/closeNav.md +3 -3
  42. package/skill/iconButton.md +27 -0
  43. package/skill/main.md +13 -9
  44. package/skill/menu.md +29 -0
  45. package/skill/scrollStrip.md +28 -0
  46. package/src/components/autocomplete.ts +1 -1
  47. package/src/components/box.ts +29 -39
  48. package/src/components/button.ts +109 -8
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/checkbox.ts +3 -3
  51. package/src/components/field.ts +3 -3
  52. package/src/components/main.ts +381 -188
  53. package/src/components/menu.ts +265 -37
  54. package/src/components/panels.ts +1136 -526
  55. package/src/components/tabs.ts +134 -68
  56. package/src/core.ts +1 -1
  57. package/src/index.ts +4 -4
  58. package/src/theme.ts +14 -3
  59. package/skill/Page.md +0 -119
  60. package/skill/panels.md +0 -10
@@ -14,31 +14,68 @@ Aberdeen attr/style string applied to the outermost shell element.
14
14
 
15
15
  ### mainOptions.title · member
16
16
 
17
- App/page title shown in the top bar.
17
+ The app's name, shown in the top bar in the brand's own styling, with the
18
+ breadcrumb stack of open panels on the line beneath it (in routed mode).
19
+ In routed mode it is a link to the app's `MainOptions.home`, as the
20
+ `MainOptions.logo` is.
18
21
 
19
22
  **Type:** `Slot`
20
23
 
21
24
  ### mainOptions.subtitle · member
22
25
 
23
- Secondary line under the title.
26
+ A tagline for the app, on the line under its name.
27
+
28
+ In routed mode that line is the breadcrumb stack's, and the tagline only
29
+ gets it while the stack would be saying nothing the screen doesn't
30
+ already: exactly one panel open, that panel being one a nav item leads to
31
+ (so the sidebar has it highlighted), and the sidebar actually on screen.
32
+ Open a panel on top of it, or narrow the shell until the nav is behind the
33
+ ☰, and the stack takes the line back — it is then the only thing naming
34
+ the screen. Pass no subtitle and the stack simply always has it.
35
+
36
+ Outside routed mode nothing competes for the line, so it always shows.
24
37
 
25
38
  **Type:** `Slot`
26
39
 
27
- ### mainOptions.icon · member
40
+ ### mainOptions.logo · member
28
41
 
29
- Leading icon/logo in the top bar.
42
+ The brand mark: the bar's leading slot while the nav is a sidebar.
43
+
44
+ A narrow shell *displaces* it with the ☰ that opens the collapsed nav. The
45
+ app never branches on which: it hands over a logo and the shell works out
46
+ whether there is room for it. In routed mode it is a link to the app's
47
+ `MainOptions.home`, as the app's name is.
30
48
 
31
49
  **Type:** `Slot`
32
50
 
51
+ ### mainOptions.home · member
52
+
53
+ The app's home: where the name and the `MainOptions.logo` in the
54
+ top bar link, as every logo on the web does. Defaults to `"/"`; set it
55
+ when your home screen lives elsewhere. It's an ordinary link, so the
56
+ usual rules apply: a home that is already open in the stack — its first
57
+ panel, usually — is returned to, closing nothing, and one that isn't is
58
+ opened the way a nav item would be. Routed mode only.
59
+
60
+ **Type:** `string`
61
+
33
62
  ### mainOptions.menu · member
34
63
 
35
- Action area on the right of the top bar (buttons, menu, ...).
64
+ The app's own chrome, at the trailing end of the top bar: an account
65
+ button, a global search box, a settings menu. It may grow into the bar's
66
+ free space (so a search box is at home here); the title truncates before it
67
+ gives any of it back.
68
+
69
+ In routed mode a narrow shell hands this slot to the current panel's
70
+ `Panel.actions` whenever it has any — on a phone the screen's own
71
+ verbs win the space — and keeps the app's menu for the screens that
72
+ declare none.
36
73
 
37
74
  **Type:** `Slot`
38
75
 
39
76
  ### mainOptions.content · member
40
77
 
41
- The scrollable page content. A string is rendered as rich text.
78
+ The scrollable panel content. A string is rendered as rich text.
42
79
  Mutually exclusive with `MainOptions.routes`.
43
80
 
44
81
  **Type:** `Slot`
@@ -47,7 +84,7 @@ Mutually exclusive with `MainOptions.routes`.
47
84
 
48
85
  Paths mapped to the functions that draw them, which hands navigation over
49
86
  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,
87
+ as many columns as fit are shown at a time: one at a time on a phone,
51
88
  several side by side on a wider screen. Mutually exclusive with
52
89
  `MainOptions.content`.
53
90
 
@@ -57,7 +94,7 @@ a string, `[name=integer]` matches one segment as a number, and a trailing
57
94
  string, so it has to come last and needs at least one segment to match.
58
95
  The first key that matches wins, a segment a param refuses falls through
59
96
  to a later route (or to `MainOptions.notFound`), and each handler's
60
- `$page.params` is typed from its own key.
97
+ `$panel.params` is typed from its own key.
61
98
 
62
99
  `integer` accepts only spellings that survive a round trip back to the
63
100
  same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
@@ -65,26 +102,38 @@ aren't safe integers, such as snowflakes, want a plain `[id]`.
65
102
 
66
103
  Navigating is just links: the shell handles the clicks itself, so do *not*
67
104
  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`.
105
+ the panel it sits in, closing everything after that panel first. The
106
+ `data-panel` attribute picks another of the three `PanelStack`
107
+ navigations instead: `replace` puts the target in place of the link's own
108
+ panel, and `open` leaves that panel behind and gives the target its own
109
+ stack, the way a nav item does. A link
110
+ to something already open goes back to it rather than opening it twice —
111
+ a move along the stack that closes nothing: the panels right of it stay
112
+ open, parked past the viewport's right edge, until a *new* panel prunes
113
+ them (pinned panels excepted — see `Panel.pinned`).
114
+ From code, use `pushPanel` and friends: navigating
115
+ with `aberdeen/route`'s own `go()` works too — a panel with
116
+ `Panel.unsaved` work still survives it — but builds the whole stack
117
+ from the path. A navigation guard the app registered with
118
+ `route.setGuard` (an auth redirect, say) keeps working: the shell
119
+ registers none of its own.
120
+
121
+ **A panel declares its chrome; the shell places it.** A panel says what it
122
+ is called (`Panel.title` — unset, its first line of text stands in)
123
+ and what it can do (`Panel.actions`); everything else in a column is
124
+ the panel's own content, boxes included. The shell writes the stack of
125
+ open panels as breadcrumbs in the top bar — click one to go back to it,
126
+ closing nothing — and places each panel's actions where the room is: on
127
+ its own column while several fit, in the bar once the shell is narrow and
128
+ the current panel *is* the screen. Nothing in an app measures the
129
+ viewport to lay its screens out twice.
130
+
131
+ Only one routed shell can be mounted at a time (a second one throws) —
132
+ the URL is global, so two of them would fight over it. Nothing else is:
133
+ the `PanelStack` belongs to its shell, which hands it back, and
134
+ each handler gets its own `$panel` rather than there being one global
135
+ "current panel", since several panels are alive at once. It's that
136
+ argument that carries the per-route typing of `params`.
88
137
 
89
138
  **Type:** `R`
90
139
 
@@ -95,18 +144,18 @@ S.main({
95
144
  title: "Trackle",
96
145
  nav: { items: [{ label: "Projects", href: "/projects" }] },
97
146
  routes: {
98
- "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
99
- "/projects/[id]": ($page) => drawProject($page.params.id), // typed string
147
+ "/projects": ($panel) => { $panel.title = "Projects"; drawProjects(); },
148
+ "/projects/[id]": ($panel) => drawProject($panel.params.id), // typed string
100
149
  },
101
- notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
150
+ notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
102
151
  });
103
152
  ```
104
153
 
105
154
  ### mainOptions.notFound · member
106
155
 
107
156
  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`.
157
+ to go with it, so `$panel.params` is empty; the path itself is in
158
+ `$panel.path`.
110
159
 
111
160
  **Type:** `RouteHandler<{}>`
112
161
 
@@ -144,19 +193,19 @@ it belongs. Paths you have no route for are skipped, as they are there.
144
193
 
145
194
  This is asked for every origin-less navigation, so a nav item and a fresh
146
195
  tab still land on the same columns; a link *inside* a panel builds on that
147
- panel instead and never asks. It has to answer without drawing anything,
148
- since the panels being replaced are asked their `Page.requestClose`
149
- before the navigation is applied — before any handler could run. From code,
150
- `panels`.`open()` takes the same list directly.
196
+ panel instead and never asks. It's consulted while the navigation is
197
+ still being worked out — before any route handler runs — so it has to
198
+ answer without drawing anything. From code,
199
+ `PanelStack.openPanelStack` takes the same list directly.
151
200
 
152
201
  **Type:** `AncestorTable<NoInfer<R>>`
153
202
 
154
203
  ### mainOptions.stacking · member
155
204
 
156
- Set `false` to show only the top panel, however wide the screen (the nav
157
- sidebar still sits beside it). Everything else behaves the same: the URL,
158
- the back button, `requestClose`, and the panels' own close buttons. This
159
- only changes how many you see. Defaults to `true`.
205
+ Set `false` to show only the current panel, however wide the screen (the
206
+ nav sidebar still sits beside it). Everything else behaves the same: the
207
+ URL, the back button, unsaved panels, and the panels' own close buttons.
208
+ This only changes how many you see. Defaults to `true`.
160
209
 
161
210
  **Type:** `boolean`
162
211
 
@@ -168,15 +217,15 @@ Footer content, pinned below the scroll area.
168
217
 
169
218
  ### mainOptions.maxWidth · member
170
219
 
171
- Max width for the page's *content*, e.g. `"60rem"`. The header and footer
220
+ Max width for the panel's *content*, e.g. `"60rem"`. The header and footer
172
221
  backgrounds still span the full shell width, but their contents — and the
173
222
  sidebar + separator + content trio (or just the content when there's no
174
223
  sidebar) — cap to this width and centre horizontally. When unset, everything
175
- fills the available width. Either way the content shares the page surface —
224
+ fills the available width. Either way the content shares the panel surface —
176
225
  it is not boxed.
177
226
 
178
227
  Ignored when you pass `MainOptions.routes`: there the open panels
179
- decide the width (see `Page.layout`), and the header and footer line
228
+ decide the width (see `Panel.maxWidth`), and the header and footer line
180
229
  themselves up with them.
181
230
 
182
231
  **Type:** `string`
@@ -195,30 +244,29 @@ Aberdeen attr/style string applied to the top bar.
195
244
 
196
245
  ### mainOptions.nav · member
197
246
 
198
- Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
199
- mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
200
- collapses to a button when the shell is too narrow — which there opens the
201
- nav as a full page sliding in from the left, not as a dropdown.
247
+ Navigation menu, rendered as a sidebar beside the content. The sidebar
248
+ collapses to a ☰ in the top bar when the shell is narrow, and there it
249
+ opens the nav as a full panel sliding in from the left, not as a dropdown.
202
250
 
203
251
  `items` may be a reactive array: the shell reads it inside the sidebar's own
204
252
  scope, so an item arriving or leaving redraws the sidebar and nothing else.
205
- The content beside it — in routed mode, the whole panel stack — is left
206
- alone.
253
+ The content beside it — in routed mode, the whole stack — is left
254
+ alone. `button` customizes the ☰; `dropdownAttrs` does nothing here, since
255
+ a collapsed nav is a panel rather than a dropdown.
207
256
 
208
257
  **Type:** `MenuOptions`
209
258
 
210
259
  ### mainOptions.navPosition · member
211
260
 
212
- Where to render the nav. Defaults to `"left"`.
213
- - `"left"` / `"right"`: sidebar next to the content area; collapses to a
214
- button in the top bar when the shell width drops below 640 px.
215
- - `"button"`: always a button, never a sidebar.
261
+ Which side the nav sidebar sits on. Defaults to `"left"`.
216
262
 
217
- The button opens a dropdown on a wide shell, and — below 640 px — a
218
- full-page nav that slides in from the left, handing over to the chosen
219
- screen with a matching slide in from the right.
263
+ Either way it collapses to a ☰ in the top bar once the shell width drops to
264
+ 640 px or below — the one threshold everything else keys off too, which is
265
+ why there is no "always a button" mode: it would make "narrow" and "the nav
266
+ is collapsed" two different things, and every rule about where a panel's
267
+ chrome goes assumes they are one.
220
268
 
221
- **Type:** `"button" | "left" | "right"`
269
+ **Type:** `"left" | "right"`
222
270
 
223
271
  ### mainOptions.navAttrs · member
224
272
 
package/skill/MenuItem.md CHANGED
@@ -29,7 +29,7 @@ Click handler.
29
29
 
30
30
  Render as a link (`<a>`) pointing here. Pairs naturally with
31
31
  `interceptLinks()` — the item is highlighted automatically when the URL
32
- matches.
32
+ matches, and scrolled into view if its list had scrolled it out.
33
33
 
34
34
  **Type:** `string`
35
35
 
@@ -50,3 +50,18 @@ Disables the item.
50
50
  Aberdeen attr/style string on the item element.
51
51
 
52
52
  **Type:** `string`
53
+
54
+ ### menuItem.items · member
55
+
56
+ Child entries, which turn the item into a collapsible **branch** of a
57
+ tree. Only the branch holding the current page is expanded; navigate away
58
+ and it folds back up. Clicking a branch *selects* rather than toggles: it
59
+ follows the item's own `href`, or failing that the first linked leaf
60
+ below it — which is what expands it. A branch with no link anywhere below
61
+ it falls back to plain open/close toggling.
62
+
63
+ Expanding is not selecting: a branch click never counts as picking an
64
+ item (see `onLeafSelect` on `menu`), so on a phone the nav stays up
65
+ while a section unfolds.
66
+
67
+ **Type:** `MenuEntry[]`
@@ -0,0 +1,24 @@
1
+ ## MenuListOptions · interface
2
+
3
+ Options for `menu`.
4
+
5
+ ### menuListOptions.items · member
6
+
7
+ The entries: items, separators, custom slots — and collapsible branches,
8
+ via `MenuItem.items`.
9
+
10
+ **Type:** `MenuEntry[]`
11
+
12
+ ### menuListOptions.onLeafSelect · member
13
+
14
+ Run when a **leaf** item is activated. A branch expanding is not a
15
+ selection, so it doesn't run this — which is what lets a menu that
16
+ dismisses itself on selection stay up while a section unfolds.
17
+
18
+ **Type:** `() => void`
19
+
20
+ ### menuListOptions.attrs · member
21
+
22
+ Aberdeen attr/style string on the list element.
23
+
24
+ **Type:** `string`
@@ -13,8 +13,9 @@ Items shown in the dropdown or sidebar nav.
13
13
  Customize the trigger button rendered by `menuButton`. Defaults to a
14
14
  `☰` icon button. The `click` handler is managed internally.
15
15
 
16
- When used as a `nav` in `S.main()`, this also customizes the hamburger
17
- button shown when the sidebar collapses.
16
+ When used as a `nav` in `S.main()`, this also customizes the ☰ the sidebar
17
+ collapses into — which is an `iconButton`, so only `icon`,
18
+ `ariaLabel` and `attrs` apply there.
18
19
 
19
20
  **Type:** `ButtonOptions`
20
21
 
package/skill/Panel.md ADDED
@@ -0,0 +1,190 @@
1
+ ## Panel · 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 current panel only. Any other
9
+ panel keeps just its path, so anything a panel needs in order to redraw
10
+ itself has to live in that path. (A panel browsed away from does get its
11
+ search and hash back when a crumb makes it current again.)
12
+
13
+ **Type Parameters:**
14
+
15
+ - `P = Record<string, string | number | string[]>`
16
+
17
+ ### panel.params · member
18
+
19
+ The params matched from this panel's path, typed per its route key:
20
+ `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
21
+ Read-only.
22
+
23
+ **Type:** `P`
24
+
25
+ ### panel.stack · member
26
+
27
+ The stack this panel is in — the very object `S.main()` hands back. It is
28
+ here as well because a route handler runs *while* that call is still
29
+ going, so its return value isn't available to it yet; this always is.
30
+
31
+ **Type:** `PanelStack`
32
+
33
+ ### panel.path · member
34
+
35
+ This panel's path, e.g. `"/projects/7"`. Read-only.
36
+
37
+ **Type:** `string`
38
+
39
+ ### panel.title · member
40
+
41
+ Names this screen, in the top bar's breadcrumb stack and in
42
+ `document.title` while the panel is current. A panel that doesn't set one
43
+ borrows the first line of text in its own body, so the stack never shows a
44
+ blank — but a borrowed paragraph makes a poor name, so say it yourself.
45
+
46
+ It does **not** conjure a heading: naming a screen and heading its content
47
+ are different jobs, and a screen that wants its name in its own body
48
+ writes it there, where it owns the typography.
49
+
50
+ **Type:** `string`
51
+
52
+ ### panel.actions · member
53
+
54
+ This screen's own actions: a couple of buttons, a menu. The shell draws
55
+ them — never the panel — and *where* depends on facts only the shell has:
56
+ a quiet strip at the top of this panel's column while several columns are
57
+ up, and the top bar (where they take the app's own `menu` slot) once the
58
+ shell is narrow and this panel is the screen.
59
+
60
+ They are drawn in exactly one of those places at a time, so crossing the
61
+ threshold redraws them; anything stateful inside (the focus in a search
62
+ box) is lost. Buttons and menus are fine.
63
+
64
+ **Type:** `Slot`
65
+
66
+ ### panel.width · member
67
+
68
+ How wide this panel's column actually is, in pixels — what
69
+ `Panel.maxWidth` asked for, resolved against the window. Reactive and
70
+ read-only, and correct *before* your handler draws, so content that sizes
71
+ itself can read it instead of measuring.
72
+
73
+ You rarely need it: the shell places the chrome for you. It's for content
74
+ that genuinely differs by width, such as a table that becomes a list.
75
+
76
+ **Type:** `number`
77
+
78
+ ### panel.visible · member
79
+
80
+ Whether this panel is on screen right now: not crowded out from under the
81
+ visible run, not parked past its right end, and not on its way out.
82
+ Reactive and read-only.
83
+
84
+ The one to hang per-panel floating UI on (a FAB, a "3 selected" bar), for
85
+ which "am I the current panel?" is the wrong question — two columns can be
86
+ visible at once, and both of them are really there.
87
+
88
+ **Type:** `boolean`
89
+
90
+ ### panel.maxWidth · member
91
+
92
+ The widest this panel can usefully be. Every panel must work at 360–540px,
93
+ because that is what it gets when two columns fit; this says how much
94
+ *more* it can take.
95
+
96
+ - `"half"` — nothing more. Half the content area (360–540px), so a second
97
+ column fits beside it. For lists and detail forms.
98
+ - `"full"` (the default) — the whole content area, up to ~1100px.
99
+ - `"screen"` — the whole window, unbounded: boards, wide tables, dense
100
+ dashboards. While one is open the shell itself stretches to the screen
101
+ edges instead of stopping at the standard 1280px page.
102
+
103
+ Below the width two columns need, everything takes the content area
104
+ whatever it asked for. Widths depend only on the window, never on what
105
+ else is open, so opening or closing a panel never resizes another.
106
+
107
+ Set it at the top of your handler and the panel is already that wide when
108
+ you draw (see `Panel.width`); set it later — when your data tells you
109
+ — and the panel reflows without being redrawn, keeping its state, while
110
+ the columns beside it move over.
111
+
112
+ **Type:** `"half" | "full" | "screen"`
113
+
114
+ ### panel.loading · member
115
+
116
+ Set this while you're fetching what the panel needs, and back to `false`
117
+ when you're done. A new panel waits a moment before sliding in, so it can
118
+ arrive with real content instead of empty; if the wait drags on it slides
119
+ in anyway and shows a loading indicator until the flag clears. It only
120
+ affects the animation; the stack and the URL never wait for it.
121
+
122
+ **Type:** `boolean`
123
+
124
+ ### panel.pinned · member
125
+
126
+ Keeps this panel from being closed by navigation happening *elsewhere*.
127
+ Opening a new panel normally closes everything after the panel it came
128
+ from; a pinned panel survives that, staying in the stack — parked past the
129
+ right edge of the viewport — slotted in beneath the new panel, one crumb
130
+ click away. The user toggles it from the crumb's context menu
131
+ (right-click or long-press), which is also where the pin shows; setting
132
+ it from code does the same thing.
133
+
134
+ A pin never blocks an *explicit* close: Escape at the stack's end,
135
+ `Panel.close`, the crumb menu's Close and `data-panel=replace` all
136
+ still close the panel.
137
+
138
+ **Type:** `boolean`
139
+
140
+ ### panel.unsaved · member
141
+
142
+ Set this while the panel holds work that must not be lost — a dirty form,
143
+ an upload in flight. An unsaved panel cannot be closed, by anything:
144
+ navigation that would prune it parks it instead, past the viewport's
145
+ right edge, wearing a ● in its crumb — even the browser's back button
146
+ only parks it. `Panel.close` and the crumb menu's Close refuse,
147
+ Escape on it steps left along the stack rather than closing, and closing
148
+ the browser tab runs into the browser's own are-you-sure (after which the
149
+ shell brings the unsaved panel back on screen).
150
+
151
+ Only the app clears it; the user has no toggle. A Save or Discard button
152
+ clears it and then closes:
153
+
154
+ ```ts
155
+ A(() => { $panel.unsaved = $form.dirty || undefined; });
156
+ S.button({ content: "Discard", attrs: ".neutral", click: () => {
157
+ $panel.unsaved = false; // explicitly — see below
158
+ void $panel.close();
159
+ }});
160
+ ```
161
+
162
+ The explicit `unsaved = false` before `close()` matters when the flag is
163
+ kept by a reactive scope, as above: resetting the form marks that scope
164
+ dirty, but it reruns *after* the running handler — after `close()` has
165
+ already been refused.
166
+
167
+ **Type:** `boolean`
168
+
169
+ ### panel.close · member
170
+
171
+ Closes **this** panel, wherever it sits in the stack. Closing the current
172
+ panel hands the focus to the panel on its left; closing any other panel
173
+ takes just it away, leaving the columns around it where they are, with
174
+ their state. Either way it becomes a history entry, so the browser's
175
+ back button brings the panel back.
176
+
177
+ Resolves `false` if the panel didn't close: it holds
178
+ `Panel.unsaved` work, it was the only panel on the stack (so
179
+ there's nothing to show instead), or another navigation got there first.
180
+ The shell's breadcrumbs already travel back, so reach for this when a
181
+ screen wants a more explicit way out: a Cancel button, or a Save that
182
+ closes.
183
+
184
+ **Type:** `() => Promise<boolean>`
185
+
186
+ **Examples:**
187
+
188
+ ```ts
189
+ S.button({ content: "Cancel", attrs: ".neutral", click: () => void $panel.close() });
190
+ ```
@@ -0,0 +1,106 @@
1
+ ## PanelStack · interface
2
+
3
+ The panel stack behind a routed `S.main()`, and what that call hands back:
4
+ the open `Panel`s, which of them is current, and the four ways to
5
+ change that. Everything on it is scoped to its own shell.
6
+
7
+ Every navigation settles asynchronously (closes travel through the
8
+ browser's history), so the methods resolve once it has: `true` when it
9
+ landed, `false` when it didn't — an unsaved panel refused to close, an
10
+ app-registered route guard said no, or another navigation superseded it.
11
+ Ignore the promise unless you care.
12
+
13
+ **Examples:**
14
+
15
+ ```ts
16
+ const shell = S.main({ title: "Trackle", routes: { ... } });
17
+
18
+ S.button({ content: "New task", click: async () => {
19
+ const task = await createTask();
20
+ shell.pushPanel(`/tasks/${task.id}`);
21
+ }});
22
+ ```
23
+
24
+ ### panelStack.panels · member
25
+
26
+ The open panels, oldest first — the stack itself, as live objects rather
27
+ than a copy of it. Writing through one is how you pin a panel, or rename
28
+ it, from outside its own handler. Reactive on the stack's shape; don't
29
+ hold a `Panel` across a navigation, since a closed one is dropped
30
+ here while its element plays out its exit.
31
+
32
+ **Type:** `readonly Panel<Record<string, string | number | string[]>>[]`
33
+
34
+ ### panelStack.currentPanelIndex · member
35
+
36
+ Index into `PanelStack.panels` of the current panel. Reactive.
37
+
38
+ **Type:** `number`
39
+
40
+ ### panelStack.currentPanel · member
41
+
42
+ The current panel — shorthand for `panels[currentPanelIndex]`, and
43
+ `undefined` only while the stack is still empty. Reactive on *which*
44
+ panel is current; the fields you then read (`title`, `actions`, …) are
45
+ reactive in their own right.
46
+
47
+ **Type:** `Panel<Record<string, string | number | string[]>>`
48
+
49
+ ### panelStack.pushPanel · member
50
+
51
+ Opens `path` in a new panel on top of the current one, closing the
52
+ unpinned panels that were after it (pinned ones stay, sliding in beneath
53
+ the new panel).
54
+
55
+ The same rules as a link click apply: pushing a path that is already open
56
+ goes back to it — a focus move along the stack, closing nothing — rather
57
+ than opening it twice, and a panel holding `Panel.unsaved` work is
58
+ never closed, only parked. That's what a plain link does, and what
59
+ `data-panel=push` says outright.
60
+
61
+ **Type:** `(path: string) => Promise<boolean>`
62
+
63
+ ### panelStack.replacePanel · member
64
+
65
+ Opens `path` in place of the current panel, which closes. The panels
66
+ beneath it stay as they are. That's what a `data-panel=replace` link does.
67
+
68
+ **Type:** `(path: string) => Promise<boolean>`
69
+
70
+ ### panelStack.openPanelStack · member
71
+
72
+ Opens `path` as a whole stack rather than on top of what's there: the same
73
+ thing a nav item or a fresh tab does. Without `beneath`, the stack under it
74
+ is worked out the way a cold link's is (see `MainOptions.ancestors`);
75
+ with it, the paths you give are opened underneath, shallowest first.
76
+
77
+ That's the one for a screen whose URL doesn't say where it belongs — the
78
+ thread a notification opens — and for seeding a stack from code in general.
79
+ Panels the new stack also holds stay as they are; ones it drops close,
80
+ except panels with `Panel.unsaved` work, which stay, parked.
81
+
82
+ A `data-panel=open` link does the same thing (without a `beneath`): it
83
+ leaves the panel it sits in behind rather than stacking on it, which is
84
+ what a link to somewhere else in the app wants — a search hit, a mention.
85
+
86
+ **Type:** `(path: string, beneath?: readonly string[]) => Promise<boolean>`
87
+
88
+ **Examples:**
89
+
90
+ ```ts
91
+ shell.openPanelStack(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
92
+ ```
93
+
94
+ ### panelStack.closePanel · member
95
+
96
+ Closes the current panel, or, given a `path`, whichever panel is open at
97
+ it. Closing the current panel hands the focus to the panel on its left;
98
+ closing any other panel takes just it away, leaving the columns around it
99
+ exactly as they are, with their state. Either way it becomes a history
100
+ entry, so the browser's back button brings the panel back.
101
+
102
+ Resolves `false` if the panel didn't close: it holds `Panel.unsaved`
103
+ work, `path` isn't open, it was the stack's only panel, or another
104
+ navigation got there first.
105
+
106
+ **Type:** `(path?: string) => Promise<boolean>`