@axzydev/axzy_ui_system 1.3.4 → 1.3.5

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.
@@ -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
@@ -101,7 +101,7 @@ import type { ITSearchSelectProps } from "./components/molecules/search-select/s
101
101
  import type { ITSelectProps } from "./components/molecules/select/select.props";
102
102
  import type { ITSearchTableProps } from "./components/organisms/searchTable/searchTable.props";
103
103
  import type { ITSegmentedControlProps } from "./components/atoms/segmented-control/segmented-control.props";
104
- import type { ITSidebarProps, ITNavigationItem, ITNavigationSubItem } from "./components/organisms/sidebar/sidebar.props";
104
+ import type { ITSidebarProps, ITNavigationItem, ITNavigationSubItem, ITNavigationSubItemEntry, ITNavigationSubItemGroup } from "./components/organisms/sidebar/sidebar.props";
105
105
  import type { ITSkeletonProps } from "./components/atoms/skeleton/skeleton.props";
106
106
  import type { ITSlideToggleProps } from "./components/atoms/slide/slide.props";
107
107
  import type { ITSliderProps } from "./components/atoms/slider/slider.props";
@@ -173,6 +173,7 @@ export {
173
173
  ITLayoutProps, ITLoaderProps, ITNavbarProps,
174
174
  ITPageProps, ITPageHeaderProps,
175
175
  ITNavigationItem, ITNavigationSubItem,
176
+ ITNavigationSubItemEntry, ITNavigationSubItemGroup,
176
177
  ITPaginationProps, ITPopoverProps, ITProgressProps,
177
178
  ITRadioGroupProps, ITRadioOption, ITSelectProps, ITSearchSelectProps,
178
179
  ITSearchTableProps,
@@ -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">
@@ -939,10 +939,23 @@ export const LayoutShowcase = () => {
939
939
  id: "sales",
940
940
  label: "Ventas",
941
941
  icon: <FaShoppingCart />,
942
- isActive: activeId === "sales" || activeId === "orders",
942
+ isActive: activeId === "sales" || activeId === "orders" || activeId === "quotes",
943
943
  subitems: [
944
- { id: "orders", label: "Órdenes", action: () => setActiveId("orders") },
945
- { id: "invoices", label: "Facturas", action: () => setActiveId("invoices") },
944
+ {
945
+ id: "sales-gestion",
946
+ label: "Gestión",
947
+ items: [
948
+ { id: "orders", label: "Órdenes", action: () => setActiveId("orders") },
949
+ { id: "invoices", label: "Facturas", action: () => setActiveId("invoices") },
950
+ ],
951
+ },
952
+ {
953
+ id: "sales-catalogo",
954
+ label: "Catálogo",
955
+ items: [
956
+ { id: "quotes", label: "Cotizaciones", action: () => setActiveId("quotes") },
957
+ ],
958
+ },
946
959
  ],
947
960
  },
948
961
  { id: "products", label: "Productos", icon: <FaBoxOpen />, isActive: activeId === "products", action: () => setActiveId("products"), badge: "3" },
@@ -968,7 +981,21 @@ export const LayoutShowcase = () => {
968
981
  userMenu: { userName, userEmail, menuItems }
969
982
  }}
970
983
  sidebar={{
971
- navigationItems: [...],
984
+ navigationItems: [
985
+ { id: "dashboard", label: "Dashboard", icon: <FaChartLine />, isActive: true },
986
+ {
987
+ id: "sales", label: "Ventas", icon: <FaShoppingCart />,
988
+ subitems: [
989
+ { id: "sales-gestion", label: "Gestión", items: [
990
+ { id: "orders", label: "Órdenes" },
991
+ { id: "invoices", label: "Facturas" },
992
+ ]},
993
+ { id: "sales-catalogo", label: "Catálogo", items: [
994
+ { id: "quotes", label: "Cotizaciones" },
995
+ ]},
996
+ ]
997
+ },
998
+ ],
972
999
  isCollapsed: ${collapsed},
973
1000
  // Slot opcional sobre la navegación (oculto al colapsar)
974
1001
  header: <span>AXZY v1.3</span>
@@ -1114,13 +1141,14 @@ export const LayoutShowcase = () => {
1114
1141
  ],
1115
1142
  props: [
1116
1143
  { name: "topBar", type: "ITTopBarProps", required: true, description: "Configuración de la barra superior." },
1117
- { name: "sidebar", type: "ITSidebarProps", required: true, description: "Configuración del sidebar (navigationItems, isCollapsed, header…)." },
1144
+ { name: "sidebar", type: "ITSidebarProps", required: true, description: "Configuración del sidebar (navigationItems con subítems planos o agrupados, isCollapsed, header…)." },
1118
1145
  { name: "children", type: "React.ReactNode", required: true, description: "Contenido principal." },
1119
1146
  { name: "className", type: "string", description: "Clases del wrapper externo." },
1120
1147
  { name: "contentClassName", type: "string", description: "Clases del contenedor de contenido." },
1121
1148
  { name: "sidebar.header", type: "React.ReactNode", description: "Slot opcional sobre la navegación (ej. wordmark o filtro). Oculto al colapsar." },
1122
1149
  ],
1123
1150
  notes: [
1151
+ "`subitems` acepta entradas planas o grupos con `label`+`items`; el título del grupo no es foco ni un tercer nivel.",
1124
1152
  "El sidebar hereda aria-current/aria-expanded y acepta un slot header opcional.",
1125
1153
  "Con `sidebar.isCollapsed` omitido el contenido reserva 88px (hover expande sobre el contenido). Si lo controlas a `false`, reserva 280px y no tapa el contenido.",
1126
1154
  "El riel colapsado se expande automáticamente al pasar el cursor por encima; no hay botón de colapso.",
@@ -39,3 +39,86 @@ export function isInteractiveTarget(
39
39
  if (boundary instanceof Node && interactive === boundary) return false;
40
40
  return true;
41
41
  }
42
+
43
+ // ──────────────────────────────────────────────
44
+ // PINNED (FROZEN) TABLE COLUMNS
45
+ // ──────────────────────────────────────────────
46
+
47
+ /** Horizontal edge a table column can be pinned to, used by `Column.pinned`. */
48
+ export type TablePinnedSide = "left" | "right";
49
+
50
+ /**
51
+ * Structural shape required to compute pinned offsets. Kept loose (not tied to
52
+ * `Column<T>`) so any column-like definition can be passed without importing
53
+ * the table props module.
54
+ */
55
+ type PinnableColumn = {
56
+ /** Unique column key; also the map key in the returned metadata. */
57
+ key: string;
58
+ /** Edge the column is frozen to, if any. */
59
+ pinned?: "left" | "right";
60
+ /** Declared width; a pixel `number` / `"<n>px"` string drives the offset. */
61
+ width?: number | string;
62
+ /** Fallback offset width (px) when `width` is not a pixel value. */
63
+ minWidth?: number;
64
+ };
65
+
66
+ /** Sticky metadata for one pinned column. */
67
+ export interface PinnedColumnMeta {
68
+ /** Edge the column is frozen to. */
69
+ side: TablePinnedSide;
70
+ /** Distance (px) from that edge to the column's sticky position. */
71
+ offset: number;
72
+ }
73
+
74
+ /**
75
+ * Pixel width a pinned column contributes to the accumulated sticky offset.
76
+ * Numbers are pixels; `"<n>px"` strings are parsed; every other length
77
+ * (`%`, `rem`, `em`, …) falls back to `minWidth ?? 0`.
78
+ */
79
+ function pinnedColumnWidth(col: PinnableColumn): number {
80
+ if (typeof col.width === "number") return col.width;
81
+ if (typeof col.width === "string" && /^\d+(\.\d+)?px$/.test(col.width.trim())) {
82
+ return parseFloat(col.width);
83
+ }
84
+ return col.minWidth ?? 0;
85
+ }
86
+
87
+ /**
88
+ * Computes the sticky `offset` for every pinned column in a table.
89
+ *
90
+ * Left-pinned columns accumulate forward in document order; right-pinned
91
+ * columns accumulate backward. Only pinned columns appear in the returned map,
92
+ * keyed by `col.key`. A column whose width is not resolvable to pixels
93
+ * contributes `minWidth ?? 0`, so declare a pixel `width` on every pinned
94
+ * column of the same side to keep offsets aligned.
95
+ *
96
+ * @param columns - Column definitions in render order.
97
+ * @returns Map of pinned column `key` → `{ side, offset }`.
98
+ */
99
+ export function getPinnedColumnMeta<T extends PinnableColumn>(
100
+ columns: T[]
101
+ ): Record<string, PinnedColumnMeta> {
102
+ const meta: Record<string, PinnedColumnMeta> = {};
103
+
104
+ // Left edge: walk forward, accumulating widths of preceding left-pinned columns.
105
+ let leftOffset = 0;
106
+ for (const col of columns) {
107
+ if (col.pinned === "left") {
108
+ meta[col.key] = { side: "left", offset: leftOffset };
109
+ leftOffset += pinnedColumnWidth(col);
110
+ }
111
+ }
112
+
113
+ // Right edge: walk backward, accumulating widths of preceding right-pinned columns.
114
+ let rightOffset = 0;
115
+ for (let i = columns.length - 1; i >= 0; i--) {
116
+ const col = columns[i];
117
+ if (col.pinned === "right") {
118
+ meta[col.key] = { side: "right", offset: rightOffset };
119
+ rightOffset += pinnedColumnWidth(col);
120
+ }
121
+ }
122
+
123
+ return meta;
124
+ }