beez-ui 2.3.1 → 2.4.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 +7 -0
- package/README.md +4 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/components/action-button.d.ts +25 -0
- package/dist/components/action-button.js +264 -0
- package/dist/components/button-label.js +5 -124
- package/dist/components/morph-text.d.ts +14 -0
- package/dist/components/morph-text.js +210 -0
- package/dist/components/split-button.d.ts +36 -0
- package/dist/components/split-button.js +306 -0
- package/dist/constants/action-button.d.ts +20 -0
- package/dist/constants/action-button.js +17 -0
- package/dist/constants/morph-text.d.ts +13 -0
- package/dist/constants/morph-text.js +13 -0
- package/dist/constants/split-button.d.ts +7 -0
- package/dist/constants/split-button.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/motion/arc-swap.d.ts +22 -0
- package/dist/motion/arc-swap.js +49 -0
- package/dist/motion/arc-tokens.d.ts +4 -0
- package/dist/motion/arc-tokens.js +2 -0
- package/dist/motion/use-morph-width.d.ts +13 -0
- package/dist/motion/use-morph-width.js +59 -0
- package/dist/styles.css +1 -1
- package/package.json +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ Todos los cambios relevantes de beez-ui se documentan en este archivo con el for
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.4.0] - 2026-10-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `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`.
|
|
12
|
+
- `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`.
|
|
13
|
+
|
|
7
14
|
## [2.3.1] - 2026-10-14
|
|
8
15
|
|
|
9
16
|
### 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
|
|
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).
|
|
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
|
|
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
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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`) 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,
|
|
8
|
-
import { AnimatePresence,
|
|
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
|
|
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,14 @@
|
|
|
1
|
+
import type { Ref } from "react";
|
|
2
|
+
export type MorphTextProps = {
|
|
3
|
+
text: string;
|
|
4
|
+
reduceMotion: boolean;
|
|
5
|
+
/** Row of glyphs, for a caller that springs its slot to the row's width with `useMorphWidth`. */
|
|
6
|
+
ref?: Ref<HTMLSpanElement>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Morphs one label into the next: kept letters glide into place and new ones rise in from a soft blur. The row
|
|
10
|
+
* keeps its natural width; callers spring the slot around it.
|
|
11
|
+
* @param props - Label text, whether the user prefers reduced motion and an optional ref to the row.
|
|
12
|
+
* @returns The row of animated letters. Callers hide it from assistive tech and name their control otherwise.
|
|
13
|
+
*/
|
|
14
|
+
export declare function MorphText({ text, reduceMotion, ref }: MorphTextProps): import("react").JSX.Element;
|