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
@@ -20,6 +20,11 @@ A.insertGlobalCss({
20
20
  // button (which is already near-white) darkens toward its ink instead.
21
21
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
22
22
  "&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
23
+ // The button sizes its glyph, for the same reason `.s-icon-btn` does below:
24
+ // a caller can't know what the button beside it passed, and only a rule
25
+ // here makes every icon in a row come out alike. It rides the font size,
26
+ // so a `.small`/`.large` button scales its icon with its text.
27
+ "> svg": "width:1.25em height:1.25em",
23
28
  // Subtle press feedback.
24
29
  "&:active:not(:disabled)": "transform: translateY(1px)",
25
30
  // Size: set on the button itself, or inherited from a `.small`/`.large`
@@ -27,7 +32,86 @@ A.insertGlobalCss({
27
32
  "&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
28
33
  "&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
29
34
  },
35
+ // A bare glyph in a square hit area: no fill and no edge, just ink that lifts
36
+ // on hover. Deliberately *not* a `.s-s` surface — chrome that sits beside a
37
+ // title (a ✕, a ☰) should read as an affordance on the bar, not as
38
+ // another button competing with it, and a filled or outlined box around a
39
+ // 16px glyph is exactly what makes a top bar look busy.
40
+ ".s-icon-btn": {
41
+ "&": "display:inline-flex align-items:center justify-content:center flex-shrink:0 " +
42
+ "width:2rem height:2rem p:0 border:0 background:transparent cursor:pointer " +
43
+ "fg:$s-muted r:$s-radius-sm line-height:1 font-size:1rem text-decoration:none " +
44
+ "transition: color 0.12s, background 0.12s;",
45
+ // The container sizes the glyph, rather than trusting whatever the caller
46
+ // passed: a row of icon buttons only reads as a row when every glyph in it
47
+ // is the same size, and the caller of one of them can't know about the
48
+ // others. CSS beats the `width`/`height` attributes the icon set writes, so
49
+ // `iconButton({ icon: trash2 })` and a hand-sized glyph come out alike; an
50
+ // `attrs` override still wins over this, being an inline style. The same
51
+ // rule is on `.s-btn` above and on a floating menu's rows in menu.ts, so
52
+ // one `1.25em` governs the lot. (`S.main`'s nav rows are deliberately out
53
+ // of it — see the note there.)
54
+ "> svg": "width:1.25em height:1.25em",
55
+ // The ink resolves against whatever surface it sits on, so one treatment
56
+ // works on the page, in a box header, and on a coloured bar alike.
57
+ "&:hover:not(:disabled):not([aria-disabled=true])": "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
58
+ "&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
59
+ // The glyph rides the font size, so it scales with the hit area.
60
+ "&.small, .small > &": "width:1.6rem height:1.6rem font-size:0.8rem",
61
+ "&.large, .large > &": "width:2.4rem height:2.4rem font-size:1.2rem",
62
+ },
30
63
  });
64
+ /**
65
+ * A bare glyph in a square hit area — no fill, no border, just ink that lifts on
66
+ * hover. The quiet end of the button family, for chrome that has to sit beside
67
+ * something more important without competing with it: a ✕ on a box, the ☰ a
68
+ * routed `S.main()` puts in its top bar, the verbs in a
69
+ * {@link Panel.actions | page's actions}.
70
+ *
71
+ * Reach for {@link button} instead whenever the thing has a name worth reading;
72
+ * an icon alone is only unambiguous for a handful of universal actions.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * import { trash2, share2 } from "staffa/icons";
77
+ *
78
+ * $panel.actions = () => {
79
+ * S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
80
+ * S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
81
+ * };
82
+ * ```
83
+ */
84
+ export function iconButton(opts) {
85
+ const tag = opts.href != null ? "a" : "button";
86
+ A(`${tag}.s-icon-btn`, opts.attrs, () => {
87
+ applyActionBehavior(opts);
88
+ A("aria-label=", opts.ariaLabel);
89
+ drawSlot(opts.icon);
90
+ });
91
+ }
92
+ /**
93
+ * The link-or-button plumbing {@link button} and {@link iconButton} share:
94
+ * href/type, disabling, label and click. A disabled link keeps `role=button`
95
+ * and `aria-disabled` but loses its `href` — an anchor without one is out of
96
+ * the tab order and follows nothing, which is what makes it as disabled as
97
+ * the `<button>` form's real `disabled` attribute.
98
+ */
99
+ function applyActionBehavior(o) {
100
+ if (o.href != null) {
101
+ A("role=button");
102
+ if (o.disabled)
103
+ A("aria-disabled=true");
104
+ else
105
+ A("href=", o.href);
106
+ }
107
+ else {
108
+ A("type=", o.type ?? "button");
109
+ if (o.disabled)
110
+ A("disabled=true");
111
+ }
112
+ if (o.click && !o.disabled)
113
+ A("click=", o.click);
114
+ }
31
115
  /**
32
116
  * A button. Tonal and outlined variants show a border; filled variants rely on
33
117
  * their solid background for affordance.
@@ -60,20 +144,9 @@ export function button(opts = {}) {
60
144
  // role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
61
145
  // role detection needed, since the default lives in CSS, not here.
62
146
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
63
- if (o.href != null) {
64
- A(`href=${o.href} role=button`);
65
- if (o.disabled)
66
- A("aria-disabled=true");
67
- }
68
- else {
69
- A("type=", o.type ?? "button");
70
- if (o.disabled)
71
- A("disabled=true");
72
- }
147
+ applyActionBehavior(o);
73
148
  if (o.ariaLabel)
74
149
  A("aria-label=", o.ariaLabel);
75
- if (o.click)
76
- A("click=", o.click);
77
150
  drawSlot(o.icon);
78
151
  drawSlot(o.content);
79
152
  });
@@ -35,6 +35,6 @@ export function buttonChooser(opts) {
35
35
  });
36
36
  if (opts.name) {
37
37
  // Hidden input carries the value into native form submission.
38
- A(() => A(`input type=hidden name=${opts.name} value=`, opts.bind.value ?? ""));
38
+ A(() => A("input type=hidden name=", opts.name, "value=", opts.bind.value ?? ""));
39
39
  }
40
40
  }
@@ -24,11 +24,11 @@ A.insertGlobalCss({
24
24
  export function checkbox(opts = {}) {
25
25
  const id = opts.id ?? uniqueId("check");
26
26
  A("div.s-check", opts.attrs, () => {
27
- A(`label for=${id}`, () => {
27
+ A("label for=", id, () => {
28
28
  A("input type=checkbox", opts.inputAttrs, () => {
29
- A(`id=${id}`);
29
+ A("id=", id);
30
30
  if (opts.name)
31
- A(`name=${opts.name}`);
31
+ A("name=", opts.name);
32
32
  // `checked` is a boolean attribute: only set it when actually true.
33
33
  if (opts.checked && !opts.bind)
34
34
  A("checked=true");
@@ -37,7 +37,7 @@ export function drawField(opts, drawControl) {
37
37
  A("div.s-field", opts.attrs, () => {
38
38
  A(() => {
39
39
  if (opts.label != null) {
40
- A(`label for=${id}`, () => {
40
+ A("label for=", id, () => {
41
41
  drawSlot(opts.label);
42
42
  if (opts.required)
43
43
  A("span.s-req aria-hidden=true #*");
@@ -61,9 +61,9 @@ export function drawField(opts, drawControl) {
61
61
  * each get their own scope so the control element is never recreated.
62
62
  */
63
63
  export function applyControlAttrs(opts, id, isInvalid, bind) {
64
- A(`id=${id}`);
64
+ A("id=", id);
65
65
  if (opts.name)
66
- A(`name=${opts.name}`);
66
+ A("name=", opts.name);
67
67
  A(() => {
68
68
  if (opts.disabled)
69
69
  A("disabled=true");
@@ -1,27 +1,70 @@
1
1
  import { type Slot, type Attributes } from "../core.js";
2
2
  import { type MenuOptions } from "./menu.js";
3
- import { type AncestorTable, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
3
+ import { type PanelStack, type AncestorTable, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
4
4
  /** Options for {@link main}. */
5
5
  export interface MainOptions<R = Routes> {
6
6
  /** Aberdeen attr/style string applied to the outermost shell element. */
7
7
  attrs?: Attributes;
8
- /** App/page title shown in the top bar. */
8
+ /**
9
+ * The app's name, shown in the top bar in the brand's own styling, with the
10
+ * breadcrumb stack of open panels on the line beneath it (in routed mode).
11
+ * In routed mode it is a link to the app's {@link MainOptions.home}, as the
12
+ * {@link MainOptions.logo} is.
13
+ */
9
14
  title?: Slot;
10
- /** Secondary line under the title. */
15
+ /**
16
+ * A tagline for the app, on the line under its name.
17
+ *
18
+ * In routed mode that line is the breadcrumb stack's, and the tagline only
19
+ * gets it while the stack would be saying nothing the screen doesn't
20
+ * already: exactly one panel open, that panel being one a nav item leads to
21
+ * (so the sidebar has it highlighted), and the sidebar actually on screen.
22
+ * Open a panel on top of it, or narrow the shell until the nav is behind the
23
+ * ☰, and the stack takes the line back — it is then the only thing naming
24
+ * the screen. Pass no subtitle and the stack simply always has it.
25
+ *
26
+ * Outside routed mode nothing competes for the line, so it always shows.
27
+ */
11
28
  subtitle?: Slot;
12
- /** Leading icon/logo in the top bar. */
13
- icon?: Slot;
14
- /** Action area on the right of the top bar (buttons, menu, ...). */
29
+ /**
30
+ * The brand mark: the bar's leading slot while the nav is a sidebar.
31
+ *
32
+ * A narrow shell *displaces* it with the ☰ that opens the collapsed nav. The
33
+ * app never branches on which: it hands over a logo and the shell works out
34
+ * whether there is room for it. In routed mode it is a link to the app's
35
+ * {@link MainOptions.home}, as the app's name is.
36
+ */
37
+ logo?: Slot;
38
+ /**
39
+ * The app's home: where the name and the {@link MainOptions.logo} in the
40
+ * top bar link, as every logo on the web does. Defaults to `"/"`; set it
41
+ * when your home screen lives elsewhere. It's an ordinary link, so the
42
+ * usual rules apply: a home that is already open in the stack — its first
43
+ * panel, usually — is returned to, closing nothing, and one that isn't is
44
+ * opened the way a nav item would be. Routed mode only.
45
+ */
46
+ home?: string;
47
+ /**
48
+ * The app's own chrome, at the trailing end of the top bar: an account
49
+ * button, a global search box, a settings menu. It may grow into the bar's
50
+ * free space (so a search box is at home here); the title truncates before it
51
+ * gives any of it back.
52
+ *
53
+ * In routed mode a narrow shell hands this slot to the current panel's
54
+ * {@link Panel.actions} whenever it has any — on a phone the screen's own
55
+ * verbs win the space — and keeps the app's menu for the screens that
56
+ * declare none.
57
+ */
15
58
  menu?: Slot;
16
59
  /**
17
- * The scrollable page content. A string is rendered as rich text.
60
+ * The scrollable panel content. A string is rendered as rich text.
18
61
  * Mutually exclusive with {@link MainOptions.routes}.
19
62
  */
20
63
  content?: Slot;
21
64
  /**
22
65
  * Paths mapped to the functions that draw them, which hands navigation over
23
66
  * to the shell. Each route draws one screen of your app, called a panel, and
24
- * as many panels as fit are shown at a time: one at a time on a phone,
67
+ * as many columns as fit are shown at a time: one at a time on a phone,
25
68
  * several side by side on a wider screen. Mutually exclusive with
26
69
  * {@link MainOptions.content}.
27
70
  *
@@ -31,7 +74,7 @@ export interface MainOptions<R = Routes> {
31
74
  * string, so it has to come last and needs at least one segment to match.
32
75
  * The first key that matches wins, a segment a param refuses falls through
33
76
  * to a later route (or to {@link MainOptions.notFound}), and each handler's
34
- * `$page.params` is typed from its own key.
77
+ * `$panel.params` is typed from its own key.
35
78
  *
36
79
  * `integer` accepts only spellings that survive a round trip back to the
37
80
  * same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
@@ -39,26 +82,38 @@ export interface MainOptions<R = Routes> {
39
82
  *
40
83
  * Navigating is just links: the shell handles the clicks itself, so do *not*
41
84
  * also call Aberdeen's `interceptLinks()`. A link opens its target on top of
42
- * the panel it sits in, closing anything that was above it first, unless it
43
- * carries `data-panel=replace`, which replaces its own panel instead. A link
44
- * to something already open goes back to it rather than opening it twice.
45
- * From code, use {@link panels} (`S.panels.push()` and friends): navigating
46
- * with `aberdeen/route`'s own `go()` works and still asks the panels'
47
- * {@link Page.requestClose}, but builds the whole stack from the path. A
48
- * navigation guard the app registered before mounting (an auth redirect,
49
- * say) keeps working: the shell asks it first, and puts it back when the
50
- * shell goes away.
51
- *
52
- * The shell draws no back arrows and no ✕ of its own: **every panel provides
53
- * its own way out**, with `S.box`'s `close` option for a ✕, or
54
- * {@link Page.close} behind a Cancel button. Escape and the browser's back
55
- * button are the shell's contribution.
56
- *
57
- * Only one routed shell can be mounted at a time (a second one throws),
58
- * which is what lets {@link panels} be a plain module-level object. Each
59
- * handler still gets its own `$page` rather than there being one global
60
- * "current page", since several panels are alive at once. It's that argument
61
- * that carries the per-route typing of `params`.
85
+ * the panel it sits in, closing everything after that panel first. The
86
+ * `data-panel` attribute picks another of the three {@link PanelStack}
87
+ * navigations instead: `replace` puts the target in place of the link's own
88
+ * panel, and `open` leaves that panel behind and gives the target its own
89
+ * stack, the way a nav item does. A link
90
+ * to something already open goes back to it rather than opening it twice —
91
+ * a move along the stack that closes nothing: the panels right of it stay
92
+ * open, parked past the viewport's right edge, until a *new* panel prunes
93
+ * them (pinned panels excepted — see {@link Panel.pinned}).
94
+ * From code, use {@link pushPanel} and friends: navigating
95
+ * with `aberdeen/route`'s own `go()` works too a panel with
96
+ * {@link Panel.unsaved} work still survives it but builds the whole stack
97
+ * from the path. A navigation guard the app registered with
98
+ * `route.setGuard` (an auth redirect, say) keeps working: the shell
99
+ * registers none of its own.
100
+ *
101
+ * **A panel declares its chrome; the shell places it.** A panel says what it
102
+ * is called ({@link Panel.title} unset, its first line of text stands in)
103
+ * and what it can do ({@link Panel.actions}); everything else in a column is
104
+ * the panel's own content, boxes included. The shell writes the stack of
105
+ * open panels as breadcrumbs in the top bar — click one to go back to it,
106
+ * closing nothing — and places each panel's actions where the room is: on
107
+ * its own column while several fit, in the bar once the shell is narrow and
108
+ * the current panel *is* the screen. Nothing in an app measures the
109
+ * viewport to lay its screens out twice.
110
+ *
111
+ * Only one routed shell can be mounted at a time (a second one throws) —
112
+ * the URL is global, so two of them would fight over it. Nothing else is:
113
+ * the {@link PanelStack} belongs to its shell, which hands it back, and
114
+ * each handler gets its own `$panel` rather than there being one global
115
+ * "current panel", since several panels are alive at once. It's that
116
+ * argument that carries the per-route typing of `params`.
62
117
  *
63
118
  * @example
64
119
  * ```ts
@@ -66,18 +121,18 @@ export interface MainOptions<R = Routes> {
66
121
  * title: "Trackle",
67
122
  * nav: { items: [{ label: "Projects", href: "/projects" }] },
68
123
  * routes: {
69
- * "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
70
- * "/projects/[id]": ($page) => drawProject($page.params.id), // typed string
124
+ * "/projects": ($panel) => { $panel.title = "Projects"; drawProjects(); },
125
+ * "/projects/[id]": ($panel) => drawProject($panel.params.id), // typed string
71
126
  * },
72
- * notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
127
+ * notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
73
128
  * });
74
129
  * ```
75
130
  */
76
131
  routes?: R;
77
132
  /**
78
133
  * Draws the panel for a path none of the routes match. There are no params
79
- * to go with it, so `$page.params` is empty; the path itself is in
80
- * `$page.path`.
134
+ * to go with it, so `$panel.params` is empty; the path itself is in
135
+ * `$panel.path`.
81
136
  */
82
137
  notFound?: RouteHandler<{}>;
83
138
  /**
@@ -113,31 +168,31 @@ export interface MainOptions<R = Routes> {
113
168
  *
114
169
  * This is asked for every origin-less navigation, so a nav item and a fresh
115
170
  * tab still land on the same columns; a link *inside* a panel builds on that
116
- * panel instead and never asks. It has to answer without drawing anything,
117
- * since the panels being replaced are asked their {@link Page.requestClose}
118
- * before the navigation is applied — before any handler could run. From code,
119
- * {@link panels}.`open()` takes the same list directly.
171
+ * panel instead and never asks. It's consulted while the navigation is
172
+ * still being worked out before any route handler runs — so it has to
173
+ * answer without drawing anything. From code,
174
+ * {@link PanelStack.openPanelStack} takes the same list directly.
120
175
  */
121
176
  ancestors?: AncestorTable<NoInfer<R>>;
122
177
  /**
123
- * Set `false` to show only the top panel, however wide the screen (the nav
124
- * sidebar still sits beside it). Everything else behaves the same: the URL,
125
- * the back button, `requestClose`, and the panels' own close buttons. This
126
- * only changes how many you see. Defaults to `true`.
178
+ * Set `false` to show only the current panel, however wide the screen (the
179
+ * nav sidebar still sits beside it). Everything else behaves the same: the
180
+ * URL, the back button, unsaved panels, and the panels' own close buttons.
181
+ * This only changes how many you see. Defaults to `true`.
127
182
  */
128
183
  stacking?: boolean;
129
184
  /** Footer content, pinned below the scroll area. */
130
185
  footer?: Slot;
131
186
  /**
132
- * Max width for the page's *content*, e.g. `"60rem"`. The header and footer
187
+ * Max width for the panel's *content*, e.g. `"60rem"`. The header and footer
133
188
  * backgrounds still span the full shell width, but their contents — and the
134
189
  * sidebar + separator + content trio (or just the content when there's no
135
190
  * sidebar) — cap to this width and centre horizontally. When unset, everything
136
- * fills the available width. Either way the content shares the page surface —
191
+ * fills the available width. Either way the content shares the panel surface —
137
192
  * it is not boxed.
138
193
  *
139
194
  * Ignored when you pass {@link MainOptions.routes}: there the open panels
140
- * decide the width (see {@link Page.layout}), and the header and footer line
195
+ * decide the width (see {@link Panel.maxWidth}), and the header and footer line
141
196
  * themselves up with them.
142
197
  */
143
198
  maxWidth?: string;
@@ -146,28 +201,27 @@ export interface MainOptions<R = Routes> {
146
201
  /** Aberdeen attr/style string applied to the top bar. */
147
202
  topbarAttrs?: Attributes;
148
203
  /**
149
- * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
150
- * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
151
- * collapses to a button when the shell is too narrow which there opens the
152
- * nav as a full page sliding in from the left, not as a dropdown.
204
+ * Navigation menu, rendered as a sidebar beside the content. The sidebar
205
+ * collapses to a in the top bar when the shell is narrow, and there it
206
+ * opens the nav as a full panel sliding in from the left, not as a dropdown.
153
207
  *
154
208
  * `items` may be a reactive array: the shell reads it inside the sidebar's own
155
209
  * scope, so an item arriving or leaving redraws the sidebar and nothing else.
156
- * The content beside it — in routed mode, the whole panel stack — is left
157
- * alone.
210
+ * The content beside it — in routed mode, the whole stack — is left
211
+ * alone. `button` customizes the ☰; `dropdownAttrs` does nothing here, since
212
+ * a collapsed nav is a panel rather than a dropdown.
158
213
  */
159
214
  nav?: MenuOptions;
160
215
  /**
161
- * Where to render the nav. Defaults to `"left"`.
162
- * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
163
- * button in the top bar when the shell width drops below 640 px.
164
- * - `"button"`: always a button, never a sidebar.
165
- *
166
- * The button opens a dropdown on a wide shell, and below 640 px a
167
- * full-page nav that slides in from the left, handing over to the chosen
168
- * screen with a matching slide in from the right.
216
+ * Which side the nav sidebar sits on. Defaults to `"left"`.
217
+ *
218
+ * Either way it collapses to a ☰ in the top bar once the shell width drops to
219
+ * 640 px or below — the one threshold everything else keys off too, which is
220
+ * why there is no "always a button" mode: it would make "narrow" and "the nav
221
+ * is collapsed" two different things, and every rule about where a panel's
222
+ * chrome goes assumes they are one.
169
223
  */
170
- navPosition?: "left" | "right" | "button";
224
+ navPosition?: "left" | "right";
171
225
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
172
226
  navAttrs?: Attributes;
173
227
  /** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
@@ -175,23 +229,27 @@ export interface MainOptions<R = Routes> {
175
229
  }
176
230
  /**
177
231
  * An application shell that wires up the things almost every app needs: a sticky
178
- * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
232
+ * top bar (logo, title, action menu), a scrollable content area, and a
179
233
  * footer. With {@link MainOptions.maxWidth} the content area is centred and its
180
- * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
181
- * menu button below 640 px, or always a button with `navPosition: "button"`).
182
- * Below 640 px that button opens the nav as a full page sliding in from the
234
+ * width capped. Add a `nav` to get a sidebar that collapses to a ☰ in the top bar
235
+ * below 640 px, which there opens the nav as a full panel sliding in from the
183
236
  * left; picking an item slides it away as the chosen screen enters from the
184
237
  * right.
185
238
  *
186
239
  * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
187
240
  * shell takes over navigation: each route draws one screen, called a panel,
188
- * and as many panels as fit are shown at a time, side by side on a wide screen
189
- * and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
241
+ * and as many columns as fit are shown at a time, side by side on a wide screen
242
+ * and one at a time on a phone. Each panel *declares* its chrome — its
243
+ * {@link Panel.title} and its {@link Panel.actions} — and this shell places it:
244
+ * the stack of titles as breadcrumbs in the bar, the actions on the panel's
245
+ * column while several fit and in the bar once the shell is narrow enough
246
+ * that the current panel is the whole screen. See {@link MainOptions.routes} and
247
+ * {@link Panel}.
190
248
  *
191
249
  * @example
192
250
  * ```ts
193
251
  * S.main({
194
- * icon: "✦",
252
+ * logo: "✦",
195
253
  * title: "Staffa Demo",
196
254
  * maxWidth: "56rem",
197
255
  * nav: {
@@ -211,11 +269,16 @@ export interface MainOptions<R = Routes> {
211
269
  * }
212
270
  * ```
213
271
  */
214
- export declare function main<R extends RouteTable<R>>(opts?: MainOptions<R>): void;
272
+ export declare function main<R extends RouteTable<R>>(opts: MainOptions<R> & {
273
+ routes: object;
274
+ }): PanelStack;
275
+ export declare function main(opts?: MainOptions<{}> & {
276
+ routes?: undefined;
277
+ }): void;
215
278
  /**
216
- * Close the navigation, if it's showing as an overlay: the full page it becomes
217
- * on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
218
- * isn't an overlay and has nothing to dismiss, so there it does nothing.
279
+ * Close the navigation, if it's showing as an overlay the full panel it becomes
280
+ * on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
281
+ * on a wider shell this does nothing.
219
282
  *
220
283
  * A navigation closes the nav by itself, links in your own custom rows included,
221
284
  * so this is for the items that *don't* navigate — one that opens a dialog, or