@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 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, Apps
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.40.0",
3
+ "version": "0.41.1",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -12,11 +12,11 @@ type ButtonProps = {
12
12
  iconRight?: string;
13
13
  iconOnly?: boolean;
14
14
  block?: boolean;
15
- /** In flight: aria-busy, disabled, and the kit's indeterminate bars. */
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;
@@ -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: disabled || busy,
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 && children != null && /* @__PURE__ */ jsx2("span", { children }),
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-btn__bars", children: [
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 useRef2 } from "react";
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 : useLayoutEffect;
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 = useRef(onClose);
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 = useRef2(null);
346
- const panel = useRef2(null);
347
- const body = useRef2(null);
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 useRef3 } from "react";
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 = useRef3(null);
379
- const panel = useRef3(null);
380
- const body = useRef3(null);
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 useRef4, useState as useState2 } from "react";
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 = useRef4(null);
436
- const panel = useRef4(null);
437
- const search = useRef4(null);
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 useRef5, useState as useState3 } from "react";
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 = useRef5(null);
659
- const pressed = useRef5(null);
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 useRef6,
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 = useRef6(null);
1146
- const panel = useRef6(null);
1147
- const trigger = useRef6(null);
1148
- const field = useRef6(null);
1149
- const landOn = useRef6(null);
1150
- const at = useRef6("");
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 = useRef6(setOpen);
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 useLayoutEffect2, useRef as useRef7 } from "react";
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 = useRef7(null);
1569
- const latest = useRef7(props);
1658
+ const host = useRef8(null);
1659
+ const latest = useRef8(props);
1570
1660
  latest.current = props;
1571
- const controller = useRef7(null);
1572
- const initial = useRef7({ __html: filterBar(props) });
1573
- useLayoutEffect2(() => {
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
- useLayoutEffect2(() => {
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
+ }
@@ -31,16 +31,17 @@ export function button({
31
31
  block && 'ui-btn--block',
32
32
  iconOnly && 'ui-btn--icon',
33
33
  );
34
- const bars = busy ? '<span class="ui-btn__bars"><i></i><i></i></span>' : '';
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) : ''}${bars}`;
37
- // busy ⇒ disabled (not clickable while it works)
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
- return href
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';
@@ -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
- /* Busy: keep the label, run an indeterminate accent shimmer along the base, and
125
- the button is disabled (not clickable). Pass busy + the .ui-btn__bars markup.
126
- The track is inset into a rounded pill so it sits INSIDE the button — not
127
- flush against the clipped corners — and tints from the live --accent so it
128
- reads as an intentional progress element in every theme (and mutes uniformly
129
- with the button in the disabled state). */
130
- .ui-btn[aria-busy="true"] {
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-btn__bars {
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
- left: 10px; right: 10px; bottom: 5px;
138
- height: 3px;
139
- border-radius: 999px;
140
- background: color-mix(in srgb, var(--accent) 22%, transparent); /* ring-gap: inherit — decorative paint has no nested focus target. */
194
+ inset: 0;
195
+ display: flex;
196
+ align-items: center;
197
+ justify-content: center;
198
+ gap: 4px;
141
199
  overflow: hidden;
142
- /* Clean entrance: grow + fade in on state change, once. */
143
- transform-origin: center;
144
- animation: ui-bars-in var(--dur-fast) var(--ease) both;
145
- }
146
- .ui-btn--xs .ui-btn__bars { left: 4px; right: 4px; bottom: 2px; height: 2px; }
147
- .ui-btn--sm .ui-btn__bars { left: 7px; right: 7px; bottom: 4px; }
148
- .ui-btn--lg .ui-btn__bars { left: 14px; right: 14px; bottom: 7px; }
149
- .ui-btn__bars i {
150
- position: absolute;
151
- height: 100%;
152
- border-radius: 999px;
153
- background: linear-gradient(90deg,
154
- color-mix(in srgb, var(--accent) 30%, transparent), var(--accent));
155
- }
156
- /* On the filled primary the surface IS the accent, so shimmer in the contrast
157
- colour instead — otherwise the bars vanish into the button. */
158
- .ui-btn--primary .ui-btn__bars {
159
- background: color-mix(in srgb, var(--accent-contrast) 26%, transparent); /* ring-gap: inherit — decorative paint has no nested focus target. */
160
- }
161
- .ui-btn--primary .ui-btn__bars i {
162
- background: linear-gradient(90deg,
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-btn__bars { animation: none; opacity: 1; transform: none; }
175
- .ui-btn__bars i { animation: none; }
176
- .ui-btn__bars i:nth-child(1) { left: 0; width: 100%; }
177
- .ui-btn__bars i:nth-child(2) { display: none; }
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); } }