@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,5 +1,6 @@
1
1
  import React, { useState, useCallback, useMemo } from 'react';
2
2
  import { Meta, StoryObj } from '@storybook/react';
3
+ import { startOfDay, endOfDay } from 'date-fns';
3
4
  import ITDataTable from './dataTable';
4
5
  import { ITDataTableFetchParams, ITDataTableResponse } from './dataTable.props';
5
6
  import ITCard from '@/components/molecules/card/card';
@@ -160,6 +161,31 @@ export const FixedLayoutCompact: Story = {
160
161
  },
161
162
  };
162
163
 
164
+ // Pixel widths push the total past the 520px wrapper: the first column stays
165
+ // frozen left and the last one frozen right while the middle scrolls sideways.
166
+ const pinnedColumns = columns.map((col, i) => ({
167
+ ...col,
168
+ width: [72, 240, 280, 160, 120][i],
169
+ pinned: i === 0 ? 'left' : i === columns.length - 1 ? 'right' : undefined,
170
+ truncate: col.type === 'string' || col.type === 'catalog',
171
+ })) as any;
172
+
173
+ export const PinnedColumns: Story = {
174
+ render: (args) => (
175
+ <div style={{ width: 520 }}>
176
+ <ITDataTable {...args} />
177
+ </div>
178
+ ),
179
+ args: {
180
+ title: 'Pinned left & right columns',
181
+ columns: pinnedColumns,
182
+ fetchData: mockFetchItems,
183
+ defaultItemsPerPage: 10,
184
+ layout: 'fixed',
185
+ density: 'compact',
186
+ },
187
+ };
188
+
163
189
  export const VirtualizedLargeDataset: Story = {
164
190
  args: {
165
191
  title: 'Virtualized · 100 rows',
@@ -243,6 +269,78 @@ export const WithRowClick: Story = {
243
269
  },
244
270
  };
245
271
 
272
+ export const WithSearchAndDateFilters: Story = {
273
+ render: () => {
274
+ const searchDateFetch = useCallback(
275
+ async (params: ITDataTableFetchParams): Promise<ITDataTableResponse<any>> => {
276
+ await new Promise((r) => setTimeout(r, 400));
277
+ let filtered = [...MOCK_DATA];
278
+
279
+ Object.entries(params.filters).forEach(([key, value]) => {
280
+ if (value === undefined || value === '') return;
281
+
282
+ if (key === 'role') {
283
+ filtered = filtered.filter((item) => item.role === value);
284
+ } else if (key === 'createdAt') {
285
+ if (Array.isArray(value)) {
286
+ const [rangeStart, rangeEnd] = value as [Date | null, Date | null];
287
+ if (!rangeStart && !rangeEnd) return;
288
+ filtered = filtered.filter((item) => {
289
+ const time = new Date(item.createdAt).getTime();
290
+ if (rangeStart && time < startOfDay(rangeStart).getTime()) return false;
291
+ if (rangeEnd && time > endOfDay(rangeEnd).getTime()) return false;
292
+ return true;
293
+ });
294
+ } else if (value instanceof Date) {
295
+ filtered = filtered.filter(
296
+ (item) => new Date(item.createdAt).toDateString() === value.toDateString()
297
+ );
298
+ }
299
+ }
300
+ });
301
+
302
+ const start = (params.page - 1) * params.limit;
303
+ return { data: filtered.slice(start, start + params.limit), total: filtered.length };
304
+ },
305
+ []
306
+ );
307
+
308
+ const filterColumns = useMemo(
309
+ () => [
310
+ { key: 'username', label: 'Usuario', type: 'string' as const, filter: true, sortable: true },
311
+ {
312
+ key: 'role',
313
+ label: 'Rol (ITSearchSelect)',
314
+ type: 'string' as const,
315
+ filter: 'search' as const,
316
+ sortable: true,
317
+ catalogOptions: { data: ROLES.map((role) => ({ id: role, name: role })) },
318
+ render: (row: any) => <ITBadget label={row.role} />,
319
+ },
320
+ {
321
+ key: 'createdAt',
322
+ label: 'Alta (ITDatePicker range)',
323
+ type: 'date' as const,
324
+ filter: 'date-range' as const,
325
+ sortable: true,
326
+ dateFilterOptions: { maxDate: new Date() },
327
+ render: (row: any) => new Date(row.createdAt).toLocaleDateString('es-MX'),
328
+ },
329
+ ],
330
+ []
331
+ );
332
+
333
+ return (
334
+ <ITDataTable
335
+ title="Filtros: ITSearchSelect + ITDatePicker (rango)"
336
+ columns={filterColumns as any}
337
+ fetchData={searchDateFetch}
338
+ defaultItemsPerPage={8}
339
+ />
340
+ );
341
+ },
342
+ };
343
+
246
344
  export const SkeletonLoading: Story = {
247
345
  args: {
248
346
  title: 'Cargando Datos...',
@@ -1,6 +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
+ import { ColumnFilters, TableDensity } from "@/types/table.types";
4
4
 
5
5
  /** Parameters passed to `fetchData` every time pagination, filters, or sorting change. */
6
6
  export interface ITDataTableFetchParams {
@@ -8,8 +8,12 @@ export interface ITDataTableFetchParams {
8
8
  page: number;
9
9
  /** Number of rows requested per page. */
10
10
  limit: number;
11
- /** Active per-column filter values (from `Column.filter`), keyed by column `key`. */
12
- filters: Record<string, string | number | boolean | Date>;
11
+ /**
12
+ * Active per-column filter values (from `Column.filter`), keyed by column
13
+ * `key`. Scalars for text/number/boolean/date filters, a `Date` for
14
+ * `filter: "date"`, and a `[start, end]` tuple for `filter: "date-range"`.
15
+ */
16
+ filters: ColumnFilters;
13
17
  /** Active sort, present only when the user has clicked a sortable column header. */
14
18
  sort?: {
15
19
  /** Column `key` currently sorted by. */
@@ -50,7 +54,7 @@ export interface ITDataTableProps<T extends Record<string, unknown>> {
50
54
  * Filters managed outside of the ITDataTable (e.g. a date range picker).
51
55
  * These will be merged with the internal column filters before calling fetchData.
52
56
  */
53
- externalFilters?: Record<string, string | number | boolean | Date>;
57
+ externalFilters?: ColumnFilters;
54
58
 
55
59
  /**
56
60
  * Custom element to display instead of the default spinner while `isLoading` is true.
@@ -9,8 +9,10 @@ import { MdOutlineSwapVert } from "react-icons/md";
9
9
  import ITInput from "@/components/atoms/input/input";
10
10
  import ITPagination from "@/components/molecules/pagination/pagination";
11
11
  import ITSelect from "@/components/molecules/select/select";
12
+ import ITSearchSelect from "@/components/molecules/search-select/search-select";
13
+ import ITDatePicker from "@/components/molecules/date-picker/datePicker";
12
14
  import { Column } from "@/components/molecules/table/table.props";
13
- import { formatCurrencyMX, isInteractiveTarget } from "@/utils/table.utils";
15
+ import { formatCurrencyMX, getPinnedColumnMeta, isInteractiveTarget } from "@/utils/table.utils";
14
16
  import { ITDataTableProps } from "./dataTable.props";
15
17
  import ITText from "@/components/atoms/text/text";
16
18
  import {
@@ -103,6 +105,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
103
105
  const isStickyHeader = stickyHeader && isTableVirtual;
104
106
  const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
105
107
  const columnCount = columns.length;
108
+ const pinnedMeta = React.useMemo(() => getPinnedColumnMeta(columns), [columns]);
106
109
  const scrollOuterClass = isTableVirtual
107
110
  ? "relative min-h-[200px]"
108
111
  : "overflow-x-auto relative min-h-[200px]";
@@ -253,6 +256,76 @@ export default function ITDataTable<T extends Record<string, unknown>>({
253
256
  );
254
257
  }
255
258
 
259
+ if (col.filter === "search" && col.catalogOptions) {
260
+ if (col.catalogOptions.loading) {
261
+ return <FaSpinner className="animate-spin" aria-label="Cargando opciones" title="Cargando opciones" />;
262
+ }
263
+ if (col.catalogOptions.error) {
264
+ return <ITText as="span" className="text-danger-500 text-xs">Error cargando</ITText>;
265
+ }
266
+ const searchValue = filters[col.key];
267
+ return (
268
+ <ITSearchSelect
269
+ name={`filter-${col.key}`}
270
+ options={col.catalogOptions.data.map((item) => ({
271
+ value: item.id,
272
+ label: item.name,
273
+ }))}
274
+ value={
275
+ typeof searchValue === "string" || typeof searchValue === "number"
276
+ ? searchValue
277
+ : ""
278
+ }
279
+ onChange={(value) => handleFilterChange(col.key, value === "" ? undefined : value)}
280
+ onSearch={col.catalogOptions.onSearch}
281
+ isLoading={col.catalogOptions.loading}
282
+ size="sm"
283
+ placeholder="Buscar..."
284
+ clearable
285
+ className="w-full"
286
+ onBlur={() => {}}
287
+ disabled={isLoading}
288
+ />
289
+ );
290
+ }
291
+
292
+ if (col.filter === "date") {
293
+ const dateValue = filters[col.key];
294
+ return (
295
+ <ITDatePicker
296
+ name={`filter-${col.key}`}
297
+ value={dateValue instanceof Date ? dateValue : undefined}
298
+ onChange={(e) => handleFilterChange(col.key, e.target.value as Date)}
299
+ minDate={col.dateFilterOptions?.minDate}
300
+ maxDate={col.dateFilterOptions?.maxDate}
301
+ size="sm"
302
+ placeholder="dd/mm/aaaa"
303
+ className="w-full"
304
+ disabled={isLoading}
305
+ />
306
+ );
307
+ }
308
+
309
+ if (col.filter === "date-range") {
310
+ const rangeValue = filters[col.key];
311
+ return (
312
+ <ITDatePicker
313
+ name={`filter-${col.key}`}
314
+ range
315
+ value={Array.isArray(rangeValue) ? rangeValue : undefined}
316
+ onChange={(e) =>
317
+ handleFilterChange(col.key, e.target.value as [Date | null, Date | null])
318
+ }
319
+ minDate={col.dateFilterOptions?.minDate}
320
+ maxDate={col.dateFilterOptions?.maxDate}
321
+ size="sm"
322
+ placeholder="dd/mm/aaaa - dd/mm/aaaa"
323
+ className="w-full"
324
+ disabled={isLoading}
325
+ />
326
+ );
327
+ }
328
+
256
329
  return (
257
330
  <ITInput
258
331
  name={`filter-${col.key}`}
@@ -369,22 +442,33 @@ export default function ITDataTable<T extends Record<string, unknown>>({
369
442
  className={clsx(tableHeaderRow, "dark:text-slate-200")}
370
443
  aria-rowindex={isTableVirtual ? 1 : undefined}
371
444
  >
372
- {columns.map((col) => (
445
+ {columns.map((col) => {
446
+ const pin = pinnedMeta[col.key];
447
+ const isPinned = pin != null;
448
+ return (
373
449
  <th
374
450
  key={col.key}
375
451
  scope="col"
376
452
  className={clsx(
377
453
  tableHeaderCell(col.className, density),
378
454
  col.align && tableAlignClasses[col.align],
379
- isStickyHeader && "sticky top-0 z-10 bg-secondary-50"
455
+ isPinned && "sticky",
456
+ isStickyHeader && "sticky top-0 bg-secondary-50",
457
+ isPinned ? "z-[15]" : isStickyHeader && "z-10",
458
+ isPinned && (col.pinned === "left" ? "it-table-pinned-left" : "it-table-pinned-right")
380
459
  )}
381
460
  style={
382
- col.minWidth != null || isStickyHeader
461
+ col.minWidth != null || isStickyHeader || isPinned
383
462
  ? {
384
463
  ...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
385
- ...(isStickyHeader
464
+ ...(isStickyHeader || isPinned
386
465
  ? { backgroundColor: "var(--color-table-headerBg, #f8fafc)" }
387
466
  : {}),
467
+ ...(isPinned
468
+ ? pin.side === "left"
469
+ ? { left: pin.offset }
470
+ : { right: pin.offset }
471
+ : {}),
388
472
  }
389
473
  : undefined
390
474
  }
@@ -411,7 +495,8 @@ export default function ITDataTable<T extends Record<string, unknown>>({
411
495
  <div className="w-full">{col.filter ? renderFilterInput(col) : null}</div>
412
496
  </div>
413
497
  </th>
414
- ))}
498
+ );
499
+ })}
415
500
  </tr>
416
501
  </thead>
417
502
  <tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
@@ -444,14 +529,30 @@ export default function ITDataTable<T extends Record<string, unknown>>({
444
529
  (typeof rawValue === "string" || typeof rawValue === "number")
445
530
  ? String(rawValue)
446
531
  : undefined;
532
+ const pin = pinnedMeta[col.key];
533
+ const isPinned = pin != null;
447
534
  return (
448
535
  <td
449
536
  key={`${absoluteIndex}-${col.key}`}
450
537
  className={clsx(
451
538
  tableCell(col.className, density),
452
- col.align && tableAlignClasses[col.align]
539
+ col.align && tableAlignClasses[col.align],
540
+ isPinned && "sticky bg-[var(--color-table-rowBg,white)] group-hover:bg-[var(--color-table-rowHover,var(--color-secondary-50))]",
541
+ isPinned && (col.pinned === "left" ? "it-table-pinned-left" : "it-table-pinned-right"),
542
+ isPinned && "z-[1]"
453
543
  )}
454
- style={col.minWidth != null ? { minWidth: `${col.minWidth}px` } : undefined}
544
+ style={
545
+ col.minWidth != null || isPinned
546
+ ? {
547
+ ...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
548
+ ...(isPinned
549
+ ? pin.side === "left"
550
+ ? { left: pin.offset }
551
+ : { right: pin.offset }
552
+ : {}),
553
+ }
554
+ : undefined
555
+ }
455
556
  title={truncateTitle}
456
557
  >
457
558
  {col.type === "actions" ? (
@@ -30,7 +30,7 @@ Vertical navigation sidebar with collapsible submenus, tooltips when collapsed,
30
30
  | `icon` | `ReactNode` | Icon rendered left of the label. |
31
31
  | `action` | `() => void` | Click handler for top-level items without submenus. |
32
32
  | `isActive` | `boolean` | Whether this item is active/highlighted. |
33
- | `subitems` | `ITNavigationSubItem[]` | Nested sub-navigation items. |
33
+ | `subitems` | `ITNavigationSubItemEntry[]` | Plain sub-items or titled groups. |
34
34
  | `badge` | `string` | Badge text (e.g. notification count). |
35
35
 
36
36
  ### ITNavigationSubItem
@@ -42,6 +42,18 @@ Vertical navigation sidebar with collapsible submenus, tooltips when collapsed,
42
42
  | `action` | `() => void` | Click handler. |
43
43
  | `isActive` | `boolean` | Whether this sub-item is active/highlighted. |
44
44
 
45
+ ### ITNavigationSubItemGroup
46
+
47
+ | Prop | Type | Description |
48
+ |------|------|-------------|
49
+ | `id` | `string` | Unique identifier for the group (used for the React key and the heading id). |
50
+ | `label` | `string` | Heading text rendered above the group's items. |
51
+ | `items` | `ITNavigationSubItem[]` | Sub-items belonging to this group. Nesting a group inside a group is not supported. |
52
+
53
+ ### ITNavigationSubItemEntry
54
+
55
+ A submenu entry is either a plain `ITNavigationSubItem` or a titled `ITNavigationSubItemGroup`; narrow it with `"items" in entry`.
56
+
45
57
  ## Usage
46
58
 
47
59
  ```tsx
@@ -70,6 +82,28 @@ const items = [
70
82
  />
71
83
  ```
72
84
 
85
+ To group sub-items under a heading, replace a plain sub-item with a group object (`label` + `items`):
86
+
87
+ ```tsx
88
+ const items = [
89
+ {
90
+ id: 'sales',
91
+ label: 'Sales',
92
+ subitems: [
93
+ { id: 'summary', label: 'Summary', action: () => {} },
94
+ {
95
+ id: 'gestion',
96
+ label: 'Management',
97
+ items: [
98
+ { id: 'orders', label: 'Orders', action: () => {} },
99
+ { id: 'invoices', label: 'Invoices', action: () => {} },
100
+ ],
101
+ },
102
+ ],
103
+ },
104
+ ];
105
+ ```
106
+
73
107
  ## Notes
74
108
 
75
109
  - 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.
@@ -83,6 +117,10 @@ const items = [
83
117
  - `visibleOnMobile` forces sidebar visibility on smaller screens for responsive layouts.
84
118
  - Themed via CSS variables for consistent colors, shadows, and glassmorphism effects across light and dark modes.
85
119
  - 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"`.
120
+ - `subitems` accepts plain sub-items or titled groups (`label` + `items`); the group heading is rendered as a plain `<span>` inside a non-focusable `<div>`, so it is non-interactive and stays out of the tab order.
121
+ - Each group's inner `<ul>` keeps list semantics and is named by its heading through `aria-labelledby`, so it is reachable as a named list.
122
+ - Groups are purely visual: they add no third nesting level (the sidebar supports exactly one level of nesting).
123
+ - Consumers that introspect `navigationItems[].subitems` must narrow each entry first, e.g. `"items" in entry ? entry.items : [entry]`, because entries are typed as `ITNavigationSubItemEntry`.
86
124
 
87
125
  ## Stories
88
126
 
@@ -10,6 +10,28 @@ export interface ITNavigationSubItem {
10
10
  isActive?: boolean;
11
11
  }
12
12
 
13
+ /**
14
+ * A titled group of sub-navigation items rendered under a parent menu.
15
+ * Groups only add a visual heading: they are never clickable and never open
16
+ * a third level (the sidebar supports exactly one level of nesting).
17
+ */
18
+ export interface ITNavigationSubItemGroup {
19
+ /** Unique identifier for the group. Used for the React key and the heading id. */
20
+ id: string;
21
+ /** Heading text rendered above the group's items. */
22
+ label: string;
23
+ /** Sub-items belonging to this group. Nesting a group inside a group is not supported. */
24
+ items: ITNavigationSubItem[];
25
+ }
26
+
27
+ /**
28
+ * A submenu entry: either a plain {@link ITNavigationSubItem} or a titled
29
+ * {@link ITNavigationSubItemGroup}. Discriminate with `"items" in entry`.
30
+ */
31
+ export type ITNavigationSubItemEntry =
32
+ | ITNavigationSubItem
33
+ | ITNavigationSubItemGroup;
34
+
13
35
  /** A top-level navigation item, optionally with sub-items. */
14
36
  export interface ITNavigationItem {
15
37
  /** Unique identifier. */
@@ -22,8 +44,12 @@ export interface ITNavigationItem {
22
44
  action?: () => void;
23
45
  /** Whether this item is currently active/highlighted. */
24
46
  isActive?: boolean;
25
- /** Nested sub-navigation items (renders as expandable submenu). */
26
- subitems?: ITNavigationSubItem[];
47
+ /**
48
+ * Nested sub-navigation entries (renders as an expandable submenu).
49
+ * Each entry is either a plain sub-item or a titled group of sub-items.
50
+ * The field `items` is reserved for {@link ITNavigationSubItemGroup}.
51
+ */
52
+ subitems?: ITNavigationSubItemEntry[];
27
53
  /** Badge text displayed on the item (e.g. notification count). */
28
54
  badge?: string;
29
55
  }
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import ITSidebar from '@/components/organisms/sidebar/sidebar';
3
- import { FaHome, FaUsers, FaCog, FaChartBar, FaShieldAlt } from 'react-icons/fa';
3
+ import { FaHome, FaUsers, FaCog, FaChartBar, FaShieldAlt, FaShoppingCart } from 'react-icons/fa';
4
+ import { expect, fn, userEvent, waitFor } from 'storybook/test';
4
5
 
5
6
  const meta: Meta<typeof ITSidebar> = {
6
7
  title: 'Components/Layout & Navigation/ITSidebar',
@@ -114,3 +115,62 @@ export const WithActiveSubmenu: Story = {
114
115
  </div>
115
116
  ),
116
117
  };
118
+
119
+ const groupedSubItemActionSpy = fn();
120
+
121
+ export const Grouped: Story = {
122
+ args: {
123
+ navigationItems: [
124
+ { id: 'dashboard', label: 'Dashboard', icon: <FaHome />, isActive: false },
125
+ {
126
+ id: 'ventas',
127
+ label: 'Ventas',
128
+ icon: <FaShoppingCart />,
129
+ subitems: [
130
+ { id: 'ventas-resumen', label: 'Resumen', action: fn() },
131
+ {
132
+ id: 'gestion',
133
+ label: 'Gestión',
134
+ items: [
135
+ { id: 'ordenes', label: 'Órdenes', action: groupedSubItemActionSpy },
136
+ { id: 'facturas', label: 'Facturas', action: fn() },
137
+ ],
138
+ },
139
+ {
140
+ id: 'catalogo',
141
+ label: 'Catálogo',
142
+ items: [
143
+ { id: 'productos', label: 'Productos', action: fn() },
144
+ { id: 'cotizaciones', label: 'Cotizaciones', action: fn() },
145
+ ],
146
+ },
147
+ ],
148
+ },
149
+ { id: 'settings', label: 'Configuración', icon: <FaCog />, action: fn() },
150
+ ],
151
+ isCollapsed: false,
152
+ visibleOnMobile: true,
153
+ },
154
+ render: (args) => (
155
+ <div className="h-screen bg-gray-50 flex">
156
+ <ITSidebar {...args} />
157
+ <div className="flex-1 p-8 text-zinc-500 font-medium">Los subítems de Ventas se agrupan bajo los títulos “Gestión” y “Catálogo”; los títulos no son interactivos.</div>
158
+ </div>
159
+ ),
160
+ play: async ({ canvas }) => {
161
+ await userEvent.click(canvas.getByRole('button', { name: 'Ventas' }));
162
+
163
+ const heading = canvas.getByText('Gestión');
164
+ // Wait out the 400ms expand transition before asserting visibility.
165
+ await waitFor(() => expect(heading).toBeVisible());
166
+
167
+ // The group <ul> is named by its heading via aria-labelledby.
168
+ await expect(canvas.getByRole('list', { name: 'Gestión' })).toBeInTheDocument();
169
+
170
+ // The heading is not focusable and is not inside anything with a tabindex.
171
+ await expect(heading.closest('[tabindex]')).toBeNull();
172
+
173
+ await userEvent.click(canvas.getByRole('button', { name: 'Órdenes' }));
174
+ await expect(groupedSubItemActionSpy).toHaveBeenCalled();
175
+ },
176
+ };