staffa 0.1.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 +186 -0
- package/dist/components/autocomplete.d.ts +44 -0
- package/dist/components/autocomplete.js +250 -0
- package/dist/components/box.d.ts +31 -0
- package/dist/components/box.js +48 -0
- package/dist/components/button.d.ts +67 -0
- package/dist/components/button.js +83 -0
- package/dist/components/buttonGroup.d.ts +31 -0
- package/dist/components/buttonGroup.js +46 -0
- package/dist/components/checkbox.d.ts +24 -0
- package/dist/components/checkbox.js +63 -0
- package/dist/components/dialog.d.ts +97 -0
- package/dist/components/dialog.js +214 -0
- package/dist/components/field.d.ts +50 -0
- package/dist/components/field.js +78 -0
- package/dist/components/form.d.ts +42 -0
- package/dist/components/form.js +59 -0
- package/dist/components/main.d.ts +47 -0
- package/dist/components/main.js +89 -0
- package/dist/components/modal.d.ts +2 -0
- package/dist/components/modal.js +2 -0
- package/dist/components/select.d.ts +27 -0
- package/dist/components/select.js +57 -0
- package/dist/components/tabs.d.ts +41 -0
- package/dist/components/tabs.js +108 -0
- package/dist/components/textarea.d.ts +31 -0
- package/dist/components/textarea.js +49 -0
- package/dist/components/textline.d.ts +38 -0
- package/dist/components/textline.js +32 -0
- package/dist/core.d.ts +73 -0
- package/dist/core.js +18 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +72 -0
- package/dist/skye.esm.js +1 -0
- package/dist/theme.d.ts +87 -0
- package/dist/theme.js +135 -0
- package/package.json +35 -0
- package/src/components/autocomplete.ts +272 -0
- package/src/components/box.ts +62 -0
- package/src/components/button.ts +137 -0
- package/src/components/buttonGroup.ts +63 -0
- package/src/components/checkbox.ts +70 -0
- package/src/components/dialog.ts +257 -0
- package/src/components/field.ts +115 -0
- package/src/components/form.ts +84 -0
- package/src/components/main.ts +110 -0
- package/src/components/select.ts +75 -0
- package/src/components/tabs.ts +144 -0
- package/src/components/textarea.ts +68 -0
- package/src/components/textline.ts +66 -0
- package/src/core.ts +88 -0
- package/src/index.ts +98 -0
- package/src/theme.ts +195 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { drawSlot } from "../core.js";
|
|
3
|
+
// The color role sets a local `--c` (and `--cfg` for text on filled); the
|
|
4
|
+
// variant rules consume them, so we avoid writing colour×variant rules.
|
|
5
|
+
A.insertGlobalCss({
|
|
6
|
+
".S_btn": {
|
|
7
|
+
"&": "--c:$sPrimary --cfg:$sPrimaryFg " +
|
|
8
|
+
"display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
9
|
+
"font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
10
|
+
"border: 1px solid transparent; r:$sRadius padding: 0.5em 1em; " +
|
|
11
|
+
"transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
|
|
12
|
+
"&:focus-visible": "outline:none box-shadow: 0 0 0 3px $sFocus;",
|
|
13
|
+
"&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
|
|
14
|
+
// Colour roles.
|
|
15
|
+
"&.S_neutral": "--c:$sBorderStrong --cfg:$sFg",
|
|
16
|
+
"&.S_danger": "--c:$sDanger --cfg:#fff",
|
|
17
|
+
"&.S_success": "--c:$sSuccess --cfg:#08110d",
|
|
18
|
+
// Variants.
|
|
19
|
+
"&.S_filled": "background:$c color:$cfg border-color:$c",
|
|
20
|
+
"&.S_filled:hover": "filter:brightness(1.1)",
|
|
21
|
+
"&.S_tonal": "color:$c background: color-mix(in srgb, $c 20%, transparent); border-color: color-mix(in srgb, $c 30%, transparent);",
|
|
22
|
+
"&.S_tonal:hover": "background: color-mix(in srgb, $c 30%, transparent);",
|
|
23
|
+
"&.S_outlined": "color:$c background:transparent border-color: color-mix(in srgb, $c 55%, $sBorder);",
|
|
24
|
+
"&.S_outlined:hover": "background: color-mix(in srgb, $c 12%, transparent);",
|
|
25
|
+
// Sizes.
|
|
26
|
+
"&.S_sm": "padding: 0.32em 0.7em; font-size:0.85em",
|
|
27
|
+
"&.S_lg": "padding: 0.66em 1.3em; font-size:1.1em",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* A button. Always carries at least a visible border so its affordance is
|
|
32
|
+
* obvious at a glance, regardless of {@link ButtonVariant | variant}.
|
|
33
|
+
*
|
|
34
|
+
* Shortcut: pass a string to use it as the label, or a function for custom
|
|
35
|
+
* content.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* S.button({ text: "Save", click: save });
|
|
40
|
+
* S.button({ text: "Delete", color: "danger", variant: "outlined", click: del });
|
|
41
|
+
* S.button("Cancel"); // shorthand for { text: "Cancel" }
|
|
42
|
+
* S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function button(opts = {}) {
|
|
46
|
+
const o = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
|
|
47
|
+
const tag = o.href != null ? "a" : "button";
|
|
48
|
+
const variant = o.variant ?? "filled";
|
|
49
|
+
const color = o.color ?? "primary";
|
|
50
|
+
const size = o.size === "sm" || o.size === "lg" ? `.S_${o.size}` : "";
|
|
51
|
+
// Semantic roles select a colour class (the CSS sets `--c`/`--cfg`); any other
|
|
52
|
+
// value is a raw CSS colour we assign to `--c`, which the variant rules consume
|
|
53
|
+
// via `var(--c)`. (`primary` is the base default — its class is a no-op.)
|
|
54
|
+
const semantic = color === "primary" || color === "neutral" || color === "danger" || color === "success";
|
|
55
|
+
const colorCls = semantic ? `.S_${color}` : "";
|
|
56
|
+
const el = A(`${tag}.S_btn.S_${variant}${colorCls}${size}`, o.root, o.inner, () => {
|
|
57
|
+
if (o.href != null) {
|
|
58
|
+
A(`href=${o.href} role=button`);
|
|
59
|
+
if (o.disabled)
|
|
60
|
+
A("aria-disabled=true");
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
A("type=", o.type ?? "button");
|
|
64
|
+
if (o.disabled)
|
|
65
|
+
A("disabled=true");
|
|
66
|
+
}
|
|
67
|
+
if (o.ariaLabel)
|
|
68
|
+
A("aria-label=", o.ariaLabel);
|
|
69
|
+
if (o.click)
|
|
70
|
+
A("click=", o.click);
|
|
71
|
+
drawSlot(o.icon);
|
|
72
|
+
if (o.content)
|
|
73
|
+
o.content();
|
|
74
|
+
else if (o.text != null)
|
|
75
|
+
A("#", o.text);
|
|
76
|
+
});
|
|
77
|
+
// Aberdeen's inline styler doesn't set CSS custom properties, so assign the
|
|
78
|
+
// custom accent on the element directly. A leading `$` is Aberdeen's shorthand
|
|
79
|
+
// for a CSS variable reference, so expand it to `var(--name)`.
|
|
80
|
+
if (!semantic && el instanceof HTMLElement) {
|
|
81
|
+
el.style.setProperty("--c", color.startsWith("$") ? `var(--${color.slice(1)})` : color);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ContentOptions } from "../core.js";
|
|
2
|
+
import { type ButtonOptions } from "./button.js";
|
|
3
|
+
/** Options for {@link buttonGroup}. */
|
|
4
|
+
export interface ButtonGroupOptions extends ContentOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Declarative list of buttons. Rendered in order. Alternatively (or
|
|
7
|
+
* additionally) draw buttons yourself via {@link ContentOptions.content}.
|
|
8
|
+
*/
|
|
9
|
+
buttons?: ButtonOptions[];
|
|
10
|
+
/**
|
|
11
|
+
* `"attached"` (default) joins the buttons into a single segmented control
|
|
12
|
+
* with shared borders; `"spaced"` lays them out with a normal gap.
|
|
13
|
+
*/
|
|
14
|
+
layout?: "attached" | "spaced";
|
|
15
|
+
/** Stack vertically instead of horizontally. */
|
|
16
|
+
vertical?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Groups related buttons, either as a joined segmented control (`attached`) or
|
|
20
|
+
* spaced out. A `role=group` is applied for assistive tech.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* S.buttonGroup({ buttons: [
|
|
25
|
+
* { text: "Day", variant: "outlined", color: "neutral" },
|
|
26
|
+
* { text: "Week", variant: "outlined", color: "neutral" },
|
|
27
|
+
* { text: "Month", variant: "outlined", color: "neutral" },
|
|
28
|
+
* ]});
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function buttonGroup(opts?: ButtonGroupOptions): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { button } from "./button.js";
|
|
3
|
+
A.insertGlobalCss({
|
|
4
|
+
".S_bgroup": {
|
|
5
|
+
"&": "display:inline-flex align-items:stretch",
|
|
6
|
+
"&.S_spaced": "gap:$2 flex-wrap:wrap",
|
|
7
|
+
"&.S_vertical": "flex-direction:column",
|
|
8
|
+
"&.S_attached": "gap:0",
|
|
9
|
+
// When attached, collapse the shared border and square off the touching
|
|
10
|
+
// corners, keeping only the outer ends of the group rounded.
|
|
11
|
+
"&.S_attached:not(.S_vertical) > .S_btn:not(:first-child)": "margin-left:-1px",
|
|
12
|
+
"&.S_attached:not(.S_vertical) > .S_btn:not(:first-child):not(:last-child)": "r:0",
|
|
13
|
+
"&.S_attached:not(.S_vertical) > .S_btn:first-child:not(:last-child)": "border-top-right-radius:0 border-bottom-right-radius:0",
|
|
14
|
+
"&.S_attached:not(.S_vertical) > .S_btn:last-child:not(:first-child)": "border-top-left-radius:0 border-bottom-left-radius:0",
|
|
15
|
+
"&.S_attached.S_vertical > .S_btn:not(:first-child)": "margin-top:-1px",
|
|
16
|
+
"&.S_attached.S_vertical > .S_btn:not(:first-child):not(:last-child)": "r:0",
|
|
17
|
+
"&.S_attached.S_vertical > .S_btn:first-child:not(:last-child)": "border-bottom-left-radius:0 border-bottom-right-radius:0",
|
|
18
|
+
"&.S_attached.S_vertical > .S_btn:last-child:not(:first-child)": "border-top-left-radius:0 border-top-right-radius:0",
|
|
19
|
+
// Keep the hovered/focused button's border above its neighbours.
|
|
20
|
+
"&.S_attached > .S_btn:hover, &.S_attached > .S_btn:focus-visible": "z-index:1",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Groups related buttons, either as a joined segmented control (`attached`) or
|
|
25
|
+
* spaced out. A `role=group` is applied for assistive tech.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* S.buttonGroup({ buttons: [
|
|
30
|
+
* { text: "Day", variant: "outlined", color: "neutral" },
|
|
31
|
+
* { text: "Week", variant: "outlined", color: "neutral" },
|
|
32
|
+
* { text: "Month", variant: "outlined", color: "neutral" },
|
|
33
|
+
* ]});
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function buttonGroup(opts = {}) {
|
|
37
|
+
const layout = opts.layout ?? "attached";
|
|
38
|
+
const cls = `.S_${layout}${opts.vertical ? ".S_vertical" : ""}`;
|
|
39
|
+
A(`div.S_bgroup${cls} role=group`, opts.root, opts.inner, () => {
|
|
40
|
+
if (opts.buttons)
|
|
41
|
+
for (const b of opts.buttons)
|
|
42
|
+
button(b);
|
|
43
|
+
if (opts.content)
|
|
44
|
+
opts.content();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Bindable, type Slot } from "../core.js";
|
|
2
|
+
import type { FieldOptions } from "./field.js";
|
|
3
|
+
/** Options for {@link checkbox}. */
|
|
4
|
+
export interface CheckboxOptions extends Omit<FieldOptions, "label"> {
|
|
5
|
+
/** The label shown next to the box. Required for a meaningful checkbox. */
|
|
6
|
+
label?: Slot;
|
|
7
|
+
/** Two-way binding target holding a boolean. */
|
|
8
|
+
bind?: Bindable<boolean>;
|
|
9
|
+
/** Static initial checked state. */
|
|
10
|
+
checked?: boolean;
|
|
11
|
+
/** Fired on `change` with the native event. */
|
|
12
|
+
change?: (event: Event) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A checkbox with an associated, clickable label. Uses the native `<input
|
|
16
|
+
* type=checkbox>` (styled with `accent-color`) for full keyboard and screen
|
|
17
|
+
* reader support.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* S.checkbox({ label: "Subscribe to newsletter", bind: A.ref($prefs, "newsletter") });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkbox(opts?: CheckboxOptions): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { drawSlot, uniqueId } from "../core.js";
|
|
3
|
+
A.insertGlobalCss({
|
|
4
|
+
".S_check": {
|
|
5
|
+
"&": "display:flex flex-direction:column gap:$1",
|
|
6
|
+
"> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
|
|
7
|
+
"> label:has(input:disabled)": "cursor:not-allowed opacity:0.6",
|
|
8
|
+
// Native control styled with accent-color: accessible and zero-fuss.
|
|
9
|
+
"input": "width:1.15em height:1.15em accent-color:$sPrimary cursor:inherit m:0",
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* A checkbox with an associated, clickable label. Uses the native `<input
|
|
14
|
+
* type=checkbox>` (styled with `accent-color`) for full keyboard and screen
|
|
15
|
+
* reader support.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* S.checkbox({ label: "Subscribe to newsletter", bind: A.ref($prefs, "newsletter") });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function checkbox(opts = {}) {
|
|
23
|
+
const id = opts.id ?? uniqueId("check");
|
|
24
|
+
A("div.S_check", opts.root, () => {
|
|
25
|
+
A(`label for=${id}`, () => {
|
|
26
|
+
A("input type=checkbox", opts.control, () => {
|
|
27
|
+
A(`id=${id}`);
|
|
28
|
+
if (opts.name)
|
|
29
|
+
A(`name=${opts.name}`);
|
|
30
|
+
// `checked` is a boolean attribute: only set it when actually true.
|
|
31
|
+
if (opts.checked && !opts.bind)
|
|
32
|
+
A("checked=true");
|
|
33
|
+
if (opts.change)
|
|
34
|
+
A("change=", opts.change);
|
|
35
|
+
A(() => {
|
|
36
|
+
if (opts.disabled)
|
|
37
|
+
A("disabled=true");
|
|
38
|
+
});
|
|
39
|
+
A(() => {
|
|
40
|
+
if (opts.required)
|
|
41
|
+
A("aria-required=true");
|
|
42
|
+
});
|
|
43
|
+
if (opts.bind)
|
|
44
|
+
A("bind=", opts.bind);
|
|
45
|
+
});
|
|
46
|
+
// Own scope so the label text/required marker don't recreate the input.
|
|
47
|
+
A(() => {
|
|
48
|
+
if (opts.label != null)
|
|
49
|
+
drawSlot(opts.label);
|
|
50
|
+
if (opts.required)
|
|
51
|
+
A("span.S_req aria-hidden=true #*");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
A(() => {
|
|
55
|
+
if (opts.help != null && !opts.error)
|
|
56
|
+
A("div.S_help", () => drawSlot(opts.help));
|
|
57
|
+
});
|
|
58
|
+
A(() => {
|
|
59
|
+
if (opts.error)
|
|
60
|
+
A("div.S_error role=alert #", opts.error);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type Slot, type Styling } from "../core.js";
|
|
2
|
+
/** Options for {@link dialog}. */
|
|
3
|
+
export interface DialogOptions {
|
|
4
|
+
/** Slot rendered in the styled header bar. */
|
|
5
|
+
header?: Slot;
|
|
6
|
+
/** Slot rendered in the styled footer bar. */
|
|
7
|
+
footer?: Slot;
|
|
8
|
+
/** Aberdeen attr/style string applied to the header bar. */
|
|
9
|
+
headerInner?: Styling;
|
|
10
|
+
/** Aberdeen attr/style string applied to the footer bar. */
|
|
11
|
+
footerInner?: Styling;
|
|
12
|
+
/** Aberdeen attr/style string applied to the scrollable content `<div>`. */
|
|
13
|
+
inner?: Styling;
|
|
14
|
+
/** Aberdeen attr/style string applied to the dialog panel itself. */
|
|
15
|
+
root?: Styling;
|
|
16
|
+
/**
|
|
17
|
+
* Allow closing via Esc or clicking the backdrop. Defaults to `true`.
|
|
18
|
+
* May be changed on a proxied options object while the dialog is open
|
|
19
|
+
* (e.g. lock when form data is dirty).
|
|
20
|
+
*/
|
|
21
|
+
allowCancel?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Dialog body. Receives a `close()` function — call it to dismiss the dialog
|
|
24
|
+
* programmatically.
|
|
25
|
+
*/
|
|
26
|
+
content?: (close: () => void) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Called when the dialog closes for any reason (explicit `close()`, Esc, or
|
|
29
|
+
* backdrop click). Useful when you want a side-effect on close but don't need
|
|
30
|
+
* the Promise returned by {@link dialog}.
|
|
31
|
+
*/
|
|
32
|
+
onClose?: () => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
|
|
36
|
+
* that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
|
37
|
+
* closes. Lifecycle is also tied to the parent reactive scope — when that scope
|
|
38
|
+
* is cleaned up the dialog disappears and the promise resolves.
|
|
39
|
+
*
|
|
40
|
+
* Only the **last** open dialog (and its backdrop) is visible; earlier pairs are
|
|
41
|
+
* hidden via the CSS `+` selector, so nested dialogs stack correctly.
|
|
42
|
+
*
|
|
43
|
+
* The header and footer are pinned; only the body content scrolls when it is
|
|
44
|
+
* taller than `88vh`.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* S.dialog({
|
|
49
|
+
* header: "Confirm",
|
|
50
|
+
* content: (close) => {
|
|
51
|
+
* A("p #Are you sure?");
|
|
52
|
+
* S.button({ text: "Yes", click: () => { doIt(); close(); } });
|
|
53
|
+
* S.button({ text: "Cancel", variant: "outlined", click: close });
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function dialog(opts: DialogOptions): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Shows a message dialog with a single OK button. Returns a `Promise<void>`
|
|
61
|
+
* that resolves when the user dismisses it.
|
|
62
|
+
*
|
|
63
|
+
* All properties of `opts` override the defaults, including `content`.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* await S.alert("File saved successfully.");
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function alert(message: string, opts?: Partial<DialogOptions>): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Shows a confirmation dialog with Cancel and OK buttons. Returns a
|
|
73
|
+
* `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise
|
|
74
|
+
* (including Esc / backdrop click when `allowCancel` is not `false`).
|
|
75
|
+
*
|
|
76
|
+
* All properties of `opts` override the defaults, including `content`.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* if (await S.confirm("Delete this item?")) deleteItem();
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function confirm(message: string, opts?: Partial<DialogOptions>): Promise<boolean>;
|
|
84
|
+
/**
|
|
85
|
+
* Shows a prompt dialog with a text input. Returns a `Promise<string | null>` —
|
|
86
|
+
* the entered string if the user confirmed, or `null` if cancelled (Esc /
|
|
87
|
+
* backdrop click / Cancel button).
|
|
88
|
+
*
|
|
89
|
+
* All properties of `opts` override the defaults, including `content`.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* const name = await S.prompt("Enter your name:", "Alice");
|
|
94
|
+
* if (name !== null) greet(name);
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export declare function prompt(message: string, defaultValue?: string, opts?: Partial<DialogOptions>): Promise<string | null>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { drawSlot } from "../core.js";
|
|
3
|
+
import { button } from "./button.js";
|
|
4
|
+
import { buttonGroup } from "./buttonGroup.js";
|
|
5
|
+
import { textline } from "./textline.js";
|
|
6
|
+
// Transition helper classes.
|
|
7
|
+
// `.S_backdrop` = backdrop, hidden when another backdrop follows it in the DOM.
|
|
8
|
+
// `.S_dialog` = dialog box, slides + fades in/out.
|
|
9
|
+
A.insertGlobalCss({
|
|
10
|
+
".S_backdrop": {
|
|
11
|
+
"&": "position:fixed inset:0 z-index:200; background: rgba(0,0,0,0.55); transition: opacity 0.2s ease;",
|
|
12
|
+
"&:not(:has(~ .S_backdrop))": "display:block",
|
|
13
|
+
"&:not(:has(~ .S_backdrop)) + .S_dialog": "display:flex flex-direction:column",
|
|
14
|
+
// Transition states: applied momentarily on create; re-applied on destroy.
|
|
15
|
+
"&.hidden": "opacity:0 pointer-events:none",
|
|
16
|
+
},
|
|
17
|
+
".S_dialog": {
|
|
18
|
+
"&": "position:fixed z-index:201 top:50% left:50% " +
|
|
19
|
+
"transform:translate(-50%,-50%) " +
|
|
20
|
+
"min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
|
|
21
|
+
"bg:$sSurface border: 1px solid $sBorder; r:$sRadiusLg box-shadow:$sShadow overflow:hidden " +
|
|
22
|
+
"transition: opacity 0.2s ease, transform 0.2s ease;",
|
|
23
|
+
// Header and footer are fixed; only the content <div> scrolls.
|
|
24
|
+
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
25
|
+
"bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600 flex-shrink:0",
|
|
26
|
+
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
27
|
+
"bg:$sSurfaceHi border-top: 1px solid $sBorder; flex-shrink:0",
|
|
28
|
+
"> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
|
|
29
|
+
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px));",
|
|
30
|
+
"&.hidden *": "pointer-events:none",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
|
|
35
|
+
* that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
|
36
|
+
* closes. Lifecycle is also tied to the parent reactive scope — when that scope
|
|
37
|
+
* is cleaned up the dialog disappears and the promise resolves.
|
|
38
|
+
*
|
|
39
|
+
* Only the **last** open dialog (and its backdrop) is visible; earlier pairs are
|
|
40
|
+
* hidden via the CSS `+` selector, so nested dialogs stack correctly.
|
|
41
|
+
*
|
|
42
|
+
* The header and footer are pinned; only the body content scrolls when it is
|
|
43
|
+
* taller than `88vh`.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* S.dialog({
|
|
48
|
+
* header: "Confirm",
|
|
49
|
+
* content: (close) => {
|
|
50
|
+
* A("p #Are you sure?");
|
|
51
|
+
* S.button({ text: "Yes", click: () => { doIt(); close(); } });
|
|
52
|
+
* S.button({ text: "Cancel", variant: "outlined", click: close });
|
|
53
|
+
* },
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function dialog(opts) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const $closed = A.proxy(false);
|
|
60
|
+
const close = () => { $closed.value = true; };
|
|
61
|
+
let resolved = false;
|
|
62
|
+
const onDone = () => {
|
|
63
|
+
if (resolved)
|
|
64
|
+
return;
|
|
65
|
+
resolved = true;
|
|
66
|
+
opts.onClose?.();
|
|
67
|
+
resolve();
|
|
68
|
+
};
|
|
69
|
+
// A.mount ties this scope to the calling reactive scope — when the parent
|
|
70
|
+
// scope is torn down, the backdrop and dialog are removed from body too.
|
|
71
|
+
A.mount(document.body, () => {
|
|
72
|
+
// The 'peek' is there such that when 'closed' is first set, this scope doesn't need to watch anything anymore.
|
|
73
|
+
if (A.peek($closed, "value"), $closed.value)
|
|
74
|
+
return;
|
|
75
|
+
// Global Esc listener — registered here so it's removed on close.
|
|
76
|
+
const onKey = (e) => {
|
|
77
|
+
if (e.key === "Escape" && opts.allowCancel !== false)
|
|
78
|
+
close();
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener("keydown", onKey);
|
|
81
|
+
A.clean(() => {
|
|
82
|
+
document.removeEventListener("keydown", onKey);
|
|
83
|
+
// Fires when this render is torn down — either because $closed became
|
|
84
|
+
// true (normal close) or because the parent reactive scope was cleaned up.
|
|
85
|
+
onDone();
|
|
86
|
+
});
|
|
87
|
+
// Backdrop: fades in on creation, fades out on removal.
|
|
88
|
+
A("div.S_backdrop create=hidden destroy=hidden", () => {
|
|
89
|
+
A("click=", () => {
|
|
90
|
+
if (opts.allowCancel !== false)
|
|
91
|
+
close();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// Dialog panel: fades + slides in/out.
|
|
95
|
+
A("div.S_dialog create=hidden destroy=hidden", opts.root, () => {
|
|
96
|
+
A(() => {
|
|
97
|
+
if (opts.header != null) {
|
|
98
|
+
A("header", opts.headerInner, () => drawSlot(opts.header));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
A("div", opts.inner, () => {
|
|
102
|
+
if (opts.content)
|
|
103
|
+
opts.content(close);
|
|
104
|
+
});
|
|
105
|
+
A(() => {
|
|
106
|
+
if (opts.footer != null) {
|
|
107
|
+
A("footer", opts.footerInner, () => drawSlot(opts.footer));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Shows a message dialog with a single OK button. Returns a `Promise<void>`
|
|
116
|
+
* that resolves when the user dismisses it.
|
|
117
|
+
*
|
|
118
|
+
* All properties of `opts` override the defaults, including `content`.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* await S.alert("File saved successfully.");
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function alert(message, opts = {}) {
|
|
126
|
+
return dialog({
|
|
127
|
+
header: "Alert",
|
|
128
|
+
allowCancel: true,
|
|
129
|
+
content: (close) => {
|
|
130
|
+
A("p", () => { A("#", message); });
|
|
131
|
+
buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
|
|
132
|
+
button({ text: "OK", click: close });
|
|
133
|
+
} });
|
|
134
|
+
},
|
|
135
|
+
...opts,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Shows a confirmation dialog with Cancel and OK buttons. Returns a
|
|
140
|
+
* `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise
|
|
141
|
+
* (including Esc / backdrop click when `allowCancel` is not `false`).
|
|
142
|
+
*
|
|
143
|
+
* All properties of `opts` override the defaults, including `content`.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* if (await S.confirm("Delete this item?")) deleteItem();
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export function confirm(message, opts = {}) {
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
let confirmed = false;
|
|
153
|
+
dialog({
|
|
154
|
+
header: "Confirm",
|
|
155
|
+
allowCancel: true,
|
|
156
|
+
content: (close) => {
|
|
157
|
+
A("p", () => { A("#", message); });
|
|
158
|
+
buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
|
|
159
|
+
button({ text: "Cancel", variant: "outlined", color: "neutral", click: close });
|
|
160
|
+
button({ text: "OK", click: () => { confirmed = true; close(); } });
|
|
161
|
+
} });
|
|
162
|
+
},
|
|
163
|
+
...opts,
|
|
164
|
+
onClose: () => {
|
|
165
|
+
resolve(confirmed);
|
|
166
|
+
opts.onClose?.();
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Shows a prompt dialog with a text input. Returns a `Promise<string | null>` —
|
|
173
|
+
* the entered string if the user confirmed, or `null` if cancelled (Esc /
|
|
174
|
+
* backdrop click / Cancel button).
|
|
175
|
+
*
|
|
176
|
+
* All properties of `opts` override the defaults, including `content`.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* const name = await S.prompt("Enter your name:", "Alice");
|
|
181
|
+
* if (name !== null) greet(name);
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function prompt(message, defaultValue = "", opts = {}) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
let result = null;
|
|
187
|
+
dialog({
|
|
188
|
+
header: "Input",
|
|
189
|
+
allowCancel: true,
|
|
190
|
+
content: (close) => {
|
|
191
|
+
A("p", () => { A("#", message); });
|
|
192
|
+
const $v = A.proxy({ value: defaultValue });
|
|
193
|
+
// Wrap in a form so Enter submits; display:contents keeps flex layout intact.
|
|
194
|
+
A("form display:contents", () => {
|
|
195
|
+
A("submit=", (e) => {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
result = $v.value;
|
|
198
|
+
close();
|
|
199
|
+
});
|
|
200
|
+
textline({ bind: A.ref($v, "value") });
|
|
201
|
+
buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
|
|
202
|
+
button({ text: "Cancel", variant: "outlined", color: "neutral", type: "button", click: close });
|
|
203
|
+
button({ text: "OK", type: "submit" });
|
|
204
|
+
} });
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
...opts,
|
|
208
|
+
onClose: () => {
|
|
209
|
+
resolve(result);
|
|
210
|
+
opts.onClose?.();
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type BaseOptions, type Bindable, type Slot, type Styling } from "../core.js";
|
|
2
|
+
/**
|
|
3
|
+
* Options shared by all *form field* components (textline, textarea, checkbox,
|
|
4
|
+
* autocomplete, ...).
|
|
5
|
+
*
|
|
6
|
+
* Fields share a consistent vertical layout: an optional label, the control
|
|
7
|
+
* itself, and optional help/error text below it. {@link form} relies on this
|
|
8
|
+
* shared structure to align groups of fields.
|
|
9
|
+
*/
|
|
10
|
+
export interface FieldOptions extends BaseOptions {
|
|
11
|
+
/** Visible label, associated with the control via `for`/`id` for a11y. */
|
|
12
|
+
label?: Slot;
|
|
13
|
+
/** Helper text shown beneath the control. */
|
|
14
|
+
help?: Slot;
|
|
15
|
+
/**
|
|
16
|
+
* Error message shown beneath the control. When set, the control is marked
|
|
17
|
+
* `aria-invalid` and styled accordingly. May be reactive.
|
|
18
|
+
*/
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Disables the control. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Marks the field required (adds a `*` and the `aria-required` attribute). */
|
|
23
|
+
required?: boolean;
|
|
24
|
+
/** The `name` attribute, for native form submission. */
|
|
25
|
+
name?: string;
|
|
26
|
+
/** Explicit id for the control; auto-generated when omitted. */
|
|
27
|
+
id?: string;
|
|
28
|
+
/** Aberdeen attr/style string applied to the control element itself. */
|
|
29
|
+
control?: Styling;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Render the standard field chrome (label + control + help/error) around a
|
|
33
|
+
* caller-supplied control.
|
|
34
|
+
*
|
|
35
|
+
* Each piece is read inside its own small reactive scope, so e.g. flipping
|
|
36
|
+
* `error` on a proxied options object only re-renders the error line — not the
|
|
37
|
+
* control.
|
|
38
|
+
*
|
|
39
|
+
* @param opts The field options.
|
|
40
|
+
* @param drawControl Receives the resolved `id` and the live "invalid" getter,
|
|
41
|
+
* and must draw the actual control element (using class `S_input` where
|
|
42
|
+
* appropriate, and passing `opts.control` as an arg for caller styling).
|
|
43
|
+
*/
|
|
44
|
+
export declare function drawField(opts: FieldOptions, drawControl: (id: string, isInvalid: () => boolean) => void): void;
|
|
45
|
+
/**
|
|
46
|
+
* Apply the shared, reactive control attributes (`id`, `name`, `disabled`,
|
|
47
|
+
* `required`, `aria-invalid`, `bind`) to the current element. The dynamic ones
|
|
48
|
+
* each get their own scope so the control element is never recreated.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyControlAttrs(opts: FieldOptions, id: string, isInvalid: () => boolean, bind?: Bindable<unknown>): void;
|