staffa 0.1.0 → 0.3.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 (68) hide show
  1. package/README.md +161 -97
  2. package/dist/components/autocomplete.js +21 -20
  3. package/dist/components/box.d.ts +8 -6
  4. package/dist/components/box.js +16 -12
  5. package/dist/components/button.d.ts +21 -35
  6. package/dist/components/button.js +46 -39
  7. package/dist/components/buttonChooser.d.ts +41 -0
  8. package/dist/components/buttonChooser.js +38 -0
  9. package/dist/components/buttonGroup.d.ts +3 -3
  10. package/dist/components/buttonGroup.js +18 -21
  11. package/dist/components/checkbox.js +7 -7
  12. package/dist/components/dialog.d.ts +20 -25
  13. package/dist/components/dialog.js +81 -91
  14. package/dist/components/field.d.ts +8 -6
  15. package/dist/components/field.js +16 -18
  16. package/dist/components/form.d.ts +3 -3
  17. package/dist/components/form.js +4 -4
  18. package/dist/components/main.d.ts +41 -14
  19. package/dist/components/main.js +193 -53
  20. package/dist/components/menu.d.ts +118 -0
  21. package/dist/components/menu.js +218 -0
  22. package/dist/components/select.d.ts +1 -1
  23. package/dist/components/select.js +5 -5
  24. package/dist/components/tabs.d.ts +5 -5
  25. package/dist/components/tabs.js +15 -22
  26. package/dist/components/textarea.js +4 -4
  27. package/dist/components/textline.js +1 -1
  28. package/dist/components/toast.d.ts +37 -0
  29. package/dist/components/toast.js +79 -0
  30. package/dist/components/tooltip.d.ts +32 -0
  31. package/dist/components/tooltip.js +130 -0
  32. package/dist/core.d.ts +26 -39
  33. package/dist/core.js +6 -5
  34. package/dist/icons-helpers.d.ts +46 -0
  35. package/dist/icons-helpers.js +44 -0
  36. package/dist/icons.d.ts +1960 -0
  37. package/dist/icons.js +1972 -0
  38. package/dist/index.d.ts +21 -8
  39. package/dist/index.js +17 -8
  40. package/dist/staffa.esm.js +1 -0
  41. package/dist/theme.d.ts +9 -75
  42. package/dist/theme.js +279 -82
  43. package/package.json +12 -5
  44. package/src/components/autocomplete.ts +21 -20
  45. package/src/components/box.ts +21 -15
  46. package/src/components/button.ts +59 -75
  47. package/src/components/buttonChooser.ts +65 -0
  48. package/src/components/buttonGroup.ts +18 -21
  49. package/src/components/checkbox.ts +7 -7
  50. package/src/components/dialog.ts +101 -102
  51. package/src/components/field.ts +22 -22
  52. package/src/components/form.ts +7 -7
  53. package/src/components/main.ts +212 -52
  54. package/src/components/menu.ts +288 -0
  55. package/src/components/select.ts +4 -4
  56. package/src/components/tabs.ts +20 -27
  57. package/src/components/textarea.ts +4 -4
  58. package/src/components/textline.ts +1 -1
  59. package/src/components/toast.ts +115 -0
  60. package/src/components/tooltip.ts +139 -0
  61. package/src/core.ts +26 -40
  62. package/src/icons-helpers.ts +90 -0
  63. package/src/icons.ts +1977 -0
  64. package/src/index.ts +21 -8
  65. package/src/theme.ts +300 -135
  66. package/dist/components/modal.d.ts +0 -2
  67. package/dist/components/modal.js +0 -2
  68. package/dist/skye.esm.js +0 -1
@@ -3,44 +3,74 @@ import { drawSlot } from "../core.js";
3
3
  import { button } from "./button.js";
4
4
  import { buttonGroup } from "./buttonGroup.js";
5
5
  import { textline } from "./textline.js";
6
- // Transition helper classes.
7
- // `.S_backdrop` = backdrop, hidden when another backdrop follows it in the DOM.
8
- // `.S_dialog` = dialog box, slides + fades in/out.
9
6
  A.insertGlobalCss({
10
- ".S_backdrop": {
11
- "&": "position:fixed inset:0 z-index:200; background: rgba(0,0,0,0.55); transition: opacity 0.2s ease;",
12
- "&:not(:has(~ .S_backdrop))": "display:block",
13
- "&:not(:has(~ .S_backdrop)) + .S_dialog": "display:flex flex-direction:column",
14
- // Transition states: applied momentarily on create; re-applied on destroy.
7
+ ".s-backdrop": {
8
+ "&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
15
9
  "&.hidden": "opacity:0 pointer-events:none",
16
10
  },
17
- ".S_dialog": {
18
- "&": "position:fixed z-index:201 top:50% left:50% " +
11
+ ".s-dialog": {
12
+ "&": "position:fixed z-index:200 top:50% left:50% " +
13
+ "display:flex flex-direction:column " +
19
14
  "transform:translate(-50%,-50%) " +
20
15
  "min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
21
- "bg:$sSurface border: 1px solid $sBorder; r:$sRadiusLg box-shadow:$sShadow overflow:hidden " +
22
- "transition: opacity 0.2s ease, transform 0.2s ease;",
23
- // Header and footer are fixed; only the content <div> scrolls.
16
+ "border: 1px solid $s-border; r: $s-radius-lg; box-shadow: $s-shadow; overflow:hidden " +
17
+ "transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
24
18
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
25
- "bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600 flex-shrink:0",
19
+ "border-bottom: 1px solid $s-border; font-weight:600 flex-shrink:0",
26
20
  "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; " +
27
- "bg:$sSurfaceHi border-top: 1px solid $sBorder; flex-shrink:0",
21
+ "border-top: 1px solid $s-border; flex-shrink:0",
28
22
  "> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
29
- "&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px));",
30
- "&.hidden *": "pointer-events:none",
23
+ "&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
31
24
  },
32
25
  });
26
+ const dialogs = A.proxy({});
27
+ let dialogCount = 0;
28
+ const topDialogId = A.derive(() => {
29
+ const keys = Object.keys(dialogs);
30
+ if (keys.length)
31
+ return keys[keys.length - 1];
32
+ });
33
+ A.mount(document.body, () => {
34
+ A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
35
+ const close = () => { delete dialogs[dialogId]; };
36
+ A.clean(() => {
37
+ // Fires when this render is torn down — either because $closed became
38
+ // true (normal close) or because the parent reactive scope was cleaned up.
39
+ opts.onClose?.();
40
+ resolve();
41
+ });
42
+ // Backdrop - hide when not the top dialog
43
+ const overlaid = A.derive(() => topDialogId.value != dialogId);
44
+ A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
45
+ if (opts.allowCancel !== false)
46
+ close();
47
+ });
48
+ // Dialog itself
49
+ A("div.s-dialog.s-s.panel create=hidden destroy=hidden", opts.attrs, () => {
50
+ A(() => {
51
+ if (opts.header != null) {
52
+ A("header.s-s.raised", opts.headerAttrs, () => drawSlot(opts.header));
53
+ }
54
+ });
55
+ A("div", opts.contentAttrs, () => {
56
+ drawSlot(opts.content, close);
57
+ });
58
+ A(() => {
59
+ if (opts.footer != null) {
60
+ A("footer.s-s.raised", opts.footerAttrs, () => drawSlot(opts.footer));
61
+ }
62
+ });
63
+ });
64
+ });
65
+ });
33
66
  /**
34
67
  * A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
35
68
  * that fades in and out. Returns a `Promise<void>` that resolves when the dialog
36
69
  * closes. Lifecycle is also tied to the parent reactive scope — when that scope
37
70
  * is cleaned up the dialog disappears and the promise resolves.
38
71
  *
39
- * Only the **last** open dialog (and its backdrop) is visible; earlier pairs are
40
- * hidden via the CSS `+` selector, so nested dialogs stack correctly.
41
- *
42
- * The header and footer are pinned; only the body content scrolls when it is
43
- * taller than `88vh`.
72
+ * Multiple dialogs stack correctly: each new pair (backdrop + dialog) has a
73
+ * higher z-index, while older dialogs are pushed behind their covering backdrop.
44
74
  *
45
75
  * @example
46
76
  * ```ts
@@ -49,74 +79,41 @@ A.insertGlobalCss({
49
79
  * content: (close) => {
50
80
  * A("p #Are you sure?");
51
81
  * S.button({ text: "Yes", click: () => { doIt(); close(); } });
52
- * S.button({ text: "Cancel", variant: "outlined", click: close });
82
+ * S.button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
53
83
  * },
54
84
  * });
55
85
  * ```
56
86
  */
57
87
  export function dialog(opts) {
58
- return new Promise((resolve) => {
59
- const $closed = A.proxy(false);
60
- const close = () => { $closed.value = true; };
61
- let resolved = false;
62
- const onDone = () => {
63
- if (resolved)
64
- return;
65
- resolved = true;
66
- opts.onClose?.();
67
- resolve();
68
- };
69
- // A.mount ties this scope to the calling reactive scope — when the parent
70
- // scope is torn down, the backdrop and dialog are removed from body too.
71
- A.mount(document.body, () => {
72
- // The 'peek' is there such that when 'closed' is first set, this scope doesn't need to watch anything anymore.
73
- if (A.peek($closed, "value"), $closed.value)
74
- return;
75
- // Global Esc listener — registered here so it's removed on close.
76
- const onKey = (e) => {
77
- if (e.key === "Escape" && opts.allowCancel !== false)
78
- close();
79
- };
80
- document.addEventListener("keydown", onKey);
81
- A.clean(() => {
82
- document.removeEventListener("keydown", onKey);
83
- // Fires when this render is torn down — either because $closed became
84
- // true (normal close) or because the parent reactive scope was cleaned up.
85
- onDone();
86
- });
87
- // Backdrop: fades in on creation, fades out on removal.
88
- A("div.S_backdrop create=hidden destroy=hidden", () => {
89
- A("click=", () => {
90
- if (opts.allowCancel !== false)
91
- close();
92
- });
93
- });
94
- // Dialog panel: fades + slides in/out.
95
- A("div.S_dialog create=hidden destroy=hidden", opts.root, () => {
96
- A(() => {
97
- if (opts.header != null) {
98
- A("header", opts.headerInner, () => drawSlot(opts.header));
99
- }
100
- });
101
- A("div", opts.inner, () => {
102
- if (opts.content)
103
- opts.content(close);
104
- });
105
- A(() => {
106
- if (opts.footer != null) {
107
- A("footer", opts.footerInner, () => drawSlot(opts.footer));
88
+ if (!dialogCount) {
89
+ // Install Esc handler the first time we create a dialog
90
+ document.addEventListener("keydown", (e) => {
91
+ if (e.key === "Escape" && opts.allowCancel !== false) {
92
+ const ds = A.unproxy(dialogs);
93
+ // Search for top-most dialog
94
+ for (let i = dialogCount; i > 0; i--) {
95
+ if (ds[i]) {
96
+ if (ds[i].opts.allowCancel !== false) {
97
+ // The clean handler should call resolve and onClose
98
+ delete dialogs[i];
99
+ }
100
+ break;
108
101
  }
109
- });
110
- });
102
+ }
103
+ }
111
104
  });
105
+ }
106
+ const dialogId = ++dialogCount;
107
+ if (opts.cancelWithScope !== false)
108
+ A.clean(() => { delete dialogs[dialogId]; });
109
+ return new Promise((resolve) => {
110
+ dialogs[dialogId] = { resolve, opts };
112
111
  });
113
112
  }
114
113
  /**
115
114
  * Shows a message dialog with a single OK button. Returns a `Promise<void>`
116
115
  * that resolves when the user dismisses it.
117
116
  *
118
- * All properties of `opts` override the defaults, including `content`.
119
- *
120
117
  * @example
121
118
  * ```ts
122
119
  * await S.alert("File saved successfully.");
@@ -128,7 +125,7 @@ export function alert(message, opts = {}) {
128
125
  allowCancel: true,
129
126
  content: (close) => {
130
127
  A("p", () => { A("#", message); });
131
- buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
128
+ buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
132
129
  button({ text: "OK", click: close });
133
130
  } });
134
131
  },
@@ -137,10 +134,7 @@ export function alert(message, opts = {}) {
137
134
  }
138
135
  /**
139
136
  * Shows a confirmation dialog with Cancel and OK buttons. Returns a
140
- * `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise
141
- * (including Esc / backdrop click when `allowCancel` is not `false`).
142
- *
143
- * All properties of `opts` override the defaults, including `content`.
137
+ * `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise.
144
138
  *
145
139
  * @example
146
140
  * ```ts
@@ -155,8 +149,8 @@ export function confirm(message, opts = {}) {
155
149
  allowCancel: true,
156
150
  content: (close) => {
157
151
  A("p", () => { A("#", message); });
158
- buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
159
- button({ text: "Cancel", variant: "outlined", color: "neutral", click: close });
152
+ buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
153
+ button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
160
154
  button({ text: "OK", click: () => { confirmed = true; close(); } });
161
155
  } });
162
156
  },
@@ -170,10 +164,7 @@ export function confirm(message, opts = {}) {
170
164
  }
171
165
  /**
172
166
  * Shows a prompt dialog with a text input. Returns a `Promise<string | null>` —
173
- * the entered string if the user confirmed, or `null` if cancelled (Esc /
174
- * backdrop click / Cancel button).
175
- *
176
- * All properties of `opts` override the defaults, including `content`.
167
+ * the entered string if the user confirmed, or `null` if cancelled.
177
168
  *
178
169
  * @example
179
170
  * ```ts
@@ -190,7 +181,6 @@ export function prompt(message, defaultValue = "", opts = {}) {
190
181
  content: (close) => {
191
182
  A("p", () => { A("#", message); });
192
183
  const $v = A.proxy({ value: defaultValue });
193
- // Wrap in a form so Enter submits; display:contents keeps flex layout intact.
194
184
  A("form display:contents", () => {
195
185
  A("submit=", (e) => {
196
186
  e.preventDefault();
@@ -198,8 +188,8 @@ export function prompt(message, defaultValue = "", opts = {}) {
198
188
  close();
199
189
  });
200
190
  textline({ bind: A.ref($v, "value") });
201
- buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
202
- button({ text: "Cancel", variant: "outlined", color: "neutral", type: "button", click: close });
191
+ buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
192
+ button({ text: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
203
193
  button({ text: "OK", type: "submit" });
204
194
  } });
205
195
  });
@@ -1,4 +1,4 @@
1
- import { type BaseOptions, type Bindable, type Slot, type Styling } from "../core.js";
1
+ import { type Bindable, type Slot, type Attributes } from "../core.js";
2
2
  /**
3
3
  * Options shared by all *form field* components (textline, textarea, checkbox,
4
4
  * autocomplete, ...).
@@ -7,7 +7,9 @@ import { type BaseOptions, type Bindable, type Slot, type Styling } from "../cor
7
7
  * itself, and optional help/error text below it. {@link form} relies on this
8
8
  * shared structure to align groups of fields.
9
9
  */
10
- export interface FieldOptions extends BaseOptions {
10
+ export interface FieldOptions {
11
+ /** Aberdeen attr/style string applied to the field's wrapper element. */
12
+ attrs?: Attributes;
11
13
  /** Visible label, associated with the control via `for`/`id` for a11y. */
12
14
  label?: Slot;
13
15
  /** Helper text shown beneath the control. */
@@ -25,8 +27,8 @@ export interface FieldOptions extends BaseOptions {
25
27
  name?: string;
26
28
  /** Explicit id for the control; auto-generated when omitted. */
27
29
  id?: string;
28
- /** Aberdeen attr/style string applied to the control element itself. */
29
- control?: Styling;
30
+ /** Aberdeen attr/style string applied to the control (input) element itself. */
31
+ inputAttrs?: Attributes;
30
32
  }
31
33
  /**
32
34
  * Render the standard field chrome (label + control + help/error) around a
@@ -38,8 +40,8 @@ export interface FieldOptions extends BaseOptions {
38
40
  *
39
41
  * @param opts The field options.
40
42
  * @param drawControl Receives the resolved `id` and the live "invalid" getter,
41
- * and must draw the actual control element (using class `S_input` where
42
- * appropriate, and passing `opts.control` as an arg for caller styling).
43
+ * and must draw the actual control element (using class `s-input` where
44
+ * appropriate, and passing `opts.inputAttrs` as an arg for caller styling).
43
45
  */
44
46
  export declare function drawField(opts: FieldOptions, drawControl: (id: string, isInvalid: () => boolean) => void): void;
45
47
  /**
@@ -1,21 +1,19 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot, uniqueId } from "../core.js";
3
3
  A.insertGlobalCss({
4
- ".S_field": {
4
+ ".s-field": {
5
5
  "&": "display:flex flex-direction:column gap:$1",
6
- "> label": "font-weight:600 font-size:0.9em fg:$sFg user-select:none",
6
+ "> label": "font-weight:600 font-size:0.9em fg:$s-fg user-select:none",
7
7
  },
8
- // Shared, reusable bits (also used by checkbox & autocomplete).
9
- ".S_req": "fg:$sDanger margin-left:2px",
10
- ".S_help": "font-size:0.82em fg:$sFgMuted",
11
- ".S_error": "font-size:0.82em fg:$sDanger",
12
- // Shared look for text-like controls.
13
- ".S_input": {
14
- "&": "w:100% bg:$sSurface fg:$sFg border: 1px solid $sBorder; r:$sRadius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
15
- "&:hover:not(:disabled)": "border-color:$sBorderStrong",
16
- "&:focus-visible": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus; outline:none",
8
+ ".s-req": "fg:$s-danger margin-left:2px",
9
+ ".s-help": "font-size:0.82em fg:$s-fg-muted",
10
+ ".s-error": "font-size:0.82em fg:$s-danger",
11
+ ".s-input": {
12
+ "&": "w:100% bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
13
+ "&:hover:not(:disabled)": "border-color:$s-border-strong",
14
+ "&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
17
15
  "&:disabled": "opacity:0.6 cursor:not-allowed",
18
- "&[aria-invalid=true]": "border-color:$sDanger",
16
+ "&[aria-invalid=true]": "border-color:$s-danger",
19
17
  },
20
18
  });
21
19
  /**
@@ -28,30 +26,30 @@ A.insertGlobalCss({
28
26
  *
29
27
  * @param opts The field options.
30
28
  * @param drawControl Receives the resolved `id` and the live "invalid" getter,
31
- * and must draw the actual control element (using class `S_input` where
32
- * appropriate, and passing `opts.control` as an arg for caller styling).
29
+ * and must draw the actual control element (using class `s-input` where
30
+ * appropriate, and passing `opts.inputAttrs` as an arg for caller styling).
33
31
  */
34
32
  export function drawField(opts, drawControl) {
35
33
  const id = opts.id ?? uniqueId("field");
36
34
  const isInvalid = () => !!opts.error;
37
- A("div.S_field", opts.root, () => {
35
+ A("div.s-field", opts.attrs, () => {
38
36
  A(() => {
39
37
  if (opts.label != null) {
40
38
  A(`label for=${id}`, () => {
41
39
  drawSlot(opts.label);
42
40
  if (opts.required)
43
- A("span.S_req aria-hidden=true #*");
41
+ A("span.s-req aria-hidden=true #*");
44
42
  });
45
43
  }
46
44
  });
47
45
  drawControl(id, isInvalid);
48
46
  A(() => {
49
47
  if (opts.help != null && !opts.error)
50
- A("div.S_help", () => drawSlot(opts.help));
48
+ A("div.s-help", () => drawSlot(opts.help));
51
49
  });
52
50
  A(() => {
53
51
  if (opts.error)
54
- A("div.S_error role=alert #", opts.error);
52
+ A("div.s-error role=alert #", opts.error);
55
53
  });
56
54
  });
57
55
  }
@@ -1,4 +1,4 @@
1
- import { type Content, type ContentOptions, type Styling } from "../core.js";
1
+ import { type Content, type ContentOptions, type Attributes } from "../core.js";
2
2
  /** Options for {@link form}. */
3
3
  export interface FormOptions extends ContentOptions {
4
4
  /**
@@ -10,11 +10,11 @@ export interface FormOptions extends ContentOptions {
10
10
  /**
11
11
  * Layout of fields. `"stacked"` (default) is a single column; `"grid"` packs
12
12
  * fields into a responsive multi-column grid. A field can span the full grid
13
- * width by adding the `.S_wide` class (e.g. `root: ".S_wide"`).
13
+ * width by adding the `.s-wide` class (e.g. `attrs: ".s-wide"`).
14
14
  */
15
15
  layout?: "stacked" | "grid";
16
16
  /** Aberdeen attr/style string for the action bar. */
17
- actionsInner?: Styling;
17
+ actionsAttrs?: Attributes;
18
18
  /** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
19
19
  actions?: Content;
20
20
  }
@@ -1,9 +1,9 @@
1
1
  import A from "aberdeen";
2
2
  A.insertGlobalCss({
3
- ".S_form": {
3
+ ".s-form": {
4
4
  "&": "display:flex flex-direction:column gap:$3",
5
5
  "&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
6
- "&.grid > .S_wide, &.grid > footer": "grid-column: 1 / -1;",
6
+ "&.grid > .s-wide, &.grid > footer": "grid-column: 1 / -1;",
7
7
  "> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
8
8
  },
9
9
  });
@@ -30,7 +30,7 @@ A.insertGlobalCss({
30
30
  */
31
31
  export function form(opts = {}) {
32
32
  const o = typeof opts === "function" ? { content: opts } : opts;
33
- A(`form.S_form`, o.root, o.inner, () => {
33
+ A(`form.s-form`, o.attrs, () => {
34
34
  // Toggle grid class in its own scope so changing layout doesn't recreate
35
35
  // the fields (which would lose focus / input state).
36
36
  A(() => {
@@ -53,7 +53,7 @@ export function form(opts = {}) {
53
53
  // Own scope so toggling actions doesn't recreate the fields above.
54
54
  A(() => {
55
55
  if (o.actions)
56
- A("footer", o.actionsInner, () => o.actions?.());
56
+ A("footer", o.actionsAttrs, () => o.actions?.());
57
57
  });
58
58
  });
59
59
  }
@@ -1,6 +1,9 @@
1
- import { type BaseOptions, type Content, type Slot, type Styling } from "../core.js";
1
+ import { type Content, type Slot, type Attributes } from "../core.js";
2
+ import { type MenuOptions } from "./menu.js";
2
3
  /** Options for {@link main}. */
3
- export interface MainOptions extends BaseOptions {
4
+ export interface MainOptions {
5
+ /** Aberdeen attr/style string applied to the outermost shell element. */
6
+ attrs?: Attributes;
4
7
  /** App/page title shown in the top bar. */
5
8
  title?: Slot;
6
9
  /** Secondary line under the title. */
@@ -14,31 +17,55 @@ export interface MainOptions extends BaseOptions {
14
17
  /** Footer content, pinned below the scroll area. */
15
18
  footer?: Slot;
16
19
  /**
17
- * Max content width. When set, the content is centered in a "sheet" with a
18
- * drop shadow and a distinct surface, against the darker page background.
19
- * e.g. `"60rem"`.
20
+ * Max width for the page's *content*, e.g. `"60rem"`. The header and footer
21
+ * backgrounds still span the full shell width, but their contents and the
22
+ * sidebar + separator + content trio (or just the content when there's no
23
+ * sidebar) — cap to this width and centre horizontally. When unset, everything
24
+ * fills the available width. Either way the content shares the page surface —
25
+ * it is not boxed.
20
26
  */
21
27
  maxWidth?: string;
22
- /** Aberdeen attr/style string applied to the content sheet. */
23
- inner?: Styling;
28
+ /** Aberdeen attr/style string applied to the content area. */
29
+ contentAttrs?: Attributes;
24
30
  /** Aberdeen attr/style string applied to the top bar. */
25
- topbarInner?: Styling;
31
+ topbarAttrs?: Attributes;
32
+ /**
33
+ * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
34
+ * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
35
+ * collapses to button mode when the shell is too narrow.
36
+ */
37
+ nav?: MenuOptions;
38
+ /**
39
+ * Where to render the nav. Defaults to `"left"`.
40
+ * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
41
+ * button+dropdown in the top bar when the shell width drops below 640 px.
42
+ * - `"button"`: always a button+dropdown, never a sidebar.
43
+ */
44
+ navPosition?: "left" | "right" | "button";
45
+ /** Aberdeen attr/style string applied to the sidebar nav panel. */
46
+ navAttrs?: Attributes;
26
47
  }
27
48
  /**
28
49
  * An application shell that wires up the things almost every app needs: a sticky
29
50
  * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
30
- * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
31
- * shadowed "sheet" the common dashboard/document look while staying fully
32
- * customisable via the various draw-function options and styling strings.
51
+ * footer. With {@link MainOptions.maxWidth} the content area is centred and its
52
+ * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
53
+ * menu button below 640 px, or always a button with `navPosition: "button"`).
33
54
  *
34
55
  * @example
35
56
  * ```ts
36
57
  * S.main({
37
58
  * icon: "✦",
38
- * title: "Skye Demo",
39
- * subtitle: "Component playground",
59
+ * title: "Staffa Demo",
40
60
  * maxWidth: "56rem",
41
- * menu: () => S.button({ text: "New", size: "sm" }),
61
+ * nav: {
62
+ * items: [
63
+ * { label: "Home", icon: () => A("#🏠"), href: "/" },
64
+ * { label: "Settings", href: "/settings" },
65
+ * ],
66
+ * },
67
+ * navPosition: "left",
68
+ * menu: () => S.button({ text: "New", attrs: ".small" }),
42
69
  * content: () => drawPage(),
43
70
  * footer: "© 2026",
44
71
  * });