@axzydev/axzy_ui_system 1.3.4 → 1.3.6

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 (35) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +754 -613
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1 -1
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.cts +148 -5
  7. package/dist/index.d.ts +148 -5
  8. package/dist/index.js +628 -488
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -3
  11. package/src/App.tsx +6 -0
  12. package/src/components/atoms/input/input.props.ts +5 -0
  13. package/src/components/atoms/input/input.tsx +24 -17
  14. package/src/components/atoms/input-number/input-number.doc.mdx +74 -0
  15. package/src/components/atoms/input-number/input-number.props.ts +66 -0
  16. package/src/components/atoms/input-number/input-number.stories.tsx +111 -0
  17. package/src/components/atoms/input-number/input-number.tsx +86 -0
  18. package/src/components/molecules/table/table.doc.mdx +12 -1
  19. package/src/components/molecules/table/table.props.ts +17 -0
  20. package/src/components/molecules/table/table.stories.tsx +45 -0
  21. package/src/components/molecules/table/table.tsx +37 -8
  22. package/src/components/organisms/data-table/ITDataTable.doc.mdx +14 -1
  23. package/src/components/organisms/data-table/ITDataTable.stories.tsx +25 -0
  24. package/src/components/organisms/data-table/dataTable.tsx +37 -8
  25. package/src/components/organisms/sidebar/sidebar.doc.mdx +50 -12
  26. package/src/components/organisms/sidebar/sidebar.props.ts +28 -2
  27. package/src/components/organisms/sidebar/sidebar.stories.tsx +61 -1
  28. package/src/components/organisms/sidebar/sidebar.tsx +111 -57
  29. package/src/index.css +10 -0
  30. package/src/index.ts +6 -3
  31. package/src/sandbox/navigation.ts +2 -0
  32. package/src/showcases/DataShowcases.tsx +61 -0
  33. package/src/showcases/FormShowcases.tsx +287 -1
  34. package/src/showcases/StructureShowcases.tsx +34 -6
  35. package/src/utils/table.utils.ts +83 -0
@@ -1,8 +1,30 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { FaChevronDown } from "react-icons/fa";
3
- import { ITNavigationItem, ITSidebarProps } from "./sidebar.props";
3
+ import {
4
+ ITNavigationItem,
5
+ ITNavigationSubItem,
6
+ ITNavigationSubItemEntry,
7
+ ITNavigationSubItemGroup,
8
+ ITSidebarProps,
9
+ } from "./sidebar.props";
4
10
  import ITText from "@/components/atoms/text/text";
5
11
 
12
+ /** Narrows a submenu entry to a titled group. */
13
+ function isSubItemGroup(
14
+ entry: ITNavigationSubItemEntry,
15
+ ): entry is ITNavigationSubItemGroup {
16
+ return "items" in entry;
17
+ }
18
+
19
+ /** Flattens a parent's entries (plain items + groups) into a single list. */
20
+ function flattenSubItems(
21
+ entries: ITNavigationSubItemEntry[],
22
+ ): ITNavigationSubItem[] {
23
+ return entries.flatMap((entry) =>
24
+ isSubItemGroup(entry) ? entry.items : [entry],
25
+ );
26
+ }
27
+
6
28
  /**
7
29
  * Vertical navigation sidebar with submenu expand/collapse, hover tooltips in collapsed mode,
8
30
  * and glassmorphism styling. The collapsed rail expands automatically on pointer hover — there
@@ -76,7 +98,7 @@ export default function ITSidebar({
76
98
  useEffect(() => {
77
99
  const activeParents = new Set<string>();
78
100
  navigationItems.forEach(item => {
79
- if (item.subitems && item.subitems.some(sub => sub.isActive)) {
101
+ if (item.subitems && flattenSubItems(item.subitems).some(sub => sub.isActive)) {
80
102
  activeParents.add(item.id);
81
103
  }
82
104
  });
@@ -115,6 +137,86 @@ export default function ITSidebar({
115
137
  const isSidebarCollapsed = visibleOnMobile ? false : (!isHovering && isCollapsed);
116
138
  const sidebarWidth = isSidebarCollapsed ? "w-[88px]" : "w-[280px]";
117
139
 
140
+ const renderSubItem = (subitem: ITNavigationSubItem) => (
141
+ <li key={subitem.id} className="relative">
142
+ <button
143
+ aria-current={subitem.isActive ? "page" : undefined}
144
+ onClick={() => {
145
+ if (subitem.action) subitem.action();
146
+ if (onSubItemClick) onSubItemClick(subitem);
147
+ }}
148
+ className={`flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-xl transition-all duration-300`}
149
+ style={{
150
+ color: subitem.isActive ? "var(--it-sidebar-active-color, var(--color-secondary-900))" : "var(--it-sidebar-label-color, var(--color-secondary-600))",
151
+ backgroundColor: subitem.isActive ? "var(--it-sidebar-active-bg, var(--color-secondary-50))" : 'transparent',
152
+ fontSize: '0.78rem',
153
+ fontWeight: subitem.isActive ? 600 : 500,
154
+ letterSpacing: '0.01em',
155
+ marginLeft: subitemConnector === '|' ? '-1px' : '0',
156
+ }}
157
+ onMouseEnter={(e) => {
158
+ if (!subitem.isActive) {
159
+ e.currentTarget.style.backgroundColor = "var(--it-sidebar-hover-bg, var(--color-secondary-100))";
160
+ e.currentTarget.style.transform = 'translateX(3px)';
161
+ }
162
+ }}
163
+ onMouseLeave={(e) => {
164
+ if (!subitem.isActive) {
165
+ e.currentTarget.style.backgroundColor = 'transparent';
166
+ e.currentTarget.style.transform = 'translateX(0)';
167
+ }
168
+ }}
169
+ >
170
+ {subitem.isActive && subitemConnector === '|' && (
171
+ <div
172
+ className="absolute left-0 top-1/3 bottom-1/3 w-[2.5px] rounded-r-full transition-all"
173
+ style={{
174
+ backgroundColor: "var(--it-sidebar-active-icon, var(--color-primary-500))",
175
+ boxShadow: "0 0 6px color-mix(in srgb, var(--it-sidebar-active-icon, var(--color-primary-500)) 25%, transparent)",
176
+ }}
177
+ />
178
+ )}
179
+ {subitemConnector === 'dot' && (
180
+ <span
181
+ className={`w-1.5 h-1.5 rounded-full flex-shrink-0 transition-all duration-300 ${subitem.isActive ? 'scale-125' : ''}`}
182
+ style={{
183
+ backgroundColor: subitem.isActive
184
+ ? "var(--it-sidebar-active-icon, var(--color-primary-500))"
185
+ : "var(--it-sidebar-icon-color, var(--color-secondary-400))"
186
+ }}
187
+ />
188
+ )}
189
+ <ITText as="span" className="truncate">{subitem.label}</ITText>
190
+ </button>
191
+ </li>
192
+ );
193
+
194
+ const renderSubItemGroup = (group: ITNavigationSubItemGroup, itemId: string) => {
195
+ const headingId = `it-sidebar-subgroup-${itemId}-${group.id}`;
196
+ return (
197
+ <li key={group.id} className="relative">
198
+ <div id={headingId} className="px-3 pt-2.5 pb-1 mt-1">
199
+ <ITText
200
+ as="span"
201
+ className="block"
202
+ style={{
203
+ color: "var(--it-sidebar-icon-color, var(--color-secondary-400))",
204
+ fontSize: "0.68rem",
205
+ fontWeight: 700,
206
+ letterSpacing: "0.08em",
207
+ textTransform: "uppercase",
208
+ }}
209
+ >
210
+ {group.label}
211
+ </ITText>
212
+ </div>
213
+ <ul aria-labelledby={headingId} className="flex flex-col gap-0">
214
+ {group.items.map(renderSubItem)}
215
+ </ul>
216
+ </li>
217
+ );
218
+ };
219
+
118
220
  return (
119
221
  <aside
120
222
  ref={sidebarRef}
@@ -256,7 +358,7 @@ export default function ITSidebar({
256
358
 
257
359
  {item.subitems && item.subitems.length > 0 ? (
258
360
  <div className="py-2">
259
- {item.subitems.map((subitem) => (
361
+ {flattenSubItems(item.subitems).map((subitem) => (
260
362
  <div
261
363
  key={subitem.id}
262
364
  className={`px-5 py-2.5 text-sm flex items-center gap-3 transition-colors relative`}
@@ -283,7 +385,7 @@ export default function ITSidebar({
283
385
 
284
386
  {/* Submenu - smooth height/opacity when not collapsed */}
285
387
  {!isSidebarCollapsed && item.subitems && item.subitems.length > 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"}`}>
388
+ <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-[1000px] opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
287
389
  <ul
288
390
  className="ml-4 flex flex-col gap-0 py-0.5"
289
391
  style={{
@@ -292,59 +394,11 @@ export default function ITSidebar({
292
394
  : 'none'
293
395
  }}
294
396
  >
295
- {item.subitems.map((subitem) => (
296
- <li key={subitem.id} className="relative">
297
- <button
298
- aria-current={subitem.isActive ? "page" : undefined}
299
- onClick={() => {
300
- if (subitem.action) subitem.action();
301
- if (onSubItemClick) onSubItemClick(subitem);
302
- }}
303
- className={`flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-xl transition-all duration-300`}
304
- style={{
305
- color: subitem.isActive ? "var(--it-sidebar-active-color, var(--color-secondary-900))" : "var(--it-sidebar-label-color, var(--color-secondary-600))",
306
- backgroundColor: subitem.isActive ? "var(--it-sidebar-active-bg, var(--color-secondary-50))" : 'transparent',
307
- fontSize: '0.78rem',
308
- fontWeight: subitem.isActive ? 600 : 500,
309
- letterSpacing: '0.01em',
310
- marginLeft: subitemConnector === '|' ? '-1px' : '0',
311
- }}
312
- onMouseEnter={(e) => {
313
- if (!subitem.isActive) {
314
- e.currentTarget.style.backgroundColor = "var(--it-sidebar-hover-bg, var(--color-secondary-100))";
315
- e.currentTarget.style.transform = 'translateX(3px)';
316
- }
317
- }}
318
- onMouseLeave={(e) => {
319
- if (!subitem.isActive) {
320
- e.currentTarget.style.backgroundColor = 'transparent';
321
- e.currentTarget.style.transform = 'translateX(0)';
322
- }
323
- }}
324
- >
325
- {subitem.isActive && subitemConnector === '|' && (
326
- <div
327
- className="absolute left-0 top-1/3 bottom-1/3 w-[2.5px] rounded-r-full transition-all"
328
- style={{
329
- backgroundColor: "var(--it-sidebar-active-icon, var(--color-primary-500))",
330
- boxShadow: "0 0 6px color-mix(in srgb, var(--it-sidebar-active-icon, var(--color-primary-500)) 25%, transparent)",
331
- }}
332
- />
333
- )}
334
- {subitemConnector === 'dot' && (
335
- <span
336
- className={`w-1.5 h-1.5 rounded-full flex-shrink-0 transition-all duration-300 ${subitem.isActive ? 'scale-125' : ''}`}
337
- style={{
338
- backgroundColor: subitem.isActive
339
- ? "var(--it-sidebar-active-icon, var(--color-primary-500))"
340
- : "var(--it-sidebar-icon-color, var(--color-secondary-400))"
341
- }}
342
- />
343
- )}
344
- <ITText as="span" className="truncate">{subitem.label}</ITText>
345
- </button>
346
- </li>
347
- ))}
397
+ {item.subitems.map((entry) =>
398
+ isSubItemGroup(entry)
399
+ ? renderSubItemGroup(entry, item.id)
400
+ : renderSubItem(entry),
401
+ )}
348
402
  </ul>
349
403
  </div>
350
404
  )}
package/src/index.css CHANGED
@@ -160,4 +160,14 @@
160
160
  .it-stepper-slide-in-left {
161
161
  animation: it-slide-in-left 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
162
162
  }
163
+
164
+ /* Pinned (frozen) table columns: 1px edge separator on the frozen side.
165
+ Background + hover live in the component as Tailwind arbitrary utilities so
166
+ the cell matches --color-table-rowBg / --color-table-rowHover. */
167
+ :where(.it-table-pinned-left) {
168
+ box-shadow: inset -1px 0 0 0 var(--color-secondary-200);
169
+ }
170
+ :where(.it-table-pinned-right) {
171
+ box-shadow: inset 1px 0 0 0 var(--color-secondary-200);
172
+ }
163
173
  }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import ITFormHeader from "./components/molecules/form-header/form-header";
36
36
  import ITGrid from "./components/atoms/grid/grid";
37
37
  import ITImage from "./components/atoms/image/image";
38
38
  import ITInput from "./components/atoms/input/input";
39
+ import ITInputNumber from "./components/atoms/input-number/input-number";
39
40
  import ITNavbar from "./components/organisms/navbar/navbar";
40
41
  import ITPage from "./components/templates/page/page";
41
42
  import ITPageHeader from "./components/organisms/page-header/page-header";
@@ -90,6 +91,7 @@ import type { ITFormHeaderProps } from "./components/molecules/form-header/form-
90
91
  import type { ITGridProps } from "./components/atoms/grid/grid.props";
91
92
  import type { ITImageProps } from "./components/atoms/image/image.props";
92
93
  import type { ITInputProps } from "./components/atoms/input/input.props";
94
+ import type { ITInputNumberProps } from "./components/atoms/input-number/input-number.props";
93
95
  import type { ITNavbarProps } from "./components/organisms/navbar/navbar.props";
94
96
  import type { ITPageProps } from "./components/templates/page/page.props";
95
97
  import type { ITPageHeaderProps } from "./components/organisms/page-header/page-header.props";
@@ -101,7 +103,7 @@ import type { ITSearchSelectProps } from "./components/molecules/search-select/s
101
103
  import type { ITSelectProps } from "./components/molecules/select/select.props";
102
104
  import type { ITSearchTableProps } from "./components/organisms/searchTable/searchTable.props";
103
105
  import type { ITSegmentedControlProps } from "./components/atoms/segmented-control/segmented-control.props";
104
- import type { ITSidebarProps, ITNavigationItem, ITNavigationSubItem } from "./components/organisms/sidebar/sidebar.props";
106
+ import type { ITSidebarProps, ITNavigationItem, ITNavigationSubItem, ITNavigationSubItemEntry, ITNavigationSubItemGroup } from "./components/organisms/sidebar/sidebar.props";
105
107
  import type { ITSkeletonProps } from "./components/atoms/skeleton/skeleton.props";
106
108
  import type { ITSlideToggleProps } from "./components/atoms/slide/slide.props";
107
109
  import type { ITSliderProps } from "./components/atoms/slider/slider.props";
@@ -145,7 +147,7 @@ export {
145
147
  ITCard, ITCheckbox, ITChip, ITChipInput, ITConfirmDialog, ITDataTable, ITDatePicker,
146
148
  ITDropdownMenu, ITField, ITMaskedInput, ITMultiSelect,
147
149
  ITDialog, ITDivider, ITDrawer,
148
- ITDropfile, ITEmptyState, ITFlex, ITFormBuilder, ITFormHeader, ITGrid, ITImage, ITInput, ITLayout,
150
+ ITDropfile, ITEmptyState, ITFlex, ITFormBuilder, ITFormHeader, ITGrid, ITImage, ITInput, ITInputNumber, ITLayout,
149
151
  ITLoader, ITNavbar, ITPage, ITPageHeader, ITPagination, ITPopover, ITProgress,
150
152
  ITRadioGroup, ITSelect, ITSearchSelect,
151
153
  ITSearchTable,
@@ -169,10 +171,11 @@ export {
169
171
  ITDialogProps, ITDividerProps, ITDrawerProps,
170
172
  ITEmptyStateProps, ITFlexProps,
171
173
  ITFormBuilderProps, ITFormHeaderProps,
172
- ITGridProps, ITImageProps, ITInputProps,
174
+ ITGridProps, ITImageProps, ITInputProps, ITInputNumberProps,
173
175
  ITLayoutProps, ITLoaderProps, ITNavbarProps,
174
176
  ITPageProps, ITPageHeaderProps,
175
177
  ITNavigationItem, ITNavigationSubItem,
178
+ ITNavigationSubItemEntry, ITNavigationSubItemGroup,
176
179
  ITPaginationProps, ITPopoverProps, ITProgressProps,
177
180
  ITRadioGroupProps, ITRadioOption, ITSelectProps, ITSearchSelectProps,
178
181
  ITSearchTableProps,
@@ -56,6 +56,7 @@ export const SANDBOX_GROUPS: SandboxGroup[] = [
56
56
  subitems: [
57
57
  { id: "button", label: "ITButton" },
58
58
  { id: "input", label: "ITInput" },
59
+ { id: "inputnumber", label: "ITInputNumber" },
59
60
  { id: "select", label: "ITSelect" },
60
61
  { id: "searchselect", label: "ITSearchSelect" },
61
62
  { id: "multiselect", label: "ITMultiSelect" },
@@ -66,6 +67,7 @@ export const SANDBOX_GROUPS: SandboxGroup[] = [
66
67
  { id: "maskedinput", label: "ITMaskedInput" },
67
68
  { id: "calendar", label: "ITCalendar" },
68
69
  { id: "slidetoggle", label: "ITSlideToggle" },
70
+ { id: "segmentedcontrol", label: "ITSegmentedControl" },
69
71
  { id: "dropfile", label: "ITDropfile" },
70
72
  { id: "wysiwyg", label: "ITWysiwyg" },
71
73
  { id: "formbuilder", label: "ITFormBuilder" },
@@ -248,6 +248,15 @@ export const TableShowcase = () => {
248
248
  truncate: col.key === "email" || col.key === "name",
249
249
  }));
250
250
 
251
+ // Pixel widths force horizontal overflow inside the 520px demo wrapper, so
252
+ // the first (Name) and last (Actions) columns can stay pinned while the
253
+ // middle ones scroll sideways.
254
+ const pinnedColumns: any = fixedColumns.map((col: any, i: number) => ({
255
+ ...col,
256
+ width: [200, 240, 180, 160, 180, 96][i],
257
+ pinned: i === 0 ? "left" : i === fixedColumns.length - 1 ? "right" : undefined,
258
+ }));
259
+
251
260
  // Single-line columns for virtualization: no avatar, no actions, no custom
252
261
  // render, so every row is a deterministic one-line box.
253
262
  const virtualColumns: any = [
@@ -344,6 +353,7 @@ export const TableShowcase = () => {
344
353
  '<ITTable columns={columns} data={data} title="Users" />',
345
354
  '<ITTable columns={columns} data={data} defaultView="cards" onRowClick={(row) => open(row)} />',
346
355
  '<ITTable columns={fixedColumns} data={data} layout="fixed" density="compact" />',
356
+ '// Columna fija: pinned "left" | "right" (los offsets acumulan width en px)\n{ key: "name", label: "Name", type: "string", width: 200, pinned: "left" },\n{ key: "actions", label: "", type: "actions", width: 96, pinned: "right" }',
347
357
  '<ITTable columns={cols} data={rows} virtualized stickyHeader layout="fixed" density="compact" defaultItemsPerPage={300} itemsPerPageOptions={[50, 100, 300]} />',
348
358
  '// ITSearchSelect typeahead (server-side search opcional vía catalogOptions.onSearch)\n{ key: "role", label: "Role", type: "string", filter: "search", catalogOptions: { data: roles } }',
349
359
  '// ITDatePicker fecha única\n{ key: "lastLogin", label: "Last login", type: "date", filter: "date", dateFilterOptions: { maxDate: new Date() } }',
@@ -377,6 +387,7 @@ export const TableShowcase = () => {
377
387
  "onRowClick funciona en ambas vistas (tabla y cards).",
378
388
  "Cuando se omite no se agrega cursor-pointer, foco, tabIndex ni role.",
379
389
  "Column acepta width (number=px | string CSS), minWidth (px), align (left/center/right) y truncate (ellipsis + title nativo).",
390
+ "Columna fija: Column acepta pinned: \"left\" | \"right\" (opt-in; omitir = sin cambios). Los offsets sticky acumulan el width en px de cada columna pinneada previa del mismo lado; una columna sin width en px aporta 0px, así que declara width en px en todas las columnas pinneadas de un lado. La celda pinneada pinta fondo opaco (rowBg + rowHover) y un separador de 1px en el borde congelado. Una columna pinneada por lado es el contrato probado. Limitación: con variant=\"striped\" el fondo opaco tapa el tinte de la franja de esa columna.",
380
391
  "Anti scroll horizontal: layout=\"fixed\" + width por columna + truncate + density.",
381
392
  "Virtualización: solo vista tabla; rinde con page size grande; filas de alto uniforme; el scroll no se reinicia al cambiar filtros.",
382
393
  "Virtualización exige alto de fila uniforme. El default es size-aware (getRowHeight(size, density)): con size=\"md\" el baseline es compact 33 / normal 45 / comfortable 53 (padding de celda + line box text-sm de 20px + 1px del divide-y); size lo desplaza por el line box (sm 16 / md 20 / lg 28 px, es decir −4 / 0 / +8 px). Una celda de dos líneas (avatar + texto) mide ~50px real, distinto de ese alto asumido, así que los spacers se desalinean: usar celdas de una sola línea o pasar un rowHeight explícito.",
@@ -483,6 +494,23 @@ export const TableShowcase = () => {
483
494
  </div>
484
495
  </div>
485
496
 
497
+ <div>
498
+ <h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Pinned columns — left (Name) &amp; right (Actions)</h4>
499
+ <div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 520 }}>
500
+ <ITTable
501
+ columns={pinnedColumns}
502
+ data={TABLE_DATA}
503
+ title="Pinned columns"
504
+ layout="fixed"
505
+ density="compact"
506
+ defaultItemsPerPage={5}
507
+ />
508
+ </div>
509
+ <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
510
+ Scroll the table sideways: <code>pinned: "left"</code> freezes the Name column, <code>pinned: "right"</code> freezes the Actions column. Offsets accumulate from each preceding pinned column's pixel <code>width</code>.
511
+ </p>
512
+ </div>
513
+
486
514
  <div>
487
515
  <h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Auto cards fallback (&lt; 640px container)</h4>
488
516
  <div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 420 }}>
@@ -598,6 +626,19 @@ export const DataTableShowcase = () => {
598
626
  { key: "active", label: "Active", type: "boolean" as const, filter: true }
599
627
  ], []);
600
628
 
629
+ // Pixel widths force horizontal overflow inside the 520px demo wrapper, so
630
+ // the first (ID) and last (Active) columns stay pinned while the middle
631
+ // ones scroll sideways.
632
+ const pinnedColumns = useMemo(
633
+ () =>
634
+ userColumns.map((col: any, i: number) => ({
635
+ ...col,
636
+ width: [72, 220, 180, 180, 120][i],
637
+ pinned: i === 0 ? "left" : i === userColumns.length - 1 ? "right" : undefined,
638
+ })),
639
+ [userColumns]
640
+ );
641
+
601
642
  const MOCK_USERS = useMemo(() =>
602
643
  [
603
644
  { id: 1, name: "Sofía Castillo", role: "Admin", active: true },
@@ -875,6 +916,7 @@ export const DataTableShowcase = () => {
875
916
  '<ITDataTable columns={columns} fetchData={api.fetchUsers} />',
876
917
  '<ITDataTable columns={columns} fetchData={api.fetchUsers} onRowClick={(row) => open(row)} />',
877
918
  '<ITDataTable columns={fixedColumns} fetchData={api.fetchUsers} layout="fixed" density="compact" />',
919
+ '// Columna fija: pinned "left" | "right" (los offsets acumulan width en px)\n{ key: "id", label: "ID", type: "number", width: 72, pinned: "left" },\n{ key: "active", label: "Active", type: "boolean", width: 120, pinned: "right" }',
878
920
  '<ITDataTable columns={cols} fetchData={api.page} virtualized stickyHeader defaultItemsPerPage={200} itemsPerPageOptions={[50, 100, 200]} debounceMs={0} />',
879
921
  '<ITDataTable columns={cols} fetchData={api.page} layout="fixed" density="compact" autoCardBreakpoint={640} />',
880
922
  '// Filtro de columna tipo ITSearchSelect (typeahead)\n{ key: "role", label: "Role", type: "string", filter: "search", catalogOptions: { data: roles, onSearch: (q) => api.searchRoles(q) } }',
@@ -913,6 +955,7 @@ export const DataTableShowcase = () => {
913
955
  "onRowClick funciona en ambas vistas (tabla y cards).",
914
956
  "Interacciones que inician en botones, links o inputs no disparan onRowClick.",
915
957
  "Column acepta width (number=px | string CSS), minWidth (px), align (left/center/right) y truncate (ellipsis + title nativo).",
958
+ "Columna fija: Column acepta pinned: \"left\" | \"right\" (opt-in; omitir = sin cambios). Los offsets sticky acumulan el width en px de cada columna pinneada previa del mismo lado; una columna sin width en px aporta 0px, así que declara width en px en todas las columnas pinneadas de un lado. La celda pinneada pinta fondo opaco (rowBg + rowHover) y un separador de 1px en el borde congelado. Una columna pinneada por lado es el contrato probado. Limitación: con variant=\"striped\" el fondo opaco tapa el tinte de la franja de esa columna.",
916
959
  "Anti scroll horizontal: layout=\"fixed\" + width por columna + truncate + density.",
917
960
  "Virtualización: solo vista tabla; rinde con page size grande; filas de alto uniforme; el scroll no se reinicia al cambiar filtros.",
918
961
  "La virtualización server-side hace ventana sobre la PÁGINA ACTUAL (data.length), no sobre total: usar un defaultItemsPerPage grande (p. ej. 200) con itemsPerPageOptions que lo incluya, o el efecto es invisible.",
@@ -980,6 +1023,24 @@ export const DataTableShowcase = () => {
980
1023
  </div>
981
1024
  </div>
982
1025
 
1026
+ <div>
1027
+ <h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Pinned columns — left (ID) &amp; right (Active)</h4>
1028
+ <div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 520 }}>
1029
+ <ITDataTable
1030
+ columns={pinnedColumns}
1031
+ fetchData={fetchData}
1032
+ title="Pinned columns"
1033
+ layout="fixed"
1034
+ density="compact"
1035
+ defaultItemsPerPage={4}
1036
+ debounceMs={0}
1037
+ />
1038
+ </div>
1039
+ <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
1040
+ Scroll the table sideways: <code>pinned: "left"</code> freezes the ID column, <code>pinned: "right"</code> freezes the Active column.
1041
+ </p>
1042
+ </div>
1043
+
983
1044
  <div>
984
1045
  <h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Server-side virtualization — 240 rows {dtVirtualized ? "(page 200)" : "(page 10)"}</h4>
985
1046
  <div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">