staffa 0.18.3 → 0.19.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 +20 -0
- 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/core.d.ts +5 -0
- package/dist/core.js +15 -7
- package/dist/staffa.esm.js +1 -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/button.md +5 -3
- package/src/components/button.ts +141 -30
- package/src/components/dialog.ts +40 -6
- package/src/components/form.ts +22 -3
- package/src/core.ts +18 -8
package/README.md
CHANGED
|
@@ -220,6 +220,17 @@ Return the paths shallowest first, or nothing to fall back to the parent-path wa
|
|
|
220
220
|
- Aberdeen's own `route.go()` works, but builds the whole stack from the path; prefer the stack's methods. A guard your app registered with `route.setGuard` keeps working — Staffa registers none of its own.
|
|
221
221
|
- Deep links need your static server to serve the app for unknown paths (the usual SPA fallback; `-P` for `http-server`, as in the demo command below).
|
|
222
222
|
|
|
223
|
+
### Waiting
|
|
224
|
+
|
|
225
|
+
A `click` handler — or a form's `submit` — that returns a promise puts its button to work until that promise settles: a spinner rides after the label, and clicks (and Enter) bounce off, so a slow save can't be started twice. There is nothing to wire up:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
S.button({ content: "Save", click: () => api.save($user) });
|
|
229
|
+
S.form({ submit: (data) => api.save(data), content: drawFields, actions: () => S.button({ content: "Save", type: "submit" }) });
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
A form marks its own submit buttons, wherever they sit, and a handler that awaits a `confirm()` waits with it. For a wait that isn't a promise, put a control in the same state yourself with `attrs: ".s-busy"`.
|
|
233
|
+
|
|
223
234
|
## Components
|
|
224
235
|
|
|
225
236
|
Every option of every component is documented in TSDoc on its `…Options` interface. Options share naming conventions: `attrs` (outermost element), `contentAttrs` (the children-holding element), `inputAttrs` (the form control) and `<region>Attrs` (`headerAttrs`, `footerAttrs`, …) — all Aberdeen attr/style strings, applied last so they can override. Form components consistently support `label`, `help`, `error`, `disabled`, `required` and `name`, and two-way binding through `bind: A.ref($obj, "key")`.
|
|
@@ -229,6 +240,15 @@ Every option of every component is documented in TSDoc on its `…Options` inter
|
|
|
229
240
|
- **Actions**: `button`, `iconButton`, `buttonGroup`, `buttonChooser`.
|
|
230
241
|
- **Overlays & feedback**: `dialog` (+ `alert`, `confirm`, `prompt`), `menu`, `menuButton`, `showFloatingMenu`, `addContextMenu`, `toast`, `addTooltip`.
|
|
231
242
|
|
|
243
|
+
Buttons, icon buttons and menu items carry a `tooltip` option of their own, so most tips need no `addTooltip` call:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
S.button({ content: "Publish", tooltip: "Not until the draft validates", disabled: true });
|
|
247
|
+
S.iconButton({ icon: trash2, ariaLabel: "Delete" }); // says "Delete" on hover
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
An icon button tips its `ariaLabel` unless given a `tooltip` of its own (`tooltip: false` for neither), a `key` is appended to whatever the tip says, and — unlike a tooltip on a plain disabled `<button>`, which the browser gives no hover events — these show while disabled, which is where a tooltip earns its keep.
|
|
251
|
+
|
|
232
252
|
`src/index.ts` is the authoritative list of exports.
|
|
233
253
|
|
|
234
254
|
### Keyboard shortcuts
|
|
@@ -5,14 +5,28 @@ export interface IconButtonOptions {
|
|
|
5
5
|
icon: Slot;
|
|
6
6
|
/** What it does, for screen readers. Required: there is no visible text to read. */
|
|
7
7
|
ariaLabel: string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Click handler. Return a promise and the glyph becomes a spinner until it
|
|
10
|
+
* settles, with further clicks bouncing off — see {@link ButtonOptions.click}.
|
|
11
|
+
*/
|
|
12
|
+
click?: (event: Event) => unknown;
|
|
10
13
|
/**
|
|
11
14
|
* A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
|
|
12
|
-
* The tooltip shows it after the `
|
|
13
|
-
* anyway), and the `?` overview
|
|
15
|
+
* The tooltip shows it after the button's `tooltip` (or, failing that, its
|
|
16
|
+
* `ariaLabel` — a glyph is worth naming there anyway), and the `?` overview
|
|
17
|
+
* lists it under that label too.
|
|
14
18
|
*/
|
|
15
19
|
key?: string;
|
|
20
|
+
/**
|
|
21
|
+
* A tooltip, shown on hover and keyboard focus; a string renders as rich
|
|
22
|
+
* text. Defaults to the `ariaLabel`, so an icon button says what it does
|
|
23
|
+
* without being told twice — pass this only to say something longer or
|
|
24
|
+
* different, or `false` for no tooltip at all. A `key` is appended to it.
|
|
25
|
+
*
|
|
26
|
+
* Works on a disabled button too, which is where a tooltip earns its keep:
|
|
27
|
+
* it is the only room there is to say why.
|
|
28
|
+
*/
|
|
29
|
+
tooltip?: Slot | false;
|
|
16
30
|
/** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
|
|
17
31
|
href?: string;
|
|
18
32
|
/** Disables it. */
|
|
@@ -30,8 +44,18 @@ export interface ButtonOptions {
|
|
|
30
44
|
content?: Slot;
|
|
31
45
|
/** Leading icon/adornment, drawn before the label. */
|
|
32
46
|
icon?: Slot;
|
|
33
|
-
/**
|
|
34
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Click handler.
|
|
49
|
+
*
|
|
50
|
+
* **Return a promise and the button goes *busy* until it settles**: a spinner
|
|
51
|
+
* rides after the label, and clicks (and keypresses) bounce off, so a slow
|
|
52
|
+
* save can't be started twice. Nothing to wire up — `click: () => save()` on
|
|
53
|
+
* an async `save` is the whole thing, and a handler awaiting a
|
|
54
|
+
* {@link confirm} counts: the button waits for the answer with everything
|
|
55
|
+
* else. For a wait that isn't a promise, put the button in the same state
|
|
56
|
+
* yourself with `attrs: ".s-busy"`.
|
|
57
|
+
*/
|
|
58
|
+
click?: (event: Event) => unknown;
|
|
35
59
|
/** Disables the button. */
|
|
36
60
|
disabled?: boolean;
|
|
37
61
|
/** Native button behaviour. Defaults to `"button"`. */
|
|
@@ -50,6 +74,16 @@ export interface ButtonOptions {
|
|
|
50
74
|
* its `ariaLabel`).
|
|
51
75
|
*/
|
|
52
76
|
key?: string;
|
|
77
|
+
/**
|
|
78
|
+
* A tooltip, shown on hover and keyboard focus. A string renders as rich
|
|
79
|
+
* text, a function draws its own markup. A `key` is appended to it, behind
|
|
80
|
+
* a `·`. Defaults to the `ariaLabel` of a button that has one (an icon-only
|
|
81
|
+
* button, that is); pass `false` for no tooltip at all.
|
|
82
|
+
*
|
|
83
|
+
* Works on a disabled button too, which is where a tooltip earns its keep:
|
|
84
|
+
* it is the only room there is to say why.
|
|
85
|
+
*/
|
|
86
|
+
tooltip?: Slot | false;
|
|
53
87
|
/**
|
|
54
88
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
55
89
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
@@ -90,16 +124,18 @@ export declare function iconButton(opts: IconButtonOptions): void;
|
|
|
90
124
|
* content.
|
|
91
125
|
*
|
|
92
126
|
* **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
|
|
93
|
-
* startup) for SPA-style navigation without manual click handlers
|
|
127
|
+
* startup) for SPA-style navigation without manual click handlers — a routed
|
|
128
|
+
* {@link main} already handles link clicks itself, so don't call it there:
|
|
94
129
|
* ```ts
|
|
95
|
-
* import {interceptLinks} from
|
|
130
|
+
* import {interceptLinks} from "aberdeen/route";
|
|
96
131
|
* interceptLinks(); // once at root
|
|
97
132
|
* S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
98
133
|
* ```
|
|
99
134
|
*
|
|
100
135
|
* @example
|
|
101
136
|
* ```ts
|
|
102
|
-
* S.button({ content: "Save", click:
|
|
137
|
+
* S.button({ content: "Save", click: () => save() }); // async: spins while it saves
|
|
138
|
+
* S.button({ content: "About", click: () => S.alert("Staffa.") });
|
|
103
139
|
* S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
104
140
|
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
105
141
|
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
@@ -12,15 +12,18 @@ A.insertGlobalCss({
|
|
|
12
12
|
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
13
13
|
// Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
|
|
14
14
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
|
|
15
|
-
"&:hover": "filter: brightness(1.06)",
|
|
16
|
-
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
15
|
+
"&:hover:not([aria-disabled=true])": "filter: brightness(1.06)",
|
|
16
|
+
"&.tonal:hover:not([aria-disabled=true]), &.outlined:hover:not([aria-disabled=true])": "background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
17
17
|
// A `.neutral` button is already near-white, so it darkens toward its ink
|
|
18
18
|
// instead of brightening.
|
|
19
|
-
"&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
19
|
+
"&.neutral:hover:not([aria-disabled=true])": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
20
20
|
// The button sizes its glyph rather than trusting the caller: only a rule here
|
|
21
21
|
// makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
|
|
22
22
|
"> svg": "width:1.25em height:1.25em",
|
|
23
|
-
"&:active:not(:disabled)": "transform: translateY(1px)",
|
|
23
|
+
"&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
|
|
24
|
+
// A disabled button that has something to say still takes hover, so its
|
|
25
|
+
// tooltip can say it (see `applyActionBehavior`); theme.ts stops the rest.
|
|
26
|
+
"&[aria-disabled=true]": "pointer-events:auto cursor:not-allowed",
|
|
24
27
|
// Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
|
|
25
28
|
// container can size all its buttons at once.
|
|
26
29
|
"&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
|
|
@@ -39,10 +42,26 @@ A.insertGlobalCss({
|
|
|
39
42
|
"> svg": "width:1.25em height:1.25em",
|
|
40
43
|
"&:hover:not(:disabled):not([aria-disabled=true])": "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
41
44
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
|
|
45
|
+
// A disabled button that has something to say still takes hover, so its
|
|
46
|
+
// tooltip can say it (see `applyActionBehavior`); theme.ts stops the rest.
|
|
47
|
+
"&[aria-disabled=true]": "pointer-events:auto cursor:not-allowed",
|
|
42
48
|
// The glyph rides the font size, so it scales with the hit area.
|
|
43
49
|
"&.small, .small > &": "width:1.6rem height:1.6rem font-size:0.8rem",
|
|
44
50
|
"&.large, .large > &": "width:2.4rem height:2.4rem font-size:1.2rem",
|
|
45
51
|
},
|
|
52
|
+
// ── Busy ──────────────────────────────────────────────────────────────────
|
|
53
|
+
// Raised by a promise-returning `click`, by a submitting `S.form` on its own
|
|
54
|
+
// submit buttons, or by a caller who has some other wait to show. The spinner
|
|
55
|
+
// is a pseudo-element, so nothing has to be redrawn to start or stop it, and
|
|
56
|
+
// the flex `gap` spaces it like any other child.
|
|
57
|
+
".s-btn.s-busy, .s-icon-btn.s-busy, .s-busy .s-btn[type=submit]": "pointer-events:none cursor:progress",
|
|
58
|
+
".s-btn.s-busy::after, .s-icon-btn.s-busy::after, .s-busy .s-btn[type=submit]::after": "content:'' flex-shrink:0 width:1em height:1em r:50% " +
|
|
59
|
+
"border: 2px solid currentColor; border-top-color: transparent; " +
|
|
60
|
+
"animation: s-spin 0.7s linear infinite;",
|
|
61
|
+
// The glyph *is* an icon button, so the spinner takes its place rather than
|
|
62
|
+
// crowding in beside it.
|
|
63
|
+
".s-icon-btn.s-busy > svg": "display:none",
|
|
64
|
+
"@keyframes s-spin": { to: "transform: rotate(360deg)" },
|
|
46
65
|
});
|
|
47
66
|
/**
|
|
48
67
|
* A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
@@ -65,11 +84,15 @@ A.insertGlobalCss({
|
|
|
65
84
|
*/
|
|
66
85
|
export function iconButton(opts) {
|
|
67
86
|
const tag = opts.href != null ? "a" : "button";
|
|
87
|
+
// A glyph says nothing on its own, so the name it carries for screen readers is
|
|
88
|
+
// the tip too, unless the caller has something better (or `false`) to say.
|
|
89
|
+
const tip = opts.tooltip === false ? undefined : opts.tooltip ?? opts.ariaLabel;
|
|
68
90
|
A(`${tag}.s-icon-btn`, opts.attrs, () => {
|
|
69
|
-
applyActionBehavior(opts);
|
|
91
|
+
applyActionBehavior(opts, tip != null);
|
|
70
92
|
A("aria-label=", opts.ariaLabel);
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
// Before the glyph, so a tooltip the caller adds in there is the later of
|
|
94
|
+
// the two and wins the hover.
|
|
95
|
+
applyTooltipAndKey(tip, opts.key, opts.ariaLabel, opts.disabled);
|
|
73
96
|
drawSlot(opts.icon);
|
|
74
97
|
});
|
|
75
98
|
}
|
|
@@ -79,7 +102,7 @@ export function iconButton(opts) {
|
|
|
79
102
|
* anchor without one is out of the tab order and follows nothing, which is what
|
|
80
103
|
* makes it as disabled as a `<button>`'s real `disabled` attribute.
|
|
81
104
|
*/
|
|
82
|
-
function applyActionBehavior(o) {
|
|
105
|
+
function applyActionBehavior(o, hasTooltip = false) {
|
|
83
106
|
if (o.href != null) {
|
|
84
107
|
A("role=button");
|
|
85
108
|
if (o.disabled)
|
|
@@ -87,35 +110,84 @@ function applyActionBehavior(o) {
|
|
|
87
110
|
else
|
|
88
111
|
A("href=", o.href);
|
|
89
112
|
}
|
|
113
|
+
else if (o.disabled && hasTooltip) {
|
|
114
|
+
// A natively `disabled` button fires no mouse events at all, so a tooltip on
|
|
115
|
+
// one — usually the one saying *why* it is disabled — would never show. Say
|
|
116
|
+
// it the ARIA way instead: the same dimmed, unclickable, out-of-the-tab-order
|
|
117
|
+
// button (theme.ts dims it, the CSS above keeps only hover alive), but one
|
|
118
|
+
// the pointer can still reach. Nothing can activate it: the `click` handler
|
|
119
|
+
// is skipped below, and this one stops a `type=submit` from reaching its form
|
|
120
|
+
// through a stray click or Enter.
|
|
121
|
+
A("type=", o.type ?? "button");
|
|
122
|
+
A("aria-disabled=true tabindex=-1");
|
|
123
|
+
A("click=", (event) => { event.preventDefault(); event.stopPropagation(); });
|
|
124
|
+
}
|
|
90
125
|
else {
|
|
91
126
|
A("type=", o.type ?? "button");
|
|
92
127
|
if (o.disabled)
|
|
93
128
|
A("disabled=true");
|
|
94
129
|
}
|
|
95
130
|
if (o.click && !o.disabled)
|
|
96
|
-
|
|
131
|
+
applyClick(o.click);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Wire a click handler that may be asynchronous: while the promise it returned
|
|
135
|
+
* is pending, the element wears `.s-busy` (spinner, no pointer events) and
|
|
136
|
+
* further clicks are ignored — the double-submit guard every save button needs.
|
|
137
|
+
*
|
|
138
|
+
* The rejection is rethrown from a promise nobody handles, so an error in the
|
|
139
|
+
* handler still reaches the console exactly as it would without us.
|
|
140
|
+
*/
|
|
141
|
+
function applyClick(click) {
|
|
142
|
+
const $busy = A.proxy({ value: false });
|
|
143
|
+
// Own scope: raising and dropping the class must not recreate the element,
|
|
144
|
+
// which would drop keyboard focus mid-click.
|
|
145
|
+
A(() => {
|
|
146
|
+
if ($busy.value)
|
|
147
|
+
A(".s-busy aria-busy=true");
|
|
148
|
+
});
|
|
149
|
+
A("click=", (event) => {
|
|
150
|
+
// A keypress on a focused button still gets here while `pointer-events:none`
|
|
151
|
+
// holds the mouse off, so the guard is what actually stops the second call.
|
|
152
|
+
if ($busy.value)
|
|
153
|
+
return;
|
|
154
|
+
const result = click(event);
|
|
155
|
+
if (!result || typeof result.then !== "function")
|
|
156
|
+
return;
|
|
157
|
+
$busy.value = true;
|
|
158
|
+
const done = () => { $busy.value = false; };
|
|
159
|
+
Promise.resolve(result).then(done, (err) => { done(); throw err; });
|
|
160
|
+
});
|
|
97
161
|
}
|
|
98
162
|
/**
|
|
99
|
-
* The shortcut plumbing {@link button} and {@link iconButton} share:
|
|
100
|
-
*
|
|
101
|
-
*
|
|
163
|
+
* The tooltip and shortcut plumbing {@link button} and {@link iconButton} share:
|
|
164
|
+
* show the tip, with the key appended — the only place a button can say what its
|
|
165
|
+
* key is without shouting it beside the label — then bind that key and announce
|
|
166
|
+
* it as `aria-keyshortcuts`.
|
|
102
167
|
*
|
|
103
168
|
* Pressing it clicks the element rather than calling `click` directly, so a
|
|
104
169
|
* `type=submit` still submits its form and an `href` still navigates. Call this
|
|
105
170
|
* inside the button's own element scope, whose element it takes and whose life
|
|
106
171
|
* the binding follows.
|
|
107
172
|
*/
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
173
|
+
function applyTooltipAndKey(tip, key, label, disabled) {
|
|
174
|
+
if (tip != null || key) {
|
|
175
|
+
addTooltip({
|
|
176
|
+
tip: () => {
|
|
177
|
+
drawSlot(tip);
|
|
178
|
+
// A draw function, not a string: a key like `*` is markup to rich text.
|
|
179
|
+
if (key)
|
|
180
|
+
A("#", tip == null ? formatKey(key) : ` · ${formatKey(key)}`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
113
184
|
// Bound only while it can be pressed — a disabled button would otherwise
|
|
114
185
|
// swallow the combination rather than leave it to whoever else wants it. The
|
|
115
186
|
// overview names it by its visible text, or its aria label failing that.
|
|
116
|
-
if (!disabled) {
|
|
187
|
+
if (key && !disabled) {
|
|
188
|
+
const el = A();
|
|
117
189
|
A("aria-keyshortcuts=", formatKey(key, true));
|
|
118
|
-
bindKey(key,
|
|
190
|
+
bindKey(key, label, () => el.click());
|
|
119
191
|
}
|
|
120
192
|
}
|
|
121
193
|
/**
|
|
@@ -126,16 +198,18 @@ function applyKey(key, label, content, disabled) {
|
|
|
126
198
|
* content.
|
|
127
199
|
*
|
|
128
200
|
* **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
|
|
129
|
-
* startup) for SPA-style navigation without manual click handlers
|
|
201
|
+
* startup) for SPA-style navigation without manual click handlers — a routed
|
|
202
|
+
* {@link main} already handles link clicks itself, so don't call it there:
|
|
130
203
|
* ```ts
|
|
131
|
-
* import {interceptLinks} from
|
|
204
|
+
* import {interceptLinks} from "aberdeen/route";
|
|
132
205
|
* interceptLinks(); // once at root
|
|
133
206
|
* S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
|
|
134
207
|
* ```
|
|
135
208
|
*
|
|
136
209
|
* @example
|
|
137
210
|
* ```ts
|
|
138
|
-
* S.button({ content: "Save", click:
|
|
211
|
+
* S.button({ content: "Save", click: () => save() }); // async: spins while it saves
|
|
212
|
+
* S.button({ content: "About", click: () => S.alert("Staffa.") });
|
|
139
213
|
* S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
140
214
|
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
141
215
|
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
@@ -145,16 +219,18 @@ function applyKey(key, label, content, disabled) {
|
|
|
145
219
|
export function button(opts = {}) {
|
|
146
220
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
147
221
|
const tag = o.href != null ? "a" : "button";
|
|
222
|
+
// An `ariaLabel` means an icon-only button, whose name is worth showing to the
|
|
223
|
+
// sighted too; anything else says nothing until asked.
|
|
224
|
+
const tip = o.tooltip === false ? undefined : o.tooltip ?? o.ariaLabel;
|
|
148
225
|
// A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
|
|
149
226
|
// detection here: `attrs` just names another role or variant.
|
|
150
227
|
A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
|
|
151
|
-
applyActionBehavior(o);
|
|
228
|
+
applyActionBehavior(o, tip != null);
|
|
152
229
|
if (o.ariaLabel)
|
|
153
230
|
A("aria-label=", o.ariaLabel);
|
|
154
231
|
// Before the content, so a tooltip the caller adds in there is the later of
|
|
155
232
|
// the two and wins the hover.
|
|
156
|
-
|
|
157
|
-
applyKey(o.key, o.ariaLabel, o.content, o.disabled);
|
|
233
|
+
applyTooltipAndKey(tip, o.key, typeof o.content === "string" ? o.content : o.ariaLabel, o.disabled);
|
|
158
234
|
drawSlot(o.icon);
|
|
159
235
|
drawSlot(o.content);
|
|
160
236
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
2
|
+
import { 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";
|
|
@@ -58,12 +58,23 @@ mountPortal(() => {
|
|
|
58
58
|
if (opts.allowCancel !== false)
|
|
59
59
|
close();
|
|
60
60
|
});
|
|
61
|
-
|
|
61
|
+
// Derived from the dialog's own key rather than a second counter: there is
|
|
62
|
+
// exactly one of these per dialog, for as long as the dialog exists.
|
|
63
|
+
const labelId = `s-dialog-title-${dialogId}`;
|
|
64
|
+
const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow role=dialog create=hidden destroy=hidden", opts.attrs, () => {
|
|
62
65
|
// A modal owns the keyboard: claiming makes the shortcuts drawn inside
|
|
63
|
-
// (the content below included) register here and silences the rest
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
+
// (the content below included) register here and silences the rest;
|
|
67
|
+
// `aria-modal` takes the page behind out of the screen reader's reading
|
|
68
|
+
// order, the way the backdrop takes it out of the pointer's reach; and
|
|
69
|
+
// Tab is held inside, so it can't wander off into what it is covering.
|
|
70
|
+
// The `?` overview passes `keyboardTransparent`: an overlay that informs
|
|
71
|
+
// rather than interrupts claims none of this.
|
|
72
|
+
if (!opts.keyboardTransparent) {
|
|
73
|
+
const el = A();
|
|
66
74
|
A.clean(claimKeyboard());
|
|
75
|
+
A("aria-modal=true");
|
|
76
|
+
A("keydown=", (event) => trapTab(el, event));
|
|
77
|
+
}
|
|
67
78
|
// Esc closes the dialog — or, while `allowCancel` forbids it, is
|
|
68
79
|
// swallowed by the no-op press, so nothing below acts on it either.
|
|
69
80
|
// Anchored at whoever owned the keyboard as this dialog opened —
|
|
@@ -76,7 +87,13 @@ mountPortal(() => {
|
|
|
76
87
|
});
|
|
77
88
|
A(() => {
|
|
78
89
|
if (opts.header != null) {
|
|
79
|
-
|
|
90
|
+
// The header names the dialog to assistive tech as well as visually:
|
|
91
|
+
// it is what a screen reader reads out as focus enters, before the
|
|
92
|
+
// control it lands on. Set from this scope (so on the dialog, and
|
|
93
|
+
// withdrawn with the header), rather than pointing at an id that
|
|
94
|
+
// isn't there.
|
|
95
|
+
A("aria-labelledby=", labelId);
|
|
96
|
+
A("header.s-s.neutral id=", labelId, opts.headerAttrs, () => drawSlot(opts.header));
|
|
80
97
|
}
|
|
81
98
|
});
|
|
82
99
|
A("div", opts.contentAttrs, () => {
|
|
@@ -94,6 +111,23 @@ mountPortal(() => {
|
|
|
94
111
|
focusFirst(dialogEl); });
|
|
95
112
|
});
|
|
96
113
|
});
|
|
114
|
+
/**
|
|
115
|
+
* Keep Tab inside a modal dialog: from its last focusable element Tab wraps
|
|
116
|
+
* around to the first, and Shift+Tab from the first to the last. Without this,
|
|
117
|
+
* a couple of Tabs walk out of the dialog and into the page it is covering —
|
|
118
|
+
* which the backdrop makes invisible but not unreachable, so a keyboard user
|
|
119
|
+
* ends up typing into something they can't see.
|
|
120
|
+
*/
|
|
121
|
+
function trapTab(dialogEl, event) {
|
|
122
|
+
if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey)
|
|
123
|
+
return;
|
|
124
|
+
const items = focusables(dialogEl);
|
|
125
|
+
const edge = event.shiftKey ? items[0] : items[items.length - 1];
|
|
126
|
+
if (!edge || document.activeElement !== edge)
|
|
127
|
+
return;
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
(event.shiftKey ? items[items.length - 1] : items[0]).focus();
|
|
130
|
+
}
|
|
97
131
|
/**
|
|
98
132
|
* A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
|
|
99
133
|
* that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
|
@@ -5,8 +5,12 @@ export interface FormOptions extends ContentOptions {
|
|
|
5
5
|
* Submit handler. Called with collected form data (keyed by each field's
|
|
6
6
|
* `name`) and the original event. `preventDefault()` is already called.
|
|
7
7
|
* Multi-value fields (e.g. multi-select) produce a `string[]`.
|
|
8
|
+
*
|
|
9
|
+
* **Return a promise and the form goes *busy* until it settles**: its submit
|
|
10
|
+
* buttons show a spinner and stop responding, and a second submit (Enter
|
|
11
|
+
* included) is ignored — so a slow save runs once, however impatient the user.
|
|
8
12
|
*/
|
|
9
|
-
submit?: (data: Record<string, string | string[]>, event: SubmitEvent) =>
|
|
13
|
+
submit?: (data: Record<string, string | string[]>, event: SubmitEvent) => unknown;
|
|
10
14
|
/**
|
|
11
15
|
* Layout of fields. `"stacked"` (default) is a single column; `"grid"` packs
|
|
12
16
|
* fields into a responsive multi-column grid. A field can span the full grid
|
package/dist/components/form.js
CHANGED
|
@@ -30,21 +30,36 @@ A.insertGlobalCss({
|
|
|
30
30
|
*/
|
|
31
31
|
export function form(opts = {}) {
|
|
32
32
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
33
|
+
// Raised while an asynchronous `submit` is still running; the CSS in button.ts
|
|
34
|
+
// turns the form's submit buttons into spinners off it.
|
|
35
|
+
const $busy = A.proxy({ value: false });
|
|
33
36
|
A(`form.s-form`, o.attrs, () => {
|
|
34
37
|
// Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
|
|
35
38
|
A(() => {
|
|
36
39
|
A(".grid=", o.layout === 'grid');
|
|
37
40
|
});
|
|
41
|
+
// Likewise: going busy must not redraw the fields being submitted.
|
|
42
|
+
A(() => {
|
|
43
|
+
if ($busy.value)
|
|
44
|
+
A(".s-busy aria-busy=true");
|
|
45
|
+
});
|
|
38
46
|
A("submit=", (event) => {
|
|
39
47
|
event.preventDefault();
|
|
40
|
-
if (o.submit) {
|
|
48
|
+
if (o.submit && !$busy.value) {
|
|
41
49
|
const fd = new FormData(event.target);
|
|
42
50
|
const data = {};
|
|
43
51
|
for (const key of new Set(fd.keys())) {
|
|
44
52
|
const vals = fd.getAll(key);
|
|
45
53
|
data[key] = vals.length === 1 ? vals[0] : vals;
|
|
46
54
|
}
|
|
47
|
-
o.submit(data, event);
|
|
55
|
+
const result = o.submit(data, event);
|
|
56
|
+
if (result && typeof result.then === "function") {
|
|
57
|
+
$busy.value = true;
|
|
58
|
+
const done = () => { $busy.value = false; };
|
|
59
|
+
// Rethrown from a promise nobody handles, so the error still
|
|
60
|
+
// reaches the console the way an unawaited one would.
|
|
61
|
+
Promise.resolve(result).then(done, (err) => { done(); throw err; });
|
|
62
|
+
}
|
|
48
63
|
}
|
|
49
64
|
});
|
|
50
65
|
drawSlot(o.content);
|
package/dist/core.d.ts
CHANGED
|
@@ -62,6 +62,11 @@ export declare function uniqueId(prefix?: string): string;
|
|
|
62
62
|
* `rich` markup (`*italic*`, `**bold**`, `` `code` ``, `[link](/path)`).
|
|
63
63
|
*/
|
|
64
64
|
export declare function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefined, ...args: Args): void;
|
|
65
|
+
/**
|
|
66
|
+
* The focusable elements inside `container`, in tab order — disabled,
|
|
67
|
+
* `aria-disabled`, `tabindex=-1` and hidden ones left out.
|
|
68
|
+
*/
|
|
69
|
+
export declare function focusables(container: HTMLElement): HTMLElement[];
|
|
65
70
|
/**
|
|
66
71
|
* Move keyboard focus to the first focusable element inside `container`, skipping
|
|
67
72
|
* disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
|
package/dist/core.js
CHANGED
|
@@ -26,6 +26,19 @@ export function drawSlot(slot, ...args) {
|
|
|
26
26
|
}
|
|
27
27
|
/** Selector matching the natively focusable elements we care about. */
|
|
28
28
|
const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
|
|
29
|
+
/** Whether this element can actually take focus right now. */
|
|
30
|
+
const isFocusable = (el) => el instanceof HTMLElement &&
|
|
31
|
+
!el.hasAttribute("disabled") &&
|
|
32
|
+
el.getAttribute("aria-disabled") !== "true" &&
|
|
33
|
+
el.tabIndex >= 0 &&
|
|
34
|
+
el.getClientRects().length > 0;
|
|
35
|
+
/**
|
|
36
|
+
* The focusable elements inside `container`, in tab order — disabled,
|
|
37
|
+
* `aria-disabled`, `tabindex=-1` and hidden ones left out.
|
|
38
|
+
*/
|
|
39
|
+
export function focusables(container) {
|
|
40
|
+
return [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
|
|
41
|
+
}
|
|
29
42
|
/**
|
|
30
43
|
* Move keyboard focus to the first focusable element inside `container`, skipping
|
|
31
44
|
* disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
|
|
@@ -36,13 +49,8 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
|
|
|
36
49
|
* the DOM and laid out — typically inside a `requestAnimationFrame`.
|
|
37
50
|
*/
|
|
38
51
|
export function focusFirst(container, prefer) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
el.getAttribute("aria-disabled") !== "true" &&
|
|
42
|
-
el.tabIndex >= 0 &&
|
|
43
|
-
el.getClientRects().length > 0;
|
|
44
|
-
const target = (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
|
|
45
|
-
[...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
|
|
52
|
+
const target = (prefer ? [...container.querySelectorAll(prefer)].find(isFocusable) : undefined) ??
|
|
53
|
+
focusables(container)[0];
|
|
46
54
|
target?.focus();
|
|
47
55
|
return target != null;
|
|
48
56
|
}
|