@axzydev/axzy_ui_system 1.8.0 → 1.8.2

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "packageManager": "pnpm@10.34.0",
5
5
  "private": false,
6
6
  "license": "MIT",
7
- "version": "1.8.0",
7
+ "version": "1.8.2",
8
8
  "type": "module",
9
9
  "sideEffects": [
10
10
  "*.css"
@@ -0,0 +1,452 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import clsx from "clsx";
4
+
5
+ /** Tono de superficie del mosaico: blanco por defecto o uno de marca. */
6
+ export type ITBentoTone = "plain" | "sea" | "lavender" | "mint" | "sun";
7
+
8
+ export interface ITBentoTileProps {
9
+ label: ReactNode;
10
+ /** Ícono de línea a la izquierda de la etiqueta. */
11
+ icon?: ReactNode;
12
+ /** Cuántas columnas del mosaico ocupa (1–12). */
13
+ cols?: number;
14
+ /** Cuántas filas de 84 px ocupa. */
15
+ rows?: number;
16
+ tone?: ITBentoTone;
17
+ /** Valor principal, grande y tabular. */
18
+ value?: ReactNode;
19
+ /** Texto de apoyo junto al valor. */
20
+ caption?: ReactNode;
21
+ /** Contenido a la derecha del pie (p. ej. un segundo número). */
22
+ aside?: ReactNode;
23
+ /** Visual al centro (medidor, ola, segmentos…); crece para llenar la tarjeta. */
24
+ children?: ReactNode;
25
+ /** En pantallas chicas ocupa las 6 columnas en vez de 3. */
26
+ wide?: boolean;
27
+ onClick?: () => void;
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ }
31
+
32
+ /**
33
+ * Tarjeta del mosaico bento: superficie de radio 20 px con etiqueta, un visual
34
+ * opcional que llena el centro y un pie con el valor grande y su contexto.
35
+ * El tono elige la superficie (`sea` oscura, `lavender`, `mint`, `sun` o blanca);
36
+ * el mosaico lo arma `ITBentoGrid`.
37
+ */
38
+ export function ITBentoTile({
39
+ label,
40
+ icon,
41
+ cols = 3,
42
+ rows = 1,
43
+ tone = "plain",
44
+ value,
45
+ caption,
46
+ aside,
47
+ children,
48
+ wide = false,
49
+ onClick,
50
+ className,
51
+ style,
52
+ }: ITBentoTileProps) {
53
+ const interactive = typeof onClick === "function";
54
+ return (
55
+ <section
56
+ className={clsx(
57
+ "it-bento-tile",
58
+ tone !== "plain" && `it-bento-tile--${tone}`,
59
+ wide && "it-bento-tile--wide",
60
+ interactive && "cursor-pointer transition-transform hover:-translate-y-0.5",
61
+ className,
62
+ )}
63
+ style={{ gridColumn: `span ${cols}`, gridRow: `span ${rows}`, ...style }}
64
+ onClick={onClick}
65
+ role={interactive ? "button" : undefined}
66
+ tabIndex={interactive ? 0 : undefined}
67
+ onKeyDown={
68
+ interactive
69
+ ? (e) => {
70
+ if (e.key === "Enter" || e.key === " ") {
71
+ e.preventDefault();
72
+ onClick?.();
73
+ }
74
+ }
75
+ : undefined
76
+ }
77
+ >
78
+ <h3 className="it-bento-tile__label">
79
+ {icon}
80
+ {label}
81
+ </h3>
82
+ {children}
83
+ {(value !== undefined || caption || aside) && (
84
+ <div className="it-bento-tile__foot">
85
+ <div className="flex items-baseline gap-2">
86
+ {value !== undefined && <div className="it-bento-tile__value">{value}</div>}
87
+ {caption && <small className="it-bento-tile__caption">{caption}</small>}
88
+ </div>
89
+ {aside}
90
+ </div>
91
+ )}
92
+ </section>
93
+ );
94
+ }
95
+
96
+ export interface ITBentoGridProps {
97
+ children: ReactNode;
98
+ className?: string;
99
+ }
100
+
101
+ /** Mosaico de 12 columnas con filas de 84 px (6 columnas y alto automático en móvil). */
102
+ export function ITBentoGrid({ children, className }: ITBentoGridProps) {
103
+ return <div className={clsx("it-bento-grid", className)}>{children}</div>;
104
+ }
105
+
106
+ export interface ITBentoGaugeProps {
107
+ /** Proporción 0..1 del arco. */
108
+ value: number;
109
+ color?: string;
110
+ trackColor?: string;
111
+ /** Ancho del SVG (el alto es proporcional). */
112
+ size?: number;
113
+ /** Tamaño del número de los deciles (para reducir el ruido). */
114
+ strokeWidth?: number;
115
+ }
116
+
117
+ /** Medidor semicircular con el arco dibujándose al montar. */
118
+ export function ITBentoGauge({
119
+ value,
120
+ color = "var(--color-warning, #f2c14e)",
121
+ trackColor = "rgb(255 255 255 / 0.14)",
122
+ size = 200,
123
+ strokeWidth = 13,
124
+ }: ITBentoGaugeProps) {
125
+ const v = Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
126
+ const [go, setGo] = useState(false);
127
+ useEffect(() => {
128
+ const frame = requestAnimationFrame(() => setGo(true));
129
+ return () => cancelAnimationFrame(frame);
130
+ }, []);
131
+ return (
132
+ <svg
133
+ viewBox="0 0 200 108"
134
+ width={size}
135
+ height={size * 0.54}
136
+ className="mx-auto block h-auto max-h-full w-full max-w-[230px]"
137
+ aria-hidden="true"
138
+ >
139
+ <path
140
+ d="M16 98 A84 84 0 0 1 184 98"
141
+ fill="none"
142
+ strokeLinecap="round"
143
+ strokeWidth={strokeWidth}
144
+ stroke={trackColor}
145
+ />
146
+ <path
147
+ d="M16 98 A84 84 0 0 1 184 98"
148
+ pathLength={100}
149
+ fill="none"
150
+ strokeLinecap="round"
151
+ strokeWidth={strokeWidth}
152
+ stroke={color}
153
+ strokeDasharray={100}
154
+ strokeDashoffset={go ? 100 - v * 100 : 100}
155
+ style={{ transition: "stroke-dashoffset 1.6s cubic-bezier(.2,.8,.2,1) .2s" }}
156
+ />
157
+ </svg>
158
+ );
159
+ }
160
+
161
+ export interface ITBentoWavesProps {
162
+ /** Proporción 0..1 que define la altura de las bandas. */
163
+ level: number;
164
+ color?: string;
165
+ /** Altura de la zona de olas (%). */
166
+ height?: number;
167
+ }
168
+
169
+ /** Dos bandas de agua animadas, ancladas al pie de la tarjeta. */
170
+ export function ITBentoWaves({ level, color = "var(--color-violet-400, #a895e0)", height = 62 }: ITBentoWavesProps) {
171
+ const v = Math.min(1, Math.max(0, Number.isFinite(level) ? level : 0));
172
+ const h = Math.max(18, Math.min(90, v * 100));
173
+ return (
174
+ <div className="it-bento-wave" style={{ height: `${Math.min(height, 30 + h)}%` }} aria-hidden="true">
175
+ <svg viewBox="0 0 800 100" preserveAspectRatio="none" className="it-bento-wave__b" style={{ opacity: 0.85 }}>
176
+ <path d="M0 55 Q100 30 200 55 T400 55 T600 55 T800 55 V100 H0z" fill={color} />
177
+ </svg>
178
+ <svg viewBox="0 0 800 100" preserveAspectRatio="none" className="it-bento-wave__a" style={{ opacity: 0.5 }}>
179
+ <path d="M0 40 Q100 10 200 40 T400 40 T600 40 T800 40 V100 H0z" fill={color} />
180
+ </svg>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export interface ITBentoSparkProps {
186
+ /** Serie real de valores; con menos de 2 puntos no se dibuja. */
187
+ data: number[];
188
+ color?: string;
189
+ height?: number;
190
+ }
191
+
192
+ /** Línea de tendencia que se dibuja al montar. */
193
+ export function ITBentoSpark({ data, color = "#c9951c", height = 44 }: ITBentoSparkProps) {
194
+ const [drawn, setDrawn] = useState(false);
195
+ useEffect(() => {
196
+ const frame = requestAnimationFrame(() => setDrawn(true));
197
+ return () => cancelAnimationFrame(frame);
198
+ }, []);
199
+ if (!data || data.length < 2) return null;
200
+ const min = Math.min(...data);
201
+ const max = Math.max(...data);
202
+ const span = max - min || 1;
203
+ const pts = data.map((d, i) => {
204
+ const x = (i / (data.length - 1)) * 100;
205
+ const y = 36 - ((d - min) / span) * 30;
206
+ return `${x.toFixed(2)} ${y.toFixed(2)}`;
207
+ });
208
+ return (
209
+ <svg viewBox="0 0 100 40" preserveAspectRatio="none" style={{ height, width: "100%" }} aria-hidden="true">
210
+ <polyline
211
+ points={pts.join(" ")}
212
+ fill="none"
213
+ stroke={color}
214
+ strokeWidth={2.5}
215
+ strokeLinecap="round"
216
+ strokeLinejoin="round"
217
+ pathLength={100}
218
+ strokeDasharray={100}
219
+ strokeDashoffset={drawn ? 0 : 100}
220
+ style={{ transition: "stroke-dashoffset 1.4s ease .3s", vectorEffect: "non-scaling-stroke" }}
221
+ />
222
+ </svg>
223
+ );
224
+ }
225
+
226
+ export interface ITBentoSegmentsProps {
227
+ /** Total de segmentos. */
228
+ total: number;
229
+ /** Segmentos encendidos. */
230
+ done: number;
231
+ color?: string;
232
+ idleColor?: string;
233
+ }
234
+
235
+ /** Tira de segmentos que se levantan uno tras otro (tareas, piezas, avance). */
236
+ export function ITBentoSegments({
237
+ total,
238
+ done,
239
+ color = "var(--color-success, #2fa374)",
240
+ idleColor = "color-mix(in srgb, currentColor 16%, transparent)",
241
+ }: ITBentoSegmentsProps) {
242
+ const n = Math.max(1, Math.min(60, Math.round(total)));
243
+ const on = Math.max(0, Math.min(n, Math.round(done)));
244
+ return (
245
+ <div className="it-bento-seg" style={{ gridTemplateColumns: `repeat(${n}, 1fr)` }} aria-hidden="true">
246
+ {Array.from({ length: n }, (_, i) => (
247
+ <i
248
+ key={i}
249
+ className={i < on ? "is-on" : undefined}
250
+ style={{ background: i < on ? color : idleColor, ["--it-bento-i" as string]: i }}
251
+ />
252
+ ))}
253
+ </div>
254
+ );
255
+ }
256
+
257
+ export interface ITBentoWeekBarsProps {
258
+ items: { label: string; value: number; tone?: "today" | "future" | "past" }[];
259
+ color?: string;
260
+ todayColor?: string;
261
+ }
262
+
263
+ /** Barras por día de la semana; el día de hoy va en el color de acento. */
264
+ export function ITBentoWeekBars({
265
+ items,
266
+ color = "var(--it-bento-ink, #0c3b52)",
267
+ todayColor = "var(--color-warning, #f2c14e)",
268
+ }: ITBentoWeekBarsProps) {
269
+ const max = Math.max(1, ...items.map((i) => i.value));
270
+ return (
271
+ <div className="mt-1 flex min-h-0 flex-1 items-end gap-1.5">
272
+ {items.map((item, i) => (
273
+ <div key={item.label} className="flex h-full flex-1 flex-col items-center justify-end gap-1 text-[10px]" style={{ color: "var(--it-bento-muted)" }}>
274
+ <i
275
+ className="w-full rounded-t-[5px] rounded-b-[2px] transition-[height] duration-700"
276
+ style={{
277
+ height: `${Math.max(6, (item.value / max) * 100)}%`,
278
+ background:
279
+ item.tone === "today" ? todayColor : item.tone === "future" ? "color-mix(in srgb, currentColor 14%, transparent)" : color,
280
+ transitionDelay: `${i * 60 + 150}ms`,
281
+ }}
282
+ />
283
+ {item.label}
284
+ </div>
285
+ ))}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ export interface ITBentoDonutProps {
291
+ segments: { label: string; value: number; color: string }[];
292
+ /** Texto central y su bajada. */
293
+ center?: ReactNode;
294
+ caption?: string;
295
+ size?: number;
296
+ }
297
+
298
+ /** Dona de segmentos con texto al centro (la leyenda va aparte). */
299
+ export function ITBentoDonut({ segments, center, caption, size = 112 }: ITBentoDonutProps) {
300
+ const total = segments.reduce((acc, s) => acc + s.value, 0);
301
+ if (total <= 0) return null;
302
+ const r = 38;
303
+ const circ = 2 * Math.PI * r;
304
+ let offset = 0;
305
+ return (
306
+ <svg width={size} height={size} viewBox="0 0 100 100" role="img" aria-label={caption}>
307
+ <circle cx="50" cy="50" r={r} fill="none" strokeWidth={13} stroke="color-mix(in srgb, currentColor 10%, transparent)" />
308
+ {segments.map((s) => {
309
+ const len = (s.value / total) * circ;
310
+ const el = (
311
+ <circle
312
+ key={s.label}
313
+ cx="50"
314
+ cy="50"
315
+ r={r}
316
+ fill="none"
317
+ strokeWidth={13}
318
+ stroke={s.color}
319
+ strokeLinecap="butt"
320
+ strokeDasharray={`${Math.max(0, len - 1)} ${circ - Math.max(0, len - 1)}`}
321
+ strokeDashoffset={-offset}
322
+ transform="rotate(-90 50 50)"
323
+ />
324
+ );
325
+ offset += len;
326
+ return el;
327
+ })}
328
+ {center !== undefined && (
329
+ <text x="50" y="52" textAnchor="middle" fontSize="24" fontWeight={800} fill="currentColor">
330
+ {center}
331
+ </text>
332
+ )}
333
+ {caption && (
334
+ <text x="50" y="65" textAnchor="middle" fontSize="7.5" fontWeight={500} fill="var(--it-bento-muted)">
335
+ {caption}
336
+ </text>
337
+ )}
338
+ </svg>
339
+ );
340
+ }
341
+
342
+ export interface ITBentoFeedItem {
343
+ id: string;
344
+ /** Ícono dentro del cuadro de color. */
345
+ icon?: ReactNode;
346
+ title: string;
347
+ meta?: string;
348
+ tone?: "success" | "violet" | "neutral";
349
+ href?: string;
350
+ }
351
+
352
+ export interface ITBentoFeedProps {
353
+ groups: { key: string; label: string; items: ITBentoFeedItem[] }[];
354
+ onItemClick?: (item: ITBentoFeedItem) => void;
355
+ /** Alto máximo del hilo; hace scroll a partir de ahí. */
356
+ maxHeight?: number;
357
+ }
358
+
359
+ /** Actividad agrupada por día, con cuadro de ícono y hora. */
360
+ export function ITBentoFeed({ groups, onItemClick, maxHeight = 330 }: ITBentoFeedProps) {
361
+ const toneClass = {
362
+ success: "bg-[var(--color-success-soft)] text-[color:var(--color-success)]",
363
+ violet: "bg-[var(--color-primary-50)] text-[color:var(--color-primary-600)]",
364
+ neutral: "bg-[var(--color-secondary-100)] text-[color:var(--color-text-muted)]",
365
+ } as const;
366
+ return (
367
+ <ul className="m-0 list-none overflow-y-auto p-0" style={{ maxHeight }}>
368
+ {groups.map((group) => (
369
+ <li key={group.key} className="list-none">
370
+ <p className="m-0 px-1.5 pb-1 pt-2.5 text-[11px] font-semibold" style={{ color: "var(--it-bento-muted)" }}>
371
+ {group.label}
372
+ </p>
373
+ <ul className="m-0 list-none p-0">
374
+ {group.items.map((item) => (
375
+ <li key={item.id} className="list-none">
376
+ <button
377
+ type="button"
378
+ onClick={() => onItemClick?.(item)}
379
+ className={clsx(
380
+ "flex w-full items-center gap-2.5 rounded-[11px] px-1.5 py-1.5 text-left",
381
+ onItemClick ? "cursor-pointer hover:bg-[color-mix(in_srgb,currentColor_6%,transparent)]" : "cursor-default",
382
+ )}
383
+ >
384
+ <span className={clsx("grid h-7 w-7 shrink-0 place-items-center rounded-[9px]", toneClass[item.tone ?? "neutral"])}>
385
+ {item.icon}
386
+ </span>
387
+ <span className="min-w-0 flex-1">
388
+ <span className="block truncate text-[12.5px] font-semibold">{item.title}</span>
389
+ {item.meta && (
390
+ <span className="block text-[11px]" style={{ color: "var(--it-bento-muted)" }}>
391
+ {item.meta}
392
+ </span>
393
+ )}
394
+ </span>
395
+ </button>
396
+ </li>
397
+ ))}
398
+ </ul>
399
+ </li>
400
+ ))}
401
+ </ul>
402
+ );
403
+ }
404
+
405
+ export interface ITBentoLiveProps {
406
+ label: string;
407
+ className?: string;
408
+ }
409
+
410
+ /** Píldora "En vivo" con punto que late. */
411
+ export function ITBentoLive({ label, className }: ITBentoLiveProps) {
412
+ return (
413
+ <span
414
+ className={clsx(
415
+ "inline-flex items-center gap-1.5 rounded-full bg-[var(--color-success-soft)] px-2.5 py-1 text-[12px] font-semibold text-[color:var(--color-success)]",
416
+ className,
417
+ )}
418
+ >
419
+ <i
420
+ className="h-1.5 w-1.5 rounded-full bg-[color:var(--color-success)]"
421
+ style={{ boxShadow: "0 0 0 0 color-mix(in srgb, var(--color-success) 55%, transparent)", animation: "it-bento-pulse 2.4s ease-out infinite" }}
422
+ />
423
+ {label}
424
+ </span>
425
+ );
426
+ }
427
+
428
+ /** Contador que sube de 0 al valor al montar (para el número grande de la tarjeta). */
429
+ export function useITBentoCountUp(target: number, duration = 1400, decimals = 0) {
430
+ const [shown, setShown] = useState(0);
431
+ const ref = useRef(0);
432
+ useEffect(() => {
433
+ if (!Number.isFinite(target)) return;
434
+ if (typeof window === "undefined" || window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
435
+ setShown(target);
436
+ return;
437
+ }
438
+ const start = performance.now();
439
+ let raf = 0;
440
+ const tick = (now: number) => {
441
+ const p = Math.min(Math.max((now - start) / duration, 0), 1);
442
+ const eased = 1 - Math.pow(1 - p, 4);
443
+ ref.current = target * eased;
444
+ setShown(Number(ref.current.toFixed(decimals)));
445
+ if (p < 1) raf = requestAnimationFrame(tick);
446
+ else setShown(target);
447
+ };
448
+ raf = requestAnimationFrame(tick);
449
+ return () => cancelAnimationFrame(raf);
450
+ }, [target, duration, decimals]);
451
+ return shown;
452
+ }
@@ -147,6 +147,29 @@ export default function ITDataTable<T extends Record<string, unknown>>({
147
147
  const isTableVirtual = virtualized && effectiveView === "table";
148
148
  const isStickyHeader = stickyHeader && isTableVirtual;
149
149
  const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
150
+
151
+ // La virtualización espacios filas de `resolvedRowHeight`; si el contenedor
152
+ // corta a una altura arbitraria, la última fila sale partida. Medimos el
153
+ // encabezado y recortamos a un múltiplo exacto de fila.
154
+ const headRef = useRef<HTMLTableSectionElement | null>(null);
155
+ const [headHeight, setHeadHeight] = useState(0);
156
+ useEffect(() => {
157
+ if (!isTableVirtual) return;
158
+ const el = headRef.current;
159
+ if (!el) return;
160
+ const measure = () => setHeadHeight(el.getBoundingClientRect().height);
161
+ measure();
162
+ if (typeof ResizeObserver === "undefined") return;
163
+ const ro = new ResizeObserver(measure);
164
+ ro.observe(el);
165
+ return () => ro.disconnect();
166
+ }, [isTableVirtual, resolvedRowHeight]);
167
+
168
+ const snappedMaxHeight =
169
+ isTableVirtual && headHeight > 0
170
+ ? headHeight +
171
+ Math.max(1, Math.floor((virtualizedMaxHeight - headHeight) / resolvedRowHeight)) * resolvedRowHeight
172
+ : virtualizedMaxHeight;
150
173
  const columnCount = columns.length;
151
174
  const pinnedMeta = React.useMemo(() => getPinnedColumnMeta(columns), [columns]);
152
175
  const scrollOuterClass = isTableVirtual
@@ -507,7 +530,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
507
530
  ))}
508
531
  </colgroup>
509
532
  )}
510
- <thead>
533
+ <thead ref={headRef}>
511
534
  <tr
512
535
  className={clsx(tableHeaderRow, "dark:text-slate-200", headerSlot.className)}
513
536
  style={headerSlot.style}
@@ -590,7 +613,10 @@ export default function ITDataTable<T extends Record<string, unknown>>({
590
613
  onRowClick && !isLoading && tableRowClickable,
591
614
  rowSlot.className
592
615
  )}
593
- style={rowSlot.style}
616
+ style={{
617
+ ...(isTableVirtual ? { height: resolvedRowHeight } : null),
618
+ ...rowSlot.style,
619
+ }}
594
620
  tabIndex={onRowClick && !isLoading ? 0 : undefined}
595
621
  onClick={onRowClick && !isLoading ? handleRowClick(row) : undefined}
596
622
  onKeyDown={onRowClick && !isLoading ? handleRowKeyDown(row) : undefined}
@@ -740,7 +766,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
740
766
  <div
741
767
  ref={scrollRef}
742
768
  className="overflow-x-auto overflow-y-auto"
743
- style={{ maxHeight: virtualizedMaxHeight }}
769
+ style={{ maxHeight: snappedMaxHeight }}
744
770
  >
745
771
  {tableEl}
746
772
  </div>