@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
@@ -1,5 +1,6 @@
1
1
  import { ReactNode } from "react";
2
2
  import { Column } from "@/components/molecules/table/table.props";
3
+ import { TableDensity } from "@/types/table.types";
3
4
 
4
5
  /** Parameters passed to `fetchData` every time pagination, filters, or sorting change. */
5
6
  export interface ITDataTableFetchParams {
@@ -91,4 +92,67 @@ export interface ITDataTableProps<T extends Record<string, unknown>> {
91
92
  showVerticalBorder?: boolean;
92
93
  /** Custom class for vertical borders (overrides the default subtle gray). */
93
94
  verticalBorderClassname?: string;
95
+ /**
96
+ * Callback fired when a row (table view) or card (cards view) is activated,
97
+ * receiving the row item and the originating event. The event is a
98
+ * `MouseEvent` for pointer interaction and a `KeyboardEvent` for Enter/Space
99
+ * activation. Not fired when the interaction starts inside an interactive
100
+ * child (button, link, input, or any `[data-row-click-ignore]` element).
101
+ */
102
+ onRowClick?: (
103
+ row: T,
104
+ event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>
105
+ ) => void;
106
+ /**
107
+ * Table layout algorithm. `"fixed"` respects column `width` values and drops
108
+ * the `min-w-max` / `min-w-[150px]` floors so content no longer forces
109
+ * horizontal scrolling; `"auto"` keeps the content-driven layout.
110
+ * @default "auto"
111
+ */
112
+ layout?: "auto" | "fixed";
113
+ /**
114
+ * Cell padding and row-height preset. Independent of `size`, which only
115
+ * controls font-size: `"compact"` | `"normal"` | `"comfortable"`.
116
+ * @default "normal"
117
+ */
118
+ density?: TableDensity;
119
+ /**
120
+ * Container width (in px) below which the table automatically falls back to
121
+ * the cards view. `0` disables the fallback.
122
+ * @default 0
123
+ */
124
+ autoCardBreakpoint?: number;
125
+ /**
126
+ * Enables row virtualization for the table view: only the visible window of
127
+ * rows is rendered, with spacer rows preserving the full scroll height.
128
+ * The cards view is never virtualized.
129
+ * @default false
130
+ */
131
+ virtualized?: boolean;
132
+ /**
133
+ * Max height (px) of the virtualized table's scroll container.
134
+ * Only used when `virtualized` is true.
135
+ * @default 400
136
+ */
137
+ virtualizedMaxHeight?: number;
138
+ /**
139
+ * Assumed uniform row height (px) used by the virtualizer. Defaults to
140
+ * `getRowHeight(size, density)`, which is size-aware: the `size="md"`
141
+ * baseline is 33 / 45 / 53 (compact / normal / comfortable) and `size`
142
+ * shifts it by the font-size line box (sm 16px / md 20px / lg 28px).
143
+ * Only used when `virtualized` is true.
144
+ */
145
+ rowHeight?: number;
146
+ /**
147
+ * Number of extra rows rendered above and below the viewport to avoid blank
148
+ * gaps while scrolling. Only used when `virtualized` is true.
149
+ * @default 5
150
+ */
151
+ overscan?: number;
152
+ /**
153
+ * Makes the header cells sticky while the virtualized body scrolls. Only
154
+ * effective when `virtualized` is true.
155
+ * @default false
156
+ */
157
+ stickyHeader?: boolean;
94
158
  }
@@ -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, useEffect, useCallback, useRef } from "react";
5
7
  import { FaCheck, FaSpinner, FaTimes, FaTable, FaThLarge } from "react-icons/fa";
@@ -8,12 +10,13 @@ import ITInput from "@/components/atoms/input/input";
8
10
  import ITPagination from "@/components/molecules/pagination/pagination";
9
11
  import ITSelect from "@/components/molecules/select/select";
10
12
  import { Column } from "@/components/molecules/table/table.props";
11
- import { formatCurrencyMX } from "@/utils/table.utils";
13
+ import { formatCurrencyMX, isInteractiveTarget } from "@/utils/table.utils";
12
14
  import { ITDataTableProps } from "./dataTable.props";
13
15
  import ITText from "@/components/atoms/text/text";
14
16
  import {
15
17
  tableActionsCell,
16
18
  tableBody,
19
+ tableCardClickable,
17
20
  tableCell,
18
21
  tableCellText,
19
22
  tableContainer,
@@ -21,6 +24,7 @@ import {
21
24
  tableHeaderCell,
22
25
  tableHeaderRow,
23
26
  tableRow,
27
+ tableRowClickable,
24
28
  } from "@/utils/styles";
25
29
 
26
30
  const getNestedValue = (obj: unknown, path: string) => {
@@ -69,12 +73,39 @@ export default function ITDataTable<T extends Record<string, unknown>>({
69
73
  defaultView,
70
74
  showVerticalBorder = true,
71
75
  verticalBorderClassname,
76
+ onRowClick,
77
+ layout = "auto",
78
+ density = "normal",
79
+ autoCardBreakpoint = 0,
80
+ virtualized = false,
81
+ virtualizedMaxHeight = 400,
82
+ rowHeight,
83
+ overscan = 5,
84
+ stickyHeader = false,
72
85
  }: ITDataTableProps<T>) {
73
86
  const [viewMode, setViewMode] = useState<"table" | "cards">(defaultView || "table");
87
+ const { ref: rootRef, width: containerWidth } = useElementSize<HTMLDivElement>();
74
88
  const [data, setData] = useState<T[]>([]);
75
89
  const [totalItems, setTotalItems] = useState(0);
76
90
  const [isLoading, setIsLoading] = useState(fetchOnMount);
77
91
 
92
+ const isFixed = layout === "fixed";
93
+ const forcedCards =
94
+ autoCardBreakpoint > 0 && containerWidth > 0 && containerWidth < autoCardBreakpoint;
95
+ const effectiveView: "table" | "cards" = forcedCards ? "cards" : viewMode;
96
+ const hasColumnWidths = columns.some((col) => col.width != null);
97
+ const tableLayoutClass = isFixed
98
+ ? "w-full text-sm text-left text-secondary-600 transition-opacity duration-300"
99
+ : "min-w-max w-full text-sm text-left text-secondary-600 transition-opacity duration-300";
100
+
101
+ const isTableVirtual = virtualized && effectiveView === "table";
102
+ const isStickyHeader = stickyHeader && isTableVirtual;
103
+ const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
104
+ const columnCount = columns.length;
105
+ const scrollOuterClass = isTableVirtual
106
+ ? "relative min-h-[200px]"
107
+ : "overflow-x-auto relative min-h-[200px]";
108
+
78
109
  const {
79
110
  currentPage,
80
111
  itemsPerPage,
@@ -86,6 +117,22 @@ export default function ITDataTable<T extends Record<string, unknown>>({
86
117
  handleItemsPerPageChange,
87
118
  } = useTableState({ defaultItemsPerPage });
88
119
 
120
+ const {
121
+ scrollRef,
122
+ startIndex,
123
+ endIndex,
124
+ topSpacerHeight,
125
+ bottomSpacerHeight,
126
+ } = useVirtualRows({
127
+ count: data.length,
128
+ rowHeight: resolvedRowHeight,
129
+ overscan,
130
+ enabled: isTableVirtual,
131
+ resetKey: `${currentPage}-${itemsPerPage}`,
132
+ });
133
+
134
+ const windowData = isTableVirtual ? data.slice(startIndex, endIndex + 1) : data;
135
+
89
136
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
90
137
  const hasFetchedRef = useRef(false);
91
138
  const fetchDataRef = useRef(fetchData);
@@ -270,13 +317,181 @@ export default function ITDataTable<T extends Record<string, unknown>>({
270
317
 
271
318
  const segCtrlClass = (mode: "table" | "cards") =>
272
319
  `flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
273
- viewMode === mode
320
+ effectiveView === mode
274
321
  ? "bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm border border-slate-200/50 dark:border-slate-700"
275
322
  : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
276
323
  }`;
277
324
 
325
+ const handleRowClick = (row: T) => (event: React.MouseEvent<HTMLElement>) => {
326
+ if (!onRowClick || isLoading || isInteractiveTarget(event.target, event.currentTarget)) return;
327
+ onRowClick(row, event);
328
+ };
329
+
330
+ const handleRowKeyDown = (row: T) => (event: React.KeyboardEvent<HTMLElement>) => {
331
+ if (!onRowClick || isLoading) return;
332
+ if (event.target !== event.currentTarget) return;
333
+ if (event.key !== "Enter" && event.key !== " ") return;
334
+ event.preventDefault();
335
+ onRowClick(row, event);
336
+ };
337
+
338
+ const tableEl = (
339
+ <table
340
+ className={clsx(
341
+ tableLayoutClass,
342
+ showVerticalBorder && "[&_th]:border-r [&_th:last-child]:border-r-0 [&_td]:border-r [&_td:last-child]:border-r-0",
343
+ showVerticalBorder && (verticalBorderClassname || "[&_th]:border-slate-100 dark:[&_th]:border-slate-700/30 [&_td]:border-slate-100 dark:[&_td]:border-slate-700/30"),
344
+ isLoading ? "opacity-50" : "opacity-100",
345
+ variantStyles[variant],
346
+ sizeStyles[size],
347
+ className
348
+ )}
349
+ style={isFixed ? { tableLayout: "fixed" } : undefined}
350
+ aria-rowcount={isTableVirtual ? data.length + 1 : undefined}
351
+ >
352
+ {hasColumnWidths && (
353
+ <colgroup>
354
+ {columns.map((col) => (
355
+ <col
356
+ key={col.key}
357
+ style={
358
+ col.width != null
359
+ ? { width: typeof col.width === "number" ? `${col.width}px` : col.width }
360
+ : undefined
361
+ }
362
+ />
363
+ ))}
364
+ </colgroup>
365
+ )}
366
+ <thead>
367
+ <tr
368
+ className={clsx(tableHeaderRow, "dark:text-slate-200")}
369
+ aria-rowindex={isTableVirtual ? 1 : undefined}
370
+ >
371
+ {columns.map((col) => (
372
+ <th
373
+ key={col.key}
374
+ scope="col"
375
+ className={clsx(
376
+ tableHeaderCell(col.className, density),
377
+ col.align && tableAlignClasses[col.align],
378
+ isStickyHeader && "sticky top-0 z-10 bg-secondary-50"
379
+ )}
380
+ style={
381
+ col.minWidth != null || isStickyHeader
382
+ ? {
383
+ ...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
384
+ ...(isStickyHeader
385
+ ? { backgroundColor: "var(--color-table-headerBg, #f8fafc)" }
386
+ : {}),
387
+ }
388
+ : undefined
389
+ }
390
+ >
391
+ <div className={isFixed ? "flex flex-col gap-3" : "flex flex-col gap-3 min-w-[150px]"}>
392
+ <div className="flex items-center justify-between gap-2">
393
+ <ITText as="span" className="text-slate-900 dark:text-white font-bold">{col.label}</ITText>
394
+ {col.sortable && col.type !== "actions" && (
395
+ <button
396
+ onClick={() => handleSort(col.key)}
397
+ disabled={isLoading}
398
+ className={`p-1 rounded-md transition-colors ${
399
+ sortConfig?.key === col.key
400
+ ? "bg-secondary-200 text-secondary-900"
401
+ : "hover:bg-secondary-200 text-secondary-400 hover:text-secondary-700"
402
+ } disabled:opacity-50`}
403
+ title={`Ordenar por ${col.label}`}
404
+ >
405
+ <MdOutlineSwapVert className="w-4 h-4" aria-hidden="true" />
406
+ </button>
407
+ )}
408
+ </div>
409
+
410
+ <div className="w-full">{col.filter ? renderFilterInput(col) : null}</div>
411
+ </div>
412
+ </th>
413
+ ))}
414
+ </tr>
415
+ </thead>
416
+ <tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
417
+ {data.length > 0 ? (
418
+ <>
419
+ {isTableVirtual && topSpacerHeight > 0 && (
420
+ <tr aria-hidden="true" style={{ height: topSpacerHeight }}>
421
+ <td colSpan={columnCount} style={{ padding: 0, border: 0 }} />
422
+ </tr>
423
+ )}
424
+ {windowData.map((row, windowIndex) => {
425
+ const absoluteIndex = isTableVirtual ? startIndex + windowIndex : windowIndex;
426
+ return (
427
+ <tr
428
+ key={absoluteIndex}
429
+ aria-rowindex={isTableVirtual ? absoluteIndex + 2 : undefined}
430
+ className={clsx(
431
+ tableRow,
432
+ variant === "striped" && absoluteIndex % 2 === 0 && "bg-secondary-50/40 dark:bg-slate-800/20",
433
+ onRowClick && !isLoading && tableRowClickable
434
+ )}
435
+ tabIndex={onRowClick && !isLoading ? 0 : undefined}
436
+ onClick={onRowClick && !isLoading ? handleRowClick(row) : undefined}
437
+ onKeyDown={onRowClick && !isLoading ? handleRowKeyDown(row) : undefined}
438
+ >
439
+ {columns.map((col) => {
440
+ const rawValue = getNestedValue(row, col.key);
441
+ const truncateTitle =
442
+ col.truncate &&
443
+ (typeof rawValue === "string" || typeof rawValue === "number")
444
+ ? String(rawValue)
445
+ : undefined;
446
+ return (
447
+ <td
448
+ key={`${absoluteIndex}-${col.key}`}
449
+ className={clsx(
450
+ tableCell(col.className, density),
451
+ col.align && tableAlignClasses[col.align]
452
+ )}
453
+ style={col.minWidth != null ? { minWidth: `${col.minWidth}px` } : undefined}
454
+ title={truncateTitle}
455
+ >
456
+ {col.type === "actions" ? (
457
+ <div className={tableActionsCell}>
458
+ {renderCellContent(col, row) as React.ReactNode}
459
+ </div>
460
+ ) : (
461
+ <div className={clsx(tableCellText, col.truncate && "truncate")}>
462
+ {renderCellContent(col, row) as React.ReactNode}
463
+ </div>
464
+ )}
465
+ </td>
466
+ );
467
+ })}
468
+ </tr>
469
+ );
470
+ })}
471
+ {isTableVirtual && bottomSpacerHeight > 0 && (
472
+ <tr aria-hidden="true" style={{ height: bottomSpacerHeight }}>
473
+ <td colSpan={columnCount} style={{ padding: 0, border: 0 }} />
474
+ </tr>
475
+ )}
476
+ </>
477
+ ) : (
478
+ <tr>
479
+ <td colSpan={columns.length} className="px-6 py-12 text-center">
480
+ {!isLoading && (
481
+ <div className={tableEmptyContent}>
482
+ <ITText as="span" className="text-lg">No se encontraron resultados</ITText>
483
+ <ITText as="span" className="text-sm mt-1">Intenta ajustar los filtros</ITText>
484
+ </div>
485
+ )}
486
+ </td>
487
+ </tr>
488
+ )}
489
+ </tbody>
490
+ </table>
491
+ );
492
+
278
493
  return (
279
- <div className={clsx("space-y-4 w-full relative", containerClassName)}>
494
+ <div ref={rootRef} className={clsx("space-y-4 w-full relative", containerClassName)}>
280
495
  <div className={tableContainer} style={{ backgroundColor: 'var(--color-table-rowBg, #ffffff)' }}>
281
496
  {title && (
282
497
  <div className="px-6 py-5 flex items-center justify-between" style={{ backgroundColor: 'var(--color-table-rowBg, #ffffff)' }}>
@@ -288,7 +503,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
288
503
  </div>
289
504
  )}
290
505
  <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">
291
- <button onClick={() => setViewMode("table")} className={segCtrlClass("table")}>
506
+ <button onClick={() => setViewMode("table")} disabled={forcedCards} className={segCtrlClass("table")}>
292
507
  <FaTable size={11} />
293
508
  Table
294
509
  </button>
@@ -304,7 +519,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
304
519
  {!title && (
305
520
  <div className="flex justify-end px-4 pt-3">
306
521
  <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">
307
- <button onClick={() => setViewMode("table")} className={segCtrlClass("table")}>
522
+ <button onClick={() => setViewMode("table")} disabled={forcedCards} className={segCtrlClass("table")}>
308
523
  <FaTable size={11} />
309
524
  Table
310
525
  </button>
@@ -316,7 +531,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
316
531
  </div>
317
532
  )}
318
533
 
319
- <div className="overflow-x-auto relative min-h-[200px]">
534
+ <div className={scrollOuterClass}>
320
535
  {isLoading && (
321
536
  <div className="absolute inset-0 z-20 flex items-center justify-center bg-white/40 backdrop-blur-[2px] transition-all duration-300">
322
537
  <div className="flex flex-col items-center gap-3 p-6 rounded-2xl shadow-xl border border-secondary-100 animate-in fade-in zoom-in duration-300" style={{ backgroundColor: 'var(--color-table-rowBg, #ffffff)' }}>
@@ -330,11 +545,18 @@ export default function ITDataTable<T extends Record<string, unknown>>({
330
545
  </div>
331
546
  )}
332
547
 
333
- {viewMode === "cards" ? (
548
+ {effectiveView === "cards" ? (
334
549
  <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
335
550
  {data.length > 0 ? (
336
551
  data.map((row, i) => (
337
- <div key={i}>
552
+ <div
553
+ key={i}
554
+ className={clsx(onRowClick && !isLoading && tableCardClickable)}
555
+ role={onRowClick && !isLoading ? "button" : undefined}
556
+ tabIndex={onRowClick && !isLoading ? 0 : undefined}
557
+ onClick={onRowClick && !isLoading ? handleRowClick(row) : undefined}
558
+ onKeyDown={onRowClick && !isLoading ? handleRowKeyDown(row) : undefined}
559
+ >
338
560
  {renderCard ? renderCard(row) : renderDefaultCard(row)}
339
561
  </div>
340
562
  ))
@@ -347,80 +569,16 @@ export default function ITDataTable<T extends Record<string, unknown>>({
347
569
  )
348
570
  )}
349
571
  </div>
350
- ) : (
351
- <table
352
- className={clsx(
353
- "min-w-max w-full text-sm text-left text-secondary-600 transition-opacity duration-300",
354
- showVerticalBorder && "[&_th]:border-r [&_th:last-child]:border-r-0 [&_td]:border-r [&_td:last-child]:border-r-0",
355
- showVerticalBorder && (verticalBorderClassname || "[&_th]:border-slate-100 dark:[&_th]:border-slate-700/30 [&_td]:border-slate-100 dark:[&_td]:border-slate-700/30"),
356
- isLoading ? "opacity-50" : "opacity-100",
357
- variantStyles[variant],
358
- sizeStyles[size],
359
- className
360
- )}
572
+ ) : isTableVirtual ? (
573
+ <div
574
+ ref={scrollRef}
575
+ className="overflow-x-auto overflow-y-auto"
576
+ style={{ maxHeight: virtualizedMaxHeight }}
361
577
  >
362
- <thead>
363
- <tr className={clsx(tableHeaderRow, "dark:text-slate-200")}>
364
- {columns.map((col) => (
365
- <th key={col.key} scope="col" className={tableHeaderCell(col.className)}>
366
- <div className="flex flex-col gap-3 min-w-[150px]">
367
- <div className="flex items-center justify-between gap-2">
368
- <ITText as="span" className="text-slate-900 dark:text-white font-bold">{col.label}</ITText>
369
- {col.sortable && col.type !== "actions" && (
370
- <button
371
- onClick={() => handleSort(col.key)}
372
- disabled={isLoading}
373
- className={`p-1 rounded-md transition-colors ${
374
- sortConfig?.key === col.key
375
- ? "bg-secondary-200 text-secondary-900"
376
- : "hover:bg-secondary-200 text-secondary-400 hover:text-secondary-700"
377
- } disabled:opacity-50`}
378
- title={`Ordenar por ${col.label}`}
379
- >
380
- <MdOutlineSwapVert className="w-4 h-4" aria-hidden="true" />
381
- </button>
382
- )}
383
- </div>
384
-
385
- <div className="w-full">{col.filter ? renderFilterInput(col) : null}</div>
386
- </div>
387
- </th>
388
- ))}
389
- </tr>
390
- </thead>
391
- <tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
392
- {data.length > 0 ? (
393
- data.map((row, rowIndex) => (
394
- <tr key={rowIndex} className={clsx(tableRow, variant === "striped" && "odd:bg-secondary-50/40 dark:odd:bg-slate-800/20")}>
395
- {columns.map((col) => (
396
- <td key={`${rowIndex}-${col.key}`} className={tableCell(col.className)}>
397
- {col.type === "actions" ? (
398
- <div className={tableActionsCell}>
399
- {renderCellContent(col, row) as React.ReactNode}
400
- </div>
401
- ) : (
402
- <div className={tableCellText}>
403
- {renderCellContent(col, row) as React.ReactNode}
404
- </div>
405
- )}
406
- </td>
407
- ))}
408
- </tr>
409
- ))
410
- ) : (
411
- <tr>
412
- <td colSpan={columns.length} className="px-6 py-12 text-center">
413
- {!isLoading && (
414
- <div className={tableEmptyContent}>
415
- <ITText as="span" className="text-lg">No se encontraron resultados</ITText>
416
- <ITText as="span" className="text-sm mt-1">Intenta ajustar los filtros</ITText>
417
- </div>
418
- )}
419
- </td>
420
- </tr>
421
- )}
422
- </tbody>
423
- </table>
578
+ {tableEl}
579
+ </div>
580
+ ) : (
581
+ tableEl
424
582
  )}
425
583
  </div>
426
584
  </div>
@@ -19,6 +19,7 @@ Vertical navigation sidebar with collapsible submenus, tooltips when collapsed,
19
19
  | `onSubItemClick` | `(subitem: ITNavigationSubItem) => void` | — | Callback when a sub-navigation item is clicked. |
20
20
  | `subitemConnector` | `"dot" \| "\|" \| "none"` | `"dot"` | Visual connector style for sub-items. |
21
21
  | `className` | `string` | — | Additional CSS classes on the sidebar `<aside>`. |
22
+ | `header` | `ReactNode` | — | Optional content rendered above the navigation list (e.g. a wordmark). Hidden while the sidebar is collapsed, and independent of the collapse toggle button, which is always visible in both states. |
22
23
 
23
24
  ### ITNavigationItem
24
25
 
@@ -71,6 +72,9 @@ const items = [
71
72
 
72
73
  ## Notes
73
74
 
75
+ - The button lives above the scrollable `<nav>` (never inside it), so it stays in the tab order and visible in both states; it is centered in the 88px rail when collapsed and right-aligned when expanded.
76
+ - The toggle is a real `<button type="button">` with `aria-expanded`, `aria-controls` pointing at the `<nav>` it controls, and a state-dependent `aria-label`/`title` ("Expandir/Colapsar barra lateral"). Its chevron rotates 180° when the sidebar is expanded, so one icon signals both directions.
77
+ - There is no collapse toggle button: the collapsed rail expands and collapses automatically on pointer hover.
74
78
  - Collapsed mode shrinks to icon-only; tooltips show labels on hover with glassmorphism style.
75
79
  - Submenus expand/collapse with smooth transitions when a parent item is clicked.
76
80
  - Active item highlighting uses a colored left-border accent and subtle background tint.
@@ -78,6 +82,7 @@ const items = [
78
82
  - `subitemConnector` styles the visual link between parent and sub-items: `dot` (default), `|` (vertical bar), or `none`.
79
83
  - `visibleOnMobile` forces sidebar visibility on smaller screens for responsive layouts.
80
84
  - Themed via CSS variables for consistent colors, shadows, and glassmorphism effects across light and dark modes.
85
+ - Accessible by default: the `<nav>` is labelled and carries the `id` referenced by the toggle's `aria-controls`, the toggle exposes `aria-expanded` plus a visible `focus-visible` ring, group toggles expose `aria-expanded`/`aria-controls` and respond to Enter/Space, submenus are labelled `role="group"`, and the active sub-item carries `aria-current="page"`.
81
86
 
82
87
  ## Stories
83
88
 
@@ -46,5 +46,10 @@ export interface ITSidebarProps {
46
46
  subitemConnector?: 'dot' | '|' | 'none';
47
47
  /** Additional CSS classes on the sidebar `<aside>`. */
48
48
  className?: string;
49
+ /**
50
+ * Optional content rendered above the navigation list (e.g. a wordmark).
51
+ * Hidden while the sidebar is collapsed, so it only shows on hover.
52
+ */
53
+ header?: React.ReactNode;
49
54
  }
50
55
 
@@ -71,7 +71,7 @@ export const Default: Story = {
71
71
  render: (args) => (
72
72
  <div className="h-screen bg-gray-50 flex">
73
73
  <ITSidebar {...args} />
74
- <div className="flex-1 p-8 text-zinc-500 font-medium">Contenido principal simulado. Juega con el botón de colapsar para ver las transiciones suaves.</div>
74
+ <div className="flex-1 p-8 text-zinc-500 font-medium">Contenido principal simulado. Pasa el cursor por los íconos para ver el efecto de glassmorphism en los tooltips flotantes.</div>
75
75
  </div>
76
76
  ),
77
77
  };
@@ -5,7 +5,8 @@ import ITText from "@/components/atoms/text/text";
5
5
 
6
6
  /**
7
7
  * Vertical navigation sidebar with submenu expand/collapse, hover tooltips in collapsed mode,
8
- * and glassmorphism styling.
8
+ * and glassmorphism styling. The collapsed rail expands automatically on pointer hover — there
9
+ * is no toggle button; the hover is the only way to expand and collapse it.
9
10
  *
10
11
  * @example
11
12
  * ```tsx
@@ -20,8 +21,7 @@ import ITText from "@/components/atoms/text/text";
20
21
  * ],
21
22
  * },
22
23
  * ]}
23
- * isCollapsed={collapsed}
24
- * onToggleCollapse={() => setCollapsed(!collapsed)}
24
+ * isCollapsed
25
25
  * />
26
26
  * ```
27
27
  */
@@ -33,6 +33,7 @@ export default function ITSidebar({
33
33
  onItemClick,
34
34
  onSubItemClick,
35
35
  subitemConnector = 'dot',
36
+ header,
36
37
  }: ITSidebarProps) {
37
38
  const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
38
39
  const [isHovering, setIsHovering] = useState(false);
@@ -133,12 +134,24 @@ export default function ITSidebar({
133
134
  backdropFilter: 'blur(12px)',
134
135
  }}
135
136
  >
137
+ {header && !isSidebarCollapsed && (
138
+ <div className="px-4 pt-4 pb-1 flex-shrink-0">{header}</div>
139
+ )}
140
+
136
141
  {/* Navigation Items */}
137
- <nav className="flex-1 py-4 overflow-y-auto overflow-x-hidden custom-scrollbar px-4">
142
+ <nav aria-label="Componentes de la librería" className="flex-1 py-4 overflow-y-auto overflow-x-hidden custom-scrollbar px-4">
138
143
  <ul className="space-y-1">
139
- {navigationItems.map((item) => (
144
+ {navigationItems.map((item) => {
145
+ const hasSubmenu = !!item.subitems && item.subitems.length > 0;
146
+ const submenuId = `it-sidebar-submenu-${item.id}`;
147
+ return (
140
148
  <li key={item.id} className="relative group/navitem">
141
149
  <div
150
+ role="button"
151
+ tabIndex={0}
152
+ aria-label={item.label}
153
+ aria-expanded={hasSubmenu ? expandedItems.has(item.id) : undefined}
154
+ aria-controls={hasSubmenu ? submenuId : undefined}
142
155
  className={`flex items-center cursor-pointer
143
156
  transition-all duration-300 ease-[cubic-bezier(0.2,0,0,1)]
144
157
  rounded-xl relative overflow-visible
@@ -156,6 +169,12 @@ export default function ITSidebar({
156
169
  if (!item.isActive) e.currentTarget.style.backgroundColor = 'transparent';
157
170
  }}
158
171
  onClick={() => handleItemClick(item)}
172
+ onKeyDown={(e) => {
173
+ if (e.key === "Enter" || e.key === " ") {
174
+ e.preventDefault();
175
+ handleItemClick(item);
176
+ }
177
+ }}
159
178
  >
160
179
  {item.isActive && !isSidebarCollapsed && (
161
180
  <div
@@ -264,7 +283,7 @@ export default function ITSidebar({
264
283
 
265
284
  {/* Submenu - smooth height/opacity when not collapsed */}
266
285
  {!isSidebarCollapsed && item.subitems && item.subitems.length > 0 && (
267
- <div className={`overflow-hidden transition-all duration-400 ease-[cubic-bezier(0.2,0,0,1)] ${expandedItems.has(item.id) ? "max-h-[500px] opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
286
+ <div id={submenuId} role="group" aria-label={item.label} className={`overflow-hidden transition-all duration-400 ease-[cubic-bezier(0.2,0,0,1)] ${expandedItems.has(item.id) ? "max-h-[500px] opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
268
287
  <ul
269
288
  className="ml-4 flex flex-col gap-0 py-0.5"
270
289
  style={{
@@ -276,6 +295,7 @@ export default function ITSidebar({
276
295
  {item.subitems.map((subitem) => (
277
296
  <li key={subitem.id} className="relative">
278
297
  <button
298
+ aria-current={subitem.isActive ? "page" : undefined}
279
299
  onClick={() => {
280
300
  if (subitem.action) subitem.action();
281
301
  if (onSubItemClick) onSubItemClick(subitem);
@@ -329,7 +349,7 @@ export default function ITSidebar({
329
349
  </div>
330
350
  )}
331
351
  </li>
332
- ))}
352
+ );})}
333
353
  </ul>
334
354
  </nav>
335
355
  </aside>
@@ -13,6 +13,8 @@ Sticky top navigation bar with glassmorphism effect, logo, breadcrumb-style nav
13
13
  |------|------|---------|-------------|
14
14
  | `logo` | `ReactNode` | — | Optional logo element (e.g. an `<img>` or SVG component). |
15
15
  | `logoText` | `string` | — | Text displayed next to the logo as the brand/title. |
16
+ | `centerContent` | `ReactNode` | — | Content rendered in the centered region between the left (logo / nav) and right (user menu) areas — e.g. a global search box. Rendered only when provided, and only from the `lg` breakpoint up. |
17
+ | `children` | `ReactNode` | — | Actions rendered in the right area, immediately before the user menu — e.g. a notification bell, a help link, a theme switcher. The component owns the placement; the caller only supplies the controls. The area renders only when this prop or `userMenu` is provided. |
16
18
  | `userMenu` | `object` | — | User dropdown configuration with name, email, avatar, and menu items. |
17
19
  | `userMenu.userName` | `string` | — | Display name shown in the trigger button and dropdown header. |
18
20
  | `userMenu.userEmail` | `string` | — | User email shown in the trigger button and dropdown header. |
@@ -53,6 +55,7 @@ import { FaChartBar, FaUserCog } from 'react-icons/fa';
53
55
 
54
56
  ## Notes
55
57
 
58
+ - The `centerContent` slot renders in a centered `flex-1` (basis 0) region that absorbs all the slack, so the left and right areas stay flush with the row edges. It is `hidden` below `lg`, which leaves the below-`lg` layout exactly as before (logo + user menu).
56
59
  - The bar uses a **glassmorphism** backdrop blur effect (`backdrop-blur-xl`), giving it a translucent appearance over scrolling content.
57
60
  - Navigation items support **icons** and highlight the active item with the primary color and an underline indicator.
58
61
  - The **user menu** dropdown opens on avatar click and closes on outside click (via `useClickOutside` hook).