@axzydev/axzy_ui_system 1.3.3 → 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,4 +1,5 @@
1
1
  import React, { useState, useMemo, useCallback } from "react";
2
+ import { startOfDay, endOfDay } from "date-fns";
2
3
  import {
3
4
  ITTable,
4
5
  ITDataTable,
@@ -47,6 +48,74 @@ const virtualUserColumns: any = [
47
48
  { key: "active", label: "Active", type: "boolean", width: "12%" },
48
49
  ];
49
50
 
51
+ // Demo data for the new column filter modes: adds a `hiredAt` date so the
52
+ // same table can showcase both single-date and date-range filters.
53
+ const filterDemoData = TABLE_DATA.map((row, i) => ({
54
+ ...row,
55
+ hiredAt: new Date(2024, (i * 5) % 12, ((i * 7) % 27) + 1).toISOString(),
56
+ }));
57
+
58
+ // Columns exercising every new filter mode: ITSearchSelect (`"search"`) and
59
+ // ITDatePicker in single (`"date"`) and range (`"date-range"`) modes.
60
+ const filterDemoColumns: any = [
61
+ {
62
+ key: "name",
63
+ label: "Name",
64
+ type: "string",
65
+ sortable: true,
66
+ filter: true,
67
+ render: (row: Record<string, unknown>) => (
68
+ <span className="font-semibold text-slate-800 dark:text-white">{row.name as string}</span>
69
+ ),
70
+ },
71
+ {
72
+ key: "role",
73
+ label: "Role",
74
+ type: "string",
75
+ sortable: true,
76
+ filter: "search",
77
+ catalogOptions: {
78
+ data: [
79
+ { id: "Admin", name: "Admin" },
80
+ { id: "Editor", name: "Editor" },
81
+ { id: "Viewer", name: "Viewer" },
82
+ ],
83
+ },
84
+ render: (row: Record<string, unknown>) => (
85
+ <span className={`px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-md ${roleColor(row.role as string)}`}>
86
+ {row.role as string}
87
+ </span>
88
+ ),
89
+ },
90
+ {
91
+ key: "lastLogin",
92
+ label: "Last login",
93
+ type: "date",
94
+ sortable: true,
95
+ filter: "date",
96
+ render: (row: Record<string, unknown>) =>
97
+ new Date(row.lastLogin as string).toLocaleDateString("es-MX", {
98
+ day: "2-digit",
99
+ month: "short",
100
+ year: "numeric",
101
+ }),
102
+ },
103
+ {
104
+ key: "hiredAt",
105
+ label: "Hired",
106
+ type: "date",
107
+ sortable: true,
108
+ filter: "date-range",
109
+ dateFilterOptions: { maxDate: new Date() },
110
+ render: (row: Record<string, unknown>) =>
111
+ new Date(row.hiredAt as string).toLocaleDateString("es-MX", {
112
+ day: "2-digit",
113
+ month: "short",
114
+ year: "numeric",
115
+ }),
116
+ },
117
+ ];
118
+
50
119
  const nameColumn = {
51
120
  key: "name",
52
121
  label: "Name",
@@ -179,6 +248,15 @@ export const TableShowcase = () => {
179
248
  truncate: col.key === "email" || col.key === "name",
180
249
  }));
181
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
+
182
260
  // Single-line columns for virtualization: no avatar, no actions, no custom
183
261
  // render, so every row is a deterministic one-line box.
184
262
  const virtualColumns: any = [
@@ -275,7 +353,11 @@ export const TableShowcase = () => {
275
353
  '<ITTable columns={columns} data={data} title="Users" />',
276
354
  '<ITTable columns={columns} data={data} defaultView="cards" onRowClick={(row) => open(row)} />',
277
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" }',
278
357
  '<ITTable columns={cols} data={rows} virtualized stickyHeader layout="fixed" density="compact" defaultItemsPerPage={300} itemsPerPageOptions={[50, 100, 300]} />',
358
+ '// ITSearchSelect typeahead (server-side search opcional vía catalogOptions.onSearch)\n{ key: "role", label: "Role", type: "string", filter: "search", catalogOptions: { data: roles } }',
359
+ '// ITDatePicker fecha única\n{ key: "lastLogin", label: "Last login", type: "date", filter: "date", dateFilterOptions: { maxDate: new Date() } }',
360
+ '// ITDatePicker rango\n{ key: "hiredAt", label: "Hired", type: "date", filter: "date-range", dateFilterOptions: { maxDate: new Date() } }',
279
361
  ],
280
362
  props: [
281
363
  { name: "columns", type: "Column<T>[]", description: "Definiciones de columna: key, label, type, sortable, filter, render, actions." },
@@ -305,10 +387,12 @@ export const TableShowcase = () => {
305
387
  "onRowClick funciona en ambas vistas (tabla y cards).",
306
388
  "Cuando se omite no se agrega cursor-pointer, foco, tabIndex ni role.",
307
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.",
308
391
  "Anti scroll horizontal: layout=\"fixed\" + width por columna + truncate + density.",
309
392
  "Virtualización: solo vista tabla; rinde con page size grande; filas de alto uniforme; el scroll no se reinicia al cambiar filtros.",
310
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.",
311
394
  "ITSearchTable no soporta layout/density/autoCardBreakpoint/virtualized/width — no asumir paridad.",
395
+ "Nuevos modos de filtro por columna: filter: \"search\" renderiza ITSearchSelect (usa catalogOptions.data; opcional catalogOptions.onSearch para búsqueda en servidor), y filter: \"date\" / \"date-range\" renderizan ITDatePicker (bounds vía dateFilterOptions). El rango se guarda como [start, end] y se considera vacío cuando ambos extremos son null.",
312
396
  ],
313
397
  }}
314
398
  gallery={
@@ -410,6 +494,23 @@ export const TableShowcase = () => {
410
494
  </div>
411
495
  </div>
412
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
+
413
514
  <div>
414
515
  <h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Auto cards fallback (&lt; 640px container)</h4>
415
516
  <div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 420 }}>
@@ -440,6 +541,22 @@ export const TableShowcase = () => {
440
541
  />
441
542
  </div>
442
543
  </div>
544
+
545
+ <div>
546
+ <h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Column filters: ITSearchSelect + ITDatePicker (single &amp; range)</h4>
547
+ <div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
548
+ <ITTable
549
+ columns={filterDemoColumns}
550
+ data={filterDemoData}
551
+ title="Filter demo"
552
+ size="sm"
553
+ defaultItemsPerPage={8}
554
+ />
555
+ </div>
556
+ <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
557
+ Role uses <code>filter: "search"</code> (ITSearchSelect). Last login uses <code>filter: "date"</code> and Hired uses <code>filter: "date-range"</code> (ITDatePicker), with <code>dateFilterOptions</code> bounds.
558
+ </p>
559
+ </div>
443
560
  </div>
444
561
  }
445
562
  />
@@ -475,8 +592,15 @@ export const DataTableShowcase = () => {
475
592
  key: "role",
476
593
  label: "Role",
477
594
  type: "string" as const,
478
- filter: true,
595
+ filter: "search" as const,
479
596
  sortable: true,
597
+ catalogOptions: {
598
+ data: [
599
+ { id: "Admin", name: "Admin" },
600
+ { id: "Editor", name: "Editor" },
601
+ { id: "Viewer", name: "Viewer" },
602
+ ],
603
+ },
480
604
  render: (row: any) => (
481
605
  <span className={`px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-md ${
482
606
  row.role === "Admin" ? "bg-primary-100 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300" :
@@ -485,23 +609,55 @@ export const DataTableShowcase = () => {
485
609
  }`}>{row.role}</span>
486
610
  )
487
611
  },
612
+ {
613
+ key: "hiredAt",
614
+ label: "Hired",
615
+ type: "date" as const,
616
+ filter: "date-range" as const,
617
+ sortable: true,
618
+ dateFilterOptions: { maxDate: new Date() },
619
+ render: (row: any) =>
620
+ new Date(row.hiredAt as string).toLocaleDateString("es-MX", {
621
+ day: "2-digit",
622
+ month: "short",
623
+ year: "numeric",
624
+ }),
625
+ },
488
626
  { key: "active", label: "Active", type: "boolean" as const, filter: true }
489
627
  ], []);
490
628
 
491
- const MOCK_USERS = useMemo(() => [
492
- { id: 1, name: "Sofía Castillo", role: "Admin", active: true },
493
- { id: 2, name: "Daniela Klein", role: "Editor", active: true },
494
- { id: 3, name: "Mariana Reyes", role: "Viewer", active: false },
495
- { id: 4, name: "Camila Torres", role: "Admin", active: true },
496
- { id: 5, name: "Valentina Méndez", role: "Editor", active: true },
497
- { id: 6, name: "Luciana Rivas", role: "Viewer", active: false },
498
- { id: 7, name: "Isabella Cruz", role: "Editor", active: true },
499
- { id: 8, name: "Gabriela Vargas", role: "Admin", active: true },
500
- { id: 9, name: "Ana López", role: "Editor", active: false },
501
- { id: 10, name: "Carla Jiménez", role: "Viewer", active: true },
502
- { id: 11, name: "Luisa Fernández", role: "Admin", active: true },
503
- { id: 12, name: "Renata Morales", role: "Viewer", active: false },
504
- ], []);
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
+
642
+ const MOCK_USERS = useMemo(() =>
643
+ [
644
+ { id: 1, name: "Sofía Castillo", role: "Admin", active: true },
645
+ { id: 2, name: "Daniela Klein", role: "Editor", active: true },
646
+ { id: 3, name: "Mariana Reyes", role: "Viewer", active: false },
647
+ { id: 4, name: "Camila Torres", role: "Admin", active: true },
648
+ { id: 5, name: "Valentina Méndez", role: "Editor", active: true },
649
+ { id: 6, name: "Luciana Rivas", role: "Viewer", active: false },
650
+ { id: 7, name: "Isabella Cruz", role: "Editor", active: true },
651
+ { id: 8, name: "Gabriela Vargas", role: "Admin", active: true },
652
+ { id: 9, name: "Ana López", role: "Editor", active: false },
653
+ { id: 10, name: "Carla Jiménez", role: "Viewer", active: true },
654
+ { id: 11, name: "Luisa Fernández", role: "Admin", active: true },
655
+ { id: 12, name: "Renata Morales", role: "Viewer", active: false },
656
+ ].map((row, i) => ({
657
+ ...row,
658
+ hiredAt: new Date(2023, (i * 5) % 12, ((i * 7) % 27) + 1).toISOString(),
659
+ })),
660
+ []);
505
661
 
506
662
  const fetchData = useCallback(async (params: ITDataTableFetchParams): Promise<ITDataTableResponse<any>> => {
507
663
  const delay = apiState === "loading" ? 8000 : 400;
@@ -517,14 +673,31 @@ export const DataTableShowcase = () => {
517
673
 
518
674
  let mockData = [...MOCK_USERS];
519
675
 
676
+ const matchesFilter = (itemValue: unknown, filterVal: unknown): boolean => {
677
+ // Single date (filter: "date")
678
+ if (filterVal instanceof Date) {
679
+ const d = new Date(String(itemValue));
680
+ return !isNaN(d.getTime()) && d.toDateString() === filterVal.toDateString();
681
+ }
682
+ // Date range (filter: "date-range") — [start, end]
683
+ if (Array.isArray(filterVal)) {
684
+ const [start, end] = filterVal as [Date | null, Date | null];
685
+ if (!start && !end) return true;
686
+ const d = new Date(String(itemValue));
687
+ if (isNaN(d.getTime())) return false;
688
+ if (start && d.getTime() < startOfDay(start).getTime()) return false;
689
+ if (end && d.getTime() > endOfDay(end).getTime()) return false;
690
+ return true;
691
+ }
692
+ // Scalar filters (text/number/boolean/catalog/search id)
693
+ return String(itemValue).toLowerCase().includes(String(filterVal).toLowerCase());
694
+ };
695
+
520
696
  if (params.filters) {
521
697
  Object.keys(params.filters).forEach(key => {
522
698
  const filterVal = params.filters[key];
523
- if (filterVal) {
524
- mockData = mockData.filter(item =>
525
- String((item as any)[key]).toLowerCase().includes(String(filterVal).toLowerCase())
526
- );
527
- }
699
+ if (filterVal === undefined || filterVal === "") return;
700
+ mockData = mockData.filter(item => matchesFilter((item as any)[key], filterVal));
528
701
  });
529
702
  }
530
703
 
@@ -743,14 +916,17 @@ export const DataTableShowcase = () => {
743
916
  '<ITDataTable columns={columns} fetchData={api.fetchUsers} />',
744
917
  '<ITDataTable columns={columns} fetchData={api.fetchUsers} onRowClick={(row) => open(row)} />',
745
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" }',
746
920
  '<ITDataTable columns={cols} fetchData={api.page} virtualized stickyHeader defaultItemsPerPage={200} itemsPerPageOptions={[50, 100, 200]} debounceMs={0} />',
747
921
  '<ITDataTable columns={cols} fetchData={api.page} layout="fixed" density="compact" autoCardBreakpoint={640} />',
922
+ '// Filtro de columna tipo ITSearchSelect (typeahead)\n{ key: "role", label: "Role", type: "string", filter: "search", catalogOptions: { data: roles, onSearch: (q) => api.searchRoles(q) } }',
923
+ '// Filtro de columna tipo ITDatePicker (rango) — params.filters[key] llega como [start, end]\n{ key: "hiredAt", label: "Hired", type: "date", filter: "date-range", dateFilterOptions: { maxDate: new Date() } }',
748
924
  ],
749
925
  props: [
750
926
  { name: "columns", type: "Column<T>[]", description: "Definiciones de columna." },
751
927
  { name: "fetchData", type: "(params: ITDataTableFetchParams) => Promise<ITDataTableResponse<T>>", required: true, description: "Callback asíncrono disparado al cambiar página, filtros u orden. Retorna { data, total }." },
752
928
  { name: "debounceMs", type: "number", default: "500", description: "Espera antes de refetch tras cambiar filtros." },
753
- { name: "externalFilters", type: "Record<string, string | number | boolean | Date>", default: "{}", description: "Filtros externos fusionados con los internos." },
929
+ { name: "externalFilters", type: "ColumnFilters", default: "{}", description: "Filtros externos fusionados con los internos. Los valores de fecha pueden ser Date o [start, end]." },
754
930
  { name: "loadingIndicator", type: "ReactNode", description: "Elemento custom mostrado mientras carga." },
755
931
  { name: "fetchOnMount", type: "boolean", default: "true", description: "Dispara fetch al montar." },
756
932
  { name: "reloadTrigger", type: "number | string | boolean", description: "Cambiar su valor fuerza un refetch." },
@@ -779,6 +955,7 @@ export const DataTableShowcase = () => {
779
955
  "onRowClick funciona en ambas vistas (tabla y cards).",
780
956
  "Interacciones que inician en botones, links o inputs no disparan onRowClick.",
781
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.",
782
959
  "Anti scroll horizontal: layout=\"fixed\" + width por columna + truncate + density.",
783
960
  "Virtualización: solo vista tabla; rinde con page size grande; filas de alto uniforme; el scroll no se reinicia al cambiar filtros.",
784
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.",
@@ -787,6 +964,7 @@ export const DataTableShowcase = () => {
787
964
  "El toggle interno Table/Cards también desactiva la virtualización; las cards nunca se virtualizan.",
788
965
  "debounceMs={0} evita el retardo de refetch en demos.",
789
966
  "variant=\"minimal\" se acepta por tipo pero no tiene estilos (igual que default). ITSearchTable no soporta layout/density/autoCardBreakpoint/virtualized/width.",
967
+ "Modos de filtro por columna: filter: \"search\" renderiza ITSearchSelect (usa catalogOptions.data; catalogOptions.onSearch habilita búsqueda server-side debounced), y filter: \"date\" / \"date-range\" renderizan ITDatePicker con dateFilterOptions { minDate, maxDate }. En fetchData, params.filters[key] llega como Date (date) o [start, end] (date-range).",
790
968
  ],
791
969
  }}
792
970
  gallery={
@@ -845,6 +1023,24 @@ export const DataTableShowcase = () => {
845
1023
  </div>
846
1024
  </div>
847
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
+
848
1044
  <div>
849
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>
850
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.",
@@ -1,5 +1,22 @@
1
1
  export type TableVariants = "default" | "striped" | "bordered";
2
2
 
3
+ /**
4
+ * Value stored for a single column filter.
5
+ *
6
+ * - `string | number | boolean` — text/number/boolean filters and catalog ids.
7
+ * - `Date` — a single-date filter (`Column.filter: "date"`).
8
+ * - `[Date | null, Date | null]` — a date-range filter (`Column.filter: "date-range"`).
9
+ */
10
+ export type ColumnFilterValue =
11
+ | string
12
+ | number
13
+ | boolean
14
+ | Date
15
+ | [Date | null, Date | null];
16
+
17
+ /** Map of column `key` to its active filter value. */
18
+ export type ColumnFilters = Record<string, ColumnFilterValue>;
19
+
3
20
  export type TableSize = "sm" | "md" | "lg";
4
21
 
5
22
  export const variantStyles: Record<TableVariants, string> = {
@@ -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
+ }