staffa 0.6.0 → 0.7.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 +36 -16
- package/dist/components/autocomplete.js +12 -9
- package/dist/components/box.js +15 -10
- package/dist/components/button.d.ts +3 -1
- package/dist/components/button.js +21 -28
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/buttonGroup.d.ts +3 -3
- package/dist/components/buttonGroup.js +3 -3
- package/dist/components/dialog.d.ts +3 -1
- package/dist/components/dialog.js +20 -10
- package/dist/components/field.js +7 -4
- package/dist/components/main.js +52 -15
- package/dist/components/menu.d.ts +23 -2
- package/dist/components/menu.js +54 -29
- package/dist/components/select.js +1 -1
- package/dist/components/tabs.js +3 -3
- package/dist/components/toast.js +8 -8
- package/dist/components/tooltip.js +4 -3
- package/dist/core.d.ts +14 -2
- package/dist/core.js +23 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +138 -224
- package/package.json +3 -2
- package/skill/Attributes.md +10 -0
- package/skill/AutocompleteOptions.md +37 -0
- package/skill/BoxOptions.md +33 -0
- package/skill/ButtonChooserOptions.md +37 -0
- package/skill/ButtonGroupOptions.md +23 -0
- package/skill/ButtonOptions.md +58 -0
- package/skill/CheckboxOptions.md +27 -0
- package/skill/ContentOptions.md +16 -0
- package/skill/DialogOptions.md +70 -0
- package/skill/FieldOptions.md +63 -0
- package/skill/FloatingMenuOptions.md +21 -0
- package/skill/FormOptions.md +31 -0
- package/skill/MainOptions.md +91 -0
- package/skill/MenuItem.md +52 -0
- package/skill/MenuOptions.md +25 -0
- package/skill/SKILL.md +609 -0
- package/skill/SelectOptions.md +21 -0
- package/skill/Slot.md +13 -0
- package/skill/Tab.md +33 -0
- package/skill/TabsOptions.md +28 -0
- package/skill/TextareaOptions.md +51 -0
- package/skill/TextlineOptions.md +45 -0
- package/skill/TextlineType.md +18 -0
- package/skill/ToastOptions.md +40 -0
- package/skill/TooltipOptions.md +22 -0
- package/skill/addContextMenu.md +28 -0
- package/skill/addTooltip.md +29 -0
- package/skill/alert.md +17 -0
- package/skill/autocomplete.md +29 -0
- package/skill/box.md +26 -0
- package/skill/button.md +31 -0
- package/skill/buttonChooser.md +23 -0
- package/skill/buttonGroup.md +22 -0
- package/skill/checkbox.md +18 -0
- package/skill/confirm.md +17 -0
- package/skill/dialog.md +28 -0
- package/skill/form.md +29 -0
- package/skill/getDarkMode.md +12 -0
- package/skill/main.md +37 -0
- package/skill/menuButton.md +27 -0
- package/skill/prompt.md +19 -0
- package/skill/select.md +18 -0
- package/skill/showFloatingMenu.md +21 -0
- package/skill/tabs.md +19 -0
- package/skill/textarea.md +17 -0
- package/skill/textline.md +20 -0
- package/skill/toast.md +19 -0
- package/src/components/autocomplete.ts +12 -9
- package/src/components/box.ts +15 -10
- package/src/components/button.ts +23 -32
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/buttonGroup.ts +3 -3
- package/src/components/dialog.ts +22 -11
- package/src/components/field.ts +7 -4
- package/src/components/main.ts +47 -16
- package/src/components/menu.ts +73 -40
- package/src/components/select.ts +1 -1
- package/src/components/tabs.ts +3 -3
- package/src/components/toast.ts +8 -8
- package/src/components/tooltip.ts +4 -3
- package/src/core.ts +30 -2
- package/src/theme.ts +152 -234
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
## textarea · function
|
|
2
|
+
|
|
3
|
+
A multi-line text input. Shares the field chrome and styling of
|
|
4
|
+
`textline`.
|
|
5
|
+
|
|
6
|
+
**Signature:** `(opts?: TextareaOptions) => void`
|
|
7
|
+
|
|
8
|
+
**Parameters:**
|
|
9
|
+
|
|
10
|
+
- `opts: TextareaOptions` (optional)
|
|
11
|
+
|
|
12
|
+
**Examples:**
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const $user = A.proxy({bio: ""});
|
|
16
|
+
S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
|
|
17
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## textline · function
|
|
2
|
+
|
|
3
|
+
A single-line text input — covering text, passwords, numbers, email, dates and
|
|
4
|
+
the other line-oriented `<input>` types.
|
|
5
|
+
|
|
6
|
+
Renders inside the standard `drawField` chrome (label, control,
|
|
7
|
+
help/error), so it aligns cleanly inside a `form`.
|
|
8
|
+
|
|
9
|
+
**Signature:** `(opts?: TextlineOptions) => void`
|
|
10
|
+
|
|
11
|
+
**Parameters:**
|
|
12
|
+
|
|
13
|
+
- `opts: TextlineOptions` (optional)
|
|
14
|
+
|
|
15
|
+
**Examples:**
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const $user = A.proxy({email: "test@example.com"});
|
|
19
|
+
S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
|
|
20
|
+
```
|
package/skill/toast.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## toast · function
|
|
2
|
+
|
|
3
|
+
Show a toast notification. Returns a `dismiss()` function to remove it
|
|
4
|
+
programmatically. Auto-dismisses after `duration` ms (default 6 000).
|
|
5
|
+
|
|
6
|
+
**Signature:** `(opts: ToastOptions) => () => void`
|
|
7
|
+
|
|
8
|
+
**Parameters:**
|
|
9
|
+
|
|
10
|
+
- `opts: ToastOptions`
|
|
11
|
+
|
|
12
|
+
**Examples:**
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
S.toast({ message: "Saved!", type: "success" });
|
|
16
|
+
S.toast({ title: "Error", message: "Upload failed.", type: "danger", duration: 0 });
|
|
17
|
+
const off = S.toast({ message: "Uploading…", duration: 0, dismissible: false });
|
|
18
|
+
setTimeout(off, 3000); // Later..
|
|
19
|
+
```
|
|
@@ -35,20 +35,23 @@ export interface AutocompleteOptions extends FieldOptions {
|
|
|
35
35
|
A.insertGlobalCss({
|
|
36
36
|
".s-ac": {
|
|
37
37
|
"&": "position:relative",
|
|
38
|
-
|
|
39
|
-
"> .s-control:
|
|
38
|
+
// Same light inset field as `.s-input` (see field.ts), derived from the surface.
|
|
39
|
+
"> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
40
|
+
"> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
40
41
|
"> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
|
|
41
42
|
"&[aria-invalid=true] > .s-control": "border-color:$s-danger",
|
|
42
|
-
".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg
|
|
43
|
-
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-
|
|
44
|
-
".s-chip > button:hover": "fg:$s-
|
|
43
|
+
".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em background: color-mix(in oklab, $s-bg, $s-text 10%); border: 1px solid $s-faint; r:$s-radius padding: 0.1em 0.2em 0.1em 0.5em;",
|
|
44
|
+
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
45
|
+
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
45
46
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
46
|
-
|
|
47
|
+
// The popup is a `.s-s.neutral.shadow` surface (see below): background, border,
|
|
48
|
+
// radius and elevation all come from the surface.
|
|
49
|
+
"> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
|
|
47
50
|
"> .s-menu li": "margin:0",
|
|
48
51
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
49
|
-
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-
|
|
52
|
+
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
50
53
|
".s-add": "fg:$s-accent font-style:italic",
|
|
51
|
-
".s-empty": "padding: 0.45em 0.6em; fg:$s-
|
|
54
|
+
".s-empty": "padding: 0.45em 0.6em; fg:$s-muted",
|
|
52
55
|
},
|
|
53
56
|
});
|
|
54
57
|
|
|
@@ -191,7 +194,7 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
191
194
|
const q = $st.query.trim();
|
|
192
195
|
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
193
196
|
|
|
194
|
-
A("ul.s-menu role=listbox", `id=${menuId}`, () => {
|
|
197
|
+
A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
|
|
195
198
|
list.forEach((option, i) => {
|
|
196
199
|
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
197
200
|
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
package/src/components/box.ts
CHANGED
|
@@ -15,16 +15,21 @@ export interface BoxOptions extends ContentOptions {
|
|
|
15
15
|
footerAttrs?: Attributes;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// The box itself is a `.
|
|
19
|
-
//
|
|
20
|
-
// come from the contextual tokens, so a box
|
|
21
|
-
// it's nested in.
|
|
18
|
+
// The box itself is a `.neutral` surface; its header/footer are `.neutral` surfaces
|
|
19
|
+
// too — nested one level deeper, so they pick up the next elevation shade
|
|
20
|
+
// automatically. Colours and borders come from the contextual tokens, so a box
|
|
21
|
+
// stays legible on whatever surface it's nested in.
|
|
22
|
+
// The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
|
|
23
|
+
// the surface itself (see theme.ts), not from here. `.s-box` only does layout and
|
|
24
|
+
// the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
|
|
25
|
+
// deeper, for the raised shade), so we cancel their full surface border down to a
|
|
26
|
+
// single divider.
|
|
22
27
|
A.insertGlobalCss({
|
|
23
28
|
".s-box": {
|
|
24
|
-
"&": "display:flex flex-direction:column
|
|
29
|
+
"&": "display:flex flex-direction:column overflow:hidden r: $s-radius-lg;",
|
|
25
30
|
"&:not(:first-child)": "margin-top: $3",
|
|
26
|
-
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-
|
|
27
|
-
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border-top: 1px solid $s-
|
|
31
|
+
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
|
|
32
|
+
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border:0 border-top: 1px solid $s-faint; r:0",
|
|
28
33
|
"> div": "p:$3 gap:$3",
|
|
29
34
|
},
|
|
30
35
|
});
|
|
@@ -51,11 +56,11 @@ A.insertGlobalCss({
|
|
|
51
56
|
export function box(opts: BoxOptions | Slot = {}): void {
|
|
52
57
|
const o: BoxOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
53
58
|
|
|
54
|
-
A("section.s-box.s-s.
|
|
59
|
+
A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
|
|
55
60
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
56
61
|
// the body (which may hold focused inputs / lots of content).
|
|
57
62
|
A(() => {
|
|
58
|
-
if (o.header != null) A("header.s-s.
|
|
63
|
+
if (o.header != null) A("header.s-s.neutral", o.headerAttrs, () => drawSlot(o.header));
|
|
59
64
|
});
|
|
60
65
|
|
|
61
66
|
A("div", o.contentAttrs, () => {
|
|
@@ -63,7 +68,7 @@ export function box(opts: BoxOptions | Slot = {}): void {
|
|
|
63
68
|
});
|
|
64
69
|
|
|
65
70
|
A(() => {
|
|
66
|
-
if (o.footer != null) A("footer.s-s.
|
|
71
|
+
if (o.footer != null) A("footer.s-s.neutral", o.footerAttrs, () => drawSlot(o.footer));
|
|
67
72
|
});
|
|
68
73
|
});
|
|
69
74
|
}
|
package/src/components/button.ts
CHANGED
|
@@ -20,7 +20,8 @@ export interface ButtonOptions {
|
|
|
20
20
|
/**
|
|
21
21
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
22
22
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
23
|
-
* `".
|
|
23
|
+
* `".danger .outlined"`, or `".neutral"` for a neutral button. Defaults to a
|
|
24
|
+
* filled `.primary` surface.
|
|
24
25
|
*
|
|
25
26
|
* Size is set here too, with `.small` or `.large` (medium is the default and
|
|
26
27
|
* needs no class), e.g. `".danger .small"`. A `.small`/`.large` parent (such
|
|
@@ -36,39 +37,29 @@ A.insertGlobalCss({
|
|
|
36
37
|
".s-btn": {
|
|
37
38
|
"&":
|
|
38
39
|
"display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
39
|
-
"font-weight:
|
|
40
|
-
"
|
|
40
|
+
"font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
41
|
+
"padding: $m2 $m3; " +
|
|
41
42
|
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// artifact where a gradient clipped to a transparent rounded border fringes
|
|
54
|
-
// the edge with the gradient's far colour.
|
|
55
|
-
"&.gradient:not(.tonal):not(.outlined)":
|
|
56
|
-
"box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), $s-glow;",
|
|
57
|
-
"&.gradient:not(.tonal):not(.outlined):hover":
|
|
58
|
-
"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);",
|
|
43
|
+
// Focus ring via `outline` (not box-shadow) so it survives a `.no-shadow`
|
|
44
|
+
// (which hard-clears box-shadow). Modern browsers round it to the border-radius.
|
|
45
|
+
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
|
|
46
|
+
// The button carries `.shadow` (added in button() below); on a filled accent
|
|
47
|
+
// surface that resolves to the signature self-coloured glow, on a neutral
|
|
48
|
+
// `.neutral` button to nothing, on tonal/outlined to nothing — all via theme.ts.
|
|
49
|
+
"&:hover": "filter: brightness(1.06)",
|
|
50
|
+
// Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
|
|
51
|
+
// button (which is already near-white) darkens toward its ink instead.
|
|
52
|
+
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
53
|
+
"&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
59
54
|
// Subtle press feedback.
|
|
60
55
|
"&:active:not(:disabled)": "transform: translateY(1px)",
|
|
61
56
|
// Size: set on the button itself, or inherited from a `.small`/`.large`
|
|
62
57
|
// parent (e.g. a buttonGroup), so a container can size all its buttons at once.
|
|
63
|
-
"&.small, .small > &": "padding:
|
|
64
|
-
"&.large, .large > &": "
|
|
58
|
+
"&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
|
|
59
|
+
"&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
|
|
65
60
|
},
|
|
66
61
|
});
|
|
67
62
|
|
|
68
|
-
// Surface-role classes a caller may pass in `attrs`. When one is present we skip
|
|
69
|
-
// the default `.gradient` base so the two roles don't stack on one element.
|
|
70
|
-
const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning|base|panel|raised)(\.|\s|$)/;
|
|
71
|
-
|
|
72
63
|
/**
|
|
73
64
|
* A button. Tonal and outlined variants show a border; filled variants rely on
|
|
74
65
|
* their solid background for affordance.
|
|
@@ -87,6 +78,7 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
|
|
|
87
78
|
* @example
|
|
88
79
|
* ```ts
|
|
89
80
|
* S.button({ content: "Save", click: S.alert("Saved.") });
|
|
81
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
90
82
|
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
91
83
|
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
92
84
|
* S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
|
|
@@ -97,12 +89,11 @@ export function button(opts: ButtonOptions | Slot = {}): void {
|
|
|
97
89
|
|
|
98
90
|
const tag = o.href != null ? "a" : "button";
|
|
99
91
|
|
|
100
|
-
// A
|
|
101
|
-
//
|
|
102
|
-
// `.neutral
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
A(`${tag}.s-btn.s-s${role}`, o.attrs, () => {
|
|
92
|
+
// A bare `.s-s` is a filled accent surface defaulting to `.primary` (see
|
|
93
|
+
// theme.ts) — the signature CTA. The caller's `attrs` simply names another
|
|
94
|
+
// role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
|
|
95
|
+
// role detection needed, since the default lives in CSS, not here.
|
|
96
|
+
A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
|
|
106
97
|
if (o.href != null) {
|
|
107
98
|
A(`href=${o.href} role=button`);
|
|
108
99
|
if (o.disabled) A("aria-disabled=true");
|
|
@@ -52,7 +52,7 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
|
|
|
52
52
|
// Icon-only options (draw-function labels) get the id as their
|
|
53
53
|
// accessible name; plain-text labels speak for themselves.
|
|
54
54
|
ariaLabel: typeof label === "function" ? id : undefined,
|
|
55
|
-
attrs: selected === id ? ".primary" : ".neutral
|
|
55
|
+
attrs: selected === id ? ".primary" : ".neutral",
|
|
56
56
|
click: () => {
|
|
57
57
|
opts.bind.value = (opts.allowDeselect && selected === id) ? undefined : id;
|
|
58
58
|
},
|
|
@@ -46,9 +46,9 @@ A.insertGlobalCss({
|
|
|
46
46
|
* @example
|
|
47
47
|
* ```ts
|
|
48
48
|
* S.buttonGroup({ buttons: [
|
|
49
|
-
* { content: "Day", attrs: ".neutral
|
|
50
|
-
* { content: "Week", attrs: ".neutral
|
|
51
|
-
* { content: "Month", attrs: ".neutral
|
|
49
|
+
* { content: "Day", attrs: ".neutral" },
|
|
50
|
+
* { content: "Week", attrs: ".neutral" },
|
|
51
|
+
* { content: "Month", attrs: ".neutral" },
|
|
52
52
|
* ]});
|
|
53
53
|
* ```
|
|
54
54
|
*/
|
package/src/components/dialog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
|
|
2
|
+
import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
3
3
|
import { button } from "./button.js";
|
|
4
4
|
import { buttonGroup } from "./buttonGroup.js";
|
|
5
5
|
import { textline } from "./textline.js";
|
|
@@ -48,20 +48,22 @@ A.insertGlobalCss({
|
|
|
48
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
|
+
// The dialog panel is a `.s-s.neutral.extra-shadow` surface: border, radius (lg)
|
|
52
|
+
// and the deep floating shadow come from the surface itself (see theme.ts).
|
|
51
53
|
".s-dialog": {
|
|
52
54
|
"&":
|
|
53
55
|
"position:fixed z-index:200 top:50% left:50% " +
|
|
54
56
|
"display:flex flex-direction:column " +
|
|
55
57
|
"transform:translate(-50%,-50%) " +
|
|
56
58
|
"min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
|
|
57
|
-
"
|
|
59
|
+
"r: $s-radius-lg; overflow:hidden " +
|
|
58
60
|
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
59
61
|
"> header":
|
|
60
62
|
"display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
61
|
-
"border-bottom: 1px solid $s-
|
|
63
|
+
"border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600 flex-shrink:0",
|
|
62
64
|
"> footer":
|
|
63
65
|
"display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; " +
|
|
64
|
-
"border-top: 1px solid $s-
|
|
66
|
+
"border:0 border-top: 1px solid $s-faint; r:0 flex-shrink:0",
|
|
65
67
|
"> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
|
|
66
68
|
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
|
|
67
69
|
},
|
|
@@ -75,6 +77,11 @@ const topDialogId = A.derive(() => {
|
|
|
75
77
|
if (keys.length) return keys[keys.length-1];
|
|
76
78
|
});
|
|
77
79
|
|
|
80
|
+
/** Whether any dialog is currently open (live state, not the lingering DOM). */
|
|
81
|
+
export function isDialogOpen(): boolean {
|
|
82
|
+
return topDialogId.value != null;
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
mountPortal(() => {
|
|
79
86
|
A.onEach(dialogs, ({resolve, opts}, dialogId) => {
|
|
80
87
|
const close = () => { delete dialogs[dialogId]; };
|
|
@@ -93,10 +100,10 @@ mountPortal(() => {
|
|
|
93
100
|
});
|
|
94
101
|
|
|
95
102
|
// Dialog itself
|
|
96
|
-
A("div.s-dialog.s-s.
|
|
103
|
+
const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
|
|
97
104
|
A(() => {
|
|
98
105
|
if (opts.header != null) {
|
|
99
|
-
A("header.s-s.
|
|
106
|
+
A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
|
|
100
107
|
}
|
|
101
108
|
});
|
|
102
109
|
|
|
@@ -106,10 +113,14 @@ mountPortal(() => {
|
|
|
106
113
|
|
|
107
114
|
A(() => {
|
|
108
115
|
if (opts.footer != null) {
|
|
109
|
-
A("footer.s-s.
|
|
116
|
+
A("footer.s-s.neutral", opts.footerAttrs, () => drawSlot(opts.footer));
|
|
110
117
|
}
|
|
111
118
|
});
|
|
112
|
-
});
|
|
119
|
+
}) as HTMLElement;
|
|
120
|
+
|
|
121
|
+
// Once laid out, move focus into the dialog (first focusable element) so it's
|
|
122
|
+
// keyboard-ready and focus doesn't linger on whatever opened it.
|
|
123
|
+
requestAnimationFrame(() => { if (document.body.contains(dialogEl)) focusFirst(dialogEl); });
|
|
113
124
|
});
|
|
114
125
|
})
|
|
115
126
|
|
|
@@ -129,7 +140,7 @@ mountPortal(() => {
|
|
|
129
140
|
* content: (close) => {
|
|
130
141
|
* A("p #Are you sure?");
|
|
131
142
|
* S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
|
|
132
|
-
* S.button({ content: "Cancel", attrs: ".neutral
|
|
143
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: close });
|
|
133
144
|
* },
|
|
134
145
|
* });
|
|
135
146
|
* ```
|
|
@@ -201,7 +212,7 @@ export function confirm(message: string, opts: Partial<DialogOptions> = {}): Pro
|
|
|
201
212
|
content: (close) => {
|
|
202
213
|
A("p", () => { A("#", message); });
|
|
203
214
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
204
|
-
button({ content: "Cancel", attrs: ".neutral
|
|
215
|
+
button({ content: "Cancel", attrs: ".neutral", click: close });
|
|
205
216
|
button({ content: "OK", click: () => { confirmed = true; close(); } });
|
|
206
217
|
}});
|
|
207
218
|
},
|
|
@@ -241,7 +252,7 @@ export function prompt(message: string, defaultValue = "", opts: Partial<DialogO
|
|
|
241
252
|
});
|
|
242
253
|
textline({ bind: A.ref($v, "value") });
|
|
243
254
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
244
|
-
button({ content: "Cancel", attrs: ".neutral
|
|
255
|
+
button({ content: "Cancel", attrs: ".neutral", type: "button", click: close });
|
|
245
256
|
button({ content: "OK", type: "submit" });
|
|
246
257
|
}});
|
|
247
258
|
});
|
package/src/components/field.ts
CHANGED
|
@@ -36,14 +36,17 @@ export interface FieldOptions {
|
|
|
36
36
|
A.insertGlobalCss({
|
|
37
37
|
".s-field": {
|
|
38
38
|
"&": "display:flex flex-direction:column gap:$1",
|
|
39
|
-
"> label": "font-weight:600 font-size:0.9em fg:$s-
|
|
39
|
+
"> label": "font-weight:600 font-size:0.9em fg:$s-text user-select:none",
|
|
40
40
|
},
|
|
41
41
|
".s-req": "fg:$s-danger margin-left:2px",
|
|
42
|
-
".s-help": "font-size:0.82em fg:$s-
|
|
42
|
+
".s-help": "font-size:0.82em fg:$s-muted",
|
|
43
43
|
".s-error": "font-size:0.82em fg:$s-danger",
|
|
44
44
|
".s-input": {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// A light inset field, derived from the surrounding surface: the surface
|
|
46
|
+
// background nudged slightly toward its ink, with the ink as text. Adapts to
|
|
47
|
+
// whatever surface (and mode) holds the field, no fixed input colour needed.
|
|
48
|
+
"&": "w:100% background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
49
|
+
"&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
47
50
|
"&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
|
|
48
51
|
"&[aria-invalid=true]": "border-color:$s-danger",
|
|
49
52
|
},
|
package/src/components/main.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
|
-
import { type MenuOptions, menuButton, drawMenu } from "./menu.js";
|
|
3
|
+
import { type MenuOptions, menuButton, drawMenu, isFloatingMenuOpen } from "./menu.js";
|
|
4
|
+
import { isDialogOpen } from "./dialog.js";
|
|
4
5
|
|
|
5
6
|
/** Options for {@link main}. */
|
|
6
7
|
export interface MainOptions {
|
|
@@ -54,27 +55,31 @@ A.insertGlobalCss({
|
|
|
54
55
|
"&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
|
|
55
56
|
// Header/footer stretch their background the full shell width; their inner
|
|
56
57
|
// `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// The top bar is a full-width `.neutral` surface; cancel its surface border and
|
|
59
|
+
// radius down to just the bottom divider (it spans edge to edge).
|
|
60
|
+
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
61
|
+
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
59
62
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
60
63
|
"> header .s-header-icon": "display:flex align-items:center font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
61
64
|
"> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
|
|
62
65
|
"> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
|
|
63
|
-
"> header .s-subtitle": "fg:$s-
|
|
66
|
+
"> header .s-subtitle": "fg:$s-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
64
67
|
"> header .s-menu": "display:flex align-items:center gap:$2",
|
|
65
68
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
66
69
|
// and scrollbar alignment work identically in both cases.
|
|
67
70
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
68
71
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
|
|
69
|
-
".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
|
|
72
|
+
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
70
73
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
71
74
|
"&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
|
|
72
75
|
// A vertical hairline between sidebar and content, fading out at both ends —
|
|
73
76
|
// the vertical sibling of the menu's `hr.s-menu-sep`.
|
|
74
|
-
".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-
|
|
75
|
-
// min-height:0
|
|
76
|
-
// shrink to fit the bounded container
|
|
77
|
-
|
|
77
|
+
".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
78
|
+
// min-height:0 / min-width:0 override the flex default of min-*:auto so <main>
|
|
79
|
+
// can shrink to fit the bounded container (rather than letting wide content push
|
|
80
|
+
// the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
|
|
81
|
+
// clips overlong content on the right; vertically it scrolls.
|
|
82
|
+
".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column",
|
|
78
83
|
// The content area fills the scroll region with comfortable padding.
|
|
79
84
|
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
80
85
|
".s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
@@ -86,7 +91,7 @@ A.insertGlobalCss({
|
|
|
86
91
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
87
92
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
88
93
|
},
|
|
89
|
-
// Sidebar nav panel. Items reuse the shared `.s-menu-item
|
|
94
|
+
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
90
95
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
91
96
|
// dropdown stay visually identical.
|
|
92
97
|
// Borderless and transparent so the page's aurora shows through — an airy,
|
|
@@ -95,7 +100,7 @@ A.insertGlobalCss({
|
|
|
95
100
|
// Extra horizontal padding leaves room for the active pill's glow, which the
|
|
96
101
|
// vertical scroll (overflow-y:auto, which also clips overflow-x) would
|
|
97
102
|
// otherwise cut off at the panel edges.
|
|
98
|
-
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1
|
|
103
|
+
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
|
|
99
104
|
},
|
|
100
105
|
// In button-only mode (or always-button navPosition), hide the sidebar and
|
|
101
106
|
// show the trigger. In sidebar mode, show the panel and hide the trigger.
|
|
@@ -152,7 +157,7 @@ export function main(opts: MainOptions = {}): void {
|
|
|
152
157
|
const hasNav = nav != null && nav.items.length > 0;
|
|
153
158
|
const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
|
|
154
159
|
|
|
155
|
-
A(`div.s-main
|
|
160
|
+
const root = A(`div.s-main${navCls}`, opts.attrs, () => {
|
|
156
161
|
// Top bar.
|
|
157
162
|
A(() => {
|
|
158
163
|
const hasBar =
|
|
@@ -162,7 +167,7 @@ export function main(opts: MainOptions = {}): void {
|
|
|
162
167
|
opts.menu != null ||
|
|
163
168
|
hasNav;
|
|
164
169
|
if (!hasBar) return;
|
|
165
|
-
A("header.s-s.
|
|
170
|
+
A("header.s-s.neutral", opts.topbarAttrs, () => {
|
|
166
171
|
A("div.s-bar", () => {
|
|
167
172
|
// Cap the bar's content to maxWidth and centre it within the full-width header.
|
|
168
173
|
A(() => {
|
|
@@ -178,7 +183,7 @@ export function main(opts: MainOptions = {}): void {
|
|
|
178
183
|
button: {
|
|
179
184
|
icon: () => A("span aria-hidden=true #☰"),
|
|
180
185
|
ariaLabel: "Open navigation",
|
|
181
|
-
attrs: ".neutral .
|
|
186
|
+
attrs: ".neutral .small",
|
|
182
187
|
...nav.button,
|
|
183
188
|
},
|
|
184
189
|
});
|
|
@@ -211,7 +216,7 @@ export function main(opts: MainOptions = {}): void {
|
|
|
211
216
|
if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
|
|
212
217
|
});
|
|
213
218
|
if (hasNav && navPos !== "button") {
|
|
214
|
-
A(`nav.s-nav-panel.s-
|
|
219
|
+
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
215
220
|
drawMenu(nav.items);
|
|
216
221
|
});
|
|
217
222
|
A("div.s-nav-sep aria-hidden=true");
|
|
@@ -233,7 +238,33 @@ export function main(opts: MainOptions = {}): void {
|
|
|
233
238
|
});
|
|
234
239
|
}
|
|
235
240
|
});
|
|
236
|
-
});
|
|
241
|
+
}) as HTMLElement;
|
|
242
|
+
|
|
243
|
+
// Escape jumps to the navigation: into the sidebar's current item when the
|
|
244
|
+
// sidebar is showing, or — when collapsed to (or always) a button — open the
|
|
245
|
+
// dropdown (which focuses its current item). Listens on `document` so it works
|
|
246
|
+
// wherever focus is, but bows out while another overlay (a dialog, or an
|
|
247
|
+
// already-open menu) is up — those handle Escape themselves.
|
|
248
|
+
if (hasNav) {
|
|
249
|
+
const onKey = (e: KeyboardEvent) => {
|
|
250
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
251
|
+
// An open dialog or menu owns Escape itself — don't also jump to the nav.
|
|
252
|
+
if (isDialogOpen() || isFloatingMenuOpen()) return;
|
|
253
|
+
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
254
|
+
const panel = root.querySelector<HTMLElement>(".s-nav-panel");
|
|
255
|
+
if (panel?.offsetParent != null) {
|
|
256
|
+
const item =
|
|
257
|
+
panel.querySelector<HTMLElement>("[aria-current=page]") ??
|
|
258
|
+
panel.querySelector<HTMLElement>(".s-menu-item:not([aria-disabled=true])");
|
|
259
|
+
if (item) { e.preventDefault(); item.focus(); }
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const trigger = root.querySelector<HTMLElement>(".s-nav-trigger button");
|
|
263
|
+
if (trigger) { e.preventDefault(); trigger.click(); }
|
|
264
|
+
};
|
|
265
|
+
document.addEventListener("keydown", onKey);
|
|
266
|
+
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
267
|
+
}
|
|
237
268
|
}
|
|
238
269
|
|
|
239
270
|
function drawMainContent(opts: MainOptions): void {
|