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/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A small, opinionated TypeScript component library for the [Aberdeen](https://abe
|
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import A from "aberdeen";
|
|
7
|
-
import S from "staffa";
|
|
7
|
+
import * as S from "staffa";
|
|
8
8
|
|
|
9
9
|
const $user = A.proxy({ name: "", email: "" });
|
|
10
10
|
|
|
@@ -172,6 +172,24 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
172
172
|
- **`S.buttonGroup(opts)`**: groups buttons, `attached` (segmented) or `spaced`.
|
|
173
173
|
- **`S.buttonChooser(opts)`**: single-select segmented control bound to a value.
|
|
174
174
|
|
|
175
|
+
|
|
176
|
+
### Icons
|
|
177
|
+
|
|
178
|
+
Staffa ships the full [Lucide icon set](https://lucide.dev/icons/) as named exports. Import only the ones you use, so a bundler tree-shakes the rest (the whole set is ~82 kB gzipped):
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { sparkles, bell } from "staffa/icons.js";
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Each icon is a draw function usable anywhere a slot is accepted (e.g. a button `icon`), or called directly. Customize per call, or globally via `setDefaults()`:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
S.button({ text: "Save", icon: bell });
|
|
188
|
+
sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `join`, `attrs`.
|
|
192
|
+
|
|
175
193
|
### Other
|
|
176
194
|
|
|
177
195
|
- **`S.menuButton(opts)` / `S.showFloatingMenu(opts)`**: menu actions and floating menus, with keyboard navigation and submenus.
|
|
@@ -195,7 +213,7 @@ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form f
|
|
|
195
213
|
</script>
|
|
196
214
|
<script type="module">
|
|
197
215
|
import A from "aberdeen";
|
|
198
|
-
import S from "staffa/all.js";
|
|
216
|
+
import * as S from "staffa/all.js";
|
|
199
217
|
// ...
|
|
200
218
|
</script>
|
|
201
219
|
```
|
|
@@ -247,4 +265,19 @@ npm run build # compile TypeScript to dist/
|
|
|
247
265
|
npm run typecheck # check types
|
|
248
266
|
npm run smoke # render every component in jsdom
|
|
249
267
|
npx http-server # allows demo to be viewed at http://localhost:8080/demo
|
|
268
|
+
npx shotest test # visual tests: click through the demo, screenshotting every step
|
|
269
|
+
npx shotest review # review/accept the visual changes against the baseline
|
|
250
270
|
```
|
|
271
|
+
|
|
272
|
+
The visual tests (`tests/*.spec.ts`) need a build first (`npm run build`); they serve the repo root themselves and click through every demo page. Accepted baselines live in `test-accepted/`.
|
|
273
|
+
|
|
274
|
+
## AI skill
|
|
275
|
+
|
|
276
|
+
If you use Claude Code, GitHub Copilot or another AI agents that supports Skills, Staffa includes a `skill/` directory that provides specialized knowledge to the AI about how to use the library effectively.
|
|
277
|
+
|
|
278
|
+
To use this, it is recommended to symlink the skill into your project's `.claude/skills` directory:
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
mkdir -p .claude/skills
|
|
282
|
+
ln -s ../../node_modules/staffa/skill .claude/skills/staffa
|
|
283
|
+
```
|
package/dist/components/box.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ContentOptions, type Slot, type Attributes } from "../core.js";
|
|
2
2
|
/** Options for {@link box}. */
|
|
3
3
|
export interface BoxOptions extends ContentOptions {
|
|
4
4
|
/** Header content, drawn in a styled bar above the body. */
|
|
@@ -30,4 +30,4 @@ export interface BoxOptions extends ContentOptions {
|
|
|
30
30
|
* S.box(() => A("p#Just some content")); // shorthand
|
|
31
31
|
* ```
|
|
32
32
|
*/
|
|
33
|
-
export declare function box(opts?: BoxOptions |
|
|
33
|
+
export declare function box(opts?: BoxOptions | Slot): void;
|
package/dist/components/box.js
CHANGED
|
@@ -9,7 +9,7 @@ A.insertGlobalCss({
|
|
|
9
9
|
"&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
|
|
10
10
|
"&:not(:first-child)": "margin-top: $3",
|
|
11
11
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
|
|
12
|
-
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
|
|
12
|
+
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
|
|
13
13
|
"> div": "p:$3 gap:$3",
|
|
14
14
|
},
|
|
15
15
|
});
|
|
@@ -32,7 +32,7 @@ A.insertGlobalCss({
|
|
|
32
32
|
* ```
|
|
33
33
|
*/
|
|
34
34
|
export function box(opts = {}) {
|
|
35
|
-
const o = typeof opts === "function" ? { content: opts } : opts;
|
|
35
|
+
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
36
36
|
A("section.s-box.s-s.panel", o.attrs, () => {
|
|
37
37
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
38
38
|
// the body (which may hold focused inputs / lots of content).
|
|
@@ -41,8 +41,7 @@ export function box(opts = {}) {
|
|
|
41
41
|
A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
|
|
42
42
|
});
|
|
43
43
|
A("div", o.contentAttrs, () => {
|
|
44
|
-
|
|
45
|
-
o.content();
|
|
44
|
+
drawSlot(o.content);
|
|
46
45
|
});
|
|
47
46
|
A(() => {
|
|
48
47
|
if (o.footer != null)
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Slot, type Attributes } from "../core.js";
|
|
2
2
|
/** Options for {@link button}. */
|
|
3
3
|
export interface ButtonOptions {
|
|
4
|
-
/** Button
|
|
5
|
-
|
|
6
|
-
/** Custom content (overrides {@link ButtonOptions.text | text}). */
|
|
7
|
-
content?: Content;
|
|
4
|
+
/** Button content: a string for plain text, or a function for custom markup. */
|
|
5
|
+
content?: Slot;
|
|
8
6
|
/** Leading icon/adornment, drawn before the label. */
|
|
9
7
|
icon?: Slot;
|
|
10
8
|
/** Click handler. */
|
|
@@ -39,15 +37,15 @@ export interface ButtonOptions {
|
|
|
39
37
|
* startup) for SPA-style navigation without manual click handlers:
|
|
40
38
|
* ```ts
|
|
41
39
|
* interceptLinks(); // once at root
|
|
42
|
-
* S.button({ href: "/dashboard",
|
|
40
|
+
* S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
43
41
|
* ```
|
|
44
42
|
*
|
|
45
43
|
* @example
|
|
46
44
|
* ```ts
|
|
47
|
-
* S.button({
|
|
48
|
-
* S.button({
|
|
49
|
-
* S.button("Cancel"); // shorthand for {
|
|
50
|
-
* S.button({ href: "/docs",
|
|
45
|
+
* S.button({ content: "Save", click: save });
|
|
46
|
+
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
47
|
+
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
48
|
+
* S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
|
|
51
49
|
* ```
|
|
52
50
|
*/
|
|
53
|
-
export declare function button(opts?: ButtonOptions |
|
|
51
|
+
export declare function button(opts?: ButtonOptions | Slot): void;
|
|
@@ -17,14 +17,15 @@ A.insertGlobalCss({
|
|
|
17
17
|
"&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
|
|
18
18
|
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
|
|
19
19
|
// A filled `.gradient` button (the default) is the app's signature call to
|
|
20
|
-
// action: a borderless gradient with a
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// gradient
|
|
26
|
-
|
|
27
|
-
"&.gradient:not(.tonal):not(.outlined)
|
|
20
|
+
// action: a borderless gradient with a hairline top highlight (a hint of
|
|
21
|
+
// top-lighting that sells the fill as a lit, rounded shape) over a soft glow.
|
|
22
|
+
// The gradient fill itself comes from the `.s-s.gradient` surface rule in
|
|
23
|
+
// theme.ts. No border: a filled gradient reads as one solid shape. Dropping
|
|
24
|
+
// the border (rather than making it transparent) also sidesteps a Chromium
|
|
25
|
+
// artifact where a gradient clipped to a transparent rounded border fringes
|
|
26
|
+
// the edge with the gradient's far colour.
|
|
27
|
+
"&.gradient:not(.tonal):not(.outlined)": "border:0 box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), $s-glow;",
|
|
28
|
+
"&.gradient:not(.tonal):not(.outlined):hover": "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);",
|
|
28
29
|
// Subtle press feedback.
|
|
29
30
|
"&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
|
|
30
31
|
// Size: set on the button itself, or inherited from a `.small`/`.large`
|
|
@@ -47,19 +48,19 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
|
|
|
47
48
|
* startup) for SPA-style navigation without manual click handlers:
|
|
48
49
|
* ```ts
|
|
49
50
|
* interceptLinks(); // once at root
|
|
50
|
-
* S.button({ href: "/dashboard",
|
|
51
|
+
* S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
51
52
|
* ```
|
|
52
53
|
*
|
|
53
54
|
* @example
|
|
54
55
|
* ```ts
|
|
55
|
-
* S.button({
|
|
56
|
-
* S.button({
|
|
57
|
-
* S.button("Cancel"); // shorthand for {
|
|
58
|
-
* S.button({ href: "/docs",
|
|
56
|
+
* S.button({ content: "Save", click: save });
|
|
57
|
+
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
58
|
+
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
59
|
+
* S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
|
|
59
60
|
* ```
|
|
60
61
|
*/
|
|
61
62
|
export function button(opts = {}) {
|
|
62
|
-
const o = typeof opts === "string"
|
|
63
|
+
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
63
64
|
const tag = o.href != null ? "a" : "button";
|
|
64
65
|
// A filled `.gradient` surface by default — the signature CTA. If the caller's
|
|
65
66
|
// `attrs` already names a surface role we omit the default, so `.danger`,
|
|
@@ -82,9 +83,6 @@ export function button(opts = {}) {
|
|
|
82
83
|
if (o.click)
|
|
83
84
|
A("click=", o.click);
|
|
84
85
|
drawSlot(o.icon);
|
|
85
|
-
|
|
86
|
-
o.content();
|
|
87
|
-
else if (o.text != null)
|
|
88
|
-
A("#", o.text);
|
|
86
|
+
drawSlot(o.content);
|
|
89
87
|
});
|
|
90
88
|
}
|
|
@@ -10,13 +10,13 @@ export interface ButtonChooserOptions {
|
|
|
10
10
|
*/
|
|
11
11
|
options: Record<string, Slot>;
|
|
12
12
|
/**
|
|
13
|
-
* Two-way binding for the selected id, or `
|
|
13
|
+
* Two-way binding for the selected id, or `undefined` when nothing is selected.
|
|
14
14
|
* Use an `A.proxy` or `A.ref`.
|
|
15
15
|
*/
|
|
16
|
-
bind: Bindable<string |
|
|
16
|
+
bind: Bindable<string | undefined>;
|
|
17
17
|
/**
|
|
18
18
|
* When `true`, clicking the already-selected button deselects it, setting
|
|
19
|
-
* `bind.value` to `
|
|
19
|
+
* `bind.value` to `undefined`. Useful for "none / auto" states.
|
|
20
20
|
*/
|
|
21
21
|
allowDeselect?: boolean;
|
|
22
22
|
/** Name attribute for the hidden `<input>`, enabling form submission. */
|
|
@@ -24,14 +24,14 @@ export interface ButtonChooserOptions {
|
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* A single-selection segmented control: an attached button group where exactly
|
|
27
|
-
* one button is active at a time. Optionally allows deselecting back to `
|
|
27
|
+
* one button is active at a time. Optionally allows deselecting back to `undefined`.
|
|
28
28
|
*
|
|
29
29
|
* Renders a hidden `<input>` alongside (when `name` is set) so the selected
|
|
30
30
|
* value is included in native form submission.
|
|
31
31
|
*
|
|
32
32
|
* @example
|
|
33
33
|
* ```ts
|
|
34
|
-
* const $view = A.proxy({ value: "day" as string |
|
|
34
|
+
* const $view = A.proxy({ value: "day" as string | undefined });
|
|
35
35
|
* S.buttonChooser({
|
|
36
36
|
* options: { day: "Day", week: "Week", month: "Month" },
|
|
37
37
|
* bind: $view,
|
|
@@ -2,14 +2,14 @@ import A from "aberdeen";
|
|
|
2
2
|
import { buttonGroup } from "./buttonGroup.js";
|
|
3
3
|
/**
|
|
4
4
|
* A single-selection segmented control: an attached button group where exactly
|
|
5
|
-
* one button is active at a time. Optionally allows deselecting back to `
|
|
5
|
+
* one button is active at a time. Optionally allows deselecting back to `undefined`.
|
|
6
6
|
*
|
|
7
7
|
* Renders a hidden `<input>` alongside (when `name` is set) so the selected
|
|
8
8
|
* value is included in native form submission.
|
|
9
9
|
*
|
|
10
10
|
* @example
|
|
11
11
|
* ```ts
|
|
12
|
-
* const $view = A.proxy({ value: "day" as string |
|
|
12
|
+
* const $view = A.proxy({ value: "day" as string | undefined });
|
|
13
13
|
* S.buttonChooser({
|
|
14
14
|
* options: { day: "Day", week: "Week", month: "Month" },
|
|
15
15
|
* bind: $view,
|
|
@@ -22,11 +22,13 @@ export function buttonChooser(opts) {
|
|
|
22
22
|
buttonGroup({
|
|
23
23
|
attrs: opts.attrs,
|
|
24
24
|
buttons: Object.entries(opts.options).map(([id, label]) => ({
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
content: label,
|
|
26
|
+
// Icon-only options (draw-function labels) get the id as their
|
|
27
|
+
// accessible name; plain-text labels speak for themselves.
|
|
28
|
+
ariaLabel: typeof label === "function" ? id : undefined,
|
|
27
29
|
attrs: selected === id ? ".primary" : ".neutral .outlined",
|
|
28
30
|
click: () => {
|
|
29
|
-
opts.bind.value = (opts.allowDeselect && selected === id) ?
|
|
31
|
+
opts.bind.value = (opts.allowDeselect && selected === id) ? undefined : id;
|
|
30
32
|
},
|
|
31
33
|
})),
|
|
32
34
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
+
import { drawSlot } from "../core.js";
|
|
2
3
|
import { button } from "./button.js";
|
|
3
4
|
A.insertGlobalCss({
|
|
4
5
|
".s-bgroup": {
|
|
@@ -37,7 +38,6 @@ export function buttonGroup(opts = {}) {
|
|
|
37
38
|
if (opts.buttons)
|
|
38
39
|
for (const b of opts.buttons)
|
|
39
40
|
button(b);
|
|
40
|
-
|
|
41
|
-
opts.content();
|
|
41
|
+
drawSlot(opts.content);
|
|
42
42
|
});
|
|
43
43
|
}
|
|
@@ -52,8 +52,8 @@ export interface DialogOptions {
|
|
|
52
52
|
* header: "Confirm",
|
|
53
53
|
* content: (close) => {
|
|
54
54
|
* A("p #Are you sure?");
|
|
55
|
-
* S.button({
|
|
56
|
-
* S.button({
|
|
55
|
+
* S.button({ content: "Yes", click: () => { doIt(); close(); } });
|
|
56
|
+
* S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
57
57
|
* },
|
|
58
58
|
* });
|
|
59
59
|
* ```
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { drawSlot } from "../core.js";
|
|
2
|
+
import { 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";
|
|
@@ -17,7 +17,7 @@ A.insertGlobalCss({
|
|
|
17
17
|
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
18
18
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
19
19
|
"border-bottom: 1px solid $s-border; font-weight:600 flex-shrink:0",
|
|
20
|
-
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
20
|
+
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; " +
|
|
21
21
|
"border-top: 1px solid $s-border; flex-shrink:0",
|
|
22
22
|
"> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
|
|
23
23
|
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
|
|
@@ -30,7 +30,7 @@ const topDialogId = A.derive(() => {
|
|
|
30
30
|
if (keys.length)
|
|
31
31
|
return keys[keys.length - 1];
|
|
32
32
|
});
|
|
33
|
-
|
|
33
|
+
mountPortal(() => {
|
|
34
34
|
A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
|
|
35
35
|
const close = () => { delete dialogs[dialogId]; };
|
|
36
36
|
A.clean(() => {
|
|
@@ -78,8 +78,8 @@ A.mount(document.body, () => {
|
|
|
78
78
|
* header: "Confirm",
|
|
79
79
|
* content: (close) => {
|
|
80
80
|
* A("p #Are you sure?");
|
|
81
|
-
* S.button({
|
|
82
|
-
* S.button({
|
|
81
|
+
* S.button({ content: "Yes", click: () => { doIt(); close(); } });
|
|
82
|
+
* S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
83
83
|
* },
|
|
84
84
|
* });
|
|
85
85
|
* ```
|
|
@@ -126,7 +126,7 @@ export function alert(message, opts = {}) {
|
|
|
126
126
|
content: (close) => {
|
|
127
127
|
A("p", () => { A("#", message); });
|
|
128
128
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
129
|
-
button({
|
|
129
|
+
button({ content: "OK", click: close });
|
|
130
130
|
} });
|
|
131
131
|
},
|
|
132
132
|
...opts,
|
|
@@ -150,8 +150,8 @@ export function confirm(message, opts = {}) {
|
|
|
150
150
|
content: (close) => {
|
|
151
151
|
A("p", () => { A("#", message); });
|
|
152
152
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
153
|
-
button({
|
|
154
|
-
button({
|
|
153
|
+
button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
154
|
+
button({ content: "OK", click: () => { confirmed = true; close(); } });
|
|
155
155
|
} });
|
|
156
156
|
},
|
|
157
157
|
...opts,
|
|
@@ -189,8 +189,8 @@ export function prompt(message, defaultValue = "", opts = {}) {
|
|
|
189
189
|
});
|
|
190
190
|
textline({ bind: A.ref($v, "value") });
|
|
191
191
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
192
|
-
button({
|
|
193
|
-
button({
|
|
192
|
+
button({ content: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
|
|
193
|
+
button({ content: "OK", type: "submit" });
|
|
194
194
|
} });
|
|
195
195
|
});
|
|
196
196
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ContentOptions, type Attributes, type Slot } from "../core.js";
|
|
2
2
|
/** Options for {@link form}. */
|
|
3
3
|
export interface FormOptions extends ContentOptions {
|
|
4
4
|
/**
|
|
@@ -16,7 +16,7 @@ export interface FormOptions extends ContentOptions {
|
|
|
16
16
|
/** Aberdeen attr/style string for the action bar. */
|
|
17
17
|
actionsAttrs?: Attributes;
|
|
18
18
|
/** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
|
|
19
|
-
actions?:
|
|
19
|
+
actions?: Slot;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* An opinionated `<form>` wrapper that lays its fields out consistently — a clean
|
|
@@ -35,8 +35,8 @@ export interface FormOptions extends ContentOptions {
|
|
|
35
35
|
* S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
|
|
36
36
|
* S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
|
|
37
37
|
* },
|
|
38
|
-
* actions: () => S.button({
|
|
38
|
+
* actions: () => S.button({ content: "Save", type: "submit" }),
|
|
39
39
|
* });
|
|
40
40
|
* ```
|
|
41
41
|
*/
|
|
42
|
-
export declare function form(opts?: FormOptions |
|
|
42
|
+
export declare function form(opts?: FormOptions | Slot): void;
|
package/dist/components/form.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
+
import { drawSlot } from "../core.js";
|
|
2
3
|
A.insertGlobalCss({
|
|
3
4
|
".s-form": {
|
|
4
5
|
"&": "display:flex flex-direction:column gap:$3",
|
|
5
6
|
"&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
|
|
6
7
|
"&.grid > .s-wide, &.grid > footer": "grid-column: 1 / -1;",
|
|
7
|
-
"> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
|
|
8
|
+
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 flex-wrap:wrap margin-top:$1",
|
|
8
9
|
},
|
|
9
10
|
});
|
|
10
11
|
/**
|
|
@@ -24,12 +25,12 @@ A.insertGlobalCss({
|
|
|
24
25
|
* S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
|
|
25
26
|
* S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
|
|
26
27
|
* },
|
|
27
|
-
* actions: () => S.button({
|
|
28
|
+
* actions: () => S.button({ content: "Save", type: "submit" }),
|
|
28
29
|
* });
|
|
29
30
|
* ```
|
|
30
31
|
*/
|
|
31
32
|
export function form(opts = {}) {
|
|
32
|
-
const o = typeof opts === "function" ? { content: opts } : opts;
|
|
33
|
+
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
33
34
|
A(`form.s-form`, o.attrs, () => {
|
|
34
35
|
// Toggle grid class in its own scope so changing layout doesn't recreate
|
|
35
36
|
// the fields (which would lose focus / input state).
|
|
@@ -48,12 +49,11 @@ export function form(opts = {}) {
|
|
|
48
49
|
o.submit(data, event);
|
|
49
50
|
}
|
|
50
51
|
});
|
|
51
|
-
|
|
52
|
-
o.content();
|
|
52
|
+
drawSlot(o.content);
|
|
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.actionsAttrs, () => o.actions
|
|
56
|
+
A("footer", o.actionsAttrs, () => drawSlot(o.actions));
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Slot, type Attributes } from "../core.js";
|
|
2
2
|
import { type MenuOptions } from "./menu.js";
|
|
3
3
|
/** Options for {@link main}. */
|
|
4
4
|
export interface MainOptions {
|
|
@@ -11,9 +11,9 @@ export interface MainOptions {
|
|
|
11
11
|
/** Leading icon/logo in the top bar. */
|
|
12
12
|
icon?: Slot;
|
|
13
13
|
/** Action area on the right of the top bar (buttons, menu, ...). */
|
|
14
|
-
menu?:
|
|
15
|
-
/** The scrollable page content. */
|
|
16
|
-
content?:
|
|
14
|
+
menu?: Slot;
|
|
15
|
+
/** The scrollable page content. A string is rendered as rich text. */
|
|
16
|
+
content?: Slot;
|
|
17
17
|
/** Footer content, pinned below the scroll area. */
|
|
18
18
|
footer?: Slot;
|
|
19
19
|
/**
|
|
@@ -65,7 +65,7 @@ export interface MainOptions {
|
|
|
65
65
|
* ],
|
|
66
66
|
* },
|
|
67
67
|
* navPosition: "left",
|
|
68
|
-
* menu: () => S.button({
|
|
68
|
+
* menu: () => S.button({ content: "New", attrs: ".small" }),
|
|
69
69
|
* content: () => drawPage(),
|
|
70
70
|
* footer: "© 2026",
|
|
71
71
|
* });
|
package/dist/components/main.js
CHANGED
|
@@ -15,8 +15,9 @@ A.insertGlobalCss({
|
|
|
15
15
|
"> 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%",
|
|
16
16
|
"> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
17
17
|
"> header .s-menu": "display:flex align-items:center gap:$2",
|
|
18
|
-
// Body
|
|
19
|
-
//
|
|
18
|
+
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
19
|
+
// and scrollbar alignment work identically in both cases.
|
|
20
|
+
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
20
21
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
|
|
21
22
|
".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
|
|
22
23
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
@@ -24,20 +25,19 @@ A.insertGlobalCss({
|
|
|
24
25
|
// A vertical hairline between sidebar and content, fading out at both ends —
|
|
25
26
|
// the vertical sibling of the menu's `hr.s-menu-sep`.
|
|
26
27
|
".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);",
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
// drawMainContent); with one, `.s-body-inner` does the capping for the trio.
|
|
28
|
+
// min-height:0 overrides the flex default of min-height:auto so <main> can
|
|
29
|
+
// shrink to fit the bounded container and show its own scrollbar.
|
|
30
|
+
".s-body main": "flex:1 min-height:0 overflow-y:auto display:flex flex-direction:column",
|
|
31
|
+
// The content area fills the scroll region with comfortable padding.
|
|
32
32
|
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
33
|
-
"
|
|
33
|
+
".s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
34
34
|
// When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
|
|
35
35
|
// toggled from JS by watchVerticalOverflow), inset it from the shell edge by
|
|
36
36
|
// $3 so the bar's right edge lines up with the header/footer content (which
|
|
37
37
|
// sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
|
|
38
38
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
39
39
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
40
|
-
"
|
|
40
|
+
".s-body main.s-scroll-y": "margin-right:$3",
|
|
41
41
|
},
|
|
42
42
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
|
|
43
43
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
@@ -63,6 +63,9 @@ A.insertGlobalCss({
|
|
|
63
63
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
64
64
|
// to negate the content padding and drop the rounded corners.
|
|
65
65
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
66
|
+
// At narrow widths, content boxes are full-bleed so there's no inset to
|
|
67
|
+
// align the scrollbar with — cancel the right margin.
|
|
68
|
+
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
66
69
|
},
|
|
67
70
|
});
|
|
68
71
|
/**
|
|
@@ -85,7 +88,7 @@ A.insertGlobalCss({
|
|
|
85
88
|
* ],
|
|
86
89
|
* },
|
|
87
90
|
* navPosition: "left",
|
|
88
|
-
* menu: () => S.button({
|
|
91
|
+
* menu: () => S.button({ content: "New", attrs: ".small" }),
|
|
89
92
|
* content: () => drawPage(),
|
|
90
93
|
* footer: "© 2026",
|
|
91
94
|
* });
|
|
@@ -146,31 +149,28 @@ export function main(opts = {}) {
|
|
|
146
149
|
});
|
|
147
150
|
A(() => {
|
|
148
151
|
if (opts.menu)
|
|
149
|
-
A("div.s-menu", () => opts.menu
|
|
152
|
+
A("div.s-menu", () => drawSlot(opts.menu));
|
|
150
153
|
});
|
|
151
154
|
});
|
|
152
155
|
});
|
|
153
156
|
});
|
|
154
|
-
// Body
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
A("div.s-body", () => {
|
|
158
|
-
A(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
// Body always wraps <main> so max-width centering and scrollbar alignment
|
|
158
|
+
// are identical with and without a sidebar nav.
|
|
159
|
+
A("div.s-body", () => {
|
|
160
|
+
A("div.s-body-inner", () => {
|
|
161
|
+
A(() => {
|
|
162
|
+
if (opts.maxWidth != null)
|
|
163
|
+
A("max-width:", opts.maxWidth);
|
|
164
|
+
});
|
|
165
|
+
if (hasNav && navPos !== "button") {
|
|
163
166
|
A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
164
167
|
drawMenu(nav.items);
|
|
165
168
|
});
|
|
166
169
|
A("div.s-nav-sep aria-hidden=true");
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
}
|
|
171
|
+
drawMainContent(opts);
|
|
169
172
|
});
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
drawMainContent(opts, true);
|
|
173
|
-
}
|
|
173
|
+
});
|
|
174
174
|
// Footer — full-width background, content centred to maxWidth via .s-bar.
|
|
175
175
|
A(() => {
|
|
176
176
|
if (opts.footer != null) {
|
|
@@ -187,22 +187,10 @@ export function main(opts = {}) {
|
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
|
-
|
|
191
|
-
* Draw the scrollable `<main>` + content area. When `capWidth` is true (no
|
|
192
|
-
* sidebar), the content caps its own width to maxWidth and centres; in sidebar
|
|
193
|
-
* mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
|
|
194
|
-
*/
|
|
195
|
-
function drawMainContent(opts, capWidth) {
|
|
190
|
+
function drawMainContent(opts) {
|
|
196
191
|
const mainEl = A("main", () => {
|
|
197
192
|
A("div.s-content", opts.contentAttrs, () => {
|
|
198
|
-
|
|
199
|
-
A(() => {
|
|
200
|
-
if (opts.maxWidth != null)
|
|
201
|
-
A("margin-inline:auto max-width:", opts.maxWidth);
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
if (opts.content)
|
|
205
|
-
opts.content();
|
|
193
|
+
drawSlot(opts.content);
|
|
206
194
|
});
|
|
207
195
|
});
|
|
208
196
|
watchVerticalOverflow(mainEl);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Slot, type Attributes } from "../core.js";
|
|
2
2
|
import { type ButtonOptions } from "./button.js";
|
|
3
3
|
/**
|
|
4
4
|
* A clickable item in a menu or sidebar nav.
|
|
@@ -36,10 +36,10 @@ export interface MenuSeparator {
|
|
|
36
36
|
* An entry in a menu or sidebar nav list. Three forms:
|
|
37
37
|
* - `MenuItem` — a clickable/linkable row with label and optional icon.
|
|
38
38
|
* - `MenuSeparator` — a visual divider (`{ separator: true }`).
|
|
39
|
-
* - A draw function
|
|
39
|
+
* - A slot (string or draw function) — renders custom content (section header,
|
|
40
40
|
* avatar, search box, …). Skipped by keyboard navigation.
|
|
41
41
|
*/
|
|
42
|
-
export type MenuEntry = MenuItem | MenuSeparator |
|
|
42
|
+
export type MenuEntry = MenuItem | MenuSeparator | Slot;
|
|
43
43
|
/** Options for {@link menuButton} and {@link MainOptions.nav}. */
|
|
44
44
|
export interface MenuOptions {
|
|
45
45
|
/** Items shown in the dropdown or sidebar nav. */
|