@axzydev/axzy_ui_system 1.3.1 → 1.3.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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/dist/index.cjs +930 -544
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1 -1
  5. package/dist/index.d.cts +187 -9
  6. package/dist/index.d.ts +187 -9
  7. package/dist/index.js +881 -495
  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 +177 -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 +240 -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/dev.css +58 -1
  32. package/src/hooks/useElementSize.ts +46 -0
  33. package/src/hooks/useVirtualRows.ts +123 -0
  34. package/src/index.ts +2 -0
  35. package/src/sandbox/LandingShowcase.tsx +465 -0
  36. package/src/sandbox/NotFoundShowcase.tsx +70 -0
  37. package/src/sandbox/TopbarSearch.tsx +85 -0
  38. package/src/sandbox/navigation.ts +148 -0
  39. package/src/sandbox/useHashRoute.ts +149 -0
  40. package/src/showcases/DataShowcases.tsx +321 -24
  41. package/src/showcases/PageShowcases.tsx +31 -0
  42. package/src/showcases/StructureShowcases.tsx +42 -4
  43. package/src/types/table.types.ts +48 -0
  44. package/src/utils/styles.ts +9 -4
  45. 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,7 @@ import ITText from "@/components/atoms/text/text";
18
20
  import {
19
21
  tableActionsCell,
20
22
  tableBody,
23
+ tableCardClickable,
21
24
  tableCell,
22
25
  tableCellText,
23
26
  tableContainer,
@@ -25,8 +28,9 @@ import {
25
28
  tableHeaderCell,
26
29
  tableHeaderRow,
27
30
  tableRow,
31
+ tableRowClickable,
28
32
  } from "@/utils/styles";
29
- import { formatCurrencyMX, getNestedValue } from "@/utils/table.utils";
33
+ import { formatCurrencyMX, getNestedValue, isInteractiveTarget } from "@/utils/table.utils";
30
34
 
31
35
  /**
32
36
  * A feature-rich data table with per-column filtering, sortable columns,
@@ -57,8 +61,27 @@ export default function ITTable<T extends Record<string, unknown>>({
57
61
  defaultView = "table",
58
62
  showVerticalBorder = true,
59
63
  verticalBorderClassname,
64
+ onRowClick,
65
+ layout = "auto",
66
+ density = "normal",
67
+ autoCardBreakpoint = 0,
68
+ virtualized = false,
69
+ virtualizedMaxHeight = 400,
70
+ rowHeight,
71
+ overscan = 5,
72
+ stickyHeader = false,
60
73
  }: ITTableProps<T>) {
61
74
  const [viewMode, setViewMode] = useState<"table" | "cards">(defaultView);
75
+ const { ref: rootRef, width: containerWidth } = useElementSize<HTMLDivElement>();
76
+
77
+ const isFixed = layout === "fixed";
78
+ const forcedCards =
79
+ autoCardBreakpoint > 0 && containerWidth > 0 && containerWidth < autoCardBreakpoint;
80
+ const effectiveView: "table" | "cards" = forcedCards ? "cards" : viewMode;
81
+ const hasColumnWidths = columns.some((col) => col.width != null);
82
+ const tableLayoutClass = isFixed
83
+ ? "w-full text-sm text-left text-secondary-600"
84
+ : "min-w-max w-full text-sm text-left text-secondary-600";
62
85
 
63
86
  const {
64
87
  currentPage,
@@ -159,6 +182,29 @@ export default function ITTable<T extends Record<string, unknown>>({
159
182
  currentPage * itemsPerPage
160
183
  );
161
184
 
185
+ const isTableVirtual = virtualized && effectiveView === "table";
186
+ const isStickyHeader = stickyHeader && isTableVirtual;
187
+ const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
188
+ const columnCount = columns.length;
189
+
190
+ const {
191
+ scrollRef,
192
+ startIndex,
193
+ endIndex,
194
+ topSpacerHeight,
195
+ bottomSpacerHeight,
196
+ } = useVirtualRows({
197
+ count: currentData.length,
198
+ rowHeight: resolvedRowHeight,
199
+ overscan,
200
+ enabled: isTableVirtual,
201
+ resetKey: `${currentPage}-${itemsPerPage}`,
202
+ });
203
+
204
+ const windowData = isTableVirtual
205
+ ? currentData.slice(startIndex, endIndex + 1)
206
+ : currentData;
207
+
162
208
  const renderFilterInput = (col: Column<T>) => {
163
209
  if (!col.filter) return null;
164
210
 
@@ -311,8 +357,21 @@ export default function ITTable<T extends Record<string, unknown>>({
311
357
  );
312
358
  };
313
359
 
360
+ const handleRowClick = (row: T) => (event: React.MouseEvent<HTMLElement>) => {
361
+ if (!onRowClick || isInteractiveTarget(event.target, event.currentTarget)) return;
362
+ onRowClick(row, event);
363
+ };
364
+
365
+ const handleRowKeyDown = (row: T) => (event: React.KeyboardEvent<HTMLElement>) => {
366
+ if (!onRowClick) return;
367
+ if (event.target !== event.currentTarget) return;
368
+ if (event.key !== "Enter" && event.key !== " ") return;
369
+ event.preventDefault();
370
+ onRowClick(row, event);
371
+ };
372
+
314
373
  return (
315
- <div className={clsx("space-y-4 w-full", containerClassName)}>
374
+ <div ref={rootRef} className={clsx("space-y-4 w-full", containerClassName)}>
316
375
  <div className={tableContainer} style={{ backgroundColor: 'var(--color-table-rowBg, #ffffff)' }}>
317
376
  {/* Header outside overflow */}
318
377
  {title && (
@@ -321,8 +380,9 @@ export default function ITTable<T extends Record<string, unknown>>({
321
380
  <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
381
  <button
323
382
  onClick={() => setViewMode("table")}
383
+ disabled={forcedCards}
324
384
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
325
- viewMode === "table"
385
+ effectiveView === "table"
326
386
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
327
387
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
328
388
  }`}
@@ -333,7 +393,7 @@ export default function ITTable<T extends Record<string, unknown>>({
333
393
  <button
334
394
  onClick={() => setViewMode("cards")}
335
395
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
336
- viewMode === "cards"
396
+ effectiveView === "cards"
337
397
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
338
398
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
339
399
  }`}
@@ -350,8 +410,9 @@ export default function ITTable<T extends Record<string, unknown>>({
350
410
  <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
411
  <button
352
412
  onClick={() => setViewMode("table")}
413
+ disabled={forcedCards}
353
414
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
354
- viewMode === "table"
415
+ effectiveView === "table"
355
416
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
356
417
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
357
418
  }`}
@@ -362,7 +423,7 @@ export default function ITTable<T extends Record<string, unknown>>({
362
423
  <button
363
424
  onClick={() => setViewMode("cards")}
364
425
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
365
- viewMode === "cards"
426
+ effectiveView === "cards"
366
427
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
367
428
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
368
429
  }`}
@@ -374,11 +435,18 @@ export default function ITTable<T extends Record<string, unknown>>({
374
435
  </div>
375
436
  )}
376
437
 
377
- {viewMode === "cards" ? (
438
+ {effectiveView === "cards" ? (
378
439
  <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
379
440
  {currentData.length > 0 ? (
380
441
  currentData.map((row, i) => (
381
- <div key={i}>
442
+ <div
443
+ key={i}
444
+ className={clsx(onRowClick && tableCardClickable)}
445
+ role={onRowClick ? "button" : undefined}
446
+ tabIndex={onRowClick ? 0 : undefined}
447
+ onClick={onRowClick ? handleRowClick(row) : undefined}
448
+ onKeyDown={onRowClick ? handleRowKeyDown(row) : undefined}
449
+ >
382
450
  {renderCard ? renderCard(row) : renderDefaultCard(row)}
383
451
  </div>
384
452
  ))
@@ -390,25 +458,62 @@ export default function ITTable<T extends Record<string, unknown>>({
390
458
  )}
391
459
  </div>
392
460
  ) : (
393
- <div className="overflow-x-auto">
461
+ <div
462
+ ref={isTableVirtual ? scrollRef : undefined}
463
+ className={isTableVirtual ? "overflow-x-auto overflow-y-auto" : "overflow-x-auto"}
464
+ style={isTableVirtual ? { maxHeight: virtualizedMaxHeight } : undefined}
465
+ >
394
466
  <table
395
467
  className={clsx(
396
- "min-w-max w-full text-sm text-left text-secondary-600",
468
+ tableLayoutClass,
397
469
  showVerticalBorder && "[&_th]:border-r [&_th:last-child]:border-r-0 [&_td]:border-r [&_td:last-child]:border-r-0",
398
470
  showVerticalBorder && (verticalBorderClassname || "[&_th]:border-slate-100 dark:[&_th]:border-slate-700/30 [&_td]:border-slate-100 dark:[&_td]:border-slate-700/30"),
399
471
  variantStyles[variant],
400
472
  sizeStyles[size]
401
473
  )}
474
+ style={isFixed ? { tableLayout: "fixed" } : undefined}
475
+ aria-rowcount={isTableVirtual ? currentData.length + 1 : undefined}
402
476
  >
477
+ {hasColumnWidths && (
478
+ <colgroup>
479
+ {columns.map((col) => (
480
+ <col
481
+ key={col.key}
482
+ style={
483
+ col.width != null
484
+ ? { width: typeof col.width === "number" ? `${col.width}px` : col.width }
485
+ : undefined
486
+ }
487
+ />
488
+ ))}
489
+ </colgroup>
490
+ )}
403
491
  <thead>
404
- <tr className={clsx(tableHeaderRow, "dark:text-slate-200")}>
492
+ <tr
493
+ className={clsx(tableHeaderRow, "dark:text-slate-200")}
494
+ aria-rowindex={isTableVirtual ? 1 : undefined}
495
+ >
405
496
  {columns.map((col) => (
406
497
  <th
407
498
  key={col.key}
408
499
  scope="col"
409
- className={tableHeaderCell(col.className)}
500
+ className={clsx(
501
+ tableHeaderCell(col.className, density),
502
+ col.align && tableAlignClasses[col.align],
503
+ isStickyHeader && "sticky top-0 z-10 bg-secondary-50"
504
+ )}
505
+ style={
506
+ col.minWidth != null || isStickyHeader
507
+ ? {
508
+ ...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
509
+ ...(isStickyHeader
510
+ ? { backgroundColor: "var(--color-table-headerBg, #f8fafc)" }
511
+ : {}),
512
+ }
513
+ : undefined
514
+ }
410
515
  >
411
- <div className="flex flex-col gap-3 min-w-[150px]">
516
+ <div className={isFixed ? "flex flex-col gap-3" : "flex flex-col gap-3 min-w-[150px]"}>
412
517
  <div className="flex items-center justify-between gap-2">
413
518
  <ITText as="span" className="text-slate-900 dark:text-white font-bold">{col.label}</ITText>
414
519
  {col.sortable && col.type !== "actions" && (
@@ -434,29 +539,65 @@ export default function ITTable<T extends Record<string, unknown>>({
434
539
  </thead>
435
540
  <tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
436
541
  {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>
542
+ <>
543
+ {isTableVirtual && topSpacerHeight > 0 && (
544
+ <tr aria-hidden="true" style={{ height: topSpacerHeight }}>
545
+ <td colSpan={columnCount} style={{ padding: 0, border: 0 }} />
546
+ </tr>
547
+ )}
548
+ {windowData.map((row, windowIndex) => {
549
+ const absoluteIndex = isTableVirtual ? startIndex + windowIndex : windowIndex;
550
+ return (
551
+ <tr
552
+ key={absoluteIndex}
553
+ aria-rowindex={isTableVirtual ? absoluteIndex + 2 : undefined}
554
+ className={clsx(
555
+ tableRow,
556
+ variant === "striped" && absoluteIndex % 2 === 0 && "bg-secondary-50/40 dark:bg-slate-800/20",
557
+ onRowClick && tableRowClickable
455
558
  )}
456
- </td>
457
- ))}
458
- </tr>
459
- ))
559
+ tabIndex={onRowClick ? 0 : undefined}
560
+ onClick={onRowClick ? handleRowClick(row) : undefined}
561
+ onKeyDown={onRowClick ? handleRowKeyDown(row) : undefined}
562
+ >
563
+ {columns.map((col) => {
564
+ const rawValue = getNestedValue(row, col.key);
565
+ const truncateTitle =
566
+ col.truncate &&
567
+ (typeof rawValue === "string" || typeof rawValue === "number")
568
+ ? String(rawValue)
569
+ : undefined;
570
+ return (
571
+ <td
572
+ key={`${absoluteIndex}-${col.key}`}
573
+ className={clsx(
574
+ tableCell(col.className, density),
575
+ col.align && tableAlignClasses[col.align]
576
+ )}
577
+ style={col.minWidth != null ? { minWidth: `${col.minWidth}px` } : undefined}
578
+ title={truncateTitle}
579
+ >
580
+ {col.type === "actions" ? (
581
+ <div className={tableActionsCell}>
582
+ {renderCellContent(col, row) as React.ReactNode}
583
+ </div>
584
+ ) : (
585
+ <div className={clsx(tableCellText, col.truncate && "truncate")}>
586
+ {renderCellContent(col, row) as React.ReactNode}
587
+ </div>
588
+ )}
589
+ </td>
590
+ );
591
+ })}
592
+ </tr>
593
+ );
594
+ })}
595
+ {isTableVirtual && bottomSpacerHeight > 0 && (
596
+ <tr aria-hidden="true" style={{ height: bottomSpacerHeight }}>
597
+ <td colSpan={columnCount} style={{ padding: 0, border: 0 }} />
598
+ </tr>
599
+ )}
600
+ </>
460
601
  ) : (
461
602
  <tr>
462
603
  <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...',