danielgm-ui 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Componentes React con estilos y APIs propios. Parte de sus interacciones y compo
4
4
 
5
5
  Componentes React con TypeScript y CSS independiente, publicados con licencia MIT.
6
6
 
7
- **Componentes:** Button, Badge, Typography, Input, Textarea, Checkbox, Switch, Select, CustomSelect, Modal, Tooltip, Tabs, Accordion, Card, Container, Alert, Separator, Transition, DropdownMenu, ContextMenu, RadioGroup, Toast, Skeleton y Loader. Card incluye CardHeader, CardContent y CardFooter.
7
+ **Componentes:** Button, Badge, Typography, Input, Textarea, Checkbox, Switch, Select, CustomSelect, Modal, Tooltip, Tabs, Accordion, Card, Container, Header, Alert, Separator, Transition, DropdownMenu, ContextMenu, RadioGroup, Toast, Skeleton y Loader. Card incluye CardHeader, CardContent y CardFooter; Header incluye HeaderBrand, HeaderNav y HeaderActions.
8
8
 
9
9
  ## Instalación
10
10
 
@@ -60,7 +60,30 @@ export default function Example() {
60
60
  }
61
61
  ```
62
62
 
63
- Badge, Card, Container, Alert, Separator, Skeleton y Loader se pueden renderizar desde Server Components. Los módulos que usan hooks o refs conservan `use client` en el paquete publicado. En React con Vite, importa el CSS en `main.tsx`. En Astro, configura `@astrojs/react` y usa una isla React con `client:load` o `client:visible` para los ejemplos interactivos.
63
+ Badge, Card, Container, Header, Alert, Separator, Skeleton y Loader se pueden renderizar desde Server Components. Los módulos que usan hooks o refs conservan `use client` en el paquete publicado. En React con Vite, importa el CSS en `main.tsx`. En Astro, configura `@astrojs/react` y usa una isla React con `client:load` o `client:visible` para los ejemplos interactivos.
64
+
65
+ La cabecera de cristal de la documentación también está disponible para otras aplicaciones:
66
+
67
+ ```tsx
68
+ import { Header, HeaderActions, HeaderBrand, HeaderNav } from 'danielgm-ui';
69
+
70
+ export function SiteHeader() {
71
+ return (
72
+ <Header sticky>
73
+ <HeaderBrand href="/">Mi proyecto</HeaderBrand>
74
+ <HeaderNav aria-label="Navegación principal">
75
+ <a href="/productos">Productos</a>
76
+ <a href="/ayuda">Ayuda</a>
77
+ </HeaderNav>
78
+ <HeaderActions>
79
+ <a href="/cuenta">Mi cuenta</a>
80
+ </HeaderActions>
81
+ </Header>
82
+ );
83
+ }
84
+ ```
85
+
86
+ `Header` usa `header` nativo, centra la isla y permite que el contenido crezca en pantallas estrechas. `sticky` es opcional. Personaliza el ancho, el margen lateral, la separación superior y el cristal mediante las variables `--dgm-header-*`; la página de documentación utiliza estos mismos estilos del paquete.
64
87
 
65
88
  ## Personalización
66
89
 
@@ -283,7 +306,7 @@ Importa también `danielgm-ui/styles.css` una vez en el layout. `size` admite `s
283
306
 
284
307
  ## Nuevos controles y estructuras
285
308
 
286
- Slider, RangeSlider, MultiSelect, NumberInput, ToggleGroup, EmptyState, NavigationMenu, AspectRatio, ResizablePanels, TreeView y Carousel. Slider y RangeSlider comparten página de documentación. Cada componente tiene ejemplos independientes en español e inglés, sus propiedades y recomendaciones de accesibilidad.
309
+ Slider, RangeSlider, MultiSelect, NumberInput, ToggleGroup, EmptyState, NavigationMenu, AspectRatio, ResizablePanels, TreeView, Carousel y AnimatedGrid. Slider y RangeSlider comparten página de documentación. Cada componente tiene ejemplos independientes en español e inglés, sus propiedades y recomendaciones de accesibilidad.
287
310
 
288
311
  Todos se importan desde `danielgm-ui` y usan la misma hoja `danielgm-ui/styles.css`. `EmptyState` y `AspectRatio` son estáticos y compatibles con Server Components; las piezas interactivas mantienen su propia frontera `use client`.
289
312
 
@@ -292,6 +315,40 @@ Todos se importan desde `danielgm-ui` y usan la misma hoja `danielgm-ui/styles.c
292
315
  - `ResizablePanels` ajusta dos paneles con porcentajes, límites, teclado y puntero.
293
316
  - `TreeView` ofrece selección única, expansión y búsqueda por prefijo; requiere IDs únicos y datos sin ciclos. El teclado mantiene visible la fila enfocada mediante un desplazamiento mínimo, sin seleccionar automáticamente ni desplazar al pulsar con el ratón.
294
317
  - `Carousel` muestra una diapositiva por vista, sin autoplay ni bucle infinito, con botones, indicadores opcionales y gestos de scroll nativos.
318
+ - `AnimatedGrid` gestiona internamente la presencia y el movimiento de elementos con claves estables. Usa el efecto scale de Transition para altas, bajas y cambios de fila; desliza los cambios dentro de una fila y anima la altura. Conserva montado el contenido real para mantener su estado y foco.
319
+
320
+ ```tsx
321
+ 'use client';
322
+
323
+ import { useState } from 'react';
324
+ import { AnimatedGrid } from 'danielgm-ui';
325
+
326
+ type Item = { id: string; title: string };
327
+
328
+ function Projects() {
329
+ const [items, setItems] = useState<Item[]>([]);
330
+ return (
331
+ <>
332
+ <button
333
+ onClick={() =>
334
+ setItems((current) => [...current, { id: crypto.randomUUID(), title: 'Nuevo proyecto' }])
335
+ }
336
+ >
337
+ Añadir
338
+ </button>
339
+ <AnimatedGrid
340
+ items={items}
341
+ getKey={(item) => item.id}
342
+ renderItem={(item) => <article>{item.title}</article>}
343
+ columns={{ base: 1, sm: 2, md: 3 }}
344
+ gap="1rem"
345
+ />
346
+ </>
347
+ );
348
+ }
349
+ ```
350
+
351
+ Las columnas responden al ancho del contenedor (36, 56 y 76 rem). Usa identificadores únicos y estables; no uses el índice. El montaje inicial no se anima. Las salidas reservan su celda hasta finalizar y las copias visuales de cambios de fila son inertes y no accesibles. `duration` controla las fases (240 ms por defecto); con `duration={0}` o movimiento reducido, el resultado es inmediato.
295
352
 
296
353
  ### Iconografía y contraseñas
297
354
 
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2020-2026 Paweł Kuna
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2026 Paweł Kuna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,32 @@
1
+ import { type CSSProperties, type HTMLAttributes, type ReactElement, type ReactNode } from 'react';
2
+ /** Número de columnas según el ancho del propio contenedor (576, 896 y 1216 px). */
3
+ export interface AnimatedGridColumns {
4
+ /** Columnas por debajo de 36rem. @defaultValue 1 */
5
+ base?: number;
6
+ /** Columnas desde 36rem. @defaultValue 2 */
7
+ sm?: number;
8
+ /** Columnas desde 56rem. @defaultValue 3 */
9
+ md?: number;
10
+ /** Columnas desde 76rem. @defaultValue igual que md */
11
+ lg?: number;
12
+ }
13
+ /** Colección controlada: cada clave debe ser única y estable entre cambios de orden. */
14
+ export interface AnimatedGridProps<T> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
15
+ /** Elementos en el orden deseado. Las salidas conservan su celda antes de aplicar el nuevo orden. */
16
+ items: readonly T[];
17
+ /** Identificador único y estable, no el índice de la lista. */
18
+ getKey: (item: T) => string;
19
+ /** Contenido de cada elemento. Conserva el tipo de sus componentes para preservar su estado. */
20
+ renderItem: (item: T) => ReactNode;
21
+ /** Columnas fijas o configuración responsive medida sobre el contenedor. @defaultValue { base: 1, sm: 2, md: 3 } */
22
+ columns?: number | AnimatedGridColumns;
23
+ /** Separación entre filas y columnas. @defaultValue '1rem' */
24
+ gap?: CSSProperties['gap'];
25
+ /** Duración de Transition, del desplazamiento y de la altura en ms. @defaultValue 240 */
26
+ duration?: number;
27
+ }
28
+ /**
29
+ * Cuadrícula con presencia interna. El DOM real se reordena por claves; los cambios de
30
+ * fila usan una copia visual efímera mientras el mismo contenido entra en su nueva celda.
31
+ */
32
+ export declare function AnimatedGrid<T>({ items, getKey, renderItem, columns, gap, duration, className, style, ...props }: AnimatedGridProps<T>): ReactElement;
@@ -0,0 +1,346 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useLayoutEffect, useRef, useState, } from 'react';
4
+ import { readGridPositions, reconcileGridSlots, } from './internal/animated-grid-layout.js';
5
+ import { Transition } from './transition.js';
6
+ import { cx } from './utils.js';
7
+ function sameKeys(left, right) {
8
+ return left.length === right.length && left.every((key, index) => key === right[index]);
9
+ }
10
+ function columnCount(value, fallback) {
11
+ return value !== undefined && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
12
+ }
13
+ function translation(node) {
14
+ const transform = getComputedStyle(node).transform;
15
+ if (transform === 'none' || typeof DOMMatrixReadOnly === 'undefined')
16
+ return { x: 0, y: 0 };
17
+ const matrix = new DOMMatrixReadOnly(transform);
18
+ return { x: matrix.m41, y: matrix.m42 };
19
+ }
20
+ /**
21
+ * La copia solo representa el pasado visual. Nunca recibe eventos, foco, nombres de
22
+ * formulario ni identificadores duplicados; el árbol React original sigue montado.
23
+ */
24
+ function inertVisualCopy(node) {
25
+ const copy = node.cloneNode(true);
26
+ copy.setAttribute('aria-hidden', 'true');
27
+ copy.setAttribute('inert', '');
28
+ copy.style.pointerEvents = 'none';
29
+ for (const element of copy.querySelectorAll('[id], [name], [tabindex], [for], a[href], button, input, select, textarea, [contenteditable]')) {
30
+ element.removeAttribute('id');
31
+ element.removeAttribute('name');
32
+ element.removeAttribute('for');
33
+ element.removeAttribute('href');
34
+ element.removeAttribute('contenteditable');
35
+ element.tabIndex = -1;
36
+ }
37
+ for (const element of copy.querySelectorAll('iframe, object, embed, audio, video'))
38
+ element.remove();
39
+ return copy;
40
+ }
41
+ /**
42
+ * Cuadrícula con presencia interna. El DOM real se reordena por claves; los cambios de
43
+ * fila usan una copia visual efímera mientras el mismo contenido entra en su nueva celda.
44
+ */
45
+ export function AnimatedGrid({ items, getKey, renderItem, columns = { base: 1, sm: 2, md: 3 }, gap = '1rem', duration = 240, className, style, ...props }) {
46
+ const desiredKeys = items.map(getKey);
47
+ const desiredItems = new Map(items.map((item, index) => [desiredKeys[index], item]));
48
+ if (desiredItems.size !== items.length)
49
+ throw new Error('AnimatedGrid requires unique item keys.');
50
+ const rootRef = useRef(null);
51
+ const gridRef = useRef(null);
52
+ const ghostLayerRef = useRef(null);
53
+ const nodesRef = useRef(new Map());
54
+ const observedRef = useRef(new Set());
55
+ const observerRef = useRef(null);
56
+ const positionsRef = useRef(null);
57
+ const heightRef = useRef(null);
58
+ const rootWidthRef = useRef(null);
59
+ const heightAnimationRef = useRef(null);
60
+ const slidesRef = useRef(new Map());
61
+ const ghostsRef = useRef(new Map());
62
+ const reentriesRef = useRef(new Map());
63
+ const frameRef = useRef(null);
64
+ const reducedRef = useRef(false);
65
+ const lastItemsRef = useRef(desiredItems);
66
+ const desiredRef = useRef({ keys: desiredKeys, items: desiredItems });
67
+ const liveKeysRef = useRef(new Set(desiredKeys));
68
+ const milliseconds = Number.isFinite(duration) ? Math.max(0, duration) : 240;
69
+ const durationRef = useRef(milliseconds);
70
+ durationRef.current = milliseconds;
71
+ desiredRef.current = { keys: desiredKeys, items: desiredItems };
72
+ const [presence, setPresence] = useState(() => ({
73
+ requested: desiredKeys,
74
+ slots: desiredKeys.map((key) => ({
75
+ key,
76
+ item: desiredItems.get(key),
77
+ exiting: false,
78
+ exited: false,
79
+ appear: false,
80
+ exitHeight: 0,
81
+ })),
82
+ }));
83
+ if (!sameKeys(presence.requested, desiredKeys)) {
84
+ const heights = new Map([...(positionsRef.current ?? [])].map(([key, value]) => [key, value.height]));
85
+ setPresence({
86
+ requested: desiredKeys,
87
+ slots: reconcileGridSlots(presence.slots, desiredKeys, desiredItems, lastItemsRef.current, heights),
88
+ });
89
+ }
90
+ lastItemsRef.current = desiredItems;
91
+ liveKeysRef.current = new Set(presence.slots.filter((slot) => !slot.exiting).map((slot) => slot.key));
92
+ const stopMotion = useCallback(() => {
93
+ heightAnimationRef.current?.cancel();
94
+ heightAnimationRef.current = null;
95
+ for (const animation of slidesRef.current.values())
96
+ animation.cancel();
97
+ slidesRef.current.clear();
98
+ for (const release of ghostsRef.current.values())
99
+ release();
100
+ ghostsRef.current.clear();
101
+ for (const release of reentriesRef.current.values())
102
+ release();
103
+ reentriesRef.current.clear();
104
+ }, []);
105
+ const makeGhost = useCallback((key, node, from, x, y) => {
106
+ ghostsRef.current.get(key)?.();
107
+ const layer = ghostLayerRef.current;
108
+ if (!layer)
109
+ return;
110
+ const copy = inertVisualCopy(node);
111
+ copy.style.position = 'absolute';
112
+ copy.style.left = `${x}px`;
113
+ copy.style.top = `${y}px`;
114
+ copy.style.width = `${from.width}px`;
115
+ copy.style.height = `${from.height}px`;
116
+ copy.style.margin = '0';
117
+ const visual = copy.querySelector('.dgm-transition');
118
+ if (!visual)
119
+ return;
120
+ visual.removeAttribute('data-enter');
121
+ visual.removeAttribute('data-grid-reenter');
122
+ visual.dataset.state = 'closed';
123
+ visual.dataset.variant = 'scale';
124
+ visual.style.setProperty('--dgm-transition-duration', `${durationRef.current}ms`);
125
+ let timeout = 0;
126
+ const release = () => {
127
+ window.clearTimeout(timeout);
128
+ visual.removeEventListener('animationend', finished);
129
+ visual.removeEventListener('animationcancel', finished);
130
+ copy.remove();
131
+ if (ghostsRef.current.get(key) === release)
132
+ ghostsRef.current.delete(key);
133
+ };
134
+ const finished = (event) => {
135
+ if (event.target === visual && event.animationName === 'dgm-transition-scale-out')
136
+ release();
137
+ };
138
+ layer.append(copy);
139
+ ghostsRef.current.set(key, release);
140
+ visual.addEventListener('animationend', finished);
141
+ visual.addEventListener('animationcancel', finished);
142
+ timeout = window.setTimeout(release, durationRef.current + 80);
143
+ }, []);
144
+ const replayScale = useCallback((key, node) => {
145
+ const visual = node.querySelector(':scope > .dgm-transition');
146
+ if (!visual || visual.hasAttribute('data-enter'))
147
+ return;
148
+ reentriesRef.current.get(key)?.();
149
+ visual.removeAttribute('data-grid-reenter');
150
+ visual.getBoundingClientRect();
151
+ let timeout = 0;
152
+ const release = () => {
153
+ window.clearTimeout(timeout);
154
+ visual.removeEventListener('animationend', finished);
155
+ visual.removeEventListener('animationcancel', finished);
156
+ visual.removeAttribute('data-grid-reenter');
157
+ if (reentriesRef.current.get(key) === release)
158
+ reentriesRef.current.delete(key);
159
+ };
160
+ const finished = (event) => {
161
+ if (event.target === visual && event.animationName === 'dgm-transition-scale-in')
162
+ release();
163
+ };
164
+ visual.setAttribute('data-grid-reenter', '');
165
+ reentriesRef.current.set(key, release);
166
+ visual.addEventListener('animationend', finished);
167
+ visual.addEventListener('animationcancel', finished);
168
+ timeout = window.setTimeout(release, durationRef.current + 80);
169
+ }, []);
170
+ const measure = useCallback(() => {
171
+ const root = rootRef.current;
172
+ const grid = gridRef.current;
173
+ if (!root || !grid)
174
+ return;
175
+ const next = readGridPositions(nodesRef.current);
176
+ const previous = positionsRef.current;
177
+ const nextHeight = grid.offsetHeight;
178
+ const oldHeight = heightRef.current;
179
+ const motion = !reducedRef.current && durationRef.current > 0;
180
+ for (const key of ghostsRef.current.keys()) {
181
+ if (!liveKeysRef.current.has(key) || !next.has(key))
182
+ ghostsRef.current.get(key)?.();
183
+ }
184
+ if (previous && motion) {
185
+ for (const [key, target] of next) {
186
+ const from = previous.get(key);
187
+ const node = nodesRef.current.get(key);
188
+ if (!from || !node || !liveKeysRef.current.has(key))
189
+ continue;
190
+ if (Math.abs(from.left - target.left) < 0.5 &&
191
+ Math.abs(from.top - target.top) < 0.5 &&
192
+ from.row === target.row)
193
+ continue;
194
+ const active = slidesRef.current.get(key);
195
+ const offset = active ? translation(node) : { x: 0, y: 0 };
196
+ const oldX = from.left + offset.x;
197
+ const oldY = from.top + offset.y;
198
+ active?.cancel();
199
+ slidesRef.current.delete(key);
200
+ if (from.row !== target.row) {
201
+ makeGhost(key, node, from, oldX, oldY);
202
+ replayScale(key, node);
203
+ }
204
+ else if (typeof node.animate === 'function') {
205
+ const dx = oldX - target.left;
206
+ const dy = oldY - target.top;
207
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)
208
+ continue;
209
+ const animation = node.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration: durationRef.current, easing: 'ease', fill: 'both' });
210
+ slidesRef.current.set(key, animation);
211
+ animation.onfinish = () => {
212
+ if (slidesRef.current.get(key) === animation) {
213
+ slidesRef.current.delete(key);
214
+ animation.cancel();
215
+ }
216
+ };
217
+ }
218
+ }
219
+ if (oldHeight !== null &&
220
+ Math.abs(oldHeight - nextHeight) > 0.5 &&
221
+ typeof root.animate === 'function') {
222
+ const fromHeight = heightAnimationRef.current
223
+ ? parseFloat(getComputedStyle(root).height)
224
+ : oldHeight;
225
+ heightAnimationRef.current?.cancel();
226
+ const animation = root.animate([{ height: `${fromHeight}px` }, { height: `${nextHeight}px` }], { duration: durationRef.current, easing: 'ease', fill: 'both' });
227
+ heightAnimationRef.current = animation;
228
+ animation.onfinish = () => {
229
+ if (heightAnimationRef.current === animation) {
230
+ heightAnimationRef.current = null;
231
+ animation.cancel();
232
+ }
233
+ };
234
+ }
235
+ }
236
+ positionsRef.current = next;
237
+ heightRef.current = nextHeight;
238
+ }, [makeGhost, replayScale]);
239
+ const measureRef = useRef(measure);
240
+ measureRef.current = measure;
241
+ useLayoutEffect(() => {
242
+ const root = rootRef.current;
243
+ const grid = gridRef.current;
244
+ if (!root || !grid)
245
+ return;
246
+ const preference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
247
+ reducedRef.current = Boolean(preference?.matches);
248
+ const onPreference = () => {
249
+ reducedRef.current = Boolean(preference?.matches);
250
+ if (reducedRef.current)
251
+ stopMotion();
252
+ };
253
+ preference?.addEventListener('change', onPreference);
254
+ if (typeof ResizeObserver !== 'undefined') {
255
+ const observer = new ResizeObserver((entries) => {
256
+ let changed = false;
257
+ for (const entry of entries) {
258
+ if (entry.target !== root) {
259
+ changed = true;
260
+ continue;
261
+ }
262
+ const width = entry.contentRect.width;
263
+ if (rootWidthRef.current === null || Math.abs(rootWidthRef.current - width) > 0.5) {
264
+ rootWidthRef.current = width;
265
+ changed = true;
266
+ }
267
+ }
268
+ if (changed && frameRef.current === null) {
269
+ frameRef.current = requestAnimationFrame(() => {
270
+ frameRef.current = null;
271
+ measureRef.current();
272
+ });
273
+ }
274
+ });
275
+ observer.observe(root);
276
+ observer.observe(grid);
277
+ observerRef.current = observer;
278
+ }
279
+ return () => {
280
+ if (frameRef.current !== null)
281
+ cancelAnimationFrame(frameRef.current);
282
+ observerRef.current?.disconnect();
283
+ observerRef.current = null;
284
+ observedRef.current.clear();
285
+ preference?.removeEventListener('change', onPreference);
286
+ stopMotion();
287
+ };
288
+ }, [stopMotion]);
289
+ useLayoutEffect(() => {
290
+ if (milliseconds === 0)
291
+ stopMotion();
292
+ }, [milliseconds, stopMotion]);
293
+ useLayoutEffect(() => {
294
+ const observer = observerRef.current;
295
+ if (observer) {
296
+ const current = new Set(nodesRef.current.values());
297
+ for (const node of observedRef.current)
298
+ if (!current.has(node))
299
+ observer.unobserve(node);
300
+ for (const node of current)
301
+ if (!observedRef.current.has(node))
302
+ observer.observe(node);
303
+ observedRef.current = current;
304
+ }
305
+ measure();
306
+ });
307
+ const onExited = useCallback((key) => {
308
+ setPresence((current) => {
309
+ if (desiredRef.current.items.has(key))
310
+ return current;
311
+ const slot = current.slots.find((candidate) => candidate.key === key);
312
+ if (!slot?.exiting || slot.exited)
313
+ return current;
314
+ const completed = current.slots.map((candidate) => candidate.key === key ? { ...candidate, exited: true } : candidate);
315
+ if (completed.some((candidate) => candidate.exiting && !candidate.exited)) {
316
+ return { ...current, slots: completed };
317
+ }
318
+ const desired = desiredRef.current;
319
+ const survivors = completed.filter((candidate) => !candidate.exiting);
320
+ return {
321
+ requested: desired.keys,
322
+ slots: reconcileGridSlots(survivors, desired.keys, desired.items, lastItemsRef.current, new Map()),
323
+ };
324
+ });
325
+ }, []);
326
+ const responsive = typeof columns === 'number'
327
+ ? { base: columns, sm: columns, md: columns, lg: columns }
328
+ : columns;
329
+ const base = columnCount(responsive.base, 1);
330
+ const sm = columnCount(responsive.sm, base);
331
+ const md = columnCount(responsive.md, sm);
332
+ const lg = columnCount(responsive.lg, md);
333
+ const gridStyle = {
334
+ '--dgm-grid-columns-base': base,
335
+ '--dgm-grid-columns-sm': sm,
336
+ '--dgm-grid-columns-md': md,
337
+ '--dgm-grid-columns-lg': lg,
338
+ gap,
339
+ };
340
+ return (_jsxs("div", { ...props, ref: rootRef, className: cx('dgm-animated-grid', className), style: style, children: [_jsx("div", { ref: gridRef, className: "dgm-animated-grid-layout", style: gridStyle, children: presence.slots.map((slot) => (_jsx("div", { ref: (node) => {
341
+ if (node)
342
+ nodesRef.current.set(slot.key, node);
343
+ else
344
+ nodesRef.current.delete(slot.key);
345
+ }, className: "dgm-animated-grid-item", style: slot.exiting ? { minBlockSize: slot.exitHeight } : undefined, children: _jsx(Transition, { show: !slot.exiting, appear: slot.appear, variant: "scale", duration: milliseconds, onExited: () => onExited(slot.key), children: renderItem(desiredItems.has(slot.key) ? desiredItems.get(slot.key) : slot.item) }) }, slot.key))) }), _jsx("div", { ref: ghostLayerRef, className: "dgm-animated-grid-ghosts", "aria-hidden": "true", inert: true })] }));
346
+ }
@@ -0,0 +1,24 @@
1
+ /** Cabecera de página estática; los enlaces y acciones mantienen su semántica nativa. */
2
+ import type { AnchorHTMLAttributes, HTMLAttributes, ReactElement } from 'react';
3
+ /** Atributos del header y posición opcional mientras se desplaza la página. */
4
+ export interface HeaderProps extends HTMLAttributes<HTMLElement> {
5
+ /** Fija la isla al borde superior durante el scroll. @defaultValue false */
6
+ sticky?: boolean;
7
+ }
8
+ /** Isla de cristal adaptable que admite marca, navegación y acciones como hijos. */
9
+ export declare function Header({ sticky, className, ...props }: HeaderProps): ReactElement;
10
+ /** Enlace de marca; proporciona un destino reconocible y conserva los atributos nativos. */
11
+ export interface HeaderBrandProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
12
+ }
13
+ /** La marca enlaza normalmente con la portada de la aplicación. */
14
+ export declare function HeaderBrand({ className, ...props }: HeaderBrandProps): ReactElement;
15
+ /** Atributos de la navegación; facilita un nombre accesible con aria-label. */
16
+ export interface HeaderNavProps extends HTMLAttributes<HTMLElement> {
17
+ }
18
+ /** Navegación principal para enlaces; usa aria-label si hay otra navegación en la página. */
19
+ export declare function HeaderNav({ className, ...props }: HeaderNavProps): ReactElement;
20
+ /** Atributos del grupo de acciones de la cabecera. */
21
+ export interface HeaderActionsProps extends HTMLAttributes<HTMLDivElement> {
22
+ }
23
+ /** Agrupa controles sin añadir roles o estados interactivos propios. */
24
+ export declare function HeaderActions({ className, ...props }: HeaderActionsProps): ReactElement;
package/dist/header.js ADDED
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from './utils.js';
3
+ /** Isla de cristal adaptable que admite marca, navegación y acciones como hijos. */
4
+ export function Header({ sticky = false, className, ...props }) {
5
+ return (_jsx("header", { ...props, className: cx('dgm-header', sticky && 'dgm-header--sticky', className) }));
6
+ }
7
+ /** La marca enlaza normalmente con la portada de la aplicación. */
8
+ export function HeaderBrand({ className, ...props }) {
9
+ return _jsx("a", { ...props, className: cx('dgm-header-brand', className) });
10
+ }
11
+ /** Navegación principal para enlaces; usa aria-label si hay otra navegación en la página. */
12
+ export function HeaderNav({ className, ...props }) {
13
+ return _jsx("nav", { ...props, className: cx('dgm-header-nav', className) });
14
+ }
15
+ /** Agrupa controles sin añadir roles o estados interactivos propios. */
16
+ export function HeaderActions({ className, ...props }) {
17
+ return _jsx("div", { ...props, className: cx('dgm-header-actions', className) });
18
+ }
@@ -0,0 +1,24 @@
1
+ /** Recorte local: el navegador decodifica la imagen y exporta el área visible a un Blob. */
2
+ import { type HTMLAttributes, type ReactElement } from 'react';
3
+ /** Opciones del recortador. Las URLs externas requieren CORS para poder exportarse. */
4
+ export interface ImageCropperProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onError'> {
5
+ /** Archivo local, Blob o URL de la imagen; null muestra el estado vacío. */
6
+ src: string | Blob | null;
7
+ /** Recibe el archivo recortado, sin realizar ninguna subida. */
8
+ onCrop: (blob: Blob) => void | Promise<void>;
9
+ /** Relación anchura/altura del recorte. @defaultValue 1 */
10
+ aspectRatio?: number;
11
+ /** Anchura de salida en píxeles. @defaultValue 512 */
12
+ outputWidth?: number;
13
+ /** Formato de salida del canvas. @defaultValue 'image/png' */
14
+ outputType?: 'image/png' | 'image/jpeg' | 'image/webp';
15
+ /** Calidad JPEG/WebP entre 0 y 1. @defaultValue 0.92 */
16
+ quality?: number;
17
+ /** Límite superior del zoom respecto al encuadre inicial. @defaultValue 4 */
18
+ maxZoom?: number;
19
+ /** Idioma de los controles integrados. @defaultValue 'en' */
20
+ locale?: 'es' | 'en';
21
+ disabled?: boolean;
22
+ }
23
+ /** Visor con arrastre, zoom, desplazamiento por teclado y exportación al confirmar. */
24
+ export declare function ImageCropper({ src, onCrop, aspectRatio, outputWidth, outputType, quality, maxZoom, locale, disabled, className, style, ...props }: ImageCropperProps): ReactElement;