@axzydev/axzy_ui_system 1.3.1 → 1.3.3

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.
Files changed (50) hide show
  1. package/README.md +6 -2
  2. package/dist/index.cjs +957 -554
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1 -1
  5. package/dist/index.d.cts +191 -9
  6. package/dist/index.d.ts +191 -9
  7. package/dist/index.js +908 -505
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/App.tsx +150 -154
  11. package/src/components/molecules/table/table.doc.mdx +64 -21
  12. package/src/components/molecules/table/table.props.ts +81 -2
  13. package/src/components/molecules/table/table.stories.tsx +155 -0
  14. package/src/components/molecules/table/table.tsx +178 -36
  15. package/src/components/organisms/data-table/ITDataTable.doc.mdx +37 -3
  16. package/src/components/organisms/data-table/ITDataTable.stories.tsx +43 -0
  17. package/src/components/organisms/data-table/dataTable.props.ts +64 -0
  18. package/src/components/organisms/data-table/dataTable.tsx +241 -82
  19. package/src/components/organisms/sidebar/sidebar.doc.mdx +5 -0
  20. package/src/components/organisms/sidebar/sidebar.props.ts +5 -0
  21. package/src/components/organisms/sidebar/sidebar.stories.tsx +1 -1
  22. package/src/components/organisms/sidebar/sidebar.tsx +27 -7
  23. package/src/components/organisms/topbar/topbar.doc.mdx +3 -0
  24. package/src/components/organisms/topbar/topbar.props.ts +16 -0
  25. package/src/components/organisms/topbar/topbar.tsx +133 -98
  26. package/src/components/templates/layout/layout.doc.mdx +2 -0
  27. package/src/components/templates/layout/layout.tsx +7 -1
  28. package/src/components/templates/page/page.doc.mdx +8 -2
  29. package/src/components/templates/page/page.props.ts +11 -1
  30. package/src/components/templates/page/page.tsx +11 -1
  31. package/src/components/theme-provider/themePresets.ts +6 -0
  32. package/src/components/theme-provider/themeProvider.doc.mdx +1 -0
  33. package/src/components/theme-provider/themeProvider.props.ts +2 -0
  34. package/src/components/theme-provider/themeProvider.tsx +13 -2
  35. package/src/dev.css +58 -1
  36. package/src/hooks/useElementSize.ts +46 -0
  37. package/src/hooks/useVirtualRows.ts +123 -0
  38. package/src/index.ts +2 -0
  39. package/src/sandbox/LandingShowcase.tsx +654 -0
  40. package/src/sandbox/NotFoundShowcase.tsx +70 -0
  41. package/src/sandbox/TopbarSearch.tsx +85 -0
  42. package/src/sandbox/navigation.ts +148 -0
  43. package/src/sandbox/useHashRoute.ts +149 -0
  44. package/src/showcases/DataShowcases.tsx +321 -24
  45. package/src/showcases/PageShowcases.tsx +31 -0
  46. package/src/showcases/StructureShowcases.tsx +42 -4
  47. package/src/theme/theme.types.ts +2 -0
  48. package/src/types/table.types.ts +48 -0
  49. package/src/utils/styles.ts +12 -5
  50. package/src/utils/table.utils.ts +31 -0
@@ -21,6 +21,14 @@ const meta: Meta<typeof ITTable> = {
21
21
  control: "select",
22
22
  options: ["sm", "md", "lg"],
23
23
  },
24
+ density: {
25
+ control: "select",
26
+ options: ["compact", "normal", "comfortable"],
27
+ },
28
+ layout: {
29
+ control: "select",
30
+ options: ["auto", "fixed"],
31
+ },
24
32
  },
25
33
  };
26
34
 
@@ -170,3 +178,150 @@ export const EmptyState: Story = {
170
178
  title: "No Data Available",
171
179
  } as any,
172
180
  };
181
+
182
+ // Wrapper for clickable rows/cards
183
+ const RowClickWrapper = (args: any) => {
184
+ const [selected, setSelected] = useState<User | null>(null);
185
+
186
+ return (
187
+ <div className="space-y-3">
188
+ <p className="text-sm font-medium text-slate-500">
189
+ {selected
190
+ ? `Selected: ${selected.name} (id ${selected.id})`
191
+ : "Click a row/card or press Enter/Space while focused"}
192
+ </p>
193
+ <ITTable
194
+ {...args}
195
+ onRowClick={(row: User) => setSelected(row)}
196
+ />
197
+ </div>
198
+ );
199
+ };
200
+
201
+ export const WithRowClick: Story = {
202
+ render: (args) => <RowClickWrapper {...args} />,
203
+ args: {
204
+ columns: basicColumns,
205
+ data: mockData.slice(0, 5),
206
+ title: "Clickable Rows",
207
+ } as any,
208
+ };
209
+
210
+ export const WithRowClickCards: Story = {
211
+ render: (args) => <RowClickWrapper {...args} />,
212
+ args: {
213
+ columns: basicColumns,
214
+ data: mockData.slice(0, 5),
215
+ title: "Clickable Cards",
216
+ defaultView: "cards",
217
+ } as any,
218
+ };
219
+
220
+ // --- CONFIGURABLE LAYOUT / DENSITY ---
221
+
222
+ const longCellData: User[] = [
223
+ {
224
+ id: 1,
225
+ name: "Extraordinariamente Largo Nombre de Usuario Que No Cabe",
226
+ email: "usuario.con.un.correo.extremadamente.largo@example-enterprise-domain.com",
227
+ roleId: 1,
228
+ balance: 28400,
229
+ isActive: true,
230
+ lastLogin: "2026-07-18T09:30:00",
231
+ },
232
+ {
233
+ id: 2,
234
+ name: "Otra Persona Con Un Nombre Bastante Extenso Para Probar",
235
+ email: "otra.persona.con.correo.extenso@example-enterprise-domain.com",
236
+ roleId: 2,
237
+ balance: 15200,
238
+ isActive: false,
239
+ lastLogin: "2026-07-17T14:22:00",
240
+ },
241
+ ];
242
+
243
+ const fixedColumns: Column<User>[] = [
244
+ { key: "id", label: "ID", type: "number", width: 64, minWidth: 48, align: "center" },
245
+ { key: "name", label: "Name", type: "string", width: "35%", truncate: true },
246
+ { key: "email", label: "Email", type: "string", width: "45%", truncate: true },
247
+ ];
248
+
249
+ export const CompactDensity: Story = {
250
+ args: {
251
+ columns: advancedColumns.map(col => ({ ...col, render: undefined })),
252
+ data: mockData,
253
+ title: "Compact Density",
254
+ density: "compact",
255
+ defaultItemsPerPage: 8,
256
+ } as any,
257
+ };
258
+
259
+ export const FixedLayoutWithWidths: Story = {
260
+ render: (args: any) => (
261
+ <div style={{ width: 480 }} className="rounded-lg border border-dashed border-slate-300">
262
+ <ITTable {...args} />
263
+ </div>
264
+ ),
265
+ args: {
266
+ columns: fixedColumns,
267
+ data: longCellData,
268
+ title: "Fixed layout · no horizontal scroll",
269
+ layout: "fixed",
270
+ density: "compact",
271
+ defaultItemsPerPage: 5,
272
+ } as any,
273
+ };
274
+
275
+ export const AutoCardsNarrowContainer: Story = {
276
+ render: (args: any) => (
277
+ <div style={{ width: 420 }} className="rounded-lg border border-dashed border-slate-300">
278
+ <ITTable {...args} />
279
+ </div>
280
+ ),
281
+ args: {
282
+ columns: basicColumns,
283
+ data: mockData.slice(0, 6),
284
+ title: "Auto cards under 640px",
285
+ autoCardBreakpoint: 640,
286
+ defaultItemsPerPage: 6,
287
+ } as any,
288
+ };
289
+
290
+ // --- VIRTUALIZATION ---
291
+
292
+ const virtualColumns: Column<User>[] = [
293
+ { key: "id", label: "ID", type: "number", width: 72, minWidth: 56, align: "center" },
294
+ { key: "name", label: "Name", type: "string", width: "30%", truncate: true },
295
+ { key: "email", label: "Email", type: "string", width: "40%", truncate: true },
296
+ {
297
+ key: "roleId",
298
+ label: "Role",
299
+ type: "catalog",
300
+ width: "30%",
301
+ truncate: true,
302
+ catalogOptions: { data: mockRoles },
303
+ },
304
+ ];
305
+
306
+ export const VirtualizedLargeDataset: Story = {
307
+ render: (args: any) => (
308
+ <div className="space-y-2">
309
+ <p className="text-xs text-slate-500">
310
+ 250 rows · only the visible window is mounted (~10–20 &lt;tr&gt; + at most 2 spacer rows)
311
+ </p>
312
+ <ITTable {...args} />
313
+ </div>
314
+ ),
315
+ args: {
316
+ columns: virtualColumns,
317
+ data: generateMockData(250),
318
+ title: "Virtualized · 250 rows",
319
+ virtualized: true,
320
+ stickyHeader: true,
321
+ layout: "fixed",
322
+ density: "compact",
323
+ virtualizedMaxHeight: 360,
324
+ defaultItemsPerPage: 250,
325
+ itemsPerPageOptions: [50, 100, 250],
326
+ } as any,
327
+ };
@@ -1,5 +1,7 @@
1
1
  import { useTableState } from "@/hooks/useTableState";
2
- import { sizeStyles, variantStyles } from "@/types/table.types";
2
+ import { useElementSize } from "@/hooks/useElementSize";
3
+ import { useVirtualRows } from "@/hooks/useVirtualRows";
4
+ import { getRowHeight, sizeStyles, tableAlignClasses, variantStyles } from "@/types/table.types";
3
5
  import clsx from "clsx";
4
6
  import React, { useState } from "react";
5
7
  import {
@@ -18,6 +20,8 @@ import ITText from "@/components/atoms/text/text";
18
20
  import {
19
21
  tableActionsCell,
20
22
  tableBody,
23
+ tableCardClickable,
24
+ tableCardHover,
21
25
  tableCell,
22
26
  tableCellText,
23
27
  tableContainer,
@@ -25,8 +29,9 @@ import {
25
29
  tableHeaderCell,
26
30
  tableHeaderRow,
27
31
  tableRow,
32
+ tableRowClickable,
28
33
  } from "@/utils/styles";
29
- import { formatCurrencyMX, getNestedValue } from "@/utils/table.utils";
34
+ import { formatCurrencyMX, getNestedValue, isInteractiveTarget } from "@/utils/table.utils";
30
35
 
31
36
  /**
32
37
  * A feature-rich data table with per-column filtering, sortable columns,
@@ -57,8 +62,27 @@ export default function ITTable<T extends Record<string, unknown>>({
57
62
  defaultView = "table",
58
63
  showVerticalBorder = true,
59
64
  verticalBorderClassname,
65
+ onRowClick,
66
+ layout = "auto",
67
+ density = "normal",
68
+ autoCardBreakpoint = 0,
69
+ virtualized = false,
70
+ virtualizedMaxHeight = 400,
71
+ rowHeight,
72
+ overscan = 5,
73
+ stickyHeader = false,
60
74
  }: ITTableProps<T>) {
61
75
  const [viewMode, setViewMode] = useState<"table" | "cards">(defaultView);
76
+ const { ref: rootRef, width: containerWidth } = useElementSize<HTMLDivElement>();
77
+
78
+ const isFixed = layout === "fixed";
79
+ const forcedCards =
80
+ autoCardBreakpoint > 0 && containerWidth > 0 && containerWidth < autoCardBreakpoint;
81
+ const effectiveView: "table" | "cards" = forcedCards ? "cards" : viewMode;
82
+ const hasColumnWidths = columns.some((col) => col.width != null);
83
+ const tableLayoutClass = isFixed
84
+ ? "w-full text-sm text-left text-secondary-600"
85
+ : "min-w-max w-full text-sm text-left text-secondary-600";
62
86
 
63
87
  const {
64
88
  currentPage,
@@ -159,6 +183,29 @@ export default function ITTable<T extends Record<string, unknown>>({
159
183
  currentPage * itemsPerPage
160
184
  );
161
185
 
186
+ const isTableVirtual = virtualized && effectiveView === "table";
187
+ const isStickyHeader = stickyHeader && isTableVirtual;
188
+ const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
189
+ const columnCount = columns.length;
190
+
191
+ const {
192
+ scrollRef,
193
+ startIndex,
194
+ endIndex,
195
+ topSpacerHeight,
196
+ bottomSpacerHeight,
197
+ } = useVirtualRows({
198
+ count: currentData.length,
199
+ rowHeight: resolvedRowHeight,
200
+ overscan,
201
+ enabled: isTableVirtual,
202
+ resetKey: `${currentPage}-${itemsPerPage}`,
203
+ });
204
+
205
+ const windowData = isTableVirtual
206
+ ? currentData.slice(startIndex, endIndex + 1)
207
+ : currentData;
208
+
162
209
  const renderFilterInput = (col: Column<T>) => {
163
210
  if (!col.filter) return null;
164
211
 
@@ -311,8 +358,21 @@ export default function ITTable<T extends Record<string, unknown>>({
311
358
  );
312
359
  };
313
360
 
361
+ const handleRowClick = (row: T) => (event: React.MouseEvent<HTMLElement>) => {
362
+ if (!onRowClick || isInteractiveTarget(event.target, event.currentTarget)) return;
363
+ onRowClick(row, event);
364
+ };
365
+
366
+ const handleRowKeyDown = (row: T) => (event: React.KeyboardEvent<HTMLElement>) => {
367
+ if (!onRowClick) return;
368
+ if (event.target !== event.currentTarget) return;
369
+ if (event.key !== "Enter" && event.key !== " ") return;
370
+ event.preventDefault();
371
+ onRowClick(row, event);
372
+ };
373
+
314
374
  return (
315
- <div className={clsx("space-y-4 w-full", containerClassName)}>
375
+ <div ref={rootRef} className={clsx("space-y-4 w-full", containerClassName)}>
316
376
  <div className={tableContainer} style={{ backgroundColor: 'var(--color-table-rowBg, #ffffff)' }}>
317
377
  {/* Header outside overflow */}
318
378
  {title && (
@@ -321,8 +381,9 @@ export default function ITTable<T extends Record<string, unknown>>({
321
381
  <div className="flex items-center bg-slate-100 dark:bg-slate-800/80 rounded-lg p-0.5 border border-slate-200 dark:border-slate-700/50">
322
382
  <button
323
383
  onClick={() => setViewMode("table")}
384
+ disabled={forcedCards}
324
385
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
325
- viewMode === "table"
386
+ effectiveView === "table"
326
387
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
327
388
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
328
389
  }`}
@@ -333,7 +394,7 @@ export default function ITTable<T extends Record<string, unknown>>({
333
394
  <button
334
395
  onClick={() => setViewMode("cards")}
335
396
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
336
- viewMode === "cards"
397
+ effectiveView === "cards"
337
398
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
338
399
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
339
400
  }`}
@@ -350,8 +411,9 @@ export default function ITTable<T extends Record<string, unknown>>({
350
411
  <div className="flex items-center bg-slate-100 dark:bg-slate-800/80 rounded-lg p-0.5 border border-slate-200 dark:border-slate-700/50">
351
412
  <button
352
413
  onClick={() => setViewMode("table")}
414
+ disabled={forcedCards}
353
415
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
354
- viewMode === "table"
416
+ effectiveView === "table"
355
417
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
356
418
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
357
419
  }`}
@@ -362,7 +424,7 @@ export default function ITTable<T extends Record<string, unknown>>({
362
424
  <button
363
425
  onClick={() => setViewMode("cards")}
364
426
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
365
- viewMode === "cards"
427
+ effectiveView === "cards"
366
428
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
367
429
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
368
430
  }`}
@@ -374,11 +436,18 @@ export default function ITTable<T extends Record<string, unknown>>({
374
436
  </div>
375
437
  )}
376
438
 
377
- {viewMode === "cards" ? (
439
+ {effectiveView === "cards" ? (
378
440
  <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
379
441
  {currentData.length > 0 ? (
380
442
  currentData.map((row, i) => (
381
- <div key={i}>
443
+ <div
444
+ key={i}
445
+ className={clsx(tableCardHover, onRowClick && tableCardClickable)}
446
+ role={onRowClick ? "button" : undefined}
447
+ tabIndex={onRowClick ? 0 : undefined}
448
+ onClick={onRowClick ? handleRowClick(row) : undefined}
449
+ onKeyDown={onRowClick ? handleRowKeyDown(row) : undefined}
450
+ >
382
451
  {renderCard ? renderCard(row) : renderDefaultCard(row)}
383
452
  </div>
384
453
  ))
@@ -390,25 +459,62 @@ export default function ITTable<T extends Record<string, unknown>>({
390
459
  )}
391
460
  </div>
392
461
  ) : (
393
- <div className="overflow-x-auto">
462
+ <div
463
+ ref={isTableVirtual ? scrollRef : undefined}
464
+ className={isTableVirtual ? "overflow-x-auto overflow-y-auto" : "overflow-x-auto"}
465
+ style={isTableVirtual ? { maxHeight: virtualizedMaxHeight } : undefined}
466
+ >
394
467
  <table
395
468
  className={clsx(
396
- "min-w-max w-full text-sm text-left text-secondary-600",
469
+ tableLayoutClass,
397
470
  showVerticalBorder && "[&_th]:border-r [&_th:last-child]:border-r-0 [&_td]:border-r [&_td:last-child]:border-r-0",
398
471
  showVerticalBorder && (verticalBorderClassname || "[&_th]:border-slate-100 dark:[&_th]:border-slate-700/30 [&_td]:border-slate-100 dark:[&_td]:border-slate-700/30"),
399
472
  variantStyles[variant],
400
473
  sizeStyles[size]
401
474
  )}
475
+ style={isFixed ? { tableLayout: "fixed" } : undefined}
476
+ aria-rowcount={isTableVirtual ? currentData.length + 1 : undefined}
402
477
  >
478
+ {hasColumnWidths && (
479
+ <colgroup>
480
+ {columns.map((col) => (
481
+ <col
482
+ key={col.key}
483
+ style={
484
+ col.width != null
485
+ ? { width: typeof col.width === "number" ? `${col.width}px` : col.width }
486
+ : undefined
487
+ }
488
+ />
489
+ ))}
490
+ </colgroup>
491
+ )}
403
492
  <thead>
404
- <tr className={clsx(tableHeaderRow, "dark:text-slate-200")}>
493
+ <tr
494
+ className={clsx(tableHeaderRow, "dark:text-slate-200")}
495
+ aria-rowindex={isTableVirtual ? 1 : undefined}
496
+ >
405
497
  {columns.map((col) => (
406
498
  <th
407
499
  key={col.key}
408
500
  scope="col"
409
- className={tableHeaderCell(col.className)}
501
+ className={clsx(
502
+ tableHeaderCell(col.className, density),
503
+ col.align && tableAlignClasses[col.align],
504
+ isStickyHeader && "sticky top-0 z-10 bg-secondary-50"
505
+ )}
506
+ style={
507
+ col.minWidth != null || isStickyHeader
508
+ ? {
509
+ ...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
510
+ ...(isStickyHeader
511
+ ? { backgroundColor: "var(--color-table-headerBg, #f8fafc)" }
512
+ : {}),
513
+ }
514
+ : undefined
515
+ }
410
516
  >
411
- <div className="flex flex-col gap-3 min-w-[150px]">
517
+ <div className={isFixed ? "flex flex-col gap-3" : "flex flex-col gap-3 min-w-[150px]"}>
412
518
  <div className="flex items-center justify-between gap-2">
413
519
  <ITText as="span" className="text-slate-900 dark:text-white font-bold">{col.label}</ITText>
414
520
  {col.sortable && col.type !== "actions" && (
@@ -434,29 +540,65 @@ export default function ITTable<T extends Record<string, unknown>>({
434
540
  </thead>
435
541
  <tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
436
542
  {currentData.length > 0 ? (
437
- currentData.map((row, rowIndex) => (
438
- <tr
439
- key={rowIndex}
440
- className={clsx(tableRow, variant === "striped" && "odd:bg-secondary-50/40 dark:odd:bg-slate-800/20")}
441
- >
442
- {columns.map((col) => (
443
- <td
444
- key={`${rowIndex}-${col.key}`}
445
- className={tableCell(col.className)}
446
- >
447
- {col.type === "actions" ? (
448
- <div className={tableActionsCell}>
449
- {renderCellContent(col, row) as React.ReactNode}
450
- </div>
451
- ) : (
452
- <div className={tableCellText}>
453
- {renderCellContent(col, row) as React.ReactNode}
454
- </div>
543
+ <>
544
+ {isTableVirtual && topSpacerHeight > 0 && (
545
+ <tr aria-hidden="true" style={{ height: topSpacerHeight }}>
546
+ <td colSpan={columnCount} style={{ padding: 0, border: 0 }} />
547
+ </tr>
548
+ )}
549
+ {windowData.map((row, windowIndex) => {
550
+ const absoluteIndex = isTableVirtual ? startIndex + windowIndex : windowIndex;
551
+ return (
552
+ <tr
553
+ key={absoluteIndex}
554
+ aria-rowindex={isTableVirtual ? absoluteIndex + 2 : undefined}
555
+ className={clsx(
556
+ tableRow,
557
+ variant === "striped" && absoluteIndex % 2 === 0 && "bg-secondary-50/40 dark:bg-slate-800/20",
558
+ onRowClick && tableRowClickable
455
559
  )}
456
- </td>
457
- ))}
458
- </tr>
459
- ))
560
+ tabIndex={onRowClick ? 0 : undefined}
561
+ onClick={onRowClick ? handleRowClick(row) : undefined}
562
+ onKeyDown={onRowClick ? handleRowKeyDown(row) : undefined}
563
+ >
564
+ {columns.map((col) => {
565
+ const rawValue = getNestedValue(row, col.key);
566
+ const truncateTitle =
567
+ col.truncate &&
568
+ (typeof rawValue === "string" || typeof rawValue === "number")
569
+ ? String(rawValue)
570
+ : undefined;
571
+ return (
572
+ <td
573
+ key={`${absoluteIndex}-${col.key}`}
574
+ className={clsx(
575
+ tableCell(col.className, density),
576
+ col.align && tableAlignClasses[col.align]
577
+ )}
578
+ style={col.minWidth != null ? { minWidth: `${col.minWidth}px` } : undefined}
579
+ title={truncateTitle}
580
+ >
581
+ {col.type === "actions" ? (
582
+ <div className={tableActionsCell}>
583
+ {renderCellContent(col, row) as React.ReactNode}
584
+ </div>
585
+ ) : (
586
+ <div className={clsx(tableCellText, col.truncate && "truncate")}>
587
+ {renderCellContent(col, row) as React.ReactNode}
588
+ </div>
589
+ )}
590
+ </td>
591
+ );
592
+ })}
593
+ </tr>
594
+ );
595
+ })}
596
+ {isTableVirtual && bottomSpacerHeight > 0 && (
597
+ <tr aria-hidden="true" style={{ height: bottomSpacerHeight }}>
598
+ <td colSpan={columnCount} style={{ padding: 0, border: 0 }} />
599
+ </tr>
600
+ )}
601
+ </>
460
602
  ) : (
461
603
  <tr>
462
604
  <td colSpan={columns.length} className="px-6 py-12 text-center">
@@ -11,7 +11,7 @@ Advanced server-side data table with automatic pagination, column sorting, and f
11
11
 
12
12
  | Prop | Type | Default | Description |
13
13
  |------|------|---------|-------------|
14
- | `columns` | `Column<T>[]` | — | Column definitions with key, label, type, sortable, filter, render, and width. |
14
+ | `columns` | `Column<T>[]` | — | Column definitions with key, label, type, sortable, filter, render, plus layout fields: `width` (`number` px or CSS `string`), `minWidth` (px), `align` (`"left" \| "center" \| "right"`), and `truncate` (ellipsis + native `title`). |
15
15
  | `fetchData` | `(params: ITDataTableFetchParams) => Promise<ITDataTableResponse<T>>` | — | Async callback triggered on pagination, filter, or sort change. Must return `{ data, total }`. |
16
16
  | `debounceMs` | `number` | `500` | Delay in ms before firing fetch after filter state changes. |
17
17
  | `externalFilters` | `Record<string, string \| number \| boolean \| Date>` | `{}` | Filters managed outside the table (e.g., date pickers) merged with internal column filters. |
@@ -20,11 +20,24 @@ Advanced server-side data table with automatic pagination, column sorting, and f
20
20
  | `reloadTrigger` | `number \| string \| boolean` | — | Changing this value forces a re-fetch. Use after mutations. |
21
21
  | `containerClassName` | `string` | — | CSS classes for the outer wrapper. |
22
22
  | `className` | `string` | — | CSS classes applied to the `<table>` element. |
23
- | `variant` | `"default" \| "striped" \| "bordered" \| "minimal"` | `"default"` | Visual style variant. |
24
- | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Row density preset. |
23
+ | `variant` | `"default" \| "striped" \| "bordered" \| "minimal"` | `"default"` | Visual style variant. `"minimal"` is accepted by the type but has no styles (renders like `"default"`). |
24
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Font-size preset (does not change padding; use `density` for that). |
25
25
  | `itemsPerPageOptions` | `number[]` | `[5, 10, 20]` | Selectable page-size options. |
26
26
  | `defaultItemsPerPage` | `number` | `10` | Initial rows per page. |
27
27
  | `title` | `string \| ReactNode` | — | Heading displayed above the table. |
28
+ | `renderCard` | `(row: T) => ReactNode` | — | Custom card renderer for the cards view. |
29
+ | `defaultView` | `"table" \| "cards"` | `"table"` | Initial view mode. |
30
+ | `showVerticalBorder` | `boolean` | `true` | Show vertical borders between columns. |
31
+ | `verticalBorderClassname` | `string` | — | Custom class for the vertical borders. |
32
+ | `onRowClick` | `(row: T, event: MouseEvent<HTMLElement> \| KeyboardEvent<HTMLElement>) => void` | — | Row/card activation. `MouseEvent` for pointer, `KeyboardEvent` for Enter/Space. Skipped when the interaction starts inside an interactive child. |
33
+ | `layout` | `"auto" \| "fixed"` | `"auto"` | Table layout algorithm. `"fixed"` honors column `width` values and drops the `min-w-max` / `min-w-[150px]` floors so content no longer forces horizontal scrolling. |
34
+ | `density` | `"compact" \| "normal" \| "comfortable"` | `"normal"` | Cell padding / row-height preset. Independent of `size`, which only controls font-size. |
35
+ | `autoCardBreakpoint` | `number` | `0` | Container width (px) below which the table automatically falls back to the cards view. `0` disables the fallback. |
36
+ | `virtualized` | `boolean` | `false` | Renders only the visible row window with spacer rows preserving scroll height. Table view only; cards view is never virtualized. |
37
+ | `virtualizedMaxHeight` | `number` | `400` | Max height (px) of the virtualized scroll container. Only used when `virtualized` is true. |
38
+ | `rowHeight` | `number` | `getRowHeight(size, density)` | Assumed uniform row height (px). Size-aware: `size="md"` baseline is 33 / 45 / 53 for compact / normal / comfortable; `size` shifts it by the font-size line box (sm 16px / md 20px / lg 28px). |
39
+ | `overscan` | `number` | `5` | Extra rows rendered above and below the viewport to avoid blank gaps. |
40
+ | `stickyHeader` | `boolean` | `false` | Keeps header cells sticky while the virtualized body scrolls. Only effective when `virtualized` is true. |
28
41
 
29
42
  ## Usage
30
43
 
@@ -42,6 +55,18 @@ import { ITDataTable } from '@axzydev/axzy_ui_system';
42
55
  fetchData={async (params) => api.fetchItems(params)}
43
56
  variant="bordered"
44
57
  />
58
+
59
+ // No horizontal scroll: fixed layout + per-column widths + truncation
60
+ <ITDataTable
61
+ columns={[
62
+ { key: 'name', label: 'Name', type: 'string', width: '30%', truncate: true },
63
+ { key: 'email', label: 'Email', type: 'string', width: 220, truncate: true },
64
+ { key: 'role', label: 'Role', type: 'string', width: 140, align: 'center' },
65
+ ]}
66
+ fetchData={async (params) => api.fetchItems(params)}
67
+ layout="fixed"
68
+ density="compact"
69
+ />
45
70
  ```
46
71
 
47
72
  ## Notes
@@ -51,6 +76,15 @@ import { ITDataTable } from '@axzydev/axzy_ui_system';
51
76
  - Debounce prevents excessive API calls while the user types in filter inputs.
52
77
  - A loading skeleton overlay covers the table body while fetching, keeping the header visible.
53
78
  - Empty state is shown automatically when no results match the current filters.
79
+ - `onRowClick` fires in **both** the table and cards views. The event is a `MouseEvent` for pointer interaction and a `KeyboardEvent` for Enter/Space activation. It is not fired while `isLoading`, and interactions that start inside an interactive child (button, link, input, or `[data-row-click-ignore]`) are ignored.
80
+ - `density` controls padding/row height (`compact` 33px / `normal` 45px / `comfortable` 53px at `size="md"`); `size` changes font-size and shifts the row height by the line box (sm 16px / md 20px / lg 28px).
81
+ - **Anti horizontal scroll recipe:** `layout="fixed"` + a `width` on every column + `truncate` on long text + `density="compact"`. `width` accepts a `number` (px) or any CSS length string (`"20%"`, `"12rem"`). A `<colgroup>` is only emitted when at least one column defines a `width`.
82
+ - With the default `layout="auto"`, `min-w-max` stays active so column widths can still reflow as different cell content enters the window during vertical scroll. Use `layout="fixed"` + column `width` for stable widths.
83
+ - `autoCardBreakpoint` swaps to the cards view when the container (not the viewport) is narrower than the given px value; the Table toggle is disabled while forced, the Cards toggle stays enabled.
84
+ - **Virtualization** applies to the **table view only** — the cards view is never virtualized. It only pays off with a large page size: raise `defaultItemsPerPage` and `itemsPerPageOptions` (defaults are `10` and `[5, 10, 20]`). Rows are assumed to have a uniform `rowHeight`; use `truncate` + `layout="fixed"` for predictable heights.
85
+ - Scroll resets to the top on page or items-per-page change, but **not** on filter change (known limitation). `aria-rowcount` reflects the current page (+1), not the whole dataset, because pagination is independent.
86
+ - `virtualizedMaxHeight`, `rowHeight`, `overscan`, and `stickyHeader` only take effect when `virtualized` is true. `stickyHeader` makes each `<th>` sticky using the `--color-table-headerBg` token (fallback `#f8fafc`).
87
+ - `TableDensity` is exported from the package. `ITSearchTable` does **not** support `layout`, `density`, `autoCardBreakpoint`, `virtualized`, or per-column `width` — do not assume parity.
54
88
 
55
89
  ## Stories
56
90
 
@@ -142,6 +142,39 @@ export const Default: Story = {
142
142
  },
143
143
  };
144
144
 
145
+ // Column widths keep the fixed layout from forcing horizontal scroll.
146
+ const fixedColumns = columns.map((col, i) => ({
147
+ ...col,
148
+ width: [64, '28%', '34%', '22%', '16%'][i],
149
+ truncate: col.type === 'string' || col.type === 'catalog',
150
+ })) as any;
151
+
152
+ export const FixedLayoutCompact: Story = {
153
+ args: {
154
+ title: 'Compact fixed layout',
155
+ columns: fixedColumns,
156
+ fetchData: mockFetchItems,
157
+ defaultItemsPerPage: 10,
158
+ layout: 'fixed',
159
+ density: 'compact',
160
+ },
161
+ };
162
+
163
+ export const VirtualizedLargeDataset: Story = {
164
+ args: {
165
+ title: 'Virtualized · 100 rows',
166
+ columns: fixedColumns,
167
+ fetchData: mockFetchItems,
168
+ defaultItemsPerPage: 100,
169
+ itemsPerPageOptions: [10, 50, 100],
170
+ virtualized: true,
171
+ stickyHeader: true,
172
+ layout: 'fixed',
173
+ density: 'compact',
174
+ virtualizedMaxHeight: 360,
175
+ },
176
+ };
177
+
145
178
  export const RemoteWithExternalFilters: Story = {
146
179
  render: () => {
147
180
  const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
@@ -200,6 +233,16 @@ export const RemoteWithExternalFilters: Story = {
200
233
  }
201
234
  };
202
235
 
236
+ export const WithRowClick: Story = {
237
+ args: {
238
+ title: 'Filas clickeables',
239
+ columns,
240
+ fetchData: mockFetchItems,
241
+ defaultItemsPerPage: 10,
242
+ onRowClick: (row: any) => console.log('Row clicked:', row),
243
+ },
244
+ };
245
+
203
246
  export const SkeletonLoading: Story = {
204
247
  args: {
205
248
  title: 'Cargando Datos...',