staffa 0.18.2 → 0.18.4
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 +20 -0
- package/dist/components/autocomplete.d.ts +4 -0
- package/dist/components/autocomplete.js +83 -35
- package/dist/components/button.d.ts +45 -9
- package/dist/components/button.js +101 -25
- package/dist/components/dialog.js +40 -6
- package/dist/components/form.d.ts +5 -1
- package/dist/components/form.js +17 -2
- package/dist/components/menu.js +8 -11
- package/dist/components/tooltip.js +8 -10
- package/dist/core.d.ts +18 -0
- package/dist/core.js +53 -7
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +8 -1
- package/package.json +1 -1
- package/skill/ButtonOptions.md +21 -1
- package/skill/FormOptions.md +5 -1
- package/skill/IconButtonOptions.md +18 -4
- package/skill/SKILL.md +20 -0
- package/skill/autocomplete.md +4 -0
- package/skill/button.md +5 -3
- package/src/components/autocomplete.ts +102 -38
- package/src/components/button.ts +141 -30
- package/src/components/dialog.ts +40 -6
- package/src/components/form.ts +22 -3
- package/src/components/menu.ts +8 -11
- package/src/components/tooltip.ts +9 -11
- package/src/core.ts +51 -8
- package/src/theme.ts +8 -2
package/skill/button.md
CHANGED
|
@@ -7,9 +7,10 @@ Shortcut: pass a string to use it as the label, or a function for custom
|
|
|
7
7
|
content.
|
|
8
8
|
|
|
9
9
|
**Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
|
|
10
|
-
startup) for SPA-style navigation without manual click handlers
|
|
10
|
+
startup) for SPA-style navigation without manual click handlers — a routed
|
|
11
|
+
`main` already handles link clicks itself, so don't call it there:
|
|
11
12
|
```ts
|
|
12
|
-
import {interceptLinks} from
|
|
13
|
+
import {interceptLinks} from "aberdeen/route";
|
|
13
14
|
interceptLinks(); // once at root
|
|
14
15
|
S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
15
16
|
```
|
|
@@ -23,7 +24,8 @@ S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
|
23
24
|
**Examples:**
|
|
24
25
|
|
|
25
26
|
```ts
|
|
26
|
-
S.button({ content: "Save", click:
|
|
27
|
+
S.button({ content: "Save", click: () => save() }); // async: spins while it saves
|
|
28
|
+
S.button({ content: "About", click: () => S.alert("Staffa.") });
|
|
27
29
|
S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
28
30
|
S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
29
31
|
S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Bindable, uniqueId } from "../core.js";
|
|
2
|
+
import { type Bindable, followAnchor, mountPortal, uniqueId } from "../core.js";
|
|
3
3
|
import { type FieldOptions, drawField } from "./field.js";
|
|
4
4
|
|
|
5
5
|
/** A selectable option: a bare string, or a `{ value, label }` pair. */
|
|
@@ -34,7 +34,6 @@ export interface AutocompleteOptions extends FieldOptions {
|
|
|
34
34
|
|
|
35
35
|
A.insertGlobalCss({
|
|
36
36
|
".s-ac": {
|
|
37
|
-
"&": "position:relative",
|
|
38
37
|
// Same light inset field as `.s-input` (see field.ts), derived from the surface.
|
|
39
38
|
"> .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
39
|
"> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
@@ -44,10 +43,14 @@ A.insertGlobalCss({
|
|
|
44
43
|
".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
44
|
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
46
45
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
},
|
|
47
|
+
// Background, border, radius and elevation come from the `.s-s.neutral.shadow`
|
|
48
|
+
// surface it carries; `place()` below sizes and positions it. Both of its
|
|
49
|
+
// classes are named here: standing in `<body>` rather than inside the field,
|
|
50
|
+
// it would otherwise lose to theme.ts's flow margins on `ul` and `li`.
|
|
51
|
+
".s-ac-menu.s-s": {
|
|
52
|
+
"&": "position:fixed max-height:15rem overflow-y:auto list-style:none p:$1 margin:0",
|
|
53
|
+
li: "margin:0",
|
|
51
54
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
52
55
|
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
53
56
|
".s-add": "fg:$s-accent font-style:italic",
|
|
@@ -55,6 +58,58 @@ A.insertGlobalCss({
|
|
|
55
58
|
},
|
|
56
59
|
});
|
|
57
60
|
|
|
61
|
+
/** The list one {@link autocomplete} currently has up. */
|
|
62
|
+
interface AcPopup {
|
|
63
|
+
/** The `<ul>`'s id, which the field points `aria-controls` at — and its identity here. */
|
|
64
|
+
id: string;
|
|
65
|
+
/** The field's control box: the list matches its width and hangs off it. */
|
|
66
|
+
anchor: HTMLElement;
|
|
67
|
+
/** Over the dialog the field stands in, or under the dialog layer altogether. */
|
|
68
|
+
zIndex: number;
|
|
69
|
+
/** Draws the `<li>`s, reading the field's own state from its closure. */
|
|
70
|
+
draw: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only one list is up at a time — it belongs to whichever field has focus — so
|
|
74
|
+
// one portal at the end of <body> serves them all. Drawn inside the field, the
|
|
75
|
+
// list would be clipped by a dialog or a scrolling column, and would stretch
|
|
76
|
+
// that scroller's bar to reach it.
|
|
77
|
+
const $popup = A.proxy<{ cur: AcPopup | null }>({ cur: null });
|
|
78
|
+
|
|
79
|
+
/** Hang the list under the field — or over it, when that's where the room is. */
|
|
80
|
+
function place(el: HTMLElement, r: DOMRect): void {
|
|
81
|
+
const gap = 4, edge = 8;
|
|
82
|
+
// Measured at the stylesheet's own cap, so the flip is decided on the height
|
|
83
|
+
// the list wants, not on whatever the last placement clamped it to.
|
|
84
|
+
el.style.maxHeight = "";
|
|
85
|
+
const want = el.offsetHeight;
|
|
86
|
+
const below = window.innerHeight - r.bottom - gap - edge;
|
|
87
|
+
const above = r.top - gap - edge;
|
|
88
|
+
const up = want > below && above > below;
|
|
89
|
+
el.style.left = `${r.left}px`;
|
|
90
|
+
el.style.width = `${r.width}px`;
|
|
91
|
+
el.style.maxHeight = `${Math.min(want, Math.max(up ? above : below, 60))}px`;
|
|
92
|
+
el.style.top = up ? "auto" : `${r.bottom + gap}px`;
|
|
93
|
+
el.style.bottom = up ? `${window.innerHeight - r.top + gap}px` : "auto";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
mountPortal(() => {
|
|
97
|
+
const p = $popup.cur;
|
|
98
|
+
if (!p) return;
|
|
99
|
+
let sizeChanged: (() => void) | undefined;
|
|
100
|
+
|
|
101
|
+
const el = A("ul.s-ac-menu.s-s.neutral.shadow role=listbox", `id=${p.id} z-index:${p.zIndex}`, () => {
|
|
102
|
+
// A press in the list must not blur the field: the click that follows is
|
|
103
|
+
// what commits, and dragging the scrollbar has to keep it open too.
|
|
104
|
+
A("mousedown=", (e: Event) => e.preventDefault());
|
|
105
|
+
p.draw();
|
|
106
|
+
// Re-run as you type, with the rows; the list's height changes with them.
|
|
107
|
+
sizeChanged?.();
|
|
108
|
+
}) as HTMLElement;
|
|
109
|
+
|
|
110
|
+
sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
|
|
111
|
+
});
|
|
112
|
+
|
|
58
113
|
function normOption(o: AutocompleteOptionInput): AcOption {
|
|
59
114
|
return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
|
|
60
115
|
}
|
|
@@ -64,6 +119,10 @@ function normOption(o: AutocompleteOptionInput): AcOption {
|
|
|
64
119
|
* optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
65
120
|
* backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
66
121
|
*
|
|
122
|
+
* The suggestion list is portalled to `document.body`, so a dialog or a
|
|
123
|
+
* scrolling column can neither clip it nor grow a scrollbar around it. It hangs
|
|
124
|
+
* off whichever side of the field has the room, and follows it as things move.
|
|
125
|
+
*
|
|
67
126
|
* @example
|
|
68
127
|
* ```ts
|
|
69
128
|
* // Single select from a fixed list
|
|
@@ -133,13 +192,40 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
133
192
|
opts.bind.value = arr.filter((v) => v !== value);
|
|
134
193
|
};
|
|
135
194
|
|
|
195
|
+
let inputEl: HTMLInputElement | undefined;
|
|
196
|
+
|
|
197
|
+
/** The list's rows. Runs in the body portal, on this field's state. */
|
|
198
|
+
const drawList = () => {
|
|
199
|
+
const list = filtered();
|
|
200
|
+
const q = $st.query.trim();
|
|
201
|
+
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
202
|
+
|
|
203
|
+
list.forEach((option, i) => {
|
|
204
|
+
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
205
|
+
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
206
|
+
A("#", option.label);
|
|
207
|
+
A("click=", () => commit(option.value, inputEl));
|
|
208
|
+
A("mousemove=", () => {
|
|
209
|
+
$st.active = i;
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
if (showAdd) {
|
|
214
|
+
A("li.s-option.s-add role=option", () => {
|
|
215
|
+
A("#", `Add "${q}"`);
|
|
216
|
+
A("click=", () => commit(q, inputEl));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (list.length === 0 && !showAdd) {
|
|
220
|
+
A("li.s-empty #No matches");
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
136
224
|
drawField(opts, (id, isInvalid) => {
|
|
137
225
|
A("div.s-ac", opts.inputAttrs, () => {
|
|
138
226
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
139
227
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
A("div.s-control", () => {
|
|
228
|
+
const controlEl = A("div.s-control", () => {
|
|
143
229
|
A("click=", () => inputEl?.focus());
|
|
144
230
|
|
|
145
231
|
// Chips for multi-select.
|
|
@@ -185,38 +271,16 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
185
271
|
});
|
|
186
272
|
A("keydown=", (e: KeyboardEvent) => onKey(e, inputEl));
|
|
187
273
|
}) as HTMLInputElement;
|
|
188
|
-
});
|
|
274
|
+
}) as HTMLElement;
|
|
189
275
|
|
|
190
|
-
//
|
|
276
|
+
// Hand the list to the portal for as long as it is up. Its layer clears
|
|
277
|
+
// the dialog the field sits in, but stays under one that may open over
|
|
278
|
+
// it — a field on the page can't paint across a modal.
|
|
191
279
|
A(() => {
|
|
192
280
|
if (!$st.open) return;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
|
|
198
|
-
list.forEach((option, i) => {
|
|
199
|
-
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
200
|
-
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
201
|
-
A("#", option.label);
|
|
202
|
-
A("mousedown=", (e: Event) => e.preventDefault());
|
|
203
|
-
A("click=", () => commit(option.value, inputEl));
|
|
204
|
-
A("mousemove=", () => {
|
|
205
|
-
$st.active = i;
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
if (showAdd) {
|
|
210
|
-
A("li.s-option.s-add role=option", () => {
|
|
211
|
-
A("#", `Add "${q}"`);
|
|
212
|
-
A("mousedown=", (e: Event) => e.preventDefault());
|
|
213
|
-
A("click=", () => commit(q, inputEl));
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
if (list.length === 0 && !showAdd) {
|
|
217
|
-
A("li.s-empty #No matches");
|
|
218
|
-
}
|
|
219
|
-
});
|
|
281
|
+
const zIndex = controlEl.closest(".s-dialog") ? 350 : 150;
|
|
282
|
+
$popup.cur = { id: menuId, anchor: controlEl, zIndex, draw: drawList };
|
|
283
|
+
A.clean(() => { if ($popup.cur?.id === menuId) $popup.cur = null; });
|
|
220
284
|
});
|
|
221
285
|
|
|
222
286
|
// Hidden inputs so the selection participates in native FormData.
|
package/src/components/button.ts
CHANGED
|
@@ -9,14 +9,28 @@ export interface IconButtonOptions {
|
|
|
9
9
|
icon: Slot;
|
|
10
10
|
/** What it does, for screen readers. Required: there is no visible text to read. */
|
|
11
11
|
ariaLabel: string;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Click handler. Return a promise and the glyph becomes a spinner until it
|
|
14
|
+
* settles, with further clicks bouncing off — see {@link ButtonOptions.click}.
|
|
15
|
+
*/
|
|
16
|
+
click?: (event: Event) => unknown;
|
|
14
17
|
/**
|
|
15
18
|
* A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
|
|
16
|
-
* The tooltip shows it after the `
|
|
17
|
-
* anyway), and the `?` overview
|
|
19
|
+
* The tooltip shows it after the button's `tooltip` (or, failing that, its
|
|
20
|
+
* `ariaLabel` — a glyph is worth naming there anyway), and the `?` overview
|
|
21
|
+
* lists it under that label too.
|
|
18
22
|
*/
|
|
19
23
|
key?: string;
|
|
24
|
+
/**
|
|
25
|
+
* A tooltip, shown on hover and keyboard focus; a string renders as rich
|
|
26
|
+
* text. Defaults to the `ariaLabel`, so an icon button says what it does
|
|
27
|
+
* without being told twice — pass this only to say something longer or
|
|
28
|
+
* different, or `false` for no tooltip at all. A `key` is appended to it.
|
|
29
|
+
*
|
|
30
|
+
* Works on a disabled button too, which is where a tooltip earns its keep:
|
|
31
|
+
* it is the only room there is to say why.
|
|
32
|
+
*/
|
|
33
|
+
tooltip?: Slot | false;
|
|
20
34
|
/** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
|
|
21
35
|
href?: string;
|
|
22
36
|
/** Disables it. */
|
|
@@ -35,8 +49,18 @@ export interface ButtonOptions {
|
|
|
35
49
|
content?: Slot;
|
|
36
50
|
/** Leading icon/adornment, drawn before the label. */
|
|
37
51
|
icon?: Slot;
|
|
38
|
-
/**
|
|
39
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Click handler.
|
|
54
|
+
*
|
|
55
|
+
* **Return a promise and the button goes *busy* until it settles**: a spinner
|
|
56
|
+
* rides after the label, and clicks (and keypresses) bounce off, so a slow
|
|
57
|
+
* save can't be started twice. Nothing to wire up — `click: () => save()` on
|
|
58
|
+
* an async `save` is the whole thing, and a handler awaiting a
|
|
59
|
+
* {@link confirm} counts: the button waits for the answer with everything
|
|
60
|
+
* else. For a wait that isn't a promise, put the button in the same state
|
|
61
|
+
* yourself with `attrs: ".s-busy"`.
|
|
62
|
+
*/
|
|
63
|
+
click?: (event: Event) => unknown;
|
|
40
64
|
/** Disables the button. */
|
|
41
65
|
disabled?: boolean;
|
|
42
66
|
/** Native button behaviour. Defaults to `"button"`. */
|
|
@@ -55,6 +79,16 @@ export interface ButtonOptions {
|
|
|
55
79
|
* its `ariaLabel`).
|
|
56
80
|
*/
|
|
57
81
|
key?: string;
|
|
82
|
+
/**
|
|
83
|
+
* A tooltip, shown on hover and keyboard focus. A string renders as rich
|
|
84
|
+
* text, a function draws its own markup. A `key` is appended to it, behind
|
|
85
|
+
* a `·`. Defaults to the `ariaLabel` of a button that has one (an icon-only
|
|
86
|
+
* button, that is); pass `false` for no tooltip at all.
|
|
87
|
+
*
|
|
88
|
+
* Works on a disabled button too, which is where a tooltip earns its keep:
|
|
89
|
+
* it is the only room there is to say why.
|
|
90
|
+
*/
|
|
91
|
+
tooltip?: Slot | false;
|
|
58
92
|
/**
|
|
59
93
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
60
94
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
@@ -79,15 +113,19 @@ A.insertGlobalCss({
|
|
|
79
113
|
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
80
114
|
// Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
|
|
81
115
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
|
|
82
|
-
"&:hover": "filter: brightness(1.06)",
|
|
83
|
-
"&.tonal:hover, &.outlined:hover
|
|
116
|
+
"&:hover:not([aria-disabled=true])": "filter: brightness(1.06)",
|
|
117
|
+
"&.tonal:hover:not([aria-disabled=true]), &.outlined:hover:not([aria-disabled=true])":
|
|
118
|
+
"background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
84
119
|
// A `.neutral` button is already near-white, so it darkens toward its ink
|
|
85
120
|
// instead of brightening.
|
|
86
|
-
"&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
121
|
+
"&.neutral:hover:not([aria-disabled=true])": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
87
122
|
// The button sizes its glyph rather than trusting the caller: only a rule here
|
|
88
123
|
// makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
|
|
89
124
|
"> svg": "width:1.25em height:1.25em",
|
|
90
|
-
"&:active:not(:disabled)": "transform: translateY(1px)",
|
|
125
|
+
"&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
|
|
126
|
+
// A disabled button that has something to say still takes hover, so its
|
|
127
|
+
// tooltip can say it (see `applyActionBehavior`); theme.ts stops the rest.
|
|
128
|
+
"&[aria-disabled=true]": "pointer-events:auto cursor:not-allowed",
|
|
91
129
|
// Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
|
|
92
130
|
// container can size all its buttons at once.
|
|
93
131
|
"&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
|
|
@@ -108,10 +146,28 @@ A.insertGlobalCss({
|
|
|
108
146
|
"&:hover:not(:disabled):not([aria-disabled=true])":
|
|
109
147
|
"fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
110
148
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
|
|
149
|
+
// A disabled button that has something to say still takes hover, so its
|
|
150
|
+
// tooltip can say it (see `applyActionBehavior`); theme.ts stops the rest.
|
|
151
|
+
"&[aria-disabled=true]": "pointer-events:auto cursor:not-allowed",
|
|
111
152
|
// The glyph rides the font size, so it scales with the hit area.
|
|
112
153
|
"&.small, .small > &": "width:1.6rem height:1.6rem font-size:0.8rem",
|
|
113
154
|
"&.large, .large > &": "width:2.4rem height:2.4rem font-size:1.2rem",
|
|
114
155
|
},
|
|
156
|
+
|
|
157
|
+
// ── Busy ──────────────────────────────────────────────────────────────────
|
|
158
|
+
// Raised by a promise-returning `click`, by a submitting `S.form` on its own
|
|
159
|
+
// submit buttons, or by a caller who has some other wait to show. The spinner
|
|
160
|
+
// is a pseudo-element, so nothing has to be redrawn to start or stop it, and
|
|
161
|
+
// the flex `gap` spaces it like any other child.
|
|
162
|
+
".s-btn.s-busy, .s-icon-btn.s-busy, .s-busy .s-btn[type=submit]": "pointer-events:none cursor:progress",
|
|
163
|
+
".s-btn.s-busy::after, .s-icon-btn.s-busy::after, .s-busy .s-btn[type=submit]::after":
|
|
164
|
+
"content:'' flex-shrink:0 width:1em height:1em r:50% " +
|
|
165
|
+
"border: 2px solid currentColor; border-top-color: transparent; " +
|
|
166
|
+
"animation: s-spin 0.7s linear infinite;",
|
|
167
|
+
// The glyph *is* an icon button, so the spinner takes its place rather than
|
|
168
|
+
// crowding in beside it.
|
|
169
|
+
".s-icon-btn.s-busy > svg": "display:none",
|
|
170
|
+
"@keyframes s-spin": { to: "transform: rotate(360deg)" },
|
|
115
171
|
});
|
|
116
172
|
|
|
117
173
|
/**
|
|
@@ -135,10 +191,15 @@ A.insertGlobalCss({
|
|
|
135
191
|
*/
|
|
136
192
|
export function iconButton(opts: IconButtonOptions): void {
|
|
137
193
|
const tag = opts.href != null ? "a" : "button";
|
|
194
|
+
// A glyph says nothing on its own, so the name it carries for screen readers is
|
|
195
|
+
// the tip too, unless the caller has something better (or `false`) to say.
|
|
196
|
+
const tip = opts.tooltip === false ? undefined : opts.tooltip ?? opts.ariaLabel;
|
|
138
197
|
A(`${tag}.s-icon-btn`, opts.attrs, () => {
|
|
139
|
-
applyActionBehavior(opts);
|
|
198
|
+
applyActionBehavior(opts, tip != null);
|
|
140
199
|
A("aria-label=", opts.ariaLabel);
|
|
141
|
-
|
|
200
|
+
// Before the glyph, so a tooltip the caller adds in there is the later of
|
|
201
|
+
// the two and wins the hover.
|
|
202
|
+
applyTooltipAndKey(tip, opts.key, opts.ariaLabel, opts.disabled);
|
|
142
203
|
drawSlot(opts.icon);
|
|
143
204
|
});
|
|
144
205
|
}
|
|
@@ -152,41 +213,86 @@ export function iconButton(opts: IconButtonOptions): void {
|
|
|
152
213
|
function applyActionBehavior(o: {
|
|
153
214
|
href?: string;
|
|
154
215
|
disabled?: boolean;
|
|
155
|
-
click?: (event: Event) =>
|
|
216
|
+
click?: (event: Event) => unknown;
|
|
156
217
|
type?: string;
|
|
157
|
-
}): void {
|
|
218
|
+
}, hasTooltip = false): void {
|
|
158
219
|
if (o.href != null) {
|
|
159
220
|
A("role=button");
|
|
160
221
|
if (o.disabled) A("aria-disabled=true");
|
|
161
222
|
else A("href=", o.href);
|
|
223
|
+
} else if (o.disabled && hasTooltip) {
|
|
224
|
+
// A natively `disabled` button fires no mouse events at all, so a tooltip on
|
|
225
|
+
// one — usually the one saying *why* it is disabled — would never show. Say
|
|
226
|
+
// it the ARIA way instead: the same dimmed, unclickable, out-of-the-tab-order
|
|
227
|
+
// button (theme.ts dims it, the CSS above keeps only hover alive), but one
|
|
228
|
+
// the pointer can still reach. Nothing can activate it: the `click` handler
|
|
229
|
+
// is skipped below, and this one stops a `type=submit` from reaching its form
|
|
230
|
+
// through a stray click or Enter.
|
|
231
|
+
A("type=", o.type ?? "button");
|
|
232
|
+
A("aria-disabled=true tabindex=-1");
|
|
233
|
+
A("click=", (event: Event) => { event.preventDefault(); event.stopPropagation(); });
|
|
162
234
|
} else {
|
|
163
235
|
A("type=", o.type ?? "button");
|
|
164
236
|
if (o.disabled) A("disabled=true");
|
|
165
237
|
}
|
|
166
|
-
if (o.click && !o.disabled)
|
|
238
|
+
if (o.click && !o.disabled) applyClick(o.click);
|
|
167
239
|
}
|
|
168
240
|
|
|
169
241
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
242
|
+
* Wire a click handler that may be asynchronous: while the promise it returned
|
|
243
|
+
* is pending, the element wears `.s-busy` (spinner, no pointer events) and
|
|
244
|
+
* further clicks are ignored — the double-submit guard every save button needs.
|
|
245
|
+
*
|
|
246
|
+
* The rejection is rethrown from a promise nobody handles, so an error in the
|
|
247
|
+
* handler still reaches the console exactly as it would without us.
|
|
248
|
+
*/
|
|
249
|
+
function applyClick(click: (event: Event) => unknown): void {
|
|
250
|
+
const $busy = A.proxy({ value: false });
|
|
251
|
+
// Own scope: raising and dropping the class must not recreate the element,
|
|
252
|
+
// which would drop keyboard focus mid-click.
|
|
253
|
+
A(() => {
|
|
254
|
+
if ($busy.value) A(".s-busy aria-busy=true");
|
|
255
|
+
});
|
|
256
|
+
A("click=", (event: Event) => {
|
|
257
|
+
// A keypress on a focused button still gets here while `pointer-events:none`
|
|
258
|
+
// holds the mouse off, so the guard is what actually stops the second call.
|
|
259
|
+
if ($busy.value) return;
|
|
260
|
+
const result = click(event) as PromiseLike<unknown> | undefined;
|
|
261
|
+
if (!result || typeof result.then !== "function") return;
|
|
262
|
+
$busy.value = true;
|
|
263
|
+
const done = () => { $busy.value = false; };
|
|
264
|
+
Promise.resolve(result).then(done, (err) => { done(); throw err; });
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The tooltip and shortcut plumbing {@link button} and {@link iconButton} share:
|
|
270
|
+
* show the tip, with the key appended — the only place a button can say what its
|
|
271
|
+
* key is without shouting it beside the label — then bind that key and announce
|
|
272
|
+
* it as `aria-keyshortcuts`.
|
|
173
273
|
*
|
|
174
274
|
* Pressing it clicks the element rather than calling `click` directly, so a
|
|
175
275
|
* `type=submit` still submits its form and an `href` still navigates. Call this
|
|
176
276
|
* inside the button's own element scope, whose element it takes and whose life
|
|
177
277
|
* the binding follows.
|
|
178
278
|
*/
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
279
|
+
function applyTooltipAndKey(tip: Slot | undefined, key: string | undefined, label: string | undefined, disabled?: boolean): void {
|
|
280
|
+
if (tip != null || key) {
|
|
281
|
+
addTooltip({
|
|
282
|
+
tip: () => {
|
|
283
|
+
drawSlot(tip);
|
|
284
|
+
// A draw function, not a string: a key like `*` is markup to rich text.
|
|
285
|
+
if (key) A("#", tip == null ? formatKey(key) : ` · ${formatKey(key)}`);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
184
289
|
// Bound only while it can be pressed — a disabled button would otherwise
|
|
185
290
|
// swallow the combination rather than leave it to whoever else wants it. The
|
|
186
291
|
// overview names it by its visible text, or its aria label failing that.
|
|
187
|
-
if (!disabled) {
|
|
292
|
+
if (key && !disabled) {
|
|
293
|
+
const el = A() as HTMLElement;
|
|
188
294
|
A("aria-keyshortcuts=", formatKey(key, true));
|
|
189
|
-
bindKey(key,
|
|
295
|
+
bindKey(key, label, () => el.click());
|
|
190
296
|
}
|
|
191
297
|
}
|
|
192
298
|
|
|
@@ -198,16 +304,18 @@ function applyKey(key: string, label: string | undefined, content: Slot | undefi
|
|
|
198
304
|
* content.
|
|
199
305
|
*
|
|
200
306
|
* **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
|
|
201
|
-
* startup) for SPA-style navigation without manual click handlers
|
|
307
|
+
* startup) for SPA-style navigation without manual click handlers — a routed
|
|
308
|
+
* {@link main} already handles link clicks itself, so don't call it there:
|
|
202
309
|
* ```ts
|
|
203
|
-
* import {interceptLinks} from
|
|
310
|
+
* import {interceptLinks} from "aberdeen/route";
|
|
204
311
|
* interceptLinks(); // once at root
|
|
205
312
|
* S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
206
313
|
* ```
|
|
207
314
|
*
|
|
208
315
|
* @example
|
|
209
316
|
* ```ts
|
|
210
|
-
* S.button({ content: "Save", click:
|
|
317
|
+
* S.button({ content: "Save", click: () => save() }); // async: spins while it saves
|
|
318
|
+
* S.button({ content: "About", click: () => S.alert("Staffa.") });
|
|
211
319
|
* S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
212
320
|
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
213
321
|
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
@@ -218,15 +326,18 @@ export function button(opts: ButtonOptions | Slot = {}): void {
|
|
|
218
326
|
const o: ButtonOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
219
327
|
|
|
220
328
|
const tag = o.href != null ? "a" : "button";
|
|
329
|
+
// An `ariaLabel` means an icon-only button, whose name is worth showing to the
|
|
330
|
+
// sighted too; anything else says nothing until asked.
|
|
331
|
+
const tip = o.tooltip === false ? undefined : o.tooltip ?? o.ariaLabel;
|
|
221
332
|
|
|
222
333
|
// A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
|
|
223
334
|
// detection here: `attrs` just names another role or variant.
|
|
224
335
|
A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
|
|
225
|
-
applyActionBehavior(o);
|
|
336
|
+
applyActionBehavior(o, tip != null);
|
|
226
337
|
if (o.ariaLabel) A("aria-label=", o.ariaLabel);
|
|
227
338
|
// Before the content, so a tooltip the caller adds in there is the later of
|
|
228
339
|
// the two and wins the hover.
|
|
229
|
-
|
|
340
|
+
applyTooltipAndKey(tip, o.key, typeof o.content === "string" ? o.content : o.ariaLabel, o.disabled);
|
|
230
341
|
|
|
231
342
|
drawSlot(o.icon);
|
|
232
343
|
drawSlot(o.content);
|
package/src/components/dialog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
2
|
+
import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst, focusables } from "../core.js";
|
|
3
3
|
import { button } from "./button.js";
|
|
4
4
|
import { buttonGroup } from "./buttonGroup.js";
|
|
5
5
|
import { textline } from "./textline.js";
|
|
@@ -113,11 +113,23 @@ mountPortal(() => {
|
|
|
113
113
|
if (opts.allowCancel !== false) close();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
// Derived from the dialog's own key rather than a second counter: there is
|
|
117
|
+
// exactly one of these per dialog, for as long as the dialog exists.
|
|
118
|
+
const labelId = `s-dialog-title-${dialogId}`;
|
|
119
|
+
const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow role=dialog create=hidden destroy=hidden", opts.attrs, () => {
|
|
117
120
|
// A modal owns the keyboard: claiming makes the shortcuts drawn inside
|
|
118
|
-
// (the content below included) register here and silences the rest
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
+
// (the content below included) register here and silences the rest;
|
|
122
|
+
// `aria-modal` takes the page behind out of the screen reader's reading
|
|
123
|
+
// order, the way the backdrop takes it out of the pointer's reach; and
|
|
124
|
+
// Tab is held inside, so it can't wander off into what it is covering.
|
|
125
|
+
// The `?` overview passes `keyboardTransparent`: an overlay that informs
|
|
126
|
+
// rather than interrupts claims none of this.
|
|
127
|
+
if (!opts.keyboardTransparent) {
|
|
128
|
+
const el = A() as HTMLElement;
|
|
129
|
+
A.clean(claimKeyboard());
|
|
130
|
+
A("aria-modal=true");
|
|
131
|
+
A("keydown=", (event: KeyboardEvent) => trapTab(el, event));
|
|
132
|
+
}
|
|
121
133
|
// Esc closes the dialog — or, while `allowCancel` forbids it, is
|
|
122
134
|
// swallowed by the no-op press, so nothing below acts on it either.
|
|
123
135
|
// Anchored at whoever owned the keyboard as this dialog opened —
|
|
@@ -131,7 +143,13 @@ mountPortal(() => {
|
|
|
131
143
|
|
|
132
144
|
A(() => {
|
|
133
145
|
if (opts.header != null) {
|
|
134
|
-
|
|
146
|
+
// The header names the dialog to assistive tech as well as visually:
|
|
147
|
+
// it is what a screen reader reads out as focus enters, before the
|
|
148
|
+
// control it lands on. Set from this scope (so on the dialog, and
|
|
149
|
+
// withdrawn with the header), rather than pointing at an id that
|
|
150
|
+
// isn't there.
|
|
151
|
+
A("aria-labelledby=", labelId);
|
|
152
|
+
A("header.s-s.neutral id=", labelId, opts.headerAttrs, () => drawSlot(opts.header));
|
|
135
153
|
}
|
|
136
154
|
});
|
|
137
155
|
|
|
@@ -152,6 +170,22 @@ mountPortal(() => {
|
|
|
152
170
|
});
|
|
153
171
|
})
|
|
154
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Keep Tab inside a modal dialog: from its last focusable element Tab wraps
|
|
175
|
+
* around to the first, and Shift+Tab from the first to the last. Without this,
|
|
176
|
+
* a couple of Tabs walk out of the dialog and into the page it is covering —
|
|
177
|
+
* which the backdrop makes invisible but not unreachable, so a keyboard user
|
|
178
|
+
* ends up typing into something they can't see.
|
|
179
|
+
*/
|
|
180
|
+
function trapTab(dialogEl: HTMLElement, event: KeyboardEvent): void {
|
|
181
|
+
if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
182
|
+
const items = focusables(dialogEl);
|
|
183
|
+
const edge = event.shiftKey ? items[0] : items[items.length - 1];
|
|
184
|
+
if (!edge || document.activeElement !== edge) return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
(event.shiftKey ? items[items.length - 1] : items[0])!.focus();
|
|
187
|
+
}
|
|
188
|
+
|
|
155
189
|
/**
|
|
156
190
|
* A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
|
|
157
191
|
* that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
package/src/components/form.ts
CHANGED
|
@@ -7,8 +7,12 @@ export interface FormOptions extends ContentOptions {
|
|
|
7
7
|
* Submit handler. Called with collected form data (keyed by each field's
|
|
8
8
|
* `name`) and the original event. `preventDefault()` is already called.
|
|
9
9
|
* Multi-value fields (e.g. multi-select) produce a `string[]`.
|
|
10
|
+
*
|
|
11
|
+
* **Return a promise and the form goes *busy* until it settles**: its submit
|
|
12
|
+
* buttons show a spinner and stop responding, and a second submit (Enter
|
|
13
|
+
* included) is ignored — so a slow save runs once, however impatient the user.
|
|
10
14
|
*/
|
|
11
|
-
submit?: (data: Record<string, string | string[]>, event: SubmitEvent) =>
|
|
15
|
+
submit?: (data: Record<string, string | string[]>, event: SubmitEvent) => unknown;
|
|
12
16
|
/**
|
|
13
17
|
* Layout of fields. `"stacked"` (default) is a single column; `"grid"` packs
|
|
14
18
|
* fields into a responsive multi-column grid. A field can span the full grid
|
|
@@ -53,22 +57,37 @@ A.insertGlobalCss({
|
|
|
53
57
|
export function form(opts: FormOptions | Slot = {}): void {
|
|
54
58
|
const o: FormOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
55
59
|
|
|
60
|
+
// Raised while an asynchronous `submit` is still running; the CSS in button.ts
|
|
61
|
+
// turns the form's submit buttons into spinners off it.
|
|
62
|
+
const $busy = A.proxy({ value: false });
|
|
63
|
+
|
|
56
64
|
A(`form.s-form`, o.attrs, () => {
|
|
57
65
|
// Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
|
|
58
66
|
A(() => {
|
|
59
67
|
A(".grid=", o.layout === 'grid');
|
|
60
68
|
});
|
|
69
|
+
// Likewise: going busy must not redraw the fields being submitted.
|
|
70
|
+
A(() => {
|
|
71
|
+
if ($busy.value) A(".s-busy aria-busy=true");
|
|
72
|
+
});
|
|
61
73
|
|
|
62
74
|
A("submit=", (event: SubmitEvent) => {
|
|
63
75
|
event.preventDefault();
|
|
64
|
-
if (o.submit) {
|
|
76
|
+
if (o.submit && !$busy.value) {
|
|
65
77
|
const fd = new FormData(event.target as HTMLFormElement);
|
|
66
78
|
const data: Record<string, string | string[]> = {};
|
|
67
79
|
for (const key of new Set(fd.keys())) {
|
|
68
80
|
const vals = fd.getAll(key) as string[];
|
|
69
81
|
data[key] = vals.length === 1 ? vals[0]! : vals;
|
|
70
82
|
}
|
|
71
|
-
o.submit(data, event);
|
|
83
|
+
const result = o.submit(data, event) as PromiseLike<unknown> | undefined;
|
|
84
|
+
if (result && typeof result.then === "function") {
|
|
85
|
+
$busy.value = true;
|
|
86
|
+
const done = () => { $busy.value = false; };
|
|
87
|
+
// Rethrown from a promise nobody handles, so the error still
|
|
88
|
+
// reaches the console the way an unawaited one would.
|
|
89
|
+
Promise.resolve(result).then(done, (err) => { done(); throw err; });
|
|
90
|
+
}
|
|
72
91
|
}
|
|
73
92
|
});
|
|
74
93
|
|