beez-ui 2.3.1 → 2.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ Todos los cambios relevantes de beez-ui se documentan en este archivo con el for
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.5.0] - 2026-10-15
8
+
9
+ ### Added
10
+
11
+ - `Input` y `Textarea` aceptan `label`, `description` y `error`, de Arc: si se pasa cualquiera de ellos (aunque valga `undefined`, así el control no se vuelve a montar cuando aparece la validación) renderizan el campo completo, con el label asociado al control, la ayuda y el error enlazados por `aria-describedby`, y filas que se abren con un resorte y cambian solo las palabras que cambiaron; los números de la ayuda (por ejemplo un contador de caracteres) giran solo los dígitos que cambian. El error marca `aria-invalid` y se anuncia con `role="alert"`. Con movimiento reducido los cambios son instantáneos. Sin esas props siguen renderizando solo el control.
12
+
13
+ ### Changed
14
+
15
+ - `Input` y `Textarea` toman el diseño de Arc: alto mínimo de 44 px (`Textarea`, 110 px con redimensión vertical y sin crecer con el contenido), radio de control, fondo `surface`, borde fuerte que pasa a `foreground` con hover de puntero fino y con foco de teclado, sin anillos de foco, y borde `danger` cuando son inválidos. `InputGroup` usa el mismo marco.
16
+
17
+ ## [2.4.0] - 2026-10-14
18
+
19
+ ### Added
20
+
21
+ - `ActionButton` (y `beez-ui/action-button`), de Arc: corre una acción asíncrona y muestra el resultado en el propio botón, con el label que cambia letra por letra de la flecha al spinner (`pendingLabel`, "Guardando" por defecto) y al check (`successLabel`, "Guardado"), y vuelve solo a idle después de `resetAfterMs` (2400 ms). Mientras corre sigue enfocable e ignora nuevas pulsaciones; si falla, vuelve a idle y entrega el error a `onActionError`. Acepta las variantes y tamaños de `Button`.
22
+ - `SplitButton` (y `beez-ui/split-button`), de Arc: una acción principal unida a un menú de acciones relacionadas (`actions`, con `destructive`, `disabled` e `icon`), en variante `primary` o `secondary` (compacta). El ícono y el label de la acción principal cambian en el lugar y los lectores de pantalla anuncian el label nuevo; la mitad del menú se nombra "Más acciones de {label}" o con `menuLabel`.
23
+
7
24
  ## [2.3.1] - 2026-10-14
8
25
 
9
26
  ### Changed
package/README.md CHANGED
@@ -195,6 +195,7 @@ Provienen de las carpetas de producto de LaTribu y agenda-mensual y quedaron des
195
195
 
196
196
  - Movimiento: `PresenceSwap`, `AnimatedCount`, `AnimatedCollapse` y `AnimatedListItem`. Los tokens que usa la biblioteca se exportan desde la raíz y desde `beez-ui/motion-tokens`, así las apps animan sus superficies con las mismas curvas y springs en lugar de copiarlos.
197
197
  - Indicadores: `ProgressRing` y `ReactionButton`. El color del ícono activo de la reacción se configura con `activeColor`.
198
+ - Acciones (de Arc, sobre `Button`): `ActionButton` corre una acción asíncrona (`onAction`) y muestra el resultado en el propio botón: pasa de la flecha al spinner con `pendingLabel` ("Guardando") y al check con `successLabel` ("Guardado"), y vuelve solo a idle después de `resetAfterMs`. Mientras corre sigue enfocable e ignora nuevas pulsaciones, y si falla vuelve a idle y entrega el error a `onActionError`. `SplitButton` une una acción principal con un menú de acciones relacionadas (`actions`): su ícono y su label cambian en el lugar, y la mitad del menú se nombra "Más acciones de {label}" o con `menuLabel`. Para un envío común sin estado de éxito, usá `Button` con `loading`.
198
199
  - Cuenta: `AccountMenu`, con inicio de sesión mediante `signInHref` o `onSignIn`.
199
200
  - Contenido: `RichTextContent`, `RichMarkdownContent`, `RichLinkEditor` y `useRichLinkEditor`. Solo interpretan links en markdown, URLs sueltas, listas y negrita; el HTML del contenido nunca se renderiza como markup.
200
201
  - Filtros guardados: `FilterPresetsBar` y `FilterPresetSaveButton` guardan, aplican, editan y borran consultas con nombre de `FilterQueryBar`. La app decide dónde persistirlas; `parseFilterPresets` valida lo que vuelve del almacenamiento.
@@ -307,7 +308,7 @@ Los releases salen sólo desde `main`, limpio y al día con origin (sólo `CHANG
307
308
  ### Flujo validado de un release nuevo
308
309
 
309
310
  1. Actualiza `main` con fast-forward si está atrás.
310
- 2. Verifica que `CHANGELOG.md` tenga un bloque `## [Unreleased]` con entradas escritas a mano y solo secciones de Keep a Changelog; si falta o está vacío, bloquea el release. No genera notas.
311
+ 2. Verifica que `CHANGELOG.md` tenga un bloque `## [Unreleased]` con entradas y solo secciones de Keep a Changelog. Si faltan, las redacta con Claude Code o, si falla, con Codex (`changelogDraft: true` en `beez-rp.config.js`) y las muestra en la terminal; si ningún agente puede, bloquea el release.
311
312
  3. Pide la versión (sugiere `patch`, `minor` o `major` según los commits) y crea el commit `X.Y.Z` con la versión del manifiesto, el CHANGELOG con `[Unreleased]` renombrado a `## [X.Y.Z] - AAAA-MM-DD` y un `## [Unreleased]` vacío arriba, y el tag anotado `vX.Y.Z`, antes de las validaciones largas.
312
313
  4. `prepare` (`scripts/release-hooks.js`): reusa el tarball ya preparado para esa versión si es posterior al último cambio de código (el commit de versión no cuenta); si no, ejecuta `pnpm release:prepare` sobre el commit de versión: instalación congelada, tests sin React Compiler, build optimizado, lint, typechecks, tests unitarios y pruebas de navegador, y genera el tarball verificado con `npm pack --ignore-scripts` (npm y no pnpm, para que sea reproducible) en `releases/<version>-<sha256>/beez-ui-<version>.tgz`.
313
314
  5. Sube `main` y el tag `vX.Y.Z` a origin con un único `git push --atomic`.
@@ -319,7 +320,7 @@ La publicación usa el cliente oficial de npm contra `https://registry.npmjs.org
319
320
 
320
321
  ### CHANGELOG
321
322
 
322
- `CHANGELOG.md` sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). Cada cambio agrega sus entradas en el bloque `## [Unreleased]`, agrupadas en `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed` y `### Security` (solo las que apliquen). Las entradas se escriben a mano: `pnpm create-version` no genera notas y bloquea el release si `[Unreleased]` no existe o no tiene entradas. Con beez-rp 0.9.0, el commit de versión renombra `[Unreleased]` a `## [X.Y.Z] - AAAA-MM-DD` (fecha en la hora de Argentina por `releaseTimeZone: "America/Argentina/Buenos_Aires"`, aunque el workflow **Release** corra en un runner en UTC) y deja un `[Unreleased]` vacío arriba; si el CHANGELOG no está en UTF-8, el release se detiene antes del bump. Ni las personas ni los hooks cierran la sección: hacerlo (por ejemplo con `releaseUnreleased`) la cerraría dos veces o dejaría `[Unreleased]` vacío y bloquearía el release. `release:prepare` exige el bloque fechado que coincide con la versión del manifiesto. La regla completa está en `AGENTS.md`.
323
+ `CHANGELOG.md` sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). Cada cambio agrega sus entradas en el bloque `## [Unreleased]`, agrupadas en `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed` y `### Security` (solo las que apliquen). Si faltan entradas, `pnpm create-version` las redacta con Claude Code o Codex y las escribe sin confirmar (revisalas en la salida del comando); si ningún agente puede, bloquea el release mientras `[Unreleased]` no exista o no tenga entradas. Con beez-rp 0.10.0, el commit de versión renombra `[Unreleased]` a `## [X.Y.Z] - AAAA-MM-DD` (fecha en la hora de Argentina por `releaseTimeZone: "America/Argentina/Buenos_Aires"`, aunque el workflow **Release** corra en un runner en UTC) y deja un `[Unreleased]` vacío arriba; si el CHANGELOG no está en UTF-8, el release se detiene antes del bump. Ni las personas ni los hooks cierran la sección: hacerlo (por ejemplo con `releaseUnreleased`) la cerraría dos veces o dejaría `[Unreleased]` vacío y bloquearía el release. `release:prepare` exige el bloque fechado que coincide con la versión del manifiesto. La regla completa está en `AGENTS.md`.
323
324
 
324
325
  Para validar y empaquetar la versión actual sin publicarla:
325
326
 
@@ -329,7 +330,7 @@ pnpm release:prepare
329
330
 
330
331
  El paquete excluye fuentes privadas, tests, scripts, `.env` y `.npmrc`; incluye JavaScript, declaraciones, CSS, fuentes tipográficas y licencias. Conserva releases anteriores.
331
332
 
332
- `prepack` ejecuta el build para los empaquetados manuales. `dist` y `releases` son generados e ignorados por Git. La CI verifica los checks y los tres providers en ambos motores de navegador en Linux y Windows. La publicación se ejecuta desde el workflow **Release** con cada push a `main` o mediante su ejecución manual.
333
+ `prepack` ejecuta el build para los empaquetados manuales. `dist` y `releases` son generados e ignorados por Git. La CI verifica los checks, los tres providers y el catálogo de Storybook (`pnpm test:storybook`) en ambos motores de navegador en Linux y Windows. La publicación se ejecuta desde el workflow **Release**, que recibe la versión, el tag y el commit creados por `pnpm create-version`.
333
334
 
334
335
  Tras publicar, los consumidores pueden instalar `pnpm add beez-ui`. También pueden instalar directamente el `.tgz` validado antes de una publicación. Las aplicaciones que todavía usan `@guidomodarelli/beez-ui` desde GitHub Packages deben migrar a `beez-ui` (ver «Uso del paquete»); su lockfile fija la resolución e integridad.
335
336
 
@@ -4,7 +4,7 @@ beez-ui includes code adapted from the following projects.
4
4
 
5
5
  ## Arc
6
6
 
7
- The theme tokens (`theme.css`, from Arc's `foundation.css`) and the `Button` API, styles and motion (`src/components/button.tsx`, `src/components/button-label.tsx`, `src/components/control-button.tsx`, `src/motion/arc-press.ts`, `src/motion/arc-tokens.ts`) are adapted from the free Arc library: https://uiarc.dev, https://github.com/kuratlielia/arc-library
7
+ The theme tokens (`theme.css`, from Arc's `foundation.css`) and the `Button` API, styles and motion (`src/components/button.tsx`, `src/components/button-label.tsx`, `src/components/control-button.tsx`, `src/motion/arc-press.ts`, `src/motion/arc-tokens.ts`, `src/motion/use-morph-width.ts`), and the `ActionButton` and `SplitButton` components and their label morph (`src/components/action-button.tsx`, `src/components/split-button.tsx`, `src/components/morph-text.tsx`), and the `Input` and `Textarea` fields with their animated helper and error rows (`src/components/input.tsx`, `src/components/textarea.tsx`, `src/components/field.tsx`) are adapted from the free Arc library: https://uiarc.dev, https://github.com/kuratlielia/arc-library
8
8
 
9
9
  ```
10
10
  MIT License
@@ -0,0 +1,25 @@
1
+ import type { ButtonProps } from "./button.js";
2
+ export type ActionButtonProps = Omit<ButtonProps, "children" | "loading" | "onClick"> & {
3
+ /** Idle label and the button's accessible name. */
4
+ label: string;
5
+ /** Runs on press; the pending state lasts until its promise settles. */
6
+ onAction: () => void | Promise<void>;
7
+ /** Label while `onAction` runs. */
8
+ pendingLabel?: string;
9
+ /** Label after `onAction` resolves. */
10
+ successLabel?: string;
11
+ /** Delay before returning to idle after success; `0` keeps the success state. */
12
+ resetAfterMs?: number;
13
+ /** Called when `onAction` rejects; the button returns to idle. Without it, the error is logged to the console. */
14
+ onActionError?: (error: unknown) => void;
15
+ };
16
+ /**
17
+ * A single async commit, such as Save, Publish or Submit, whose result shows on the button itself. While the
18
+ * action runs the button stays focusable (`aria-disabled` and `aria-busy` instead of `disabled`) and ignores new
19
+ * presses; a polite status region announces the pending and success labels. For a plain form submit without a
20
+ * success state, use `Button` with `loading`.
21
+ * @param props - Labels, the action, its error handler, the reset delay and `Button` props except `onClick`.
22
+ * @returns The button followed by its visually hidden status region.
23
+ */
24
+ declare function ActionButton({ label, onAction, pendingLabel, successLabel, resetAfterMs, onActionError, ...props }: ActionButtonProps): import("react").JSX.Element;
25
+ export { ActionButton };
@@ -0,0 +1,264 @@
1
+ "use client";
2
+ import { c as _c } from "react/compiler-runtime";
3
+ /**
4
+ * Arc's ActionButton (https://uiarc.dev, MIT License, see THIRD_PARTY_NOTICES.md) on top of beez-ui's `Button`: a
5
+ * call to action with a trailing arrow that runs an async action and morphs, letter by letter, through a pending
6
+ * and a success label before returning to idle on its own.
7
+ */
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { AnimatePresence, motion } from "motion/react";
10
+ import { ArrowRight } from "lucide-react";
11
+ import { ACTION_BUTTON_ARROW_OFFSET_PX, ACTION_BUTTON_CHECK_DRAW_DELAY_S, ACTION_BUTTON_DEFAULT_RESET_AFTER_MS, ACTION_BUTTON_ICON_SIZE_PX, ACTION_BUTTON_STATE } from "../constants/action-button.js";
12
+ import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
13
+ import { ARC_BLUR, ARC_DURATION, ARC_EASE } from "../motion/arc-tokens.js";
14
+ import { ARC_ICON_ENTER as iconEnter, ARC_ICON_EXIT as iconExit, ARC_ICON_TRANSITION as iconTransition, ARC_REDUCED_ENTER as reducedEnter, ARC_REDUCED_EXIT as reducedExit, ARC_REDUCED_TRANSITION as reducedTransition, ARC_SWAP_EXIT_TRANSITION as exitTransition, ARC_SWAP_REST as restState } from "../motion/arc-swap.js";
15
+ import { useMorphWidth } from "../motion/use-morph-width.js";
16
+ import { Button } from "./button.js";
17
+ import { MorphText } from "./morph-text.js";
18
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ /** The arrow leaves in the direction of the action and returns from behind once the button resets. */
20
+ const arrowEnter = {
21
+ opacity: 0,
22
+ x: ACTION_BUTTON_ARROW_OFFSET_PX.enter,
23
+ filter: `blur(${ARC_BLUR.subtle}px)`
24
+ };
25
+ const arrowExit = {
26
+ opacity: 0,
27
+ x: ACTION_BUTTON_ARROW_OFFSET_PX.exit,
28
+ filter: `blur(${ARC_BLUR.subtle}px)`,
29
+ transition: exitTransition
30
+ };
31
+ const iconSlotStyle = {
32
+ width: ACTION_BUTTON_ICON_SIZE_PX,
33
+ height: ACTION_BUTTON_ICON_SIZE_PX
34
+ };
35
+ /**
36
+ * The success tick draws itself from its short stroke, the way a hand would write it.
37
+ * @param props - Whether the user prefers reduced motion, which shows the tick at once.
38
+ * @returns The check icon.
39
+ */
40
+ function DrawnCheck(t0) {
41
+ const $ = _c(6);
42
+ const { reduceMotion } = t0;
43
+ let t1;
44
+ if ($[0] !== reduceMotion) {
45
+ t1 = reduceMotion ? false : {
46
+ pathLength: 0,
47
+ opacity: 0
48
+ };
49
+ $[0] = reduceMotion;
50
+ $[1] = t1;
51
+ } else {
52
+ t1 = $[1];
53
+ }
54
+ let t2;
55
+ if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
56
+ t2 = {
57
+ pathLength: 1,
58
+ opacity: 1
59
+ };
60
+ $[2] = t2;
61
+ } else {
62
+ t2 = $[2];
63
+ }
64
+ let t3;
65
+ if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
66
+ t3 = {
67
+ pathLength: {
68
+ duration: ARC_DURATION.standard,
69
+ ease: [...ARC_EASE.enter],
70
+ delay: ACTION_BUTTON_CHECK_DRAW_DELAY_S
71
+ },
72
+ opacity: {
73
+ duration: ACTION_BUTTON_CHECK_DRAW_DELAY_S,
74
+ delay: ACTION_BUTTON_CHECK_DRAW_DELAY_S
75
+ }
76
+ };
77
+ $[3] = t3;
78
+ } else {
79
+ t3 = $[3];
80
+ }
81
+ let t4;
82
+ if ($[4] !== t1) {
83
+ t4 = /* @__PURE__ */ _jsx("svg", {
84
+ width: ACTION_BUTTON_ICON_SIZE_PX,
85
+ height: ACTION_BUTTON_ICON_SIZE_PX,
86
+ viewBox: "0 0 24 24",
87
+ fill: "none",
88
+ stroke: "currentColor",
89
+ strokeWidth: 2,
90
+ strokeLinecap: "round",
91
+ strokeLinejoin: "round",
92
+ children: /* @__PURE__ */ _jsx(motion.path, {
93
+ d: "M4 12l5 5L20 6",
94
+ initial: t1,
95
+ animate: t2,
96
+ transition: t3
97
+ })
98
+ });
99
+ $[4] = t1;
100
+ $[5] = t4;
101
+ } else {
102
+ t4 = $[5];
103
+ }
104
+ return t4;
105
+ }
106
+ /**
107
+ * Visible content of the button: the morphing label, whose width follows on a spring, and a slot that swaps the
108
+ * arrow, the spinner and the check.
109
+ * @param props - Current state, the text it shows and whether the user prefers reduced motion.
110
+ * @returns Content hidden from assistive tech; the button's name and status region carry its meaning.
111
+ */
112
+ function ActionButtonContent(t0) {
113
+ const $ = _c(20);
114
+ const { state, text, reduceMotion } = t0;
115
+ const rowRef = useRef(null);
116
+ const width = useMorphWidth(rowRef, text, reduceMotion);
117
+ const isIdle = state === ACTION_BUTTON_STATE.idle;
118
+ let t1;
119
+ if ($[0] !== width) {
120
+ t1 = { width };
121
+ $[0] = width;
122
+ $[1] = t1;
123
+ } else {
124
+ t1 = $[1];
125
+ }
126
+ let t2;
127
+ if ($[2] !== reduceMotion || $[3] !== text) {
128
+ t2 = /* @__PURE__ */ _jsx(MorphText, {
129
+ ref: rowRef,
130
+ text,
131
+ reduceMotion
132
+ });
133
+ $[2] = reduceMotion;
134
+ $[3] = text;
135
+ $[4] = t2;
136
+ } else {
137
+ t2 = $[4];
138
+ }
139
+ let t3;
140
+ if ($[5] !== t1 || $[6] !== t2) {
141
+ t3 = /* @__PURE__ */ _jsx(motion.span, {
142
+ "data-slot": "action-button-label",
143
+ className: "inline-flex min-w-0 data-morphing:[clip-path:inset(-0.6em_0_-0.6em_-0.3em)]",
144
+ style: t1,
145
+ children: t2
146
+ });
147
+ $[5] = t1;
148
+ $[6] = t2;
149
+ $[7] = t3;
150
+ } else {
151
+ t3 = $[7];
152
+ }
153
+ const t4 = reduceMotion ? reducedEnter : isIdle ? arrowEnter : iconEnter;
154
+ const t5 = reduceMotion ? reducedExit : isIdle ? arrowExit : iconExit;
155
+ const t6 = reduceMotion ? reducedTransition : iconTransition;
156
+ let t7;
157
+ if ($[8] !== reduceMotion || $[9] !== state) {
158
+ t7 = state === ACTION_BUTTON_STATE.pending ? /* @__PURE__ */ _jsx("span", { className: "size-4 animate-spin rounded-full border-[1.5px] border-current border-r-transparent motion-reduce:animate-none" }) : state === ACTION_BUTTON_STATE.success ? /* @__PURE__ */ _jsx(DrawnCheck, { reduceMotion }) : /* @__PURE__ */ _jsx(ArrowRight, {
159
+ width: ACTION_BUTTON_ICON_SIZE_PX,
160
+ height: ACTION_BUTTON_ICON_SIZE_PX
161
+ });
162
+ $[8] = reduceMotion;
163
+ $[9] = state;
164
+ $[10] = t7;
165
+ } else {
166
+ t7 = $[10];
167
+ }
168
+ let t8;
169
+ if ($[11] !== state || $[12] !== t4 || $[13] !== t5 || $[14] !== t6 || $[15] !== t7) {
170
+ t8 = /* @__PURE__ */ _jsx("span", {
171
+ className: "grid flex-none place-items-center",
172
+ style: iconSlotStyle,
173
+ children: /* @__PURE__ */ _jsx(AnimatePresence, {
174
+ initial: false,
175
+ children: /* @__PURE__ */ _jsx(motion.span, {
176
+ className: "col-start-1 row-start-1 grid place-items-center",
177
+ style: iconSlotStyle,
178
+ initial: t4,
179
+ animate: restState,
180
+ exit: t5,
181
+ transition: t6,
182
+ children: t7
183
+ }, state)
184
+ })
185
+ });
186
+ $[11] = state;
187
+ $[12] = t4;
188
+ $[13] = t5;
189
+ $[14] = t6;
190
+ $[15] = t7;
191
+ $[16] = t8;
192
+ } else {
193
+ t8 = $[16];
194
+ }
195
+ let t9;
196
+ if ($[17] !== t3 || $[18] !== t8) {
197
+ t9 = /* @__PURE__ */ _jsxs("span", {
198
+ className: "inline-flex items-center justify-center gap-[inherit] whitespace-nowrap",
199
+ "aria-hidden": "true",
200
+ children: [t3, t8]
201
+ });
202
+ $[17] = t3;
203
+ $[18] = t8;
204
+ $[19] = t9;
205
+ } else {
206
+ t9 = $[19];
207
+ }
208
+ return t9;
209
+ }
210
+ /**
211
+ * A single async commit, such as Save, Publish or Submit, whose result shows on the button itself. While the
212
+ * action runs the button stays focusable (`aria-disabled` and `aria-busy` instead of `disabled`) and ignores new
213
+ * presses; a polite status region announces the pending and success labels. For a plain form submit without a
214
+ * success state, use `Button` with `loading`.
215
+ * @param props - Labels, the action, its error handler, the reset delay and `Button` props except `onClick`.
216
+ * @returns The button followed by its visually hidden status region.
217
+ */
218
+ function ActionButton({ label, onAction, pendingLabel = "Guardando", successLabel = "Guardado", resetAfterMs = ACTION_BUTTON_DEFAULT_RESET_AFTER_MS, onActionError, ...props }) {
219
+ const [state, setState] = useState(ACTION_BUTTON_STATE.idle);
220
+ const resetTimer = useRef(null);
221
+ const isRunning = useRef(false);
222
+ const reduceMotion = usePrefersReducedMotion();
223
+ const isPending = state === ACTION_BUTTON_STATE.pending;
224
+ const text = isPending ? pendingLabel : state === ACTION_BUTTON_STATE.success ? successLabel : label;
225
+ useEffect(() => () => {
226
+ if (resetTimer.current) clearTimeout(resetTimer.current);
227
+ }, []);
228
+ async function runAction() {
229
+ if (isRunning.current) return;
230
+ isRunning.current = true;
231
+ if (resetTimer.current) clearTimeout(resetTimer.current);
232
+ setState(ACTION_BUTTON_STATE.pending);
233
+ try {
234
+ await onAction();
235
+ setState(ACTION_BUTTON_STATE.success);
236
+ if (resetAfterMs > 0) resetTimer.current = setTimeout(() => setState(ACTION_BUTTON_STATE.idle), resetAfterMs);
237
+ } catch (error) {
238
+ setState(ACTION_BUTTON_STATE.idle);
239
+ if (onActionError) onActionError(error);
240
+ else console.error(`ActionButton:onAction failed for "${label}"; pass onActionError to handle it.`, error);
241
+ } finally {
242
+ isRunning.current = false;
243
+ }
244
+ }
245
+ return /* @__PURE__ */ _jsxs(_Fragment, { children: [/* @__PURE__ */ _jsx(Button, {
246
+ ...props,
247
+ "data-slot": "action-button",
248
+ "data-action-state": state,
249
+ "aria-label": label,
250
+ "aria-busy": isPending || undefined,
251
+ "aria-disabled": isPending || props["aria-disabled"] || undefined,
252
+ onClick: runAction,
253
+ children: /* @__PURE__ */ _jsx(ActionButtonContent, {
254
+ state,
255
+ text,
256
+ reduceMotion
257
+ })
258
+ }), /* @__PURE__ */ _jsx("span", {
259
+ className: "sr-only",
260
+ role: "status",
261
+ children: isPending ? pendingLabel : state === ACTION_BUTTON_STATE.success ? successLabel : ""
262
+ })] });
263
+ }
264
+ export { ActionButton };
@@ -4,23 +4,13 @@ import { c as _c } from "react/compiler-runtime";
4
4
  * Label morph and loading spinner adapted from Arc (https://uiarc.dev), Copyright (c) 2026 Elia Kuratli,
5
5
  * MIT License. The full license text ships in THIRD_PARTY_NOTICES.md. Internal to Button.
6
6
  */
7
- import { isValidElement, useEffect, useLayoutEffect, useRef } from "react";
8
- import { AnimatePresence, animate, motion, useIsPresent, useMotionValue } from "motion/react";
9
- import { BUTTON_MORPH_WINDOW_MS } from "../constants/button.js";
7
+ import { isValidElement, useRef } from "react";
8
+ import { AnimatePresence, motion, useIsPresent } from "motion/react";
10
9
  import { cn } from "../lib/utils.js";
11
- import { ARC_BLUR, ARC_DURATION, ARC_EASE, ARC_SPRING_MORPH, ARC_SPRING_SNAPPY } from "../motion/arc-tokens.js";
10
+ import { ARC_BLUR, ARC_DURATION, ARC_EASE } from "../motion/arc-tokens.js";
11
+ import { ARC_ICON_ENTER as iconEnter, ARC_ICON_EXIT as iconExit, ARC_ICON_TRANSITION as iconTransition, ARC_REDUCED_ENTER as reducedEnter, ARC_REDUCED_EXIT as reducedExit, ARC_REDUCED_TRANSITION as reducedTransition, ARC_SWAP_EXIT_TRANSITION as exitTransition, ARC_SWAP_REST as restState } from "../motion/arc-swap.js";
12
+ import { useMorphWidth } from "../motion/use-morph-width.js";
12
13
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
13
- const restState = {
14
- opacity: 1,
15
- y: 0,
16
- scale: 1,
17
- filter: "blur(0px)"
18
- };
19
- /** Outgoing labels and icons leave faster than they enter. */
20
- const exitTransition = {
21
- duration: ARC_DURATION.fast,
22
- ease: [...ARC_EASE.standard]
23
- };
24
14
  /** Text rises out of a soft blur; the outgoing label lifts away a little faster. */
25
15
  const textEnter = {
26
16
  opacity: 0,
@@ -33,36 +23,6 @@ const textExit = {
33
23
  filter: `blur(${ARC_BLUR.soft}px)`,
34
24
  transition: exitTransition
35
25
  };
36
- const iconEnter = {
37
- opacity: 0,
38
- scale: .6,
39
- filter: `blur(${ARC_BLUR.subtle}px)`
40
- };
41
- const iconExit = {
42
- ...iconEnter,
43
- transition: exitTransition
44
- };
45
- const reducedTransition = { duration: ARC_DURATION.instant };
46
- const reducedEnter = {
47
- ...restState,
48
- opacity: 0
49
- };
50
- const reducedExit = {
51
- opacity: 0,
52
- transition: reducedTransition
53
- };
54
- /** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */
55
- const iconTransition = {
56
- ...ARC_SPRING_SNAPPY,
57
- opacity: {
58
- duration: ARC_DURATION.fast,
59
- ease: [...ARC_EASE.enter]
60
- },
61
- filter: {
62
- duration: ARC_DURATION.fast,
63
- ease: [...ARC_EASE.enter]
64
- }
65
- };
66
26
  const textTransition = {
67
27
  duration: ARC_DURATION.standard,
68
28
  ease: [...ARC_EASE.enter]
@@ -81,85 +41,6 @@ function getLabelKey(node) {
81
41
  const elementName = typeof elementType === "string" ? elementType : elementType?.displayName ?? elementType?.name ?? "";
82
42
  return `<${elementName}>${getLabelKey(node.props.children)}`;
83
43
  }
84
- /**
85
- * Springs the label slot to the natural width of an incoming label, so a new label never snaps the
86
- * button's size. Other resizes jump straight to the new width.
87
- */
88
- function useMorphWidth(contentRef, labelKey, reduceMotion) {
89
- const $ = _c(8);
90
- const width = useMotionValue("auto");
91
- const lastLabelKey = useRef(labelKey);
92
- const morphDeadline = useRef(0);
93
- let t0;
94
- let t1;
95
- if ($[0] !== labelKey) {
96
- t0 = () => {
97
- if (lastLabelKey.current === labelKey) {
98
- return;
99
- }
100
- lastLabelKey.current = labelKey;
101
- morphDeadline.current = performance.now() + BUTTON_MORPH_WINDOW_MS;
102
- };
103
- t1 = [labelKey];
104
- $[0] = labelKey;
105
- $[1] = t0;
106
- $[2] = t1;
107
- } else {
108
- t0 = $[1];
109
- t1 = $[2];
110
- }
111
- useLayoutEffect(t0, t1);
112
- let t2;
113
- let t3;
114
- if ($[3] !== contentRef || $[4] !== reduceMotion || $[5] !== width) {
115
- t2 = () => {
116
- const content = contentRef.current;
117
- const slot = content?.parentElement;
118
- if (!content || !slot || typeof ResizeObserver === "undefined") {
119
- return;
120
- }
121
- let hasMeasured = false;
122
- const observer = new ResizeObserver((t4) => {
123
- const [entry] = t4;
124
- if (!entry) {
125
- return;
126
- }
127
- const nextWidth = entry.contentRect.width;
128
- const shouldJump = !nextWidth || !hasMeasured || reduceMotion || performance.now() > morphDeadline.current;
129
- if (shouldJump) {
130
- hasMeasured = nextWidth > 0;
131
- width.jump(nextWidth || "auto");
132
- delete slot.dataset.morphing;
133
- return;
134
- }
135
- slot.dataset.morphing = "";
136
- animate(width, nextWidth, {
137
- ...ARC_SPRING_MORPH,
138
- onComplete: () => {
139
- delete slot.dataset.morphing;
140
- }
141
- });
142
- });
143
- observer.observe(content);
144
- return () => observer.disconnect();
145
- };
146
- t3 = [
147
- contentRef,
148
- reduceMotion,
149
- width
150
- ];
151
- $[3] = contentRef;
152
- $[4] = reduceMotion;
153
- $[5] = width;
154
- $[6] = t2;
155
- $[7] = t3;
156
- } else {
157
- t2 = $[6];
158
- t3 = $[7];
159
- }
160
- useEffect(t2, t3);
161
- return width;
162
- }
163
44
  /** One generation of the label; the outgoing one is hidden from assistive tech while it leaves. */
164
45
  function LabelPhase(t0) {
165
46
  const $ = _c(7);
@@ -0,0 +1,51 @@
1
+ import type { AriaAttributes, ReactNode } from "react";
2
+ /** Copy that turns a bare control into a labelled field. */
3
+ export type FieldCopyProps = {
4
+ /** Visible label, tied to the control with `htmlFor`. */
5
+ label?: string;
6
+ /** Helper copy under the control, linked through `aria-describedby`; counts in it roll their digits. */
7
+ description?: string;
8
+ /** Error copy: sets `aria-invalid` and renders in a `role="alert"` row that rewords in place. */
9
+ error?: string;
10
+ };
11
+ type FieldAriaOptions = FieldCopyProps & {
12
+ id?: string;
13
+ "aria-describedby"?: string;
14
+ "aria-invalid"?: AriaAttributes["aria-invalid"];
15
+ };
16
+ /** Ids and ARIA wiring a labelled field passes to its control. */
17
+ export type FieldAria = {
18
+ controlId: string;
19
+ descriptionId: string;
20
+ errorId: string;
21
+ "aria-describedby"?: string;
22
+ "aria-invalid"?: AriaAttributes["aria-invalid"];
23
+ };
24
+ /**
25
+ * Resolves the control id (the caller's or a generated one) and merges the helper and error ids into the caller's
26
+ * `aria-describedby`. An error always marks the control invalid.
27
+ * @param options - Field copy and the caller's id and ARIA attributes.
28
+ * @returns The ids and ARIA attributes for the control.
29
+ */
30
+ export declare function useFieldAria({ id, description, error, ...aria }: FieldAriaOptions): FieldAria;
31
+ /**
32
+ * Tells whether a control renders as a labelled field. It looks at which props were passed, not at their values, so a
33
+ * field whose error or description comes and goes keeps the same tree and its control never remounts (losing an
34
+ * uncontrolled value or the focus) when validation appears.
35
+ * @param props - Props passed to the control.
36
+ * @returns `true` when the caller passed `label`, `description` or `error`, even as `undefined`.
37
+ */
38
+ export declare function usesFieldCopy(props: FieldCopyProps): boolean;
39
+ type FieldProps = Omit<FieldCopyProps, "label"> & {
40
+ label?: string;
41
+ aria: FieldAria;
42
+ className?: string;
43
+ children: ReactNode;
44
+ };
45
+ /**
46
+ * Arc's field layout: label, control, then the helper and error rows. There is no row gap, because grid tracks clamp
47
+ * a negative margin at zero and a closed row would still pay it; each row carries its own space instead. The wrapper
48
+ * keeps the bare control's full-width contract, so a field fills a flex row like the control alone does.
49
+ */
50
+ export declare function Field({ label, description, error, aria, className, children }: FieldProps): import("react").JSX.Element;
51
+ export {};