@apliteni/apliteni-ui 0.40.0 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/guidelines/component-choice.md +12 -0
- package/package.json +1 -1
- package/react/dist/index.d.ts +2 -2
- package/react/dist/index.js +127 -37
- package/src/components/button-busy.js +69 -0
- package/src/components/index.js +6 -5
- package/src/index.js +1 -0
- package/src/styles/button.css +100 -52
package/README.md
CHANGED
|
@@ -144,6 +144,30 @@ Storybook. A stranger on the port is never composed — the "React components"
|
|
|
144
144
|
section is absent instead, and the terminal says why. Details in
|
|
145
145
|
[`react/README.md`](./react/README.md).
|
|
146
146
|
|
|
147
|
+
### Updating a busy button
|
|
148
|
+
|
|
149
|
+
Keep the factory-created element in place to animate its label:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
import { button, setButtonBusy } from '@apliteni/apliteni-ui';
|
|
153
|
+
host.innerHTML = button({ label: 'Save changes', variant: 'primary' });
|
|
154
|
+
const control = host.querySelector('button');
|
|
155
|
+
setButtonBusy(control, { busy: true });
|
|
156
|
+
// When the request completes:
|
|
157
|
+
setButtonBusy(control, { busy: false });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The helper keeps focus on the button, uses `aria-disabled` and blocks clicks,
|
|
161
|
+
Enter and Space while busy. Its sibling polite live region announces progress and completion.
|
|
162
|
+
Explicit `disabled: true` stays natively disabled. Static `button({ busy: true })`
|
|
163
|
+
markup uses native disabled until the helper wires it; call `setButtonBusy` to
|
|
164
|
+
switch to the focus-preserving behavior. React `<Button busy={saving}>Save changes</Button>` uses the same treatment.
|
|
165
|
+
The action label slides down, three dots take its place, and the label returns from below
|
|
166
|
+
when complete. Its hidden label preserves the width and accessible name. React retains
|
|
167
|
+
the last ready children while busy; changed children appear on completion. Reduced
|
|
168
|
+
motion switches immediately to static dots.
|
|
169
|
+
See Storybook's **Button / Busy Transition** for the live vanilla example.
|
|
170
|
+
|
|
147
171
|
## Theming
|
|
148
172
|
|
|
149
173
|
Theme is a `data-theme="dark|light"` attribute on `<html>`; accent is an orthogonal
|
|
@@ -187,7 +211,7 @@ src/
|
|
|
187
211
|
inline.js # CSS as strings for server-render consumers (…/inline)
|
|
188
212
|
assets/ # brand mark (seedling) + line-icon set
|
|
189
213
|
components/ # HTML-string factories: button(), card(), badge(), topbar()…
|
|
190
|
-
stories/ # Storybook: Foundations, Components,
|
|
214
|
+
stories/ # Storybook: Foundations, Components, Showcases
|
|
191
215
|
site/ # ui.apli.tech landing page (static site build)
|
|
192
216
|
react/ # React components — private workspace, built to react/dist/
|
|
193
217
|
dist/ # tsup output; shipped as …/react and …/react/css
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Component choice
|
|
2
2
|
|
|
3
|
+
## Make the committing action stand out
|
|
4
|
+
|
|
5
|
+
<!-- rule: paired-actions -->
|
|
6
|
+
|
|
7
|
+
**Rule:** In a pair of actions, give the committing action more visual weight than the dismissing action.
|
|
8
|
+
|
|
9
|
+
**Why:** Equal weight makes it harder to see which button commits the change.
|
|
10
|
+
|
|
11
|
+
**Do:** Use a primary Save button beside a ghost Cancel button.
|
|
12
|
+
|
|
13
|
+
**Don't:** Give Save and Cancel the same primary style.
|
|
14
|
+
|
|
3
15
|
## Interrupt with a question
|
|
4
16
|
|
|
5
17
|
<!-- rule: interrupt -->
|
package/package.json
CHANGED
package/react/dist/index.d.ts
CHANGED
|
@@ -12,11 +12,11 @@ type ButtonProps = {
|
|
|
12
12
|
iconRight?: string;
|
|
13
13
|
iconOnly?: boolean;
|
|
14
14
|
block?: boolean;
|
|
15
|
-
/** In flight:
|
|
15
|
+
/** In flight: keeps focus, blocks activation, announces progress, and shows dots. */
|
|
16
16
|
busy?: boolean;
|
|
17
17
|
children?: ReactNode;
|
|
18
18
|
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
|
19
|
-
declare function Button({ variant, size, icon, iconRight, iconOnly, block, busy, children, type, disabled, ...rest }: ButtonProps): react.JSX.Element;
|
|
19
|
+
declare function Button({ variant, size, icon, iconRight, iconOnly, block, busy, children, type, disabled, onClick, onClickCapture, onKeyDown, onKeyDownCapture, onKeyUp, onKeyUpCapture, ...rest }: ButtonProps): react.JSX.Element;
|
|
20
20
|
|
|
21
21
|
declare function Badge({ variant, children }: {
|
|
22
22
|
variant?: string;
|
package/react/dist/index.js
CHANGED
|
@@ -15,7 +15,38 @@ function Icon({ name }) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// src/primitives/Button.tsx
|
|
18
|
+
import { useLayoutEffect, useRef } from "react";
|
|
18
19
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
20
|
+
var announcers = /* @__PURE__ */ new WeakMap();
|
|
21
|
+
function acquireAnnouncer(doc) {
|
|
22
|
+
let entry = announcers.get(doc);
|
|
23
|
+
if (!entry) {
|
|
24
|
+
const node = doc.createElement("span");
|
|
25
|
+
node.className = "ui-sr ui-btn__status";
|
|
26
|
+
node.setAttribute("role", "status");
|
|
27
|
+
node.setAttribute("aria-live", "polite");
|
|
28
|
+
doc.body.append(node);
|
|
29
|
+
entry = { node, users: 0 };
|
|
30
|
+
announcers.set(doc, entry);
|
|
31
|
+
}
|
|
32
|
+
const current = entry;
|
|
33
|
+
current.users++;
|
|
34
|
+
return {
|
|
35
|
+
announce(text2) {
|
|
36
|
+
clearTimeout(current.timer);
|
|
37
|
+
current.timer = setTimeout(() => {
|
|
38
|
+
current.node.textContent = text2;
|
|
39
|
+
}, 0);
|
|
40
|
+
},
|
|
41
|
+
release() {
|
|
42
|
+
if (--current.users === 0) {
|
|
43
|
+
clearTimeout(current.timer);
|
|
44
|
+
current.node.remove();
|
|
45
|
+
announcers.delete(doc);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
19
50
|
var cx = (...a) => a.filter(Boolean).join(" ");
|
|
20
51
|
function Button({
|
|
21
52
|
variant = "secondary",
|
|
@@ -28,8 +59,45 @@ function Button({
|
|
|
28
59
|
children,
|
|
29
60
|
type = "button",
|
|
30
61
|
disabled,
|
|
62
|
+
onClick,
|
|
63
|
+
onClickCapture,
|
|
64
|
+
onKeyDown,
|
|
65
|
+
onKeyDownCapture,
|
|
66
|
+
onKeyUp,
|
|
67
|
+
onKeyUpCapture,
|
|
31
68
|
...rest
|
|
32
69
|
}) {
|
|
70
|
+
const buttonRef = useRef(null);
|
|
71
|
+
const labelRef = useRef(null);
|
|
72
|
+
const announcer = useRef(null);
|
|
73
|
+
const hasBeenBusy = useRef(false);
|
|
74
|
+
if (busy) hasBeenBusy.current = true;
|
|
75
|
+
const readyChildren = useRef(children);
|
|
76
|
+
if (!busy) readyChildren.current = children;
|
|
77
|
+
const renderedChildren = busy ? readyChildren.current : children;
|
|
78
|
+
const previous = useRef(null);
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
const label = labelRef.current;
|
|
81
|
+
const text2 = label?.textContent ?? buttonRef.current?.getAttribute("aria-label") ?? "";
|
|
82
|
+
const prior = previous.current;
|
|
83
|
+
if ((busy || prior?.busy) && (!prior || prior.text !== text2 || prior.busy !== busy)) {
|
|
84
|
+
announcer.current ?? (announcer.current = acquireAnnouncer(buttonRef.current.ownerDocument));
|
|
85
|
+
announcer.current.announce(`${text2}: ${busy ? "in progress" : "complete"}`);
|
|
86
|
+
}
|
|
87
|
+
previous.current = { text: text2, busy };
|
|
88
|
+
});
|
|
89
|
+
useLayoutEffect(() => () => {
|
|
90
|
+
announcer.current?.release();
|
|
91
|
+
announcer.current = null;
|
|
92
|
+
previous.current = null;
|
|
93
|
+
}, []);
|
|
94
|
+
const blockActivation = (event) => {
|
|
95
|
+
if (!busy) return false;
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
const blockKey = (event) => (event.key === "Enter" || event.key === " ") && blockActivation(event);
|
|
33
101
|
const cls = cx(
|
|
34
102
|
"ui-btn",
|
|
35
103
|
variant && `ui-btn--${variant}`,
|
|
@@ -43,18 +111,40 @@ function Button({
|
|
|
43
111
|
return /* @__PURE__ */ jsxs(
|
|
44
112
|
"button",
|
|
45
113
|
{
|
|
114
|
+
ref: buttonRef,
|
|
115
|
+
"data-btn-wired": "",
|
|
116
|
+
"data-btn-ready": !busy && hasBeenBusy.current ? "" : void 0,
|
|
46
117
|
type,
|
|
47
118
|
className: cls,
|
|
48
|
-
disabled
|
|
119
|
+
disabled,
|
|
49
120
|
"aria-disabled": disabled || busy ? true : void 0,
|
|
50
121
|
"aria-busy": busy ? true : void 0,
|
|
51
122
|
...rest,
|
|
52
123
|
...named,
|
|
124
|
+
onClickCapture: (event) => {
|
|
125
|
+
if (!blockActivation(event)) onClickCapture?.(event);
|
|
126
|
+
},
|
|
127
|
+
onClick: (event) => {
|
|
128
|
+
if (!blockActivation(event)) onClick?.(event);
|
|
129
|
+
},
|
|
130
|
+
onKeyDownCapture: (event) => {
|
|
131
|
+
if (!blockKey(event)) onKeyDownCapture?.(event);
|
|
132
|
+
},
|
|
133
|
+
onKeyDown: (event) => {
|
|
134
|
+
if (!blockKey(event)) onKeyDown?.(event);
|
|
135
|
+
},
|
|
136
|
+
onKeyUpCapture: (event) => {
|
|
137
|
+
if (!blockKey(event)) onKeyUpCapture?.(event);
|
|
138
|
+
},
|
|
139
|
+
onKeyUp: (event) => {
|
|
140
|
+
if (!blockKey(event)) onKeyUp?.(event);
|
|
141
|
+
},
|
|
53
142
|
children: [
|
|
54
143
|
icon4 && /* @__PURE__ */ jsx2(Icon, { name: icon4 }),
|
|
55
|
-
!iconOnly &&
|
|
144
|
+
!iconOnly && renderedChildren != null && /* @__PURE__ */ jsx2("span", { className: "ui-btn__label-slot", children: /* @__PURE__ */ jsx2("span", { className: "ui-btn__label", ref: labelRef, children: renderedChildren }) }),
|
|
56
145
|
iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight }),
|
|
57
|
-
busy && /* @__PURE__ */ jsxs("span", { className: "ui-
|
|
146
|
+
busy && /* @__PURE__ */ jsxs("span", { className: "ui-btn__dots", "aria-hidden": "true", children: [
|
|
147
|
+
/* @__PURE__ */ jsx2("i", {}),
|
|
58
148
|
/* @__PURE__ */ jsx2("i", {}),
|
|
59
149
|
/* @__PURE__ */ jsx2("i", {})
|
|
60
150
|
] })
|
|
@@ -127,7 +217,7 @@ function StatBand({ stats, variant = "tiles", basis, label, id }) {
|
|
|
127
217
|
}
|
|
128
218
|
|
|
129
219
|
// src/Modal.tsx
|
|
130
|
-
import { useRef as
|
|
220
|
+
import { useRef as useRef3 } from "react";
|
|
131
221
|
import { createPortal } from "react-dom";
|
|
132
222
|
|
|
133
223
|
// src/dialog.ts
|
|
@@ -135,8 +225,8 @@ import {
|
|
|
135
225
|
createContext,
|
|
136
226
|
useContext,
|
|
137
227
|
useEffect,
|
|
138
|
-
useLayoutEffect,
|
|
139
|
-
useRef,
|
|
228
|
+
useLayoutEffect as useLayoutEffect2,
|
|
229
|
+
useRef as useRef2,
|
|
140
230
|
useState
|
|
141
231
|
} from "react";
|
|
142
232
|
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), details > summary:first-of-type';
|
|
@@ -158,7 +248,7 @@ var dismissOnScrim = (onClose) => (e) => {
|
|
|
158
248
|
e.preventDefault();
|
|
159
249
|
onClose();
|
|
160
250
|
};
|
|
161
|
-
var useIsoLayoutEffect = typeof document === "undefined" ? useEffect :
|
|
251
|
+
var useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect2;
|
|
162
252
|
function transitionMs(el) {
|
|
163
253
|
const cs = getComputedStyle(el);
|
|
164
254
|
const times = (list) => list.split(",").map((t) => {
|
|
@@ -318,7 +408,7 @@ function usePresence(open, root, panel) {
|
|
|
318
408
|
function useDialog(active, { root, panel, body }, onClose) {
|
|
319
409
|
const parent = useContext(DialogScope);
|
|
320
410
|
const [scope] = useState(() => ({ parent }));
|
|
321
|
-
const close =
|
|
411
|
+
const close = useRef2(onClose);
|
|
322
412
|
useIsoLayoutEffect(() => {
|
|
323
413
|
close.current = onClose;
|
|
324
414
|
});
|
|
@@ -342,9 +432,9 @@ function useDialog(active, { root, panel, body }, onClose) {
|
|
|
342
432
|
// src/Modal.tsx
|
|
343
433
|
import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
344
434
|
function Modal({ open, title, onClose, footer, children }) {
|
|
345
|
-
const root =
|
|
346
|
-
const panel =
|
|
347
|
-
const body =
|
|
435
|
+
const root = useRef3(null);
|
|
436
|
+
const panel = useRef3(null);
|
|
437
|
+
const body = useRef3(null);
|
|
348
438
|
const { mounted, shown } = usePresence(open, root, panel);
|
|
349
439
|
const scope = useDialog(shown, { root, panel, body }, onClose);
|
|
350
440
|
if (!mounted) return null;
|
|
@@ -362,7 +452,7 @@ function Modal({ open, title, onClose, footer, children }) {
|
|
|
362
452
|
}
|
|
363
453
|
|
|
364
454
|
// src/Drawer.tsx
|
|
365
|
-
import { useId as useId2, useRef as
|
|
455
|
+
import { useId as useId2, useRef as useRef4 } from "react";
|
|
366
456
|
import { createPortal as createPortal2 } from "react-dom";
|
|
367
457
|
import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
368
458
|
function Drawer({
|
|
@@ -375,9 +465,9 @@ function Drawer({
|
|
|
375
465
|
children,
|
|
376
466
|
closeLabel = "Close"
|
|
377
467
|
}) {
|
|
378
|
-
const root =
|
|
379
|
-
const panel =
|
|
380
|
-
const body =
|
|
468
|
+
const root = useRef4(null);
|
|
469
|
+
const panel = useRef4(null);
|
|
470
|
+
const body = useRef4(null);
|
|
381
471
|
const titleId = useId2();
|
|
382
472
|
const { mounted, shown } = usePresence(open, root, panel);
|
|
383
473
|
const scope = useDialog(shown, { root, panel, body }, onClose);
|
|
@@ -411,7 +501,7 @@ function Drawer({
|
|
|
411
501
|
}
|
|
412
502
|
|
|
413
503
|
// src/CommandPalette.tsx
|
|
414
|
-
import { useCallback, useEffect as useEffect2, useId as useId3, useMemo, useRef as
|
|
504
|
+
import { useCallback, useEffect as useEffect2, useId as useId3, useMemo, useRef as useRef5, useState as useState2 } from "react";
|
|
415
505
|
import { createPortal as createPortal3 } from "react-dom";
|
|
416
506
|
import { rankGroups, icon as icon2 } from "@apliteni/apliteni-ui";
|
|
417
507
|
import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
@@ -432,9 +522,9 @@ function CommandPalette({
|
|
|
432
522
|
onQueryChange
|
|
433
523
|
}) {
|
|
434
524
|
const uid = useId3().replace(/:/g, "");
|
|
435
|
-
const root =
|
|
436
|
-
const panel =
|
|
437
|
-
const search =
|
|
525
|
+
const root = useRef5(null);
|
|
526
|
+
const panel = useRef5(null);
|
|
527
|
+
const search = useRef5(null);
|
|
438
528
|
const [query, setQuery] = useState2("");
|
|
439
529
|
const [at, setAt] = useState2(0);
|
|
440
530
|
useEffect2(() => {
|
|
@@ -608,7 +698,7 @@ import { useMemo as useMemo2, useState as useState4 } from "react";
|
|
|
608
698
|
import { DEFAULT_PAGE_SIZE as DEFAULT_PAGE_SIZE2 } from "@apliteni/apliteni-ui";
|
|
609
699
|
|
|
610
700
|
// src/Pagination.tsx
|
|
611
|
-
import { useEffect as useEffect3, useId as useId4, useRef as
|
|
701
|
+
import { useEffect as useEffect3, useId as useId4, useRef as useRef6, useState as useState3 } from "react";
|
|
612
702
|
import { DEFAULT_PAGE_SIZE } from "@apliteni/apliteni-ui";
|
|
613
703
|
import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
614
704
|
var GHOST_SM = "ui-btn ui-btn--ghost ui-btn--sm";
|
|
@@ -655,8 +745,8 @@ function Pagination({
|
|
|
655
745
|
const auto = useId4();
|
|
656
746
|
const [draft, setDraft] = useState3("");
|
|
657
747
|
const [drafting, setDrafting] = useState3(false);
|
|
658
|
-
const nav =
|
|
659
|
-
const pressed =
|
|
748
|
+
const nav = useRef6(null);
|
|
749
|
+
const pressed = useRef6(null);
|
|
660
750
|
const uid = id ?? auto;
|
|
661
751
|
const kind = VARIANTS.includes(variant) ? variant : "steps";
|
|
662
752
|
const size = sizeOf(pageSize, DEFAULT_PAGE_SIZE);
|
|
@@ -1081,7 +1171,7 @@ import {
|
|
|
1081
1171
|
useCallback as useCallback2,
|
|
1082
1172
|
useEffect as useEffect4,
|
|
1083
1173
|
useId as useId5,
|
|
1084
|
-
useRef as
|
|
1174
|
+
useRef as useRef7,
|
|
1085
1175
|
useState as useState5
|
|
1086
1176
|
} from "react";
|
|
1087
1177
|
import { icon as icon3, dropdownMatch, dropdownFiltering } from "@apliteni/apliteni-ui";
|
|
@@ -1142,19 +1232,19 @@ function Dropdown({
|
|
|
1142
1232
|
const [pick, setPick] = useState5(null);
|
|
1143
1233
|
const [query, setQuery] = useState5(() => search && search !== true ? search.query || "" : "");
|
|
1144
1234
|
const [activeKey, setActiveKey] = useState5(null);
|
|
1145
|
-
const root =
|
|
1146
|
-
const panel =
|
|
1147
|
-
const trigger =
|
|
1148
|
-
const field =
|
|
1149
|
-
const landOn =
|
|
1150
|
-
const at =
|
|
1235
|
+
const root = useRef7(null);
|
|
1236
|
+
const panel = useRef7(null);
|
|
1237
|
+
const trigger = useRef7(null);
|
|
1238
|
+
const field = useRef7(null);
|
|
1239
|
+
const landOn = useRef7(null);
|
|
1240
|
+
const at = useRef7("");
|
|
1151
1241
|
const auto = useId5().replace(/:/g, "");
|
|
1152
1242
|
const uid = id ?? auto;
|
|
1153
1243
|
const setOpen = useCallback2((next) => {
|
|
1154
1244
|
if (openProp === void 0) setSelfOpen(next);
|
|
1155
1245
|
onOpenChange?.(next);
|
|
1156
1246
|
}, [openProp, onOpenChange]);
|
|
1157
|
-
const close =
|
|
1247
|
+
const close = useRef7(setOpen);
|
|
1158
1248
|
useIsoLayoutEffect(() => {
|
|
1159
1249
|
close.current = setOpen;
|
|
1160
1250
|
});
|
|
@@ -1561,16 +1651,16 @@ function Segmented({ options, value, onChange, label, appearance = "pill", disab
|
|
|
1561
1651
|
}
|
|
1562
1652
|
|
|
1563
1653
|
// src/FilterBar.tsx
|
|
1564
|
-
import { useLayoutEffect as
|
|
1654
|
+
import { useLayoutEffect as useLayoutEffect3, useRef as useRef8 } from "react";
|
|
1565
1655
|
import { filterBar, initFilterBar } from "@apliteni/apliteni-ui";
|
|
1566
1656
|
import { jsx as jsx16 } from "react/jsx-runtime";
|
|
1567
1657
|
function FilterBar(props) {
|
|
1568
|
-
const host =
|
|
1569
|
-
const latest =
|
|
1658
|
+
const host = useRef8(null);
|
|
1659
|
+
const latest = useRef8(props);
|
|
1570
1660
|
latest.current = props;
|
|
1571
|
-
const controller =
|
|
1572
|
-
const initial =
|
|
1573
|
-
|
|
1661
|
+
const controller = useRef8(null);
|
|
1662
|
+
const initial = useRef8({ __html: filterBar(props) });
|
|
1663
|
+
useLayoutEffect3(() => {
|
|
1574
1664
|
const el = host.current;
|
|
1575
1665
|
controller.current = initFilterBar(el, latest.current);
|
|
1576
1666
|
const remove = (e) => latest.current.onRemove(e.detail.id);
|
|
@@ -1589,7 +1679,7 @@ function FilterBar(props) {
|
|
|
1589
1679
|
el.removeEventListener("ui-filter-change", change);
|
|
1590
1680
|
};
|
|
1591
1681
|
}, []);
|
|
1592
|
-
|
|
1682
|
+
useLayoutEffect3(() => {
|
|
1593
1683
|
controller.current?.update(props);
|
|
1594
1684
|
}, [props.filters, props.label, props.clearLabel, props.disabled, props.busy]);
|
|
1595
1685
|
return /* @__PURE__ */ jsx16("div", { ref: host, dangerouslySetInnerHTML: initial.current });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const disabledBeforeBusy = new WeakMap();
|
|
2
|
+
const wired = new WeakSet();
|
|
3
|
+
|
|
4
|
+
function wireActivationGuard(element) {
|
|
5
|
+
if (wired.has(element)) return;
|
|
6
|
+
const block = event => {
|
|
7
|
+
if (element.getAttribute('aria-busy') !== 'true') return;
|
|
8
|
+
if (event.type !== 'click' && event.key !== 'Enter' && event.key !== ' ') return;
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
event.stopImmediatePropagation();
|
|
11
|
+
};
|
|
12
|
+
for (const type of ['click', 'keydown', 'keyup']) element.addEventListener(type, block, true);
|
|
13
|
+
element.setAttribute('data-btn-wired', '');
|
|
14
|
+
wired.add(element);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Update a factory button in place, preserving its icons and prior disabled state. */
|
|
18
|
+
export function setButtonBusy(element, { busy } = {}) {
|
|
19
|
+
wireActivationGuard(element);
|
|
20
|
+
const wasBusy = element.getAttribute('aria-busy') === 'true';
|
|
21
|
+
if ((busy || wasBusy) && !disabledBeforeBusy.has(element)) disabledBeforeBusy.set(element, {
|
|
22
|
+
disabled: wasBusy ? element.hasAttribute('data-btn-disabled') : element.hasAttribute('disabled'),
|
|
23
|
+
ariaDisabled: wasBusy && !element.hasAttribute('data-btn-disabled') ? null : element.getAttribute('aria-disabled'),
|
|
24
|
+
});
|
|
25
|
+
if (busy) {
|
|
26
|
+
element.removeAttribute('data-btn-ready');
|
|
27
|
+
element.setAttribute('aria-busy', 'true');
|
|
28
|
+
element.toggleAttribute('disabled', disabledBeforeBusy.get(element).disabled);
|
|
29
|
+
element.setAttribute('aria-disabled', 'true');
|
|
30
|
+
if (!element.querySelector('.ui-btn__dots')) {
|
|
31
|
+
const dots = element.ownerDocument.createElement('span');
|
|
32
|
+
dots.className = 'ui-btn__dots';
|
|
33
|
+
dots.setAttribute('aria-hidden', 'true');
|
|
34
|
+
dots.innerHTML = '<i></i><i></i><i></i>';
|
|
35
|
+
element.append(dots);
|
|
36
|
+
}
|
|
37
|
+
} else if (wasBusy) {
|
|
38
|
+
element.setAttribute('data-btn-ready', '');
|
|
39
|
+
element.removeAttribute('aria-busy');
|
|
40
|
+
element.querySelector('.ui-btn__dots')?.remove();
|
|
41
|
+
const prior = disabledBeforeBusy.get(element);
|
|
42
|
+
element.toggleAttribute('disabled', prior?.disabled ?? false);
|
|
43
|
+
if (prior?.ariaDisabled != null) element.setAttribute('aria-disabled', prior.ariaDisabled);
|
|
44
|
+
else element.removeAttribute('aria-disabled');
|
|
45
|
+
disabledBeforeBusy.delete(element);
|
|
46
|
+
}
|
|
47
|
+
if (busy || wasBusy) {
|
|
48
|
+
let status = element.nextElementSibling;
|
|
49
|
+
if (!status?.classList.contains('ui-btn__status')) {
|
|
50
|
+
status = element.ownerDocument.createElement('span');
|
|
51
|
+
status.className = 'ui-sr ui-btn__status';
|
|
52
|
+
status.setAttribute('role', 'status');
|
|
53
|
+
status.setAttribute('aria-live', 'polite');
|
|
54
|
+
element.after(status);
|
|
55
|
+
// Register the empty live region before its first text update.
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
status.textContent = messageFor(element);
|
|
58
|
+
}, 0);
|
|
59
|
+
} else {
|
|
60
|
+
const message = messageFor(element);
|
|
61
|
+
if (status.textContent !== message) status.textContent = message;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function messageFor(element) {
|
|
67
|
+
const name = element.querySelector('.ui-btn__label')?.textContent ?? element.getAttribute('aria-label') ?? 'Button';
|
|
68
|
+
return `${name}: ${element.getAttribute('aria-busy') === 'true' ? 'in progress' : 'complete'}`;
|
|
69
|
+
}
|
package/src/components/index.js
CHANGED
|
@@ -31,16 +31,17 @@ export function button({
|
|
|
31
31
|
block && 'ui-btn--block',
|
|
32
32
|
iconOnly && 'ui-btn--icon',
|
|
33
33
|
);
|
|
34
|
-
const
|
|
34
|
+
const dots = busy ? '<span class="ui-btn__dots" aria-hidden="true"><i></i><i></i><i></i></span>' : '';
|
|
35
35
|
const lead = iconSvg || (ic ? icon(ic) : '');
|
|
36
|
-
const inner = `${lead}${iconOnly ? '' : `<span>${esc(label)}</span>`}${iconRight ? icon(iconRight) : ''}${
|
|
37
|
-
// busy ⇒ disabled
|
|
36
|
+
const inner = `${lead}${iconOnly ? '' : `<span class="ui-btn__label-slot"><span class="ui-btn__label">${esc(label)}</span></span>`}${iconRight ? icon(iconRight) : ''}${dots}`;
|
|
37
|
+
// Static busy ⇒ disabled; setButtonBusy wires focus-preserving activation guards.
|
|
38
38
|
const name = String(label == null ? '' : label).trim() || ic || 'Button';
|
|
39
39
|
const named = iconOnly ? ` aria-label="${esc(name)}" title="${esc(name)}"` : '';
|
|
40
|
-
const attrs = `class="${cls}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${named}`;
|
|
41
|
-
|
|
40
|
+
const attrs = `class="${cls}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${disabled && busy ? ' data-btn-disabled' : ''}${named}`;
|
|
41
|
+
const control = href
|
|
42
42
|
? `<a href="${href}" ${attrs}>${inner}</a>`
|
|
43
43
|
: `<button type="${type}" ${attrs}>${inner}</button>`;
|
|
44
|
+
return busy ? `${control}<span class="ui-sr ui-btn__status" role="status" aria-live="polite"></span>` : control;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// ---- Badge / Pill --------------------------------------------------------
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export * from './components/stat.js';
|
|
|
21
21
|
export * from './assets/icons.js';
|
|
22
22
|
export * from './assets/brand.js';
|
|
23
23
|
export * from './motion.js';
|
|
24
|
+
export { setButtonBusy } from './components/button-busy.js';
|
|
24
25
|
|
|
25
26
|
export * from './components/table-values.js';
|
|
26
27
|
export * from './components/segmented.js';
|
package/src/styles/button.css
CHANGED
|
@@ -79,9 +79,9 @@
|
|
|
79
79
|
.ui-btn--lg { --btn-pad-y: 13px; --btn-pad-x: 24px; --btn-font: var(--text-md); border-radius: var(--radius-md); }
|
|
80
80
|
|
|
81
81
|
/* Icon-only */
|
|
82
|
-
.ui-btn--icon { padding: 9px; border-radius: var(--radius-sm); }
|
|
83
|
-
.ui-btn--icon.ui-btn--xs { padding: 4.5px; }
|
|
84
|
-
.ui-btn--icon.ui-btn--sm { padding: 6px; }
|
|
82
|
+
.ui-btn--icon { --btn-pad-y: 9px; padding: 9px; border-radius: var(--radius-sm); }
|
|
83
|
+
.ui-btn--icon.ui-btn--xs { --btn-pad-y: 4.5px; padding: 4.5px; }
|
|
84
|
+
.ui-btn--icon.ui-btn--sm { --btn-pad-y: 6px; padding: 6px; }
|
|
85
85
|
|
|
86
86
|
.ui-btn--block { width: 100%; }
|
|
87
87
|
|
|
@@ -121,60 +121,108 @@
|
|
|
121
121
|
contrast table shows that. The box was reverted and this ink shipped.
|
|
122
122
|
Held by src/styles/button-disabled.test.js. */
|
|
123
123
|
|
|
124
|
-
/*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
/* why: docs/specification.md#busy-button-labels */
|
|
125
|
+
.ui-btn { position: relative; }
|
|
126
|
+
.ui-btn[aria-busy="true"] { pointer-events: none; }
|
|
127
|
+
.ui-btn[aria-busy="true"][data-btn-wired] {
|
|
128
|
+
pointer-events: auto;
|
|
129
|
+
filter: none;
|
|
130
|
+
transform: none;
|
|
131
|
+
}
|
|
132
|
+
/* Busy keeps the resting variant paint. The disabled selector above owns the
|
|
133
|
+
ordinary unavailable state; a wired busy control remains focused and must
|
|
134
|
+
not flatten its action identity while work is in flight. */
|
|
135
|
+
.ui-btn[aria-busy="true"],
|
|
136
|
+
.ui-btn[aria-busy="true"]:hover,
|
|
137
|
+
.ui-btn[aria-busy="true"]:active {
|
|
138
|
+
background: var(--surface-2); /* ring-gap: inherit — busy paint retains the button's containing gap. */
|
|
139
|
+
border-color: var(--border);
|
|
140
|
+
color: var(--text);
|
|
141
|
+
filter: none;
|
|
142
|
+
transform: none;
|
|
143
|
+
}
|
|
144
|
+
.ui-btn[aria-busy="true"].ui-btn--primary,
|
|
145
|
+
.ui-btn[aria-busy="true"].ui-btn--primary:hover,
|
|
146
|
+
.ui-btn[aria-busy="true"].ui-btn--primary:active {
|
|
147
|
+
background: var(--accent-strong); /* ring-gap: inherit — busy paint retains the button's containing gap. */
|
|
148
|
+
border-color: var(--accent-strong);
|
|
149
|
+
color: var(--accent-contrast);
|
|
150
|
+
}
|
|
151
|
+
.ui-btn[aria-busy="true"].ui-btn--ghost,
|
|
152
|
+
.ui-btn[aria-busy="true"].ui-btn--ghost:hover,
|
|
153
|
+
.ui-btn[aria-busy="true"].ui-btn--ghost:active {
|
|
154
|
+
background: transparent; /* ring-gap: inherit — busy paint retains the button's containing gap. */
|
|
155
|
+
border-color: transparent;
|
|
156
|
+
color: var(--text);
|
|
157
|
+
}
|
|
158
|
+
.ui-btn[aria-busy="true"].ui-btn--danger,
|
|
159
|
+
.ui-btn[aria-busy="true"].ui-btn--danger:hover,
|
|
160
|
+
.ui-btn[aria-busy="true"].ui-btn--danger:active {
|
|
161
|
+
background: var(--surface-2); /* ring-gap: inherit — busy paint retains the button's containing gap. */
|
|
162
|
+
border-color: var(--border);
|
|
163
|
+
color: var(--text);
|
|
164
|
+
}
|
|
165
|
+
/* A caller can mark a control both busy and unavailable. The explicit marker
|
|
166
|
+
keeps that ordinary disabled state from inheriting the busy variant paint. */
|
|
167
|
+
.ui-btn[aria-busy="true"][data-btn-disabled] {
|
|
168
|
+
background: var(--disabled-surface); /* ring-gap: inherit — busy+disabled keeps its containing gap. */
|
|
169
|
+
border-color: var(--disabled-border);
|
|
170
|
+
/* muted-ink: state — busy+disabled control */ color: var(--disabled-ink);
|
|
171
|
+
cursor: not-allowed;
|
|
131
172
|
pointer-events: none;
|
|
132
|
-
position: relative;
|
|
133
|
-
overflow: hidden;
|
|
134
173
|
}
|
|
135
|
-
.ui-
|
|
174
|
+
.ui-btn--ghost[aria-busy="true"][data-btn-disabled] {
|
|
175
|
+
background: transparent; /* ring-gap: inherit — busy+disabled keeps its containing gap. */
|
|
176
|
+
border-color: transparent;
|
|
177
|
+
/* muted-ink: state — busy+disabled ghost */ color: var(--disabled-ink-bare);
|
|
178
|
+
}
|
|
179
|
+
.ui-btn__label-slot { display: inline-grid; overflow: hidden; }
|
|
180
|
+
.ui-btn__label {
|
|
181
|
+
grid-area: 1 / 1;
|
|
182
|
+
transform: translateY(0);
|
|
183
|
+
|
|
184
|
+
}
|
|
185
|
+
.ui-btn[data-btn-ready] .ui-btn__label { animation: ui-btn-label-in var(--dur-med) var(--ease-out) both; }
|
|
186
|
+
.ui-btn[aria-busy="true"] .ui-btn__label {
|
|
187
|
+
transform: translateY(100%);
|
|
188
|
+
animation: ui-btn-label-out var(--dur-med) var(--ease-in) both;
|
|
189
|
+
}
|
|
190
|
+
.ui-btn[aria-busy="true"] > svg,
|
|
191
|
+
.ui-btn[aria-busy="true"] > span[aria-hidden="true"]:not(.ui-btn__dots) { visibility: hidden; }
|
|
192
|
+
.ui-btn__dots {
|
|
136
193
|
position: absolute;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
194
|
+
inset: 0;
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
justify-content: center;
|
|
198
|
+
gap: 4px;
|
|
141
199
|
overflow: hidden;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.ui-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
color-mix(in srgb, var(--accent-contrast) 35%, transparent),
|
|
164
|
-
var(--accent-contrast));
|
|
165
|
-
}
|
|
166
|
-
.ui-btn__bars i:nth-child(1) { width: 38%; animation: ui-bars1 2s cubic-bezier(0.65, 0.05, 0.35, 1) infinite; } /* motion: ambient — an indeterminate loader loops until the work returns; its period is not a response time */
|
|
167
|
-
.ui-btn__bars i:nth-child(2) { width: 22%; animation: ui-bars2 2s cubic-bezier(0.65, 0.05, 0.35, 1) 0.8s infinite; } /* motion: ambient — the second bar runs the same loop 0.8s out of phase */
|
|
168
|
-
@keyframes ui-bars1 { 0% { left: -45%; } 100% { left: 110%; } }
|
|
169
|
-
@keyframes ui-bars2 { 0% { left: -28%; } 100% { left: 120%; } }
|
|
170
|
-
@keyframes ui-bars-in { from { opacity: 0; transform: scaleX(0.72); } to { opacity: 1; transform: scaleX(1); } }
|
|
171
|
-
/* Reduced motion: no entrance, no sweep — just a static, intentional accent
|
|
172
|
-
fill so the busy state still reads clearly. */
|
|
200
|
+
pointer-events: none;
|
|
201
|
+
}
|
|
202
|
+
.ui-btn__dots i {
|
|
203
|
+
width: 5px;
|
|
204
|
+
height: 5px;
|
|
205
|
+
border-radius: 50%;
|
|
206
|
+
background: currentColor;
|
|
207
|
+
animation: ui-btn-dot-in var(--dur-med) var(--ease-out) both, ui-btn-dot-pulse 1.4s ease-in-out infinite; /* motion: ambient — dots pulse until the request completes */
|
|
208
|
+
}
|
|
209
|
+
.ui-btn--xs .ui-btn__dots { gap: 2px; }
|
|
210
|
+
.ui-btn--xs .ui-btn__dots i { width: 3px; height: 3px; }
|
|
211
|
+
.ui-btn--sm .ui-btn__dots { gap: 3px; }
|
|
212
|
+
.ui-btn--sm .ui-btn__dots i { width: 4px; height: 4px; }
|
|
213
|
+
.ui-btn--lg .ui-btn__dots { gap: 5px; }
|
|
214
|
+
.ui-btn--lg .ui-btn__dots i { width: 6px; height: 6px; }
|
|
215
|
+
.ui-btn__dots i:nth-child(2) { animation-delay: 0s, 0.2s; } /* motion: ambient — stagger the second pulse */
|
|
216
|
+
.ui-btn__dots i:nth-child(3) { animation-delay: 0s, 0.4s; } /* motion: ambient — stagger the third pulse */
|
|
217
|
+
@keyframes ui-btn-label-in { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
218
|
+
@keyframes ui-btn-label-out { from { transform: translateY(0); } 45%, to { transform: translateY(100%); } }
|
|
219
|
+
@keyframes ui-btn-dot-in { from, 45% { translate: 0 24px; } to { translate: 0 0; } }
|
|
220
|
+
@keyframes ui-btn-dot-pulse { 0%, 100% { opacity: 1; scale: 1; } 50% { opacity: 0.3; scale: 0.8; } }
|
|
173
221
|
@media (prefers-reduced-motion: reduce) {
|
|
174
|
-
.ui-
|
|
175
|
-
.ui-
|
|
176
|
-
.ui-
|
|
177
|
-
.ui-
|
|
222
|
+
.ui-btn, .ui-btn__label { transition: none; }
|
|
223
|
+
.ui-btn[data-btn-ready] .ui-btn__label,
|
|
224
|
+
.ui-btn[aria-busy="true"] .ui-btn__label { animation: none; }
|
|
225
|
+
.ui-btn__dots i { animation: none; }
|
|
178
226
|
}
|
|
179
227
|
|
|
180
228
|
@keyframes ui-spin { to { transform: rotate(360deg); } }
|