danielgm-ui 0.1.0 → 0.2.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
@@ -2,11 +2,17 @@
2
2
 
3
3
  Componentes React con estilos y APIs propios. Parte de sus interacciones y comportamientos de accesibilidad se apoya en [Radix Primitives](https://www.radix-ui.com/primitives).
4
4
 
5
- Componentes React con TypeScript y CSS independiente. El paquete todavía no está publicado en npm.
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
- ## Instalación local
9
+ ## Instalación
10
+
11
+ ```sh
12
+ npm install danielgm-ui
13
+ ```
14
+
15
+ Para probar una compilación local del monorepo:
10
16
 
11
17
  Desde el monorepo:
12
18
 
@@ -18,13 +24,7 @@ npm run pack:ui
18
24
  En el proyecto consumidor, instala el archivo generado (ajusta la ruta):
19
25
 
20
26
  ```sh
21
- npm install /ruta/danielgm-ui-0.1.0.tgz
22
- ```
23
-
24
- Después de publicarlo en npm con este nombre:
25
-
26
- ```sh
27
- npm install danielgm-ui
27
+ npm install /ruta/danielgm-ui-<versión>.tgz
28
28
  ```
29
29
 
30
30
  ## Uso en Next.js
@@ -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
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * El CSS se importa por separado desde `danielgm-ui/styles.css`.
7
7
  */
8
8
  export { Button, type ButtonProps } from './button.js';
9
+ export { Header, HeaderBrand, HeaderNav, HeaderActions, type HeaderProps, type HeaderBrandProps, type HeaderNavProps, type HeaderActionsProps, } from './header.js';
9
10
  export { Typography, type TypographyProps, type TypographyVariant, type TypographyElement, type TypographyTone, } from './typography.js';
10
11
  export { Slider, RangeSlider, type SliderProps, type RangeSliderProps, type SliderBaseProps, } from './slider.js';
11
12
  export { MultiSelect, type MultiSelectProps } from './multi-select.js';
@@ -41,6 +42,7 @@ export { CustomSelect, type CustomSelectProps, type SelectOption } from './custo
41
42
  export { Modal, type ModalProps } from './modal.js';
42
43
  export { Tooltip, type TooltipProps } from './tooltip.js';
43
44
  export { Transition, type TransitionProps } from './transition.js';
45
+ export { AnimatedGrid, type AnimatedGridProps, type AnimatedGridColumns } from './animated-grid.js';
44
46
  export { Tabs, type TabsProps, type TabItem } from './tabs.js';
45
47
  export { Accordion, type AccordionProps, type AccordionItem, type AccordionSingleProps, type AccordionMultipleProps, } from './accordion.js';
46
48
  export { Input, Textarea, Select, type InputProps, type TextareaProps, type SelectProps, } from './fields.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * El CSS se importa por separado desde `danielgm-ui/styles.css`.
7
7
  */
8
8
  export { Button } from './button.js';
9
+ export { Header, HeaderBrand, HeaderNav, HeaderActions, } from './header.js';
9
10
  export { Typography, } from './typography.js';
10
11
  export { Slider, RangeSlider, } from './slider.js';
11
12
  export { MultiSelect } from './multi-select.js';
@@ -41,6 +42,7 @@ export { CustomSelect } from './custom-select.js';
41
42
  export { Modal } from './modal.js';
42
43
  export { Tooltip } from './tooltip.js';
43
44
  export { Transition } from './transition.js';
45
+ export { AnimatedGrid } from './animated-grid.js';
44
46
  export { Tabs } from './tabs.js';
45
47
  export { Accordion, } from './accordion.js';
46
48
  export { Input, Textarea, Select, } from './fields.js';
@@ -0,0 +1,24 @@
1
+ /** Datos de presencia independientes de React: una salida conserva su celda hasta completarse. */
2
+ export interface GridSlot<T> {
3
+ key: string;
4
+ item: T;
5
+ exiting: boolean;
6
+ exited: boolean;
7
+ appear: boolean;
8
+ exitHeight: number;
9
+ }
10
+ /** Reconciliación de claves: las altas y el nuevo orden esperan mientras exista una salida. */
11
+ export declare function reconcileGridSlots<T>(previous: readonly GridSlot<T>[], desiredKeys: readonly string[], desiredItems: ReadonlyMap<string, T>, lastItems: ReadonlyMap<string, T>, heights: ReadonlyMap<string, number>): GridSlot<T>[];
12
+ /** Coordenadas de layout, ajenas a transformaciones visuales que aún estén animándose. */
13
+ export interface GridPosition {
14
+ left: number;
15
+ top: number;
16
+ width: number;
17
+ height: number;
18
+ row: number;
19
+ }
20
+ /**
21
+ * Las celdas de una fila comparten offsetTop aunque su contenido tenga alturas distintas.
22
+ * Agrupar esos valores evita confundir el cambio de altura de una fila con un salto de fila.
23
+ */
24
+ export declare function readGridPositions(nodes: ReadonlyMap<string, HTMLDivElement>): Map<string, GridPosition>;
@@ -0,0 +1,55 @@
1
+ /** Reconciliación de claves: las altas y el nuevo orden esperan mientras exista una salida. */
2
+ export function reconcileGridSlots(previous, desiredKeys, desiredItems, lastItems, heights) {
3
+ const desired = new Set(desiredKeys);
4
+ const retained = previous.map((slot) => {
5
+ if (desired.has(slot.key)) {
6
+ return slot.exiting ? { ...slot, exiting: false, exited: false } : slot;
7
+ }
8
+ if (slot.exiting)
9
+ return slot;
10
+ return {
11
+ ...slot,
12
+ item: lastItems.has(slot.key) ? lastItems.get(slot.key) : slot.item,
13
+ exiting: true,
14
+ exited: false,
15
+ exitHeight: heights.get(slot.key) ?? 0,
16
+ };
17
+ });
18
+ if (retained.some((slot) => slot.exiting))
19
+ return retained;
20
+ const existing = new Map(retained.map((slot) => [slot.key, slot]));
21
+ return desiredKeys.map((key) => existing.get(key) ?? {
22
+ key,
23
+ item: desiredItems.get(key),
24
+ exiting: false,
25
+ exited: false,
26
+ appear: true,
27
+ exitHeight: 0,
28
+ });
29
+ }
30
+ /**
31
+ * Las celdas de una fila comparten offsetTop aunque su contenido tenga alturas distintas.
32
+ * Agrupar esos valores evita confundir el cambio de altura de una fila con un salto de fila.
33
+ */
34
+ export function readGridPositions(nodes) {
35
+ const measured = [...nodes]
36
+ .filter(([, node]) => node.isConnected)
37
+ .map(([key, node]) => ({
38
+ key,
39
+ left: node.offsetLeft,
40
+ top: node.offsetTop,
41
+ width: node.offsetWidth,
42
+ height: node.offsetHeight,
43
+ }));
44
+ const rows = new Map();
45
+ let row = -1;
46
+ let rowTop = Number.NEGATIVE_INFINITY;
47
+ for (const { key, top } of [...measured].sort((a, b) => a.top - b.top)) {
48
+ if (row < 0 || Math.abs(rowTop - top) > 2) {
49
+ row++;
50
+ rowTop = top;
51
+ }
52
+ rows.set(key, row);
53
+ }
54
+ return new Map(measured.map(({ key, ...position }) => [key, { ...position, row: rows.get(key) }]));
55
+ }
package/dist/styles.css CHANGED
@@ -20,6 +20,15 @@
20
20
  --dgm-danger-hover: #912018;
21
21
  --dgm-on-danger: #fff;
22
22
  --dgm-radius: 0.625rem;
23
+ /* Isla de cristal: los tokens permiten igualar la cabecera con el tema anfitrión. */
24
+ --dgm-header-max-width: 85rem;
25
+ --dgm-header-gutter: 1.5rem;
26
+ --dgm-header-offset: 1rem;
27
+ --dgm-header-height: 4rem;
28
+ --dgm-header-glass: color-mix(in srgb, var(--dgm-surface) 76%, transparent);
29
+ --dgm-header-border: color-mix(in srgb, var(--dgm-border) 65%, transparent);
30
+ --dgm-header-shadow: rgb(0 0 0 / 18%);
31
+ --dgm-header-highlight: rgb(255 255 255 / 8%);
23
32
  /* Escala pública y roles: la aplicación puede adaptar toda la tipografía desde la raíz. */
24
33
  --dgm-font: inherit;
25
34
  --dgm-font-mono: 'Cascadia Code', 'SFMono-Regular', Consolas, monospace;
@@ -199,7 +208,9 @@
199
208
  .dgm-multi-select,
200
209
  .dgm-resizable,
201
210
  .dgm-tree,
202
- .dgm-carousel
211
+ .dgm-carousel,
212
+ .dgm-header,
213
+ .dgm-animated-grid
203
214
  ) {
204
215
  font-family: var(--dgm-font, inherit);
205
216
  font-size: var(--dgm-text-body);
@@ -294,6 +305,84 @@
294
305
  padding-inline: 0;
295
306
  }
296
307
 
308
+ /* Header: el desenfoque pertenece a la isla, sin transformar ni recortar los portales. */
309
+ .dgm-header {
310
+ box-sizing: border-box;
311
+ inline-size: min(var(--dgm-header-max-width), calc(100% - 2 * var(--dgm-header-gutter)));
312
+ min-block-size: var(--dgm-header-height);
313
+ margin-inline: auto;
314
+ padding: 0.5rem 1.1rem;
315
+ display: flex;
316
+ align-items: center;
317
+ justify-content: space-between;
318
+ flex-wrap: wrap;
319
+ gap: 0.5rem 1rem;
320
+ border: 1px solid var(--dgm-header-border);
321
+ border-radius: 1.25rem;
322
+ background: var(--dgm-header-glass);
323
+ -webkit-backdrop-filter: blur(24px) saturate(125%);
324
+ backdrop-filter: blur(24px) saturate(125%);
325
+ box-shadow:
326
+ 0 10px 32px var(--dgm-header-shadow),
327
+ inset 0 1px 0 var(--dgm-header-highlight);
328
+ color: var(--dgm-text);
329
+ }
330
+ .dgm-header--sticky {
331
+ position: sticky;
332
+ inset-block-start: var(--dgm-header-offset);
333
+ z-index: 30;
334
+ }
335
+ .dgm-header-brand,
336
+ .dgm-header-nav,
337
+ .dgm-header-actions {
338
+ display: inline-flex;
339
+ align-items: center;
340
+ min-inline-size: 0;
341
+ }
342
+ .dgm-header-brand {
343
+ gap: 0.75rem;
344
+ color: inherit;
345
+ font-weight: var(--dgm-font-weight-bold);
346
+ text-decoration: none;
347
+ overflow-wrap: anywhere;
348
+ }
349
+ .dgm-header-nav,
350
+ .dgm-header-actions {
351
+ gap: 0.75rem;
352
+ flex-wrap: wrap;
353
+ }
354
+ .dgm-header-nav a,
355
+ .dgm-header-actions a {
356
+ border-radius: var(--dgm-radius);
357
+ padding: 0.4rem 0.6rem;
358
+ color: inherit;
359
+ text-decoration: none;
360
+ transition: var(--dgm-hover-transition);
361
+ }
362
+ .dgm-header-nav a:hover,
363
+ .dgm-header-actions a:hover {
364
+ background: var(--dgm-hover);
365
+ color: var(--dgm-on-hover);
366
+ }
367
+ .dgm-header-brand:focus-visible,
368
+ .dgm-header-nav a:focus-visible,
369
+ .dgm-header-actions a:focus-visible {
370
+ outline: 3px solid var(--dgm-primary);
371
+ outline-offset: 3px;
372
+ }
373
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
374
+ .dgm-header {
375
+ background: var(--dgm-surface);
376
+ }
377
+ }
378
+ @media (forced-colors: active) {
379
+ .dgm-header {
380
+ border-color: CanvasText;
381
+ background: Canvas;
382
+ box-shadow: none;
383
+ }
384
+ }
385
+
297
386
  /* 3. Botones: jerarquías, tamaños y ancho. Se conserva el texto durante la carga. */
298
387
  /* Los controles internos (cierre, calendario, contraseña, etc.) comparten el hover
299
388
  * aunque no usen Button. La baja especificidad preserva sus transiciones propias. */
@@ -1415,7 +1504,51 @@ button.dgm-step:hover:not(:disabled),
1415
1504
  transform: translateY(8px);
1416
1505
  }
1417
1506
  }
1507
+
1508
+ /* AnimatedGrid usa las mismas fases scale de Transition y desplaza solo las celdas afectadas. */
1509
+ .dgm-animated-grid {
1510
+ position: relative;
1511
+ min-inline-size: 0;
1512
+ container: dgm-animated-grid / inline-size;
1513
+ }
1514
+ .dgm-animated-grid-layout {
1515
+ position: relative;
1516
+ display: grid;
1517
+ grid-template-columns: repeat(var(--dgm-grid-columns-base), minmax(0, 1fr));
1518
+ align-items: start;
1519
+ min-inline-size: 0;
1520
+ }
1521
+ .dgm-animated-grid-item {
1522
+ min-inline-size: 0;
1523
+ }
1524
+ .dgm-animated-grid-ghosts {
1525
+ position: absolute;
1526
+ inset: 0;
1527
+ z-index: 1;
1528
+ pointer-events: none;
1529
+ }
1530
+ .dgm-animated-grid .dgm-transition[data-grid-reenter] {
1531
+ animation-name: dgm-transition-scale-in;
1532
+ }
1533
+ @container dgm-animated-grid (min-width: 36rem) {
1534
+ .dgm-animated-grid-layout {
1535
+ grid-template-columns: repeat(var(--dgm-grid-columns-sm), minmax(0, 1fr));
1536
+ }
1537
+ }
1538
+ @container dgm-animated-grid (min-width: 56rem) {
1539
+ .dgm-animated-grid-layout {
1540
+ grid-template-columns: repeat(var(--dgm-grid-columns-md), minmax(0, 1fr));
1541
+ }
1542
+ }
1543
+ @container dgm-animated-grid (min-width: 76rem) {
1544
+ .dgm-animated-grid-layout {
1545
+ grid-template-columns: repeat(var(--dgm-grid-columns-lg), minmax(0, 1fr));
1546
+ }
1547
+ }
1418
1548
  @media (prefers-reduced-motion: reduce) {
1549
+ .dgm-animated-grid .dgm-transition[data-grid-reenter] {
1550
+ animation: none;
1551
+ }
1419
1552
  .dgm-transition-collapse {
1420
1553
  transition: none;
1421
1554
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "danielgm-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Accessible React components for Next.js and the web.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,6 +40,7 @@
40
40
  "publishConfig": {
41
41
  "access": "public"
42
42
  },
43
+ "homepage": "https://components.danielgm.dev",
43
44
  "license": "MIT",
44
45
  "keywords": [
45
46
  "react",