@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.
- package/README.md +1 -1
- package/dist/index.cjs +1195 -946
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +90 -10
- package/dist/index.d.ts +90 -10
- package/dist/index.js +1189 -940
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
- package/src/components/molecules/table/table.doc.mdx +23 -1
- package/src/components/molecules/table/table.props.ts +47 -2
- package/src/components/molecules/table/table.stories.tsx +75 -0
- package/src/components/molecules/table/table.tsx +140 -15
- package/src/components/organisms/data-table/ITDataTable.doc.mdx +25 -3
- package/src/components/organisms/data-table/ITDataTable.stories.tsx +98 -0
- package/src/components/organisms/data-table/dataTable.props.ts +8 -4
- package/src/components/organisms/data-table/dataTable.tsx +109 -8
- package/src/components/organisms/sidebar/sidebar.doc.mdx +39 -1
- package/src/components/organisms/sidebar/sidebar.props.ts +28 -2
- package/src/components/organisms/sidebar/sidebar.stories.tsx +61 -1
- package/src/components/organisms/sidebar/sidebar.tsx +111 -57
- package/src/hooks/useTableState.ts +17 -11
- package/src/index.css +10 -0
- package/src/index.ts +5 -4
- package/src/showcases/DataShowcases.tsx +217 -21
- package/src/showcases/StructureShowcases.tsx +33 -5
- package/src/types/table.types.ts +17 -0
- package/src/utils/table.utils.ts +83 -0
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
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?:
|
|
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
|
-
|
|
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={
|
|
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` | `
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
+
};
|