staffa 0.1.0 → 0.2.1
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 +65 -27
- package/dist/components/autocomplete.js +20 -20
- package/dist/components/box.d.ts +8 -6
- package/dist/components/box.js +15 -11
- package/dist/components/button.d.ts +10 -33
- package/dist/components/button.js +17 -38
- package/dist/components/buttonChooser.d.ts +42 -0
- package/dist/components/buttonChooser.js +38 -0
- package/dist/components/buttonGroup.d.ts +3 -3
- package/dist/components/buttonGroup.js +18 -18
- package/dist/components/checkbox.js +7 -7
- package/dist/components/dialog.d.ts +20 -25
- package/dist/components/dialog.js +81 -91
- package/dist/components/field.d.ts +8 -6
- package/dist/components/field.js +18 -17
- package/dist/components/form.d.ts +3 -3
- package/dist/components/form.js +4 -4
- package/dist/components/main.d.ts +7 -5
- package/dist/components/main.js +25 -23
- package/dist/components/select.d.ts +1 -1
- package/dist/components/select.js +5 -5
- package/dist/components/tabs.d.ts +5 -3
- package/dist/components/tabs.js +25 -19
- package/dist/components/textarea.js +4 -4
- package/dist/components/textline.js +1 -1
- package/dist/core.d.ts +26 -39
- package/dist/core.js +6 -5
- package/dist/index.d.ts +10 -8
- package/dist/index.js +9 -8
- package/dist/staffa.esm.js +1 -0
- package/dist/theme.d.ts +9 -75
- package/dist/theme.js +178 -82
- package/package.json +3 -2
- package/src/components/autocomplete.ts +20 -20
- package/src/components/box.ts +20 -14
- package/src/components/button.ts +24 -72
- package/src/components/buttonChooser.ts +66 -0
- package/src/components/buttonGroup.ts +18 -18
- package/src/components/checkbox.ts +7 -7
- package/src/components/dialog.ts +101 -102
- package/src/components/field.ts +24 -21
- package/src/components/form.ts +7 -7
- package/src/components/main.ts +30 -26
- package/src/components/select.ts +4 -4
- package/src/components/tabs.ts +29 -22
- package/src/components/textarea.ts +4 -4
- package/src/components/textline.ts +1 -1
- package/src/core.ts +26 -40
- package/src/index.ts +10 -8
- package/src/theme.ts +190 -135
package/src/components/button.ts
CHANGED
|
@@ -1,33 +1,8 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Visual weight of a button.
|
|
6
|
-
* - `filled`: solid background, highest emphasis.
|
|
7
|
-
* - `tonal`: soft tinted background, medium emphasis.
|
|
8
|
-
* - `outlined`: bordered, transparent background, lowest emphasis.
|
|
9
|
-
*
|
|
10
|
-
* Every variant carries at least a visible border, per Staffa's "everything is
|
|
11
|
-
* legible at a glance" principle.
|
|
12
|
-
*/
|
|
13
|
-
export type ButtonVariant = "filled" | "tonal" | "outlined";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Color of a button.
|
|
17
|
-
*
|
|
18
|
-
* The four named **semantic roles** map to theme colours and are offered as
|
|
19
|
-
* autocomplete suggestions. You may also pass *any* CSS colour the browser
|
|
20
|
-
* understands and it becomes the button's accent directly: a literal like
|
|
21
|
-
* `"#ef6b00"` / `"rgb(255 107 0)"`, or a theme custom-property reference like
|
|
22
|
-
* `"$sWarning"` (Aberdeen's `$name` shorthand for `var(--name)`).
|
|
23
|
-
*
|
|
24
|
-
* The `(string & {})` member is what keeps the literal suggestions visible while
|
|
25
|
-
* still allowing arbitrary strings — TypeScript only widens to `string` lazily.
|
|
26
|
-
*/
|
|
27
|
-
export type ButtonColor = "primary" | "neutral" | "danger" | "success" | (string & {});
|
|
2
|
+
import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
|
|
28
3
|
|
|
29
4
|
/** Options for {@link button}. */
|
|
30
|
-
export interface ButtonOptions
|
|
5
|
+
export interface ButtonOptions {
|
|
31
6
|
/** Button label text. */
|
|
32
7
|
text?: string;
|
|
33
8
|
/** Custom content (overrides {@link ButtonOptions.text | text}). */
|
|
@@ -36,10 +11,6 @@ export interface ButtonOptions extends BaseOptions {
|
|
|
36
11
|
icon?: Slot;
|
|
37
12
|
/** Click handler. */
|
|
38
13
|
click?: (event: Event) => void;
|
|
39
|
-
/** Visual weight. Defaults to `"filled"`. */
|
|
40
|
-
variant?: ButtonVariant;
|
|
41
|
-
/** Color role. Defaults to `"primary"`. */
|
|
42
|
-
color?: ButtonColor;
|
|
43
14
|
/** Size. Defaults to `"md"`. */
|
|
44
15
|
size?: "sm" | "md" | "lg";
|
|
45
16
|
/** Disables the button. */
|
|
@@ -50,42 +21,36 @@ export interface ButtonOptions extends BaseOptions {
|
|
|
50
21
|
href?: string;
|
|
51
22
|
/** Accessible label, when the button has only an icon. */
|
|
52
23
|
ariaLabel?: string;
|
|
53
|
-
/**
|
|
54
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
26
|
+
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
27
|
+
* `".neutral .outlined"`. Defaults to a filled `.primary` surface.
|
|
28
|
+
*/
|
|
29
|
+
attrs?: Attributes;
|
|
55
30
|
}
|
|
56
31
|
|
|
57
|
-
// The
|
|
58
|
-
//
|
|
32
|
+
// The button is a `.s-s` surface (defaulting to `.primary` in button() below), so
|
|
33
|
+
// its colours come from the surface classes in theme.ts. This rule only handles
|
|
34
|
+
// layout, border, focus, hover and sizing.
|
|
59
35
|
A.insertGlobalCss({
|
|
60
|
-
".
|
|
36
|
+
".s-btn": {
|
|
61
37
|
"&":
|
|
62
|
-
"--c:$sPrimary --cfg:$sPrimaryFg " +
|
|
63
38
|
"display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
64
39
|
"font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
65
|
-
"border: 1px solid
|
|
40
|
+
"border: 1px solid $s-border; r: $s-radius; padding: 0.5em 1em; " +
|
|
66
41
|
"transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
|
|
67
|
-
"&:focus-visible": "outline:none box-shadow: 0 0 0 3px $
|
|
42
|
+
"&:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus;",
|
|
68
43
|
"&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
|
|
69
|
-
|
|
70
|
-
"&.
|
|
71
|
-
"&.
|
|
72
|
-
"&.
|
|
73
|
-
// Variants.
|
|
74
|
-
"&.S_filled": "background:$c color:$cfg border-color:$c",
|
|
75
|
-
"&.S_filled:hover": "filter:brightness(1.1)",
|
|
76
|
-
"&.S_tonal": "color:$c background: color-mix(in srgb, $c 20%, transparent); border-color: color-mix(in srgb, $c 30%, transparent);",
|
|
77
|
-
"&.S_tonal:hover": "background: color-mix(in srgb, $c 30%, transparent);",
|
|
78
|
-
"&.S_outlined": "color:$c background:transparent border-color: color-mix(in srgb, $c 55%, $sBorder);",
|
|
79
|
-
"&.S_outlined:hover": "background: color-mix(in srgb, $c 12%, transparent);",
|
|
80
|
-
// Sizes.
|
|
81
|
-
"&.S_sm": "padding: 0.32em 0.7em; font-size:0.85em",
|
|
82
|
-
"&.S_lg": "padding: 0.66em 1.3em; font-size:1.1em",
|
|
44
|
+
"&:hover": "filter: brightness(1.08)",
|
|
45
|
+
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
|
|
46
|
+
"&.s-sm": "padding: 0.32em 0.7em; font-size:0.85em",
|
|
47
|
+
"&.s-lg": "padding: 0.66em 1.3em; font-size:1.1em",
|
|
83
48
|
},
|
|
84
49
|
});
|
|
85
50
|
|
|
86
51
|
/**
|
|
87
52
|
* A button. Always carries at least a visible border so its affordance is
|
|
88
|
-
* obvious at a glance
|
|
53
|
+
* obvious at a glance.
|
|
89
54
|
*
|
|
90
55
|
* Shortcut: pass a string to use it as the label, or a function for custom
|
|
91
56
|
* content.
|
|
@@ -93,7 +58,7 @@ A.insertGlobalCss({
|
|
|
93
58
|
* @example
|
|
94
59
|
* ```ts
|
|
95
60
|
* S.button({ text: "Save", click: save });
|
|
96
|
-
* S.button({ text: "Delete",
|
|
61
|
+
* S.button({ text: "Delete", attrs: ".danger .outlined", click: del });
|
|
97
62
|
* S.button("Cancel"); // shorthand for { text: "Cancel" }
|
|
98
63
|
* S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
|
|
99
64
|
* ```
|
|
@@ -102,17 +67,11 @@ export function button(opts: ButtonOptions | string | Content = {}): void {
|
|
|
102
67
|
const o: ButtonOptions = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
|
|
103
68
|
|
|
104
69
|
const tag = o.href != null ? "a" : "button";
|
|
105
|
-
const
|
|
106
|
-
const color = o.color ?? "primary";
|
|
107
|
-
const size = o.size === "sm" || o.size === "lg" ? `.S_${o.size}` : "";
|
|
70
|
+
const sizeCls = o.size != null ? `.s-${o.size}` : "";
|
|
108
71
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
const semantic = color === "primary" || color === "neutral" || color === "danger" || color === "success";
|
|
113
|
-
const colorCls = semantic ? `.S_${color}` : "";
|
|
114
|
-
|
|
115
|
-
const el = A(`${tag}.S_btn.S_${variant}${colorCls}${size}`, o.root, o.inner, () => {
|
|
72
|
+
// A filled `.primary` surface by default; `attrs` (applied after) can override
|
|
73
|
+
// the role/variant with e.g. `.danger`, `.neutral .outlined`.
|
|
74
|
+
A(`${tag}.s-btn.s-s.primary${sizeCls}`, o.attrs, () => {
|
|
116
75
|
if (o.href != null) {
|
|
117
76
|
A(`href=${o.href} role=button`);
|
|
118
77
|
if (o.disabled) A("aria-disabled=true");
|
|
@@ -127,11 +86,4 @@ export function button(opts: ButtonOptions | string | Content = {}): void {
|
|
|
127
86
|
if (o.content) o.content();
|
|
128
87
|
else if (o.text != null) A("#", o.text);
|
|
129
88
|
});
|
|
130
|
-
|
|
131
|
-
// Aberdeen's inline styler doesn't set CSS custom properties, so assign the
|
|
132
|
-
// custom accent on the element directly. A leading `$` is Aberdeen's shorthand
|
|
133
|
-
// for a CSS variable reference, so expand it to `var(--name)`.
|
|
134
|
-
if (!semantic && el instanceof HTMLElement) {
|
|
135
|
-
el.style.setProperty("--c", color.startsWith("$") ? `var(--${color.slice(1)})` : color);
|
|
136
|
-
}
|
|
137
89
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type Bindable, type Attributes } from "../core.js";
|
|
3
|
+
import { buttonGroup } from "./buttonGroup.js";
|
|
4
|
+
|
|
5
|
+
/** Options for {@link buttonChooser}. */
|
|
6
|
+
export interface ButtonChooserOptions {
|
|
7
|
+
/** Aberdeen attr/style string applied to the button group. */
|
|
8
|
+
attrs?: Attributes;
|
|
9
|
+
/**
|
|
10
|
+
* The options to display, as a plain object mapping id → display label.
|
|
11
|
+
* Buttons appear in insertion order.
|
|
12
|
+
*/
|
|
13
|
+
options: Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* Two-way binding for the selected id, or `null` when nothing is selected.
|
|
16
|
+
* Use an `A.proxy` or `A.ref`.
|
|
17
|
+
*/
|
|
18
|
+
bind: Bindable<string | null>;
|
|
19
|
+
/**
|
|
20
|
+
* When `true`, clicking the already-selected button deselects it, setting
|
|
21
|
+
* `bind.value` to `null`. Useful for "none / auto" states.
|
|
22
|
+
*/
|
|
23
|
+
allowDeselect?: boolean;
|
|
24
|
+
/** Button size. Defaults to `"md"`. */
|
|
25
|
+
size?: "sm" | "md" | "lg";
|
|
26
|
+
/** Name attribute for the hidden `<input>`, enabling form submission. */
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A single-selection segmented control: an attached button group where exactly
|
|
32
|
+
* one button is active at a time. Optionally allows deselecting back to `null`.
|
|
33
|
+
*
|
|
34
|
+
* Renders a hidden `<input>` alongside (when `name` is set) so the selected
|
|
35
|
+
* value is included in native form submission.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const $view = A.proxy({ value: "day" as string | null });
|
|
40
|
+
* S.buttonChooser({
|
|
41
|
+
* options: { day: "Day", week: "Week", month: "Month" },
|
|
42
|
+
* bind: $view,
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function buttonChooser(opts: ButtonChooserOptions): void {
|
|
47
|
+
A(() => {
|
|
48
|
+
const selected = opts.bind.value;
|
|
49
|
+
buttonGroup({
|
|
50
|
+
attrs: opts.attrs,
|
|
51
|
+
buttons: Object.entries(opts.options).map(([id, label]) => ({
|
|
52
|
+
text: label,
|
|
53
|
+
size: opts.size,
|
|
54
|
+
attrs: selected === id ? ".primary" : ".neutral .outlined",
|
|
55
|
+
click: () => {
|
|
56
|
+
opts.bind.value = (opts.allowDeselect && selected === id) ? null : id;
|
|
57
|
+
},
|
|
58
|
+
})),
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (opts.name) {
|
|
63
|
+
// Hidden input carries the value into native form submission.
|
|
64
|
+
A(() => A(`input type=hidden name=${opts.name} value=`, opts.bind.value ?? ""));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -19,23 +19,23 @@ export interface ButtonGroupOptions extends ContentOptions {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
A.insertGlobalCss({
|
|
22
|
-
".
|
|
22
|
+
".s-bgroup": {
|
|
23
23
|
"&": "display:inline-flex align-items:stretch",
|
|
24
|
-
"&.
|
|
25
|
-
"&.
|
|
26
|
-
"&.
|
|
24
|
+
"&.s-spaced": "gap:$2 flex-wrap:wrap",
|
|
25
|
+
"&.s-vertical": "flex-direction:column",
|
|
26
|
+
"&.s-attached": "gap:0",
|
|
27
27
|
// When attached, collapse the shared border and square off the touching
|
|
28
28
|
// corners, keeping only the outer ends of the group rounded.
|
|
29
|
-
"&.
|
|
30
|
-
"&.
|
|
31
|
-
"&.
|
|
32
|
-
"&.
|
|
33
|
-
"&.
|
|
34
|
-
"&.
|
|
35
|
-
"&.
|
|
36
|
-
"&.
|
|
29
|
+
"&.s-attached:not(.s-vertical) > .s-btn:not(:first-child)": "margin-left:-1px",
|
|
30
|
+
"&.s-attached:not(.s-vertical) > .s-btn:not(:first-child):not(:last-child)": "r:0",
|
|
31
|
+
"&.s-attached:not(.s-vertical) > .s-btn:first-child:not(:last-child)": "border-top-right-radius:0 border-bottom-right-radius:0",
|
|
32
|
+
"&.s-attached:not(.s-vertical) > .s-btn:last-child:not(:first-child)": "border-top-left-radius:0 border-bottom-left-radius:0",
|
|
33
|
+
"&.s-attached.s-vertical > .s-btn:not(:first-child)": "margin-top:-1px",
|
|
34
|
+
"&.s-attached.s-vertical > .s-btn:not(:first-child):not(:last-child)": "r:0",
|
|
35
|
+
"&.s-attached.s-vertical > .s-btn:first-child:not(:last-child)": "border-bottom-left-radius:0 border-bottom-right-radius:0",
|
|
36
|
+
"&.s-attached.s-vertical > .s-btn:last-child:not(:first-child)": "border-top-left-radius:0 border-top-right-radius:0",
|
|
37
37
|
// Keep the hovered/focused button's border above its neighbours.
|
|
38
|
-
"&.
|
|
38
|
+
"&.s-attached > .s-btn:hover, &.s-attached > .s-btn:focus-visible": "z-index:1",
|
|
39
39
|
},
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -46,17 +46,17 @@ A.insertGlobalCss({
|
|
|
46
46
|
* @example
|
|
47
47
|
* ```ts
|
|
48
48
|
* S.buttonGroup({ buttons: [
|
|
49
|
-
* { text: "Day",
|
|
50
|
-
* { text: "Week",
|
|
51
|
-
* { text: "Month",
|
|
49
|
+
* { text: "Day", attrs: ".neutral .outlined" },
|
|
50
|
+
* { text: "Week", attrs: ".neutral .outlined" },
|
|
51
|
+
* { text: "Month", attrs: ".neutral .outlined" },
|
|
52
52
|
* ]});
|
|
53
53
|
* ```
|
|
54
54
|
*/
|
|
55
55
|
export function buttonGroup(opts: ButtonGroupOptions = {}): void {
|
|
56
56
|
const layout = opts.layout ?? "attached";
|
|
57
|
-
const cls = `.
|
|
57
|
+
const cls = `.s-${layout}${opts.vertical ? ".s-vertical" : ""}`;
|
|
58
58
|
|
|
59
|
-
A(`div.
|
|
59
|
+
A(`div.s-bgroup${cls} role=group`, opts.attrs, () => {
|
|
60
60
|
if (opts.buttons) for (const b of opts.buttons) button(b);
|
|
61
61
|
if (opts.content) opts.content();
|
|
62
62
|
});
|
|
@@ -15,12 +15,12 @@ export interface CheckboxOptions extends Omit<FieldOptions, "label"> {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
A.insertGlobalCss({
|
|
18
|
-
".
|
|
18
|
+
".s-check": {
|
|
19
19
|
"&": "display:flex flex-direction:column gap:$1",
|
|
20
20
|
"> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
|
|
21
21
|
"> label:has(input:disabled)": "cursor:not-allowed opacity:0.6",
|
|
22
22
|
// Native control styled with accent-color: accessible and zero-fuss.
|
|
23
|
-
"input": "width:1.15em height:1.15em accent-color:$
|
|
23
|
+
"input": "width:1.15em height:1.15em accent-color:$s-accent cursor:inherit m:0",
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -37,9 +37,9 @@ A.insertGlobalCss({
|
|
|
37
37
|
export function checkbox(opts: CheckboxOptions = {}): void {
|
|
38
38
|
const id = opts.id ?? uniqueId("check");
|
|
39
39
|
|
|
40
|
-
A("div.
|
|
40
|
+
A("div.s-check", opts.attrs, () => {
|
|
41
41
|
A(`label for=${id}`, () => {
|
|
42
|
-
A("input type=checkbox", opts.
|
|
42
|
+
A("input type=checkbox", opts.inputAttrs, () => {
|
|
43
43
|
A(`id=${id}`);
|
|
44
44
|
if (opts.name) A(`name=${opts.name}`);
|
|
45
45
|
// `checked` is a boolean attribute: only set it when actually true.
|
|
@@ -56,15 +56,15 @@ export function checkbox(opts: CheckboxOptions = {}): void {
|
|
|
56
56
|
// Own scope so the label text/required marker don't recreate the input.
|
|
57
57
|
A(() => {
|
|
58
58
|
if (opts.label != null) drawSlot(opts.label);
|
|
59
|
-
if (opts.required) A("span.
|
|
59
|
+
if (opts.required) A("span.s-req aria-hidden=true #*");
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
A(() => {
|
|
64
|
-
if (opts.help != null && !opts.error) A("div.
|
|
64
|
+
if (opts.help != null && !opts.error) A("div.s-help", () => drawSlot(opts.help));
|
|
65
65
|
});
|
|
66
66
|
A(() => {
|
|
67
|
-
if (opts.error) A("div.
|
|
67
|
+
if (opts.error) A("div.s-error role=alert #", opts.error);
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
}
|
package/src/components/dialog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Slot, type
|
|
2
|
+
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
3
|
import { button } from "./button.js";
|
|
4
4
|
import { buttonGroup } from "./buttonGroup.js";
|
|
5
5
|
import { textline } from "./textline.js";
|
|
@@ -10,14 +10,14 @@ export interface DialogOptions {
|
|
|
10
10
|
header?: Slot;
|
|
11
11
|
/** Slot rendered in the styled footer bar. */
|
|
12
12
|
footer?: Slot;
|
|
13
|
+
/** Aberdeen attr/style string applied to the dialog panel. A surface — pass modifier classes (e.g. `".warning"`) to recolour it. */
|
|
14
|
+
attrs?: Attributes;
|
|
13
15
|
/** Aberdeen attr/style string applied to the header bar. */
|
|
14
|
-
|
|
16
|
+
headerAttrs?: Attributes;
|
|
15
17
|
/** Aberdeen attr/style string applied to the footer bar. */
|
|
16
|
-
|
|
18
|
+
footerAttrs?: Attributes;
|
|
17
19
|
/** Aberdeen attr/style string applied to the scrollable content `<div>`. */
|
|
18
|
-
|
|
19
|
-
/** Aberdeen attr/style string applied to the dialog panel itself. */
|
|
20
|
-
root?: Styling;
|
|
20
|
+
contentAttrs?: Attributes;
|
|
21
21
|
/**
|
|
22
22
|
* Allow closing via Esc or clicking the backdrop. Defaults to `true`.
|
|
23
23
|
* May be changed on a proxied options object while the dialog is open
|
|
@@ -25,10 +25,16 @@ export interface DialogOptions {
|
|
|
25
25
|
*/
|
|
26
26
|
allowCancel?: boolean;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* When set to `true` (default) the model will be destroyed when the `dialog()`-calling
|
|
29
|
+
* scope is destroyed.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
cancelWithScope?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Dialog body. A {@link Slot} whose draw-function receives a `close()` function
|
|
34
|
+
* — call it to dismiss the dialog programmatically. (A plain string renders as
|
|
35
|
+
* rich text.)
|
|
36
|
+
*/
|
|
37
|
+
content?: Slot<[close: () => void]>;
|
|
32
38
|
/**
|
|
33
39
|
* Called when the dialog closes for any reason (explicit `close()`, Esc, or
|
|
34
40
|
* backdrop click). Useful when you want a side-effect on close but don't need
|
|
@@ -37,48 +43,84 @@ export interface DialogOptions {
|
|
|
37
43
|
onClose?: () => void;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
// Transition helper classes.
|
|
41
|
-
// `.S_backdrop` = backdrop, hidden when another backdrop follows it in the DOM.
|
|
42
|
-
// `.S_dialog` = dialog box, slides + fades in/out.
|
|
43
46
|
A.insertGlobalCss({
|
|
44
|
-
".
|
|
45
|
-
"&": "position:fixed inset:0 z-index:200
|
|
46
|
-
"&:not(:has(~ .S_backdrop))": "display:block",
|
|
47
|
-
"&:not(:has(~ .S_backdrop)) + .S_dialog": "display:flex flex-direction:column",
|
|
48
|
-
// Transition states: applied momentarily on create; re-applied on destroy.
|
|
47
|
+
".s-backdrop": {
|
|
48
|
+
"&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
|
|
49
49
|
"&.hidden": "opacity:0 pointer-events:none",
|
|
50
50
|
},
|
|
51
|
-
".
|
|
51
|
+
".s-dialog": {
|
|
52
52
|
"&":
|
|
53
|
-
"position:fixed z-index:
|
|
53
|
+
"position:fixed z-index:200 top:50% left:50% " +
|
|
54
|
+
"display:flex flex-direction:column " +
|
|
54
55
|
"transform:translate(-50%,-50%) " +
|
|
55
56
|
"min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
|
|
56
|
-
"
|
|
57
|
-
"transition: opacity 0.2s ease, transform 0.2s ease;",
|
|
58
|
-
// Header and footer are fixed; only the content <div> scrolls.
|
|
57
|
+
"border: 1px solid $s-border; r: $s-radius-lg; box-shadow: $s-shadow; overflow:hidden " +
|
|
58
|
+
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
59
59
|
"> header":
|
|
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
63
|
"display:flex align-items:center 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
|
-
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px));",
|
|
67
|
-
"&.hidden *": "pointer-events:none",
|
|
66
|
+
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
|
|
68
67
|
},
|
|
69
68
|
});
|
|
70
69
|
|
|
70
|
+
const dialogs = A.proxy({} as Record<number,{resolve: (value: void | PromiseLike<void>) => void, opts: DialogOptions}>);
|
|
71
|
+
let dialogCount = 0;
|
|
72
|
+
|
|
73
|
+
const topDialogId = A.derive(() => {
|
|
74
|
+
const keys = Object.keys(dialogs);
|
|
75
|
+
if (keys.length) return keys[keys.length-1];
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
A.mount(document.body, () => {
|
|
79
|
+
A.onEach(dialogs, ({resolve, opts}, dialogId) => {
|
|
80
|
+
const close = () => { delete dialogs[dialogId]; };
|
|
81
|
+
|
|
82
|
+
A.clean(() => {
|
|
83
|
+
// Fires when this render is torn down — either because $closed became
|
|
84
|
+
// true (normal close) or because the parent reactive scope was cleaned up.
|
|
85
|
+
opts.onClose?.();
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Backdrop - hide when not the top dialog
|
|
90
|
+
const overlaid = A.derive(() => topDialogId.value != dialogId);
|
|
91
|
+
A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
|
|
92
|
+
if (opts.allowCancel !== false) close();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Dialog itself
|
|
96
|
+
A("div.s-dialog.s-s.panel create=hidden destroy=hidden", opts.attrs, () => {
|
|
97
|
+
A(() => {
|
|
98
|
+
if (opts.header != null) {
|
|
99
|
+
A("header.s-s.raised", opts.headerAttrs, () => drawSlot(opts.header));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
A("div", opts.contentAttrs, () => {
|
|
104
|
+
drawSlot(opts.content, close);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
A(() => {
|
|
108
|
+
if (opts.footer != null) {
|
|
109
|
+
A("footer.s-s.raised", opts.footerAttrs, () => drawSlot(opts.footer));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
})
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
|
|
73
118
|
* that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
|
74
119
|
* closes. Lifecycle is also tied to the parent reactive scope — when that scope
|
|
75
120
|
* is cleaned up the dialog disappears and the promise resolves.
|
|
76
121
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* The header and footer are pinned; only the body content scrolls when it is
|
|
81
|
-
* taller than `88vh`.
|
|
122
|
+
* Multiple dialogs stack correctly: each new pair (backdrop + dialog) has a
|
|
123
|
+
* higher z-index, while older dialogs are pushed behind their covering backdrop.
|
|
82
124
|
*
|
|
83
125
|
* @example
|
|
84
126
|
* ```ts
|
|
@@ -87,68 +129,34 @@ A.insertGlobalCss({
|
|
|
87
129
|
* content: (close) => {
|
|
88
130
|
* A("p #Are you sure?");
|
|
89
131
|
* S.button({ text: "Yes", click: () => { doIt(); close(); } });
|
|
90
|
-
* S.button({ text: "Cancel",
|
|
132
|
+
* S.button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
91
133
|
* },
|
|
92
134
|
* });
|
|
93
135
|
* ```
|
|
94
136
|
*/
|
|
95
137
|
export function dialog(opts: DialogOptions): Promise<void> {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// scope is torn down, the backdrop and dialog are removed from body too.
|
|
110
|
-
A.mount(document.body, () => {
|
|
111
|
-
// The 'peek' is there such that when 'closed' is first set, this scope doesn't need to watch anything anymore.
|
|
112
|
-
if (A.peek($closed, "value"), $closed.value) return;
|
|
113
|
-
|
|
114
|
-
// Global Esc listener — registered here so it's removed on close.
|
|
115
|
-
const onKey = (e: KeyboardEvent) => {
|
|
116
|
-
if (e.key === "Escape" && opts.allowCancel !== false) close();
|
|
117
|
-
};
|
|
118
|
-
document.addEventListener("keydown", onKey);
|
|
119
|
-
A.clean(() => {
|
|
120
|
-
document.removeEventListener("keydown", onKey);
|
|
121
|
-
// Fires when this render is torn down — either because $closed became
|
|
122
|
-
// true (normal close) or because the parent reactive scope was cleaned up.
|
|
123
|
-
onDone();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// Backdrop: fades in on creation, fades out on removal.
|
|
127
|
-
A("div.S_backdrop create=hidden destroy=hidden", () => {
|
|
128
|
-
A("click=", () => {
|
|
129
|
-
if (opts.allowCancel !== false) close();
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Dialog panel: fades + slides in/out.
|
|
134
|
-
A("div.S_dialog create=hidden destroy=hidden", opts.root, () => {
|
|
135
|
-
A(() => {
|
|
136
|
-
if (opts.header != null) {
|
|
137
|
-
A("header", opts.headerInner, () => drawSlot(opts.header));
|
|
138
|
+
if (!dialogCount) {
|
|
139
|
+
// Install Esc handler the first time we create a dialog
|
|
140
|
+
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
141
|
+
if (e.key === "Escape" && opts.allowCancel !== false) {
|
|
142
|
+
const ds = A.unproxy(dialogs);
|
|
143
|
+
// Search for top-most dialog
|
|
144
|
+
for(let i=dialogCount; i>0; i--) {
|
|
145
|
+
if (ds[i]) {
|
|
146
|
+
if (ds[i].opts.allowCancel !== false) {
|
|
147
|
+
// The clean handler should call resolve and onClose
|
|
148
|
+
delete dialogs[i];
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
138
151
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
A("div", opts.inner, () => {
|
|
142
|
-
if (opts.content) opts.content(close);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
A(() => {
|
|
146
|
-
if (opts.footer != null) {
|
|
147
|
-
A("footer", opts.footerInner, () => drawSlot(opts.footer));
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
151
154
|
});
|
|
155
|
+
}
|
|
156
|
+
const dialogId = ++dialogCount;
|
|
157
|
+
if (opts.cancelWithScope !== false) A.clean(() => { delete dialogs[dialogId]; });
|
|
158
|
+
return new Promise<void>((resolve) => {
|
|
159
|
+
dialogs[dialogId] = {resolve, opts};
|
|
152
160
|
});
|
|
153
161
|
}
|
|
154
162
|
|
|
@@ -156,8 +164,6 @@ export function dialog(opts: DialogOptions): Promise<void> {
|
|
|
156
164
|
* Shows a message dialog with a single OK button. Returns a `Promise<void>`
|
|
157
165
|
* that resolves when the user dismisses it.
|
|
158
166
|
*
|
|
159
|
-
* All properties of `opts` override the defaults, including `content`.
|
|
160
|
-
*
|
|
161
167
|
* @example
|
|
162
168
|
* ```ts
|
|
163
169
|
* await S.alert("File saved successfully.");
|
|
@@ -169,7 +175,7 @@ export function alert(message: string, opts: Partial<DialogOptions> = {}): Promi
|
|
|
169
175
|
allowCancel: true,
|
|
170
176
|
content: (close) => {
|
|
171
177
|
A("p", () => { A("#", message); });
|
|
172
|
-
buttonGroup({ layout: "spaced",
|
|
178
|
+
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
173
179
|
button({ text: "OK", click: close });
|
|
174
180
|
}});
|
|
175
181
|
},
|
|
@@ -179,10 +185,7 @@ export function alert(message: string, opts: Partial<DialogOptions> = {}): Promi
|
|
|
179
185
|
|
|
180
186
|
/**
|
|
181
187
|
* Shows a confirmation dialog with Cancel and OK buttons. Returns a
|
|
182
|
-
* `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise
|
|
183
|
-
* (including Esc / backdrop click when `allowCancel` is not `false`).
|
|
184
|
-
*
|
|
185
|
-
* All properties of `opts` override the defaults, including `content`.
|
|
188
|
+
* `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise.
|
|
186
189
|
*
|
|
187
190
|
* @example
|
|
188
191
|
* ```ts
|
|
@@ -197,8 +200,8 @@ export function confirm(message: string, opts: Partial<DialogOptions> = {}): Pro
|
|
|
197
200
|
allowCancel: true,
|
|
198
201
|
content: (close) => {
|
|
199
202
|
A("p", () => { A("#", message); });
|
|
200
|
-
buttonGroup({ layout: "spaced",
|
|
201
|
-
button({ text: "Cancel",
|
|
203
|
+
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
204
|
+
button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
|
|
202
205
|
button({ text: "OK", click: () => { confirmed = true; close(); } });
|
|
203
206
|
}});
|
|
204
207
|
},
|
|
@@ -213,10 +216,7 @@ export function confirm(message: string, opts: Partial<DialogOptions> = {}): Pro
|
|
|
213
216
|
|
|
214
217
|
/**
|
|
215
218
|
* Shows a prompt dialog with a text input. Returns a `Promise<string | null>` —
|
|
216
|
-
* the entered string if the user confirmed, or `null` if cancelled
|
|
217
|
-
* backdrop click / Cancel button).
|
|
218
|
-
*
|
|
219
|
-
* All properties of `opts` override the defaults, including `content`.
|
|
219
|
+
* the entered string if the user confirmed, or `null` if cancelled.
|
|
220
220
|
*
|
|
221
221
|
* @example
|
|
222
222
|
* ```ts
|
|
@@ -233,7 +233,6 @@ export function prompt(message: string, defaultValue = "", opts: Partial<DialogO
|
|
|
233
233
|
content: (close) => {
|
|
234
234
|
A("p", () => { A("#", message); });
|
|
235
235
|
const $v = A.proxy({ value: defaultValue });
|
|
236
|
-
// Wrap in a form so Enter submits; display:contents keeps flex layout intact.
|
|
237
236
|
A("form display:contents", () => {
|
|
238
237
|
A("submit=", (e: Event) => {
|
|
239
238
|
e.preventDefault();
|
|
@@ -241,8 +240,8 @@ export function prompt(message: string, defaultValue = "", opts: Partial<DialogO
|
|
|
241
240
|
close();
|
|
242
241
|
});
|
|
243
242
|
textline({ bind: A.ref($v, "value") });
|
|
244
|
-
buttonGroup({ layout: "spaced",
|
|
245
|
-
button({ text: "Cancel",
|
|
243
|
+
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
244
|
+
button({ text: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
|
|
246
245
|
button({ text: "OK", type: "submit" });
|
|
247
246
|
}});
|
|
248
247
|
});
|