staffa 0.2.1 → 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.
- package/README.md +153 -127
- package/dist/components/autocomplete.js +3 -2
- package/dist/components/box.js +2 -2
- package/dist/components/button.d.ts +11 -2
- package/dist/components/button.js +36 -8
- package/dist/components/buttonChooser.d.ts +4 -5
- package/dist/components/buttonChooser.js +2 -2
- package/dist/components/buttonGroup.js +0 -3
- package/dist/components/field.js +0 -3
- package/dist/components/main.d.ts +34 -9
- package/dist/components/main.js +187 -49
- package/dist/components/menu.d.ts +118 -0
- package/dist/components/menu.js +218 -0
- package/dist/components/tabs.d.ts +0 -2
- package/dist/components/tabs.js +6 -19
- package/dist/components/toast.d.ts +37 -0
- package/dist/components/toast.js +79 -0
- package/dist/components/tooltip.d.ts +32 -0
- package/dist/components/tooltip.js +130 -0
- package/dist/core.d.ts +1 -1
- package/dist/icons-helpers.d.ts +46 -0
- package/dist/icons-helpers.js +44 -0
- package/dist/icons.d.ts +1960 -0
- package/dist/icons.js +1972 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +114 -13
- package/package.json +10 -4
- package/src/components/autocomplete.ts +3 -2
- package/src/components/box.ts +2 -2
- package/src/components/button.ts +42 -10
- package/src/components/buttonChooser.ts +6 -7
- package/src/components/buttonGroup.ts +0 -3
- package/src/components/field.ts +0 -3
- package/src/components/main.ts +201 -45
- package/src/components/menu.ts +288 -0
- package/src/components/tabs.ts +6 -20
- package/src/components/toast.ts +115 -0
- package/src/components/tooltip.ts +139 -0
- package/src/core.ts +1 -1
- package/src/icons-helpers.ts +90 -0
- package/src/icons.ts +1977 -0
- package/src/index.ts +11 -0
- package/src/theme.ts +128 -18
- package/dist/components/modal.d.ts +0 -2
- package/dist/components/modal.js +0 -2
- package/dist/skye.esm.js +0 -1
package/src/components/button.ts
CHANGED
|
@@ -11,8 +11,6 @@ export interface ButtonOptions {
|
|
|
11
11
|
icon?: Slot;
|
|
12
12
|
/** Click handler. */
|
|
13
13
|
click?: (event: Event) => void;
|
|
14
|
-
/** Size. Defaults to `"md"`. */
|
|
15
|
-
size?: "sm" | "md" | "lg";
|
|
16
14
|
/** Disables the button. */
|
|
17
15
|
disabled?: boolean;
|
|
18
16
|
/** Native button behaviour. Defaults to `"button"`. */
|
|
@@ -25,6 +23,10 @@ export interface ButtonOptions {
|
|
|
25
23
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
26
24
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
27
25
|
* `".neutral .outlined"`. Defaults to a filled `.primary` surface.
|
|
26
|
+
*
|
|
27
|
+
* Size is set here too, with `.small` or `.large` (medium is the default and
|
|
28
|
+
* needs no class), e.g. `".danger .small"`. A `.small`/`.large` parent (such
|
|
29
|
+
* as a {@link buttonGroup}) also sizes its buttons, so you can set it once.
|
|
28
30
|
*/
|
|
29
31
|
attrs?: Attributes;
|
|
30
32
|
}
|
|
@@ -38,16 +40,37 @@ A.insertGlobalCss({
|
|
|
38
40
|
"display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
39
41
|
"font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
40
42
|
"border: 1px solid $s-border; r: $s-radius; padding: 0.5em 1em; " +
|
|
41
|
-
"transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
|
|
43
|
+
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
42
44
|
"&:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus;",
|
|
43
45
|
"&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
|
|
44
|
-
|
|
46
|
+
// Every button lifts a little toward the cursor on hover (the transform is in
|
|
47
|
+
// the transition list above). The filled `.gradient` CTA below layers a deeper
|
|
48
|
+
// shadow on top of the same lift, so it still reads as the signature action.
|
|
49
|
+
"&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
|
|
45
50
|
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
// A filled `.gradient` button (the default) is the app's signature call to
|
|
52
|
+
// action: a borderless gradient with a soft glow that lifts on hover. The
|
|
53
|
+
// gradient fill itself comes from the `.s-s.gradient` surface rule in theme.ts.
|
|
54
|
+
// No border: a filled gradient reads as one solid shape. Dropping the border
|
|
55
|
+
// (rather than making it transparent) also sidesteps a Chromium artifact where
|
|
56
|
+
// a gradient clipped to a transparent rounded border fringes the edge with the
|
|
57
|
+
// gradient's far colour.
|
|
58
|
+
"&.gradient:not(.tonal):not(.outlined)": "border:0 box-shadow: $s-glow;",
|
|
59
|
+
"&.gradient:not(.tonal):not(.outlined):hover":
|
|
60
|
+
"filter: brightness(1.06); box-shadow: 0 10px 28px color-mix(in srgb, $s-primary 42%, transparent); transform: translateY(-1px);",
|
|
61
|
+
// Subtle press feedback.
|
|
62
|
+
"&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
|
|
63
|
+
// Size: set on the button itself, or inherited from a `.small`/`.large`
|
|
64
|
+
// parent (e.g. a buttonGroup), so a container can size all its buttons at once.
|
|
65
|
+
"&.small, .small > &": "padding: 0.32em 0.7em; font-size:0.85em",
|
|
66
|
+
"&.large, .large > &": "padding: 0.66em 1.3em; font-size:1.1em",
|
|
48
67
|
},
|
|
49
68
|
});
|
|
50
69
|
|
|
70
|
+
// Surface-role classes a caller may pass in `attrs`. When one is present we skip
|
|
71
|
+
// the default `.gradient` base so the two roles don't stack on one element.
|
|
72
|
+
const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning|base|panel|raised)(\.|\s|$)/;
|
|
73
|
+
|
|
51
74
|
/**
|
|
52
75
|
* A button. Always carries at least a visible border so its affordance is
|
|
53
76
|
* obvious at a glance.
|
|
@@ -55,6 +78,13 @@ A.insertGlobalCss({
|
|
|
55
78
|
* Shortcut: pass a string to use it as the label, or a function for custom
|
|
56
79
|
* content.
|
|
57
80
|
*
|
|
81
|
+
* **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
|
|
82
|
+
* startup) for SPA-style navigation without manual click handlers:
|
|
83
|
+
* ```ts
|
|
84
|
+
* interceptLinks(); // once at root
|
|
85
|
+
* S.button({ href: "/dashboard", text: "Dashboard" }); // navigates via router
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
58
88
|
* @example
|
|
59
89
|
* ```ts
|
|
60
90
|
* S.button({ text: "Save", click: save });
|
|
@@ -67,11 +97,13 @@ export function button(opts: ButtonOptions | string | Content = {}): void {
|
|
|
67
97
|
const o: ButtonOptions = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
|
|
68
98
|
|
|
69
99
|
const tag = o.href != null ? "a" : "button";
|
|
70
|
-
const sizeCls = o.size != null ? `.s-${o.size}` : "";
|
|
71
100
|
|
|
72
|
-
// A filled `.
|
|
73
|
-
//
|
|
74
|
-
|
|
101
|
+
// A filled `.gradient` surface by default — the signature CTA. If the caller's
|
|
102
|
+
// `attrs` already names a surface role we omit the default, so `.danger`,
|
|
103
|
+
// `.neutral .outlined`, etc. fully take over (rather than stacking two roles).
|
|
104
|
+
// A bare variant/size (`.outlined`, `.small`) keeps the gradient base.
|
|
105
|
+
const role = o.attrs && ROLE_CLASS.test(o.attrs) ? "" : ".gradient";
|
|
106
|
+
A(`${tag}.s-btn.s-s${role}`, o.attrs, () => {
|
|
75
107
|
if (o.href != null) {
|
|
76
108
|
A(`href=${o.href} role=button`);
|
|
77
109
|
if (o.disabled) A("aria-disabled=true");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Bindable, type Attributes } from "../core.js";
|
|
2
|
+
import { type Bindable, type Attributes, type Slot } from "../core.js";
|
|
3
3
|
import { buttonGroup } from "./buttonGroup.js";
|
|
4
4
|
|
|
5
5
|
/** Options for {@link buttonChooser}. */
|
|
@@ -8,9 +8,10 @@ export interface ButtonChooserOptions {
|
|
|
8
8
|
attrs?: Attributes;
|
|
9
9
|
/**
|
|
10
10
|
* The options to display, as a plain object mapping id → display label.
|
|
11
|
-
* Buttons appear in insertion order.
|
|
11
|
+
* Buttons appear in insertion order. A label may be a plain (rich-text)
|
|
12
|
+
* string, or a draw-function for custom content such as an icon.
|
|
12
13
|
*/
|
|
13
|
-
options: Record<string,
|
|
14
|
+
options: Record<string, Slot>;
|
|
14
15
|
/**
|
|
15
16
|
* Two-way binding for the selected id, or `null` when nothing is selected.
|
|
16
17
|
* Use an `A.proxy` or `A.ref`.
|
|
@@ -21,8 +22,6 @@ export interface ButtonChooserOptions {
|
|
|
21
22
|
* `bind.value` to `null`. Useful for "none / auto" states.
|
|
22
23
|
*/
|
|
23
24
|
allowDeselect?: boolean;
|
|
24
|
-
/** Button size. Defaults to `"md"`. */
|
|
25
|
-
size?: "sm" | "md" | "lg";
|
|
26
25
|
/** Name attribute for the hidden `<input>`, enabling form submission. */
|
|
27
26
|
name?: string;
|
|
28
27
|
}
|
|
@@ -49,8 +48,8 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
|
|
|
49
48
|
buttonGroup({
|
|
50
49
|
attrs: opts.attrs,
|
|
51
50
|
buttons: Object.entries(opts.options).map(([id, label]) => ({
|
|
52
|
-
text: label,
|
|
53
|
-
|
|
51
|
+
text: typeof label === "string" ? label : undefined,
|
|
52
|
+
content: typeof label === "function" ? label : undefined,
|
|
54
53
|
attrs: selected === id ? ".primary" : ".neutral .outlined",
|
|
55
54
|
click: () => {
|
|
56
55
|
opts.bind.value = (opts.allowDeselect && selected === id) ? null : id;
|
|
@@ -24,8 +24,6 @@ A.insertGlobalCss({
|
|
|
24
24
|
"&.s-spaced": "gap:$2 flex-wrap:wrap",
|
|
25
25
|
"&.s-vertical": "flex-direction:column",
|
|
26
26
|
"&.s-attached": "gap:0",
|
|
27
|
-
// When attached, collapse the shared border and square off the touching
|
|
28
|
-
// corners, keeping only the outer ends of the group rounded.
|
|
29
27
|
"&.s-attached:not(.s-vertical) > .s-btn:not(:first-child)": "margin-left:-1px",
|
|
30
28
|
"&.s-attached:not(.s-vertical) > .s-btn:not(:first-child):not(:last-child)": "r:0",
|
|
31
29
|
"&.s-attached:not(.s-vertical) > .s-btn:first-child:not(:last-child)": "border-top-right-radius:0 border-bottom-right-radius:0",
|
|
@@ -34,7 +32,6 @@ A.insertGlobalCss({
|
|
|
34
32
|
"&.s-attached.s-vertical > .s-btn:not(:first-child):not(:last-child)": "r:0",
|
|
35
33
|
"&.s-attached.s-vertical > .s-btn:first-child:not(:last-child)": "border-bottom-left-radius:0 border-bottom-right-radius:0",
|
|
36
34
|
"&.s-attached.s-vertical > .s-btn:last-child:not(:first-child)": "border-top-left-radius:0 border-top-right-radius:0",
|
|
37
|
-
// Keep the hovered/focused button's border above its neighbours.
|
|
38
35
|
"&.s-attached > .s-btn:hover, &.s-attached > .s-btn:focus-visible": "z-index:1",
|
|
39
36
|
},
|
|
40
37
|
});
|
package/src/components/field.ts
CHANGED
|
@@ -38,12 +38,9 @@ A.insertGlobalCss({
|
|
|
38
38
|
"&": "display:flex flex-direction:column gap:$1",
|
|
39
39
|
"> label": "font-weight:600 font-size:0.9em fg:$s-fg user-select:none",
|
|
40
40
|
},
|
|
41
|
-
// Shared, reusable bits (also used by checkbox & autocomplete).
|
|
42
41
|
".s-req": "fg:$s-danger margin-left:2px",
|
|
43
42
|
".s-help": "font-size:0.82em fg:$s-fg-muted",
|
|
44
43
|
".s-error": "font-size:0.82em fg:$s-danger",
|
|
45
|
-
// Shared look for text-like controls: a panel fill with a contextual border,
|
|
46
|
-
// the brand accent for focus, and the semantic danger ink when invalid.
|
|
47
44
|
".s-input": {
|
|
48
45
|
"&": "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;",
|
|
49
46
|
"&:hover:not(:disabled)": "border-color:$s-border-strong",
|
package/src/components/main.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
|
+
import { type MenuOptions, menuButton, drawMenu } from "./menu.js";
|
|
3
4
|
|
|
4
5
|
/** Options for {@link main}. */
|
|
5
6
|
export interface MainOptions {
|
|
@@ -18,97 +19,252 @@ export interface MainOptions {
|
|
|
18
19
|
/** Footer content, pinned below the scroll area. */
|
|
19
20
|
footer?: Slot;
|
|
20
21
|
/**
|
|
21
|
-
* Max
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Max width for the page's *content*, e.g. `"60rem"`. The header and footer
|
|
23
|
+
* backgrounds still span the full shell width, but their contents — and the
|
|
24
|
+
* sidebar + separator + content trio (or just the content when there's no
|
|
25
|
+
* sidebar) — cap to this width and centre horizontally. When unset, everything
|
|
26
|
+
* fills the available width. Either way the content shares the page surface —
|
|
27
|
+
* it is not boxed.
|
|
24
28
|
*/
|
|
25
29
|
maxWidth?: string;
|
|
26
|
-
/** Aberdeen attr/style string applied to the content
|
|
30
|
+
/** Aberdeen attr/style string applied to the content area. */
|
|
27
31
|
contentAttrs?: Attributes;
|
|
28
32
|
/** Aberdeen attr/style string applied to the top bar. */
|
|
29
33
|
topbarAttrs?: Attributes;
|
|
34
|
+
/**
|
|
35
|
+
* Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
|
|
36
|
+
* mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
|
|
37
|
+
* collapses to button mode when the shell is too narrow.
|
|
38
|
+
*/
|
|
39
|
+
nav?: MenuOptions;
|
|
40
|
+
/**
|
|
41
|
+
* Where to render the nav. Defaults to `"left"`.
|
|
42
|
+
* - `"left"` / `"right"`: sidebar next to the content area; collapses to a
|
|
43
|
+
* button+dropdown in the top bar when the shell width drops below 640 px.
|
|
44
|
+
* - `"button"`: always a button+dropdown, never a sidebar.
|
|
45
|
+
*/
|
|
46
|
+
navPosition?: "left" | "right" | "button";
|
|
47
|
+
/** Aberdeen attr/style string applied to the sidebar nav panel. */
|
|
48
|
+
navAttrs?: Attributes;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
A.insertGlobalCss({
|
|
33
52
|
".s-main": {
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"> header
|
|
53
|
+
// container-type so @container queries below can respond to shell width.
|
|
54
|
+
"&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
|
|
55
|
+
// Header/footer stretch their background the full shell width; their inner
|
|
56
|
+
// `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
|
|
57
|
+
"> header": "border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
|
|
58
|
+
"> footer": "border-top: 1px solid $s-border; fg:$s-fg-muted",
|
|
59
|
+
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
60
|
+
"> header .s-header-icon": "display:flex align-items:center font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
39
61
|
"> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
|
|
40
|
-
"> header .s-title": "font-weight:
|
|
62
|
+
"> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
|
|
41
63
|
"> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
42
64
|
"> header .s-menu": "display:flex align-items:center gap:$2",
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
65
|
+
// Body holds sidebar + separator + <main> side by side (only used in sidebar
|
|
66
|
+
// nav mode). It centres `.s-body-inner`, which caps the trio to maxWidth.
|
|
67
|
+
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
|
|
68
|
+
".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
|
|
69
|
+
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
70
|
+
"&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
|
|
71
|
+
// A vertical hairline between sidebar and content, fading out at both ends —
|
|
72
|
+
// the vertical sibling of the menu's `hr.s-menu-sep`.
|
|
73
|
+
".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
|
|
74
|
+
// Without a sidebar, <main> is a direct child; with one it lives in .s-body.
|
|
75
|
+
"> main, .s-body main": "flex:1 overflow-y:auto display:flex flex-direction:column",
|
|
76
|
+
// The content area fills the scroll region with comfortable padding. Without a
|
|
77
|
+
// sidebar it caps its own width to maxWidth and centres (applied inline in
|
|
78
|
+
// drawMainContent); with one, `.s-body-inner` does the capping for the trio.
|
|
79
|
+
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
80
|
+
"> main > .s-content, .s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
81
|
+
// When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
|
|
82
|
+
// toggled from JS by watchVerticalOverflow), inset it from the shell edge by
|
|
83
|
+
// $3 so the bar's right edge lines up with the header/footer content (which
|
|
84
|
+
// sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
|
|
85
|
+
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
86
|
+
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
87
|
+
"> main.s-scroll-y, .s-body main.s-scroll-y": "margin-right:$3",
|
|
88
|
+
},
|
|
89
|
+
// Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
|
|
90
|
+
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
91
|
+
// dropdown stay visually identical.
|
|
92
|
+
// Borderless and transparent so the page's aurora shows through — an airy,
|
|
93
|
+
// floating sidebar whose only chrome is the active item's gradient pill.
|
|
94
|
+
".s-nav-panel": {
|
|
95
|
+
// Extra horizontal padding leaves room for the active pill's glow, which the
|
|
96
|
+
// vertical scroll (overflow-y:auto, which also clips overflow-x) would
|
|
97
|
+
// otherwise cut off at the panel edges.
|
|
98
|
+
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1 background:transparent",
|
|
99
|
+
},
|
|
100
|
+
// In button-only mode (or always-button navPosition), hide the sidebar and
|
|
101
|
+
// show the trigger. In sidebar mode, show the panel and hide the trigger.
|
|
102
|
+
// CSS @container queries handle the responsive collapse automatically.
|
|
103
|
+
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
|
|
104
|
+
".s-main.s-nav-btn-only .s-nav-panel": "display:none",
|
|
105
|
+
".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
|
|
106
|
+
// Collapse sidebar → button when shell is narrow.
|
|
107
|
+
"@container (max-width: 640px)": {
|
|
108
|
+
".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
|
|
109
|
+
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
|
|
110
|
+
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
111
|
+
// to negate the content padding and drop the rounded corners.
|
|
112
|
+
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
48
113
|
},
|
|
49
114
|
});
|
|
50
115
|
|
|
51
116
|
/**
|
|
52
117
|
* An application shell that wires up the things almost every app needs: a sticky
|
|
53
118
|
* top bar (icon, title, subtitle, action menu), a scrollable content area, and a
|
|
54
|
-
* footer. With {@link MainOptions.maxWidth} the content
|
|
55
|
-
*
|
|
56
|
-
*
|
|
119
|
+
* footer. With {@link MainOptions.maxWidth} the content area is centred and its
|
|
120
|
+
* width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
|
|
121
|
+
* menu button below 640 px, or always a button with `navPosition: "button"`).
|
|
57
122
|
*
|
|
58
123
|
* @example
|
|
59
124
|
* ```ts
|
|
60
125
|
* S.main({
|
|
61
126
|
* icon: "✦",
|
|
62
127
|
* title: "Staffa Demo",
|
|
63
|
-
* subtitle: "Component playground",
|
|
64
128
|
* maxWidth: "56rem",
|
|
65
|
-
*
|
|
129
|
+
* nav: {
|
|
130
|
+
* items: [
|
|
131
|
+
* { label: "Home", icon: () => A("#🏠"), href: "/" },
|
|
132
|
+
* { label: "Settings", href: "/settings" },
|
|
133
|
+
* ],
|
|
134
|
+
* },
|
|
135
|
+
* navPosition: "left",
|
|
136
|
+
* menu: () => S.button({ text: "New", attrs: ".small" }),
|
|
66
137
|
* content: () => drawPage(),
|
|
67
138
|
* footer: "© 2026",
|
|
68
139
|
* });
|
|
69
140
|
* ```
|
|
70
141
|
*/
|
|
71
142
|
export function main(opts: MainOptions = {}): void {
|
|
72
|
-
|
|
73
|
-
|
|
143
|
+
const nav = opts.nav;
|
|
144
|
+
const navPos = opts.navPosition ?? "left";
|
|
145
|
+
const hasNav = nav != null && nav.items.length > 0;
|
|
146
|
+
const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
|
|
147
|
+
|
|
148
|
+
A(`div.s-main.s-s.base${navCls}`, opts.attrs, () => {
|
|
149
|
+
// Top bar.
|
|
74
150
|
A(() => {
|
|
75
|
-
const hasBar =
|
|
151
|
+
const hasBar =
|
|
152
|
+
opts.title != null ||
|
|
153
|
+
opts.subtitle != null ||
|
|
154
|
+
opts.icon != null ||
|
|
155
|
+
opts.menu != null ||
|
|
156
|
+
hasNav;
|
|
76
157
|
if (!hasBar) return;
|
|
77
158
|
A("header.s-s.raised", opts.topbarAttrs, () => {
|
|
78
|
-
A(() => {
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
A("div.s-titles", () => {
|
|
159
|
+
A("div.s-bar", () => {
|
|
160
|
+
// Cap the bar's content to maxWidth and centre it within the full-width header.
|
|
82
161
|
A(() => {
|
|
83
|
-
if (opts.
|
|
162
|
+
if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
|
|
84
163
|
});
|
|
164
|
+
// Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
|
|
85
165
|
A(() => {
|
|
86
|
-
if (
|
|
166
|
+
if (!hasNav) return;
|
|
167
|
+
// .s-nav-trigger: CSS toggles display based on sidebar visibility.
|
|
168
|
+
A("div.s-nav-trigger", () => {
|
|
169
|
+
menuButton({
|
|
170
|
+
...nav,
|
|
171
|
+
button: {
|
|
172
|
+
icon: () => A("span aria-hidden=true #☰"),
|
|
173
|
+
ariaLabel: "Open navigation",
|
|
174
|
+
attrs: ".neutral .outlined .small",
|
|
175
|
+
...nav.button,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
A(() => {
|
|
182
|
+
if (opts.icon != null) A("div.s-header-icon", () => drawSlot(opts.icon));
|
|
183
|
+
});
|
|
184
|
+
A("div.s-titles", () => {
|
|
185
|
+
A(() => {
|
|
186
|
+
if (opts.title != null) A("div.s-title", () => drawSlot(opts.title));
|
|
187
|
+
});
|
|
188
|
+
A(() => {
|
|
189
|
+
if (opts.subtitle != null) A("div.s-subtitle", () => drawSlot(opts.subtitle));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
A(() => {
|
|
193
|
+
if (opts.menu) A("div.s-menu", () => opts.menu?.());
|
|
87
194
|
});
|
|
88
|
-
});
|
|
89
|
-
A(() => {
|
|
90
|
-
if (opts.menu) A("div.s-menu", () => opts.menu?.());
|
|
91
195
|
});
|
|
92
196
|
});
|
|
93
197
|
});
|
|
94
198
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
199
|
+
// Body — wraps sidebar + separator + main when nav is in sidebar mode. The
|
|
200
|
+
// trio together caps to maxWidth (via .s-body-inner); main fills the rest.
|
|
201
|
+
if (hasNav && navPos !== "button") {
|
|
202
|
+
A("div.s-body", () => {
|
|
203
|
+
A("div.s-body-inner", () => {
|
|
204
|
+
A(() => {
|
|
205
|
+
if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
|
|
206
|
+
});
|
|
207
|
+
A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
208
|
+
drawMenu(nav.items);
|
|
209
|
+
});
|
|
210
|
+
A("div.s-nav-sep aria-hidden=true");
|
|
211
|
+
drawMainContent(opts, false);
|
|
104
212
|
});
|
|
105
|
-
if (opts.content) opts.content();
|
|
106
213
|
});
|
|
107
|
-
}
|
|
214
|
+
} else {
|
|
215
|
+
drawMainContent(opts, true);
|
|
216
|
+
}
|
|
108
217
|
|
|
109
|
-
// Footer.
|
|
218
|
+
// Footer — full-width background, content centred to maxWidth via .s-bar.
|
|
110
219
|
A(() => {
|
|
111
|
-
if (opts.footer != null)
|
|
220
|
+
if (opts.footer != null) {
|
|
221
|
+
A("footer", () => {
|
|
222
|
+
A("div.s-bar", () => {
|
|
223
|
+
A(() => {
|
|
224
|
+
if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
|
|
225
|
+
});
|
|
226
|
+
drawSlot(opts.footer);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
112
230
|
});
|
|
113
231
|
});
|
|
114
232
|
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Draw the scrollable `<main>` + content area. When `capWidth` is true (no
|
|
236
|
+
* sidebar), the content caps its own width to maxWidth and centres; in sidebar
|
|
237
|
+
* mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
|
|
238
|
+
*/
|
|
239
|
+
function drawMainContent(opts: MainOptions, capWidth: boolean): void {
|
|
240
|
+
const mainEl = A("main", () => {
|
|
241
|
+
A("div.s-content", opts.contentAttrs, () => {
|
|
242
|
+
if (capWidth) {
|
|
243
|
+
A(() => {
|
|
244
|
+
if (opts.maxWidth != null) A("margin-inline:auto max-width:", opts.maxWidth);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (opts.content) opts.content();
|
|
248
|
+
});
|
|
249
|
+
}) as HTMLElement;
|
|
250
|
+
watchVerticalOverflow(mainEl);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Toggle the `.s-scroll-y` class on `el` whenever a vertical scrollbar is eating
|
|
255
|
+
* into its width, so CSS can inset the bar from the shell edge (see the
|
|
256
|
+
* `.s-scroll-y` rule above). We key on `offsetWidth > clientWidth` — a
|
|
257
|
+
* *space-consuming* scrollbar — rather than on content overflow, so overlay
|
|
258
|
+
* scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
|
|
259
|
+
* A `ResizeObserver` watches both the viewport and its content, so the class
|
|
260
|
+
* tracks live content/layout changes; it's disconnected when the scope tears down.
|
|
261
|
+
*/
|
|
262
|
+
function watchVerticalOverflow(el: HTMLElement): void {
|
|
263
|
+
if (typeof ResizeObserver === "undefined") return; // No-op outside the browser.
|
|
264
|
+
const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
|
|
265
|
+
const ro = new ResizeObserver(update);
|
|
266
|
+
ro.observe(el);
|
|
267
|
+
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
|
268
|
+
update();
|
|
269
|
+
A.clean(() => ro.disconnect());
|
|
270
|
+
}
|