staffa 0.3.0 → 0.4.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 +35 -2
- package/dist/components/box.d.ts +2 -2
- package/dist/components/box.js +3 -4
- package/dist/components/button.d.ts +9 -11
- package/dist/components/button.js +16 -18
- package/dist/components/buttonChooser.d.ts +5 -5
- package/dist/components/buttonChooser.js +7 -5
- package/dist/components/buttonGroup.js +2 -2
- package/dist/components/dialog.d.ts +2 -2
- package/dist/components/dialog.js +10 -10
- package/dist/components/form.d.ts +4 -4
- package/dist/components/form.js +6 -6
- package/dist/components/main.d.ts +5 -5
- package/dist/components/main.js +28 -40
- package/dist/components/menu.d.ts +3 -3
- package/dist/components/menu.js +8 -6
- package/dist/components/tabs.d.ts +3 -3
- package/dist/components/tabs.js +1 -1
- package/dist/components/toast.d.ts +1 -3
- package/dist/components/toast.js +2 -2
- package/dist/components/tooltip.js +2 -2
- package/dist/core.d.ts +10 -4
- package/dist/core.js +13 -0
- package/dist/index.d.ts +19 -65
- package/dist/index.js +18 -48
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +0 -7
- package/dist/theme.js +37 -14
- package/package.json +9 -2
- package/src/components/box.ts +5 -5
- package/src/components/button.ts +21 -22
- package/src/components/buttonChooser.ts +10 -8
- package/src/components/buttonGroup.ts +2 -1
- package/src/components/dialog.ts +10 -10
- package/src/components/form.ts +8 -8
- package/src/components/main.ts +31 -40
- package/src/components/menu.ts +9 -7
- package/src/components/tabs.ts +4 -4
- package/src/components/toast.ts +3 -5
- package/src/components/tooltip.ts +2 -2
- package/src/core.ts +16 -5
- package/src/index.ts +21 -76
- package/src/theme.ts +36 -22
package/dist/theme.js
CHANGED
|
@@ -38,7 +38,9 @@ import A from "aberdeen";
|
|
|
38
38
|
* | `--s-fg-faint` | placeholders, disabled |
|
|
39
39
|
* | `--s-border` / `--s-border-strong` | borders |
|
|
40
40
|
* | `--s-accent` | brand "pop" colour (active indicators, etc.) |
|
|
41
|
-
* | `--s-gradient` | primary→secondary brand sweep (mark,
|
|
41
|
+
* | `--s-gradient` | full primary→secondary brand sweep (mark, active nav) |
|
|
42
|
+
* | `--s-gradient-surface` | compressed sweep for filled gradient surfaces (buttons) |
|
|
43
|
+
* | `--s-tint` | brand mid colour; the hue the neutral greys lean toward |
|
|
42
44
|
* | `--s-glow` | soft coloured shadow for lit brand elements |
|
|
43
45
|
* | `--s-link` / `--s-focus` | link & focus-ring colours |
|
|
44
46
|
* | `--s-radius` / `--s-radius-lg` / `--s-shadow` | shape tokens |
|
|
@@ -134,12 +136,22 @@ export function getDarkMode(allowAuto = false) {
|
|
|
134
136
|
// names (--s-primary/-danger/-success/-warning) double as semantic *ink*
|
|
135
137
|
// colours, legible as text/borders on neutral surfaces.
|
|
136
138
|
// ---------------------------------------------------------------------------
|
|
139
|
+
// The neutral fills/inks aren't hard-coded greys: each one mixes a small dose
|
|
140
|
+
// of `--s-tint` (the brand's mid colour, defined in the static block below)
|
|
141
|
+
// into a true-grey base. Re-skin the brand and every "grey" — page, panels,
|
|
142
|
+
// ink, the neutral fill — drifts subtly toward the new brand hue, light and
|
|
143
|
+
// dark alike. The percentages are deliberately small: a tint you'd only spot
|
|
144
|
+
// in a side-by-side, never a colour cast.
|
|
137
145
|
A(() => {
|
|
138
146
|
if (getDarkMode()) {
|
|
139
147
|
A.insertGlobalCss({
|
|
140
148
|
":root": "--s-primary:#8b7bff --s-secondary:#ef7fd0 --s-danger:#ff6b6b --s-success:#46d39a --s-warning:#fbbf24 " +
|
|
141
|
-
"--s-neutral
|
|
142
|
-
"--s-
|
|
149
|
+
"--s-neutral: color-mix(in oklab, #3d4047, $s-tint 14%); " +
|
|
150
|
+
"--s-page: color-mix(in oklab, #0e0f12, $s-tint 5%); " +
|
|
151
|
+
"--s-panel: color-mix(in oklab, #17181c, $s-tint 6%); " +
|
|
152
|
+
"--s-raised: color-mix(in oklab, #212327, $s-tint 8%); " +
|
|
153
|
+
"--s-ink: color-mix(in oklab, #e9eaec, $s-tint 8%); " +
|
|
154
|
+
"--s-on-accent:#0c0a14 --s-focus: color-mix(in srgb, $s-primary 45%, transparent); " +
|
|
143
155
|
"--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 34px rgba(0,0,0,0.5);",
|
|
144
156
|
// Contextual link, restored across the neutral group (see static block).
|
|
145
157
|
":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#6db3ff",
|
|
@@ -148,8 +160,12 @@ A(() => {
|
|
|
148
160
|
else {
|
|
149
161
|
A.insertGlobalCss({
|
|
150
162
|
":root": "--s-primary:#6c5ce7 --s-secondary:#d6459e --s-danger:#e23b3b --s-success:#1f9d6b --s-warning:#d97706 " +
|
|
151
|
-
"--s-neutral
|
|
152
|
-
"--s-
|
|
163
|
+
"--s-neutral: color-mix(in oklab, #c9cbd0, $s-tint 14%); " +
|
|
164
|
+
"--s-page: color-mix(in oklab, #f3f4f6, $s-tint 5%); " +
|
|
165
|
+
"--s-panel: color-mix(in oklab, #ffffff, $s-tint 2%); " +
|
|
166
|
+
"--s-raised: color-mix(in oklab, #edeef0, $s-tint 7%); " +
|
|
167
|
+
"--s-ink: color-mix(in oklab, #1d1f24, $s-tint 7%); " +
|
|
168
|
+
"--s-on-accent:#0c0a14 --s-focus: color-mix(in srgb, $s-primary 35%, transparent); " +
|
|
153
169
|
"--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 30px rgba(20,24,40,0.13);",
|
|
154
170
|
":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#2563eb",
|
|
155
171
|
});
|
|
@@ -174,12 +190,18 @@ A.setSpacingCssVars();
|
|
|
174
190
|
A.insertGlobalCss({
|
|
175
191
|
// Derived brand tokens. These only reference the per-mode palette colours, so
|
|
176
192
|
// they're defined once here and track the active mode (and any re-skin):
|
|
177
|
-
// `--s-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
193
|
+
// `--s-tint` is the brand's mid colour, the hue the neutral greys above lean
|
|
194
|
+
// toward; `--s-gradient` is the full primary→secondary sweep used where the
|
|
195
|
+
// brand should *show* (the headline mark, the active nav pill, tab edges);
|
|
196
|
+
// `--s-gradient-surface` is a compressed, near-vertical cut of that same sweep
|
|
197
|
+
// for *filled* gradient surfaces (buttons): it reads as one rich colour with
|
|
198
|
+
// depth rather than a two-colour banner; `--s-glow` is a soft coloured shadow
|
|
199
|
+
// that makes lit brand elements feel raised; `--s-page-bg` is a faint
|
|
180
200
|
// twin-corner aurora wash painted on the page surface.
|
|
181
|
-
":root": "--s-
|
|
182
|
-
"--s-
|
|
201
|
+
":root": "--s-tint: color-mix(in oklab, $s-primary, $s-secondary); " +
|
|
202
|
+
"--s-gradient: linear-gradient(135deg, $s-primary, $s-secondary); " +
|
|
203
|
+
"--s-gradient-surface: linear-gradient(170deg, color-mix(in oklab, $s-primary 85%, $s-secondary), color-mix(in oklab, $s-primary 30%, $s-secondary)); " +
|
|
204
|
+
"--s-glow: 0 5px 16px color-mix(in srgb, $s-primary 26%, transparent); " +
|
|
183
205
|
"--s-page-bg: radial-gradient(120% 80% at 100% 0%, color-mix(in oklab, $s-secondary, transparent 86%), transparent 56%), radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, $s-primary, transparent 87%), transparent 56%), $s-page;",
|
|
184
206
|
// Level/role modifier → anchors. Levels (neutral elevations) use the shared
|
|
185
207
|
// ink; accent roles use the on-accent ink over their named fill. The page
|
|
@@ -231,10 +253,11 @@ A.insertGlobalCss({
|
|
|
231
253
|
// overrides a component's default `.tonal`/`.outlined` (resetting both the
|
|
232
254
|
// anchors and the painted background).
|
|
233
255
|
".s-s.filled": "--s-fg:$s-a --s-bg:$s-b background:$s-bg;",
|
|
234
|
-
// Paint the brand sweep for a filled `.gradient` surface
|
|
235
|
-
//
|
|
236
|
-
// variants keep their
|
|
237
|
-
|
|
256
|
+
// Paint the brand sweep for a filled `.gradient` surface — the compressed
|
|
257
|
+
// surface cut, not the full banner sweep. Sits after the variant rules and is
|
|
258
|
+
// keyed on `:not(.tonal):not(.outlined)`, so those variants keep their
|
|
259
|
+
// solid-primary tint/edge.
|
|
260
|
+
".s-s.gradient:not(.tonal):not(.outlined)": "background: $s-gradient-surface;",
|
|
238
261
|
// Contextual accent: the brand pop colour on neutral surfaces. Declared on the
|
|
239
262
|
// whole neutral group so re-entering a neutral surface under a coloured one
|
|
240
263
|
// restores it. (--s-link gets the same treatment in the reactive block, where
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staffa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "An opinionated component library for the Aberdeen reactive UI library.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build:icons": "node scripts/generate-icons.mjs",
|
|
27
27
|
"build": "npm run build:icons && tsc && tsc -p demo && esbuild src/index.ts --bundle --external:aberdeen --minify --format=esm --outfile=dist/staffa.esm.js",
|
|
28
|
+
"build:skill": "rm -rf skill ; mkdir skill && readme-tsdoc --create src/index.ts --file skill/REFERENCE.md --split && awk '/^@@include /{while((getline line < $2)>0) print line; close($2); next} 1' skill-template.md > skill/SKILL.md && rm skill/REFERENCE.md",
|
|
28
29
|
"typecheck": "tsc --noEmit && tsc -p demo --noEmit",
|
|
29
30
|
"smoke": "tsc && node smoke.mjs",
|
|
30
|
-
"prepublish": "npm run build"
|
|
31
|
+
"prepublish": "npm run build && shotest test && npm run build:skill",
|
|
32
|
+
"test": "shotest test"
|
|
31
33
|
},
|
|
32
34
|
"peerDependencies": {
|
|
33
35
|
"aberdeen": "^1.17.1"
|
|
@@ -35,8 +37,13 @@
|
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"aberdeen": "^1.17.1",
|
|
37
39
|
"esbuild": "^0.28.0",
|
|
40
|
+
"http-server": "^14.1.1",
|
|
38
41
|
"jsdom": "^29.1.1",
|
|
39
42
|
"lucide-static": "^1.17.0",
|
|
43
|
+
"readme-tsdoc": "^1.2.1",
|
|
40
44
|
"typescript": "^5.9.3"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"shotest": "^1.6.1"
|
|
41
48
|
}
|
|
42
49
|
}
|
package/src/components/box.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type
|
|
2
|
+
import { type ContentOptions, type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
3
|
|
|
4
4
|
/** Options for {@link box}. */
|
|
5
5
|
export interface BoxOptions extends ContentOptions {
|
|
@@ -24,7 +24,7 @@ A.insertGlobalCss({
|
|
|
24
24
|
"&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
|
|
25
25
|
"&:not(:first-child)": "margin-top: $3",
|
|
26
26
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
|
|
27
|
-
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
|
|
27
|
+
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
|
|
28
28
|
"> div": "p:$3 gap:$3",
|
|
29
29
|
},
|
|
30
30
|
});
|
|
@@ -47,8 +47,8 @@ A.insertGlobalCss({
|
|
|
47
47
|
* S.box(() => A("p#Just some content")); // shorthand
|
|
48
48
|
* ```
|
|
49
49
|
*/
|
|
50
|
-
export function box(opts: BoxOptions |
|
|
51
|
-
const o: BoxOptions = typeof opts === "function" ? { content: opts } : opts;
|
|
50
|
+
export function box(opts: BoxOptions | Slot = {}): void {
|
|
51
|
+
const o: BoxOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
52
52
|
|
|
53
53
|
A("section.s-box.s-s.panel", o.attrs, () => {
|
|
54
54
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
@@ -58,7 +58,7 @@ export function box(opts: BoxOptions | Content = {}): void {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
A("div", o.contentAttrs, () => {
|
|
61
|
-
|
|
61
|
+
drawSlot(o.content);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
A(() => {
|
package/src/components/button.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type
|
|
2
|
+
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
3
|
|
|
4
4
|
/** Options for {@link button}. */
|
|
5
5
|
export interface ButtonOptions {
|
|
6
|
-
/** Button
|
|
7
|
-
|
|
8
|
-
/** Custom content (overrides {@link ButtonOptions.text | text}). */
|
|
9
|
-
content?: Content;
|
|
6
|
+
/** Button content: a string for plain text, or a function for custom markup. */
|
|
7
|
+
content?: Slot;
|
|
10
8
|
/** Leading icon/adornment, drawn before the label. */
|
|
11
9
|
icon?: Slot;
|
|
12
10
|
/** Click handler. */
|
|
@@ -49,15 +47,17 @@ A.insertGlobalCss({
|
|
|
49
47
|
"&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
|
|
50
48
|
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
|
|
51
49
|
// A filled `.gradient` button (the default) is the app's signature call to
|
|
52
|
-
// action: a borderless gradient with a
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// gradient
|
|
58
|
-
|
|
50
|
+
// action: a borderless gradient with a hairline top highlight (a hint of
|
|
51
|
+
// top-lighting that sells the fill as a lit, rounded shape) over a soft glow.
|
|
52
|
+
// The gradient fill itself comes from the `.s-s.gradient` surface rule in
|
|
53
|
+
// theme.ts. No border: a filled gradient reads as one solid shape. Dropping
|
|
54
|
+
// the border (rather than making it transparent) also sidesteps a Chromium
|
|
55
|
+
// artifact where a gradient clipped to a transparent rounded border fringes
|
|
56
|
+
// the edge with the gradient's far colour.
|
|
57
|
+
"&.gradient:not(.tonal):not(.outlined)":
|
|
58
|
+
"border:0 box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), $s-glow;",
|
|
59
59
|
"&.gradient:not(.tonal):not(.outlined):hover":
|
|
60
|
-
"filter: brightness(1.
|
|
60
|
+
"filter: brightness(1.05); box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), 0 7px 18px color-mix(in srgb, $s-primary 34%, transparent); transform: translateY(-1px);",
|
|
61
61
|
// Subtle press feedback.
|
|
62
62
|
"&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
|
|
63
63
|
// Size: set on the button itself, or inherited from a `.small`/`.large`
|
|
@@ -82,19 +82,19 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
|
|
|
82
82
|
* startup) for SPA-style navigation without manual click handlers:
|
|
83
83
|
* ```ts
|
|
84
84
|
* interceptLinks(); // once at root
|
|
85
|
-
* S.button({ href: "/dashboard",
|
|
85
|
+
* S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
86
86
|
* ```
|
|
87
87
|
*
|
|
88
88
|
* @example
|
|
89
89
|
* ```ts
|
|
90
|
-
* S.button({
|
|
91
|
-
* S.button({
|
|
92
|
-
* S.button("Cancel"); // shorthand for {
|
|
93
|
-
* S.button({ href: "/docs",
|
|
90
|
+
* S.button({ content: "Save", click: save });
|
|
91
|
+
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
92
|
+
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
93
|
+
* S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
|
|
94
94
|
* ```
|
|
95
95
|
*/
|
|
96
|
-
export function button(opts: ButtonOptions |
|
|
97
|
-
const o: ButtonOptions = typeof opts === "string"
|
|
96
|
+
export function button(opts: ButtonOptions | Slot = {}): void {
|
|
97
|
+
const o: ButtonOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
98
98
|
|
|
99
99
|
const tag = o.href != null ? "a" : "button";
|
|
100
100
|
|
|
@@ -115,7 +115,6 @@ export function button(opts: ButtonOptions | string | Content = {}): void {
|
|
|
115
115
|
if (o.click) A("click=", o.click);
|
|
116
116
|
|
|
117
117
|
drawSlot(o.icon);
|
|
118
|
-
|
|
119
|
-
else if (o.text != null) A("#", o.text);
|
|
118
|
+
drawSlot(o.content);
|
|
120
119
|
});
|
|
121
120
|
}
|
|
@@ -13,13 +13,13 @@ export interface ButtonChooserOptions {
|
|
|
13
13
|
*/
|
|
14
14
|
options: Record<string, Slot>;
|
|
15
15
|
/**
|
|
16
|
-
* Two-way binding for the selected id, or `
|
|
16
|
+
* Two-way binding for the selected id, or `undefined` when nothing is selected.
|
|
17
17
|
* Use an `A.proxy` or `A.ref`.
|
|
18
18
|
*/
|
|
19
|
-
bind: Bindable<string |
|
|
19
|
+
bind: Bindable<string | undefined>;
|
|
20
20
|
/**
|
|
21
21
|
* When `true`, clicking the already-selected button deselects it, setting
|
|
22
|
-
* `bind.value` to `
|
|
22
|
+
* `bind.value` to `undefined`. Useful for "none / auto" states.
|
|
23
23
|
*/
|
|
24
24
|
allowDeselect?: boolean;
|
|
25
25
|
/** Name attribute for the hidden `<input>`, enabling form submission. */
|
|
@@ -28,14 +28,14 @@ export interface ButtonChooserOptions {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* A single-selection segmented control: an attached button group where exactly
|
|
31
|
-
* one button is active at a time. Optionally allows deselecting back to `
|
|
31
|
+
* one button is active at a time. Optionally allows deselecting back to `undefined`.
|
|
32
32
|
*
|
|
33
33
|
* Renders a hidden `<input>` alongside (when `name` is set) so the selected
|
|
34
34
|
* value is included in native form submission.
|
|
35
35
|
*
|
|
36
36
|
* @example
|
|
37
37
|
* ```ts
|
|
38
|
-
* const $view = A.proxy({ value: "day" as string |
|
|
38
|
+
* const $view = A.proxy({ value: "day" as string | undefined });
|
|
39
39
|
* S.buttonChooser({
|
|
40
40
|
* options: { day: "Day", week: "Week", month: "Month" },
|
|
41
41
|
* bind: $view,
|
|
@@ -48,11 +48,13 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
|
|
|
48
48
|
buttonGroup({
|
|
49
49
|
attrs: opts.attrs,
|
|
50
50
|
buttons: Object.entries(opts.options).map(([id, label]) => ({
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
content: label,
|
|
52
|
+
// Icon-only options (draw-function labels) get the id as their
|
|
53
|
+
// accessible name; plain-text labels speak for themselves.
|
|
54
|
+
ariaLabel: typeof label === "function" ? id : undefined,
|
|
53
55
|
attrs: selected === id ? ".primary" : ".neutral .outlined",
|
|
54
56
|
click: () => {
|
|
55
|
-
opts.bind.value = (opts.allowDeselect && selected === id) ?
|
|
57
|
+
opts.bind.value = (opts.allowDeselect && selected === id) ? undefined : id;
|
|
56
58
|
},
|
|
57
59
|
})),
|
|
58
60
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import type { ContentOptions } from "../core.js";
|
|
3
|
+
import { drawSlot } from "../core.js";
|
|
3
4
|
import { type ButtonOptions, button } from "./button.js";
|
|
4
5
|
|
|
5
6
|
/** Options for {@link buttonGroup}. */
|
|
@@ -55,6 +56,6 @@ export function buttonGroup(opts: ButtonGroupOptions = {}): void {
|
|
|
55
56
|
|
|
56
57
|
A(`div.s-bgroup${cls} role=group`, opts.attrs, () => {
|
|
57
58
|
if (opts.buttons) for (const b of opts.buttons) button(b);
|
|
58
|
-
|
|
59
|
+
drawSlot(opts.content);
|
|
59
60
|
});
|
|
60
61
|
}
|
package/src/components/dialog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
2
|
+
import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
|
|
3
3
|
import { button } from "./button.js";
|
|
4
4
|
import { buttonGroup } from "./buttonGroup.js";
|
|
5
5
|
import { textline } from "./textline.js";
|
|
@@ -60,7 +60,7 @@ A.insertGlobalCss({
|
|
|
60
60
|
"display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
61
61
|
"border-bottom: 1px solid $s-border; font-weight:600 flex-shrink:0",
|
|
62
62
|
"> footer":
|
|
63
|
-
"display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
63
|
+
"display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; " +
|
|
64
64
|
"border-top: 1px solid $s-border; flex-shrink:0",
|
|
65
65
|
"> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
|
|
66
66
|
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
|
|
@@ -75,7 +75,7 @@ const topDialogId = A.derive(() => {
|
|
|
75
75
|
if (keys.length) return keys[keys.length-1];
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
mountPortal(() => {
|
|
79
79
|
A.onEach(dialogs, ({resolve, opts}, dialogId) => {
|
|
80
80
|
const close = () => { delete dialogs[dialogId]; };
|
|
81
81
|
|
|
@@ -128,8 +128,8 @@ A.mount(document.body, () => {
|
|
|
128
128
|
* header: "Confirm",
|
|
129
129
|
* content: (close) => {
|
|
130
130
|
* A("p #Are you sure?");
|
|
131
|
-
* S.button({
|
|
132
|
-
* S.button({
|
|
131
|
+
* S.button({ content: "Yes", click: () => { doIt(); close(); } });
|
|
132
|
+
* S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
133
133
|
* },
|
|
134
134
|
* });
|
|
135
135
|
* ```
|
|
@@ -176,7 +176,7 @@ export function alert(message: string, opts: Partial<DialogOptions> = {}): Promi
|
|
|
176
176
|
content: (close) => {
|
|
177
177
|
A("p", () => { A("#", message); });
|
|
178
178
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
179
|
-
button({
|
|
179
|
+
button({ content: "OK", click: close });
|
|
180
180
|
}});
|
|
181
181
|
},
|
|
182
182
|
...opts,
|
|
@@ -201,8 +201,8 @@ export function confirm(message: string, opts: Partial<DialogOptions> = {}): Pro
|
|
|
201
201
|
content: (close) => {
|
|
202
202
|
A("p", () => { A("#", message); });
|
|
203
203
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
204
|
-
button({
|
|
205
|
-
button({
|
|
204
|
+
button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
205
|
+
button({ content: "OK", click: () => { confirmed = true; close(); } });
|
|
206
206
|
}});
|
|
207
207
|
},
|
|
208
208
|
...opts,
|
|
@@ -241,8 +241,8 @@ export function prompt(message: string, defaultValue = "", opts: Partial<DialogO
|
|
|
241
241
|
});
|
|
242
242
|
textline({ bind: A.ref($v, "value") });
|
|
243
243
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
244
|
-
button({
|
|
245
|
-
button({
|
|
244
|
+
button({ content: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
|
|
245
|
+
button({ content: "OK", type: "submit" });
|
|
246
246
|
}});
|
|
247
247
|
});
|
|
248
248
|
},
|
package/src/components/form.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type
|
|
2
|
+
import { type ContentOptions, type Attributes, type Slot, drawSlot } from "../core.js";
|
|
3
3
|
|
|
4
4
|
/** Options for {@link form}. */
|
|
5
5
|
export interface FormOptions extends ContentOptions {
|
|
@@ -18,7 +18,7 @@ export interface FormOptions extends ContentOptions {
|
|
|
18
18
|
/** Aberdeen attr/style string for the action bar. */
|
|
19
19
|
actionsAttrs?: Attributes;
|
|
20
20
|
/** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
|
|
21
|
-
actions?:
|
|
21
|
+
actions?: Slot;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
A.insertGlobalCss({
|
|
@@ -26,7 +26,7 @@ A.insertGlobalCss({
|
|
|
26
26
|
"&": "display:flex flex-direction:column gap:$3",
|
|
27
27
|
"&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
|
|
28
28
|
"&.grid > .s-wide, &.grid > footer": "grid-column: 1 / -1;",
|
|
29
|
-
"> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
|
|
29
|
+
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 flex-wrap:wrap margin-top:$1",
|
|
30
30
|
},
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -47,12 +47,12 @@ A.insertGlobalCss({
|
|
|
47
47
|
* S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
|
|
48
48
|
* S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
|
|
49
49
|
* },
|
|
50
|
-
* actions: () => S.button({
|
|
50
|
+
* actions: () => S.button({ content: "Save", type: "submit" }),
|
|
51
51
|
* });
|
|
52
52
|
* ```
|
|
53
53
|
*/
|
|
54
|
-
export function form(opts: FormOptions |
|
|
55
|
-
const o: FormOptions = typeof opts === "function" ? { content: opts } : opts;
|
|
54
|
+
export function form(opts: FormOptions | Slot = {}): void {
|
|
55
|
+
const o: FormOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
56
56
|
|
|
57
57
|
A(`form.s-form`, o.attrs, () => {
|
|
58
58
|
// Toggle grid class in its own scope so changing layout doesn't recreate
|
|
@@ -74,11 +74,11 @@ export function form(opts: FormOptions | Content = {}): void {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
drawSlot(o.content);
|
|
78
78
|
|
|
79
79
|
// Own scope so toggling actions doesn't recreate the fields above.
|
|
80
80
|
A(() => {
|
|
81
|
-
if (o.actions) A("footer", o.actionsAttrs, () => o.actions
|
|
81
|
+
if (o.actions) A("footer", o.actionsAttrs, () => drawSlot(o.actions));
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
}
|
package/src/components/main.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type
|
|
2
|
+
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
3
|
import { type MenuOptions, menuButton, drawMenu } from "./menu.js";
|
|
4
4
|
|
|
5
5
|
/** Options for {@link main}. */
|
|
@@ -13,9 +13,9 @@ export interface MainOptions {
|
|
|
13
13
|
/** Leading icon/logo in the top bar. */
|
|
14
14
|
icon?: Slot;
|
|
15
15
|
/** Action area on the right of the top bar (buttons, menu, ...). */
|
|
16
|
-
menu?:
|
|
17
|
-
/** The scrollable page content. */
|
|
18
|
-
content?:
|
|
16
|
+
menu?: Slot;
|
|
17
|
+
/** The scrollable page content. A string is rendered as rich text. */
|
|
18
|
+
content?: Slot;
|
|
19
19
|
/** Footer content, pinned below the scroll area. */
|
|
20
20
|
footer?: Slot;
|
|
21
21
|
/**
|
|
@@ -62,8 +62,9 @@ A.insertGlobalCss({
|
|
|
62
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%",
|
|
63
63
|
"> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
64
64
|
"> header .s-menu": "display:flex align-items:center gap:$2",
|
|
65
|
-
// Body
|
|
66
|
-
//
|
|
65
|
+
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
66
|
+
// and scrollbar alignment work identically in both cases.
|
|
67
|
+
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
67
68
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
|
|
68
69
|
".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
|
|
69
70
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
@@ -71,20 +72,19 @@ A.insertGlobalCss({
|
|
|
71
72
|
// A vertical hairline between sidebar and content, fading out at both ends —
|
|
72
73
|
// the vertical sibling of the menu's `hr.s-menu-sep`.
|
|
73
74
|
".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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
// drawMainContent); with one, `.s-body-inner` does the capping for the trio.
|
|
75
|
+
// min-height:0 overrides the flex default of min-height:auto so <main> can
|
|
76
|
+
// shrink to fit the bounded container and show its own scrollbar.
|
|
77
|
+
".s-body main": "flex:1 min-height:0 overflow-y:auto display:flex flex-direction:column",
|
|
78
|
+
// The content area fills the scroll region with comfortable padding.
|
|
79
79
|
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
80
|
-
"
|
|
80
|
+
".s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
81
81
|
// When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
|
|
82
82
|
// toggled from JS by watchVerticalOverflow), inset it from the shell edge by
|
|
83
83
|
// $3 so the bar's right edge lines up with the header/footer content (which
|
|
84
84
|
// sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
|
|
85
85
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
86
86
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
87
|
-
"
|
|
87
|
+
".s-body main.s-scroll-y": "margin-right:$3",
|
|
88
88
|
},
|
|
89
89
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
|
|
90
90
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
@@ -110,6 +110,9 @@ A.insertGlobalCss({
|
|
|
110
110
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
111
111
|
// to negate the content padding and drop the rounded corners.
|
|
112
112
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
113
|
+
// At narrow widths, content boxes are full-bleed so there's no inset to
|
|
114
|
+
// align the scrollbar with — cancel the right margin.
|
|
115
|
+
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
113
116
|
},
|
|
114
117
|
});
|
|
115
118
|
|
|
@@ -133,7 +136,7 @@ A.insertGlobalCss({
|
|
|
133
136
|
* ],
|
|
134
137
|
* },
|
|
135
138
|
* navPosition: "left",
|
|
136
|
-
* menu: () => S.button({
|
|
139
|
+
* menu: () => S.button({ content: "New", attrs: ".small" }),
|
|
137
140
|
* content: () => drawPage(),
|
|
138
141
|
* footer: "© 2026",
|
|
139
142
|
* });
|
|
@@ -190,30 +193,28 @@ export function main(opts: MainOptions = {}): void {
|
|
|
190
193
|
});
|
|
191
194
|
});
|
|
192
195
|
A(() => {
|
|
193
|
-
if (opts.menu) A("div.s-menu", () => opts.menu
|
|
196
|
+
if (opts.menu) A("div.s-menu", () => drawSlot(opts.menu));
|
|
194
197
|
});
|
|
195
198
|
});
|
|
196
199
|
});
|
|
197
200
|
});
|
|
198
201
|
|
|
199
|
-
// Body
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
A("div.s-body", () => {
|
|
203
|
-
A(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
// Body always wraps <main> so max-width centering and scrollbar alignment
|
|
203
|
+
// are identical with and without a sidebar nav.
|
|
204
|
+
A("div.s-body", () => {
|
|
205
|
+
A("div.s-body-inner", () => {
|
|
206
|
+
A(() => {
|
|
207
|
+
if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
|
|
208
|
+
});
|
|
209
|
+
if (hasNav && navPos !== "button") {
|
|
207
210
|
A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
208
211
|
drawMenu(nav.items);
|
|
209
212
|
});
|
|
210
213
|
A("div.s-nav-sep aria-hidden=true");
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
}
|
|
215
|
+
drawMainContent(opts);
|
|
213
216
|
});
|
|
214
|
-
}
|
|
215
|
-
drawMainContent(opts, true);
|
|
216
|
-
}
|
|
217
|
+
});
|
|
217
218
|
|
|
218
219
|
// Footer — full-width background, content centred to maxWidth via .s-bar.
|
|
219
220
|
A(() => {
|
|
@@ -231,20 +232,10 @@ export function main(opts: MainOptions = {}): void {
|
|
|
231
232
|
});
|
|
232
233
|
}
|
|
233
234
|
|
|
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 {
|
|
235
|
+
function drawMainContent(opts: MainOptions): void {
|
|
240
236
|
const mainEl = A("main", () => {
|
|
241
237
|
A("div.s-content", opts.contentAttrs, () => {
|
|
242
|
-
|
|
243
|
-
A(() => {
|
|
244
|
-
if (opts.maxWidth != null) A("margin-inline:auto max-width:", opts.maxWidth);
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
if (opts.content) opts.content();
|
|
238
|
+
drawSlot(opts.content);
|
|
248
239
|
});
|
|
249
240
|
}) as HTMLElement;
|
|
250
241
|
watchVerticalOverflow(mainEl);
|
package/src/components/menu.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent } from "aberdeen/route";
|
|
3
|
-
import { type
|
|
3
|
+
import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
|
|
4
4
|
import { button, type ButtonOptions } from "./button.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -41,10 +41,10 @@ export interface MenuSeparator {
|
|
|
41
41
|
* An entry in a menu or sidebar nav list. Three forms:
|
|
42
42
|
* - `MenuItem` — a clickable/linkable row with label and optional icon.
|
|
43
43
|
* - `MenuSeparator` — a visual divider (`{ separator: true }`).
|
|
44
|
-
* - A draw function
|
|
44
|
+
* - A slot (string or draw function) — renders custom content (section header,
|
|
45
45
|
* avatar, search box, …). Skipped by keyboard navigation.
|
|
46
46
|
*/
|
|
47
|
-
export type MenuEntry = MenuItem | MenuSeparator |
|
|
47
|
+
export type MenuEntry = MenuItem | MenuSeparator | Slot;
|
|
48
48
|
|
|
49
49
|
/** Options for {@link menuButton} and {@link MainOptions.nav}. */
|
|
50
50
|
export interface MenuOptions {
|
|
@@ -91,7 +91,7 @@ A.insertGlobalCss({
|
|
|
91
91
|
// cleanly: transitioning background from `transparent` toward an opaque colour
|
|
92
92
|
// flashes through dark mid-tones in browsers that interpolate non-premultiplied.
|
|
93
93
|
// Staying ink-hued at low alpha keeps the fade the right colour throughout.
|
|
94
|
-
".s-menu-item:hover:not([aria-disabled=true]), .s-menu-item-link:hover":
|
|
94
|
+
".s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page]), .s-menu-item-link:hover:not([aria-current=page])":
|
|
95
95
|
"background: color-mix(in srgb, $s-fg 10%, transparent);",
|
|
96
96
|
// Active (current page): a filled brand-gradient pill with a soft glow — the
|
|
97
97
|
// one place the menu shows real colour, so the current page is unmistakable.
|
|
@@ -148,7 +148,7 @@ export function drawMenu(items: MenuEntry[], onActivate?: () => void): void {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
for (const entry of items) {
|
|
151
|
-
if (typeof entry === "function") { entry
|
|
151
|
+
if (typeof entry === "string" || typeof entry === "function") { drawSlot(entry); continue; }
|
|
152
152
|
if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
|
|
153
153
|
|
|
154
154
|
A(entry.href ? "a.s-menu-item-link" : "button.s-menu-item type=button", entry.attrs, () => {
|
|
@@ -193,7 +193,7 @@ function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
|
|
|
193
193
|
menuEl.style.top = Math.max(8, y) + "px";
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
mountPortal(() => {
|
|
197
197
|
const f = $floating.opts;
|
|
198
198
|
if (!f) return;
|
|
199
199
|
|
|
@@ -275,7 +275,9 @@ export function menuButton(opts: MenuOptions): void {
|
|
|
275
275
|
|
|
276
276
|
button({
|
|
277
277
|
icon: () => A("span aria-hidden=true #☰"),
|
|
278
|
-
|
|
278
|
+
// Only label the trigger "Open menu" when it has no visible text of its
|
|
279
|
+
// own — an aria-label would otherwise *hide* that text from AT.
|
|
280
|
+
...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),
|
|
279
281
|
attrs: ".neutral .outlined",
|
|
280
282
|
...opts.button,
|
|
281
283
|
click: (e: Event) => {
|