@axzydev/axzy_ui_system 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.cjs +754 -613
- 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 +148 -5
- package/dist/index.d.ts +148 -5
- package/dist/index.js +628 -488
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
- package/src/App.tsx +6 -0
- package/src/components/atoms/input/input.props.ts +5 -0
- package/src/components/atoms/input/input.tsx +24 -17
- package/src/components/atoms/input-number/input-number.doc.mdx +74 -0
- package/src/components/atoms/input-number/input-number.props.ts +66 -0
- package/src/components/atoms/input-number/input-number.stories.tsx +111 -0
- package/src/components/atoms/input-number/input-number.tsx +86 -0
- package/src/components/molecules/table/table.doc.mdx +12 -1
- package/src/components/molecules/table/table.props.ts +17 -0
- package/src/components/molecules/table/table.stories.tsx +45 -0
- package/src/components/molecules/table/table.tsx +37 -8
- package/src/components/organisms/data-table/ITDataTable.doc.mdx +14 -1
- package/src/components/organisms/data-table/ITDataTable.stories.tsx +25 -0
- package/src/components/organisms/data-table/dataTable.tsx +37 -8
- package/src/components/organisms/sidebar/sidebar.doc.mdx +50 -12
- 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/index.css +10 -0
- package/src/index.ts +6 -3
- package/src/sandbox/navigation.ts +2 -0
- package/src/showcases/DataShowcases.tsx +61 -0
- package/src/showcases/FormShowcases.tsx +287 -1
- package/src/showcases/StructureShowcases.tsx +34 -6
- package/src/utils/table.utils.ts +83 -0
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
tableRow,
|
|
35
35
|
tableRowClickable,
|
|
36
36
|
} from "@/utils/styles";
|
|
37
|
-
import { formatCurrencyMX, getNestedValue, isInteractiveTarget } from "@/utils/table.utils";
|
|
37
|
+
import { formatCurrencyMX, getNestedValue, getPinnedColumnMeta, isInteractiveTarget } from "@/utils/table.utils";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* A feature-rich data table with per-column filtering, sortable columns,
|
|
@@ -208,6 +208,7 @@ export default function ITTable<T extends Record<string, unknown>>({
|
|
|
208
208
|
const isStickyHeader = stickyHeader && isTableVirtual;
|
|
209
209
|
const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
|
|
210
210
|
const columnCount = columns.length;
|
|
211
|
+
const pinnedMeta = React.useMemo(() => getPinnedColumnMeta(columns), [columns]);
|
|
211
212
|
|
|
212
213
|
const {
|
|
213
214
|
scrollRef,
|
|
@@ -590,22 +591,33 @@ export default function ITTable<T extends Record<string, unknown>>({
|
|
|
590
591
|
className={clsx(tableHeaderRow, "dark:text-slate-200")}
|
|
591
592
|
aria-rowindex={isTableVirtual ? 1 : undefined}
|
|
592
593
|
>
|
|
593
|
-
{columns.map((col) =>
|
|
594
|
+
{columns.map((col) => {
|
|
595
|
+
const pin = pinnedMeta[col.key];
|
|
596
|
+
const isPinned = pin != null;
|
|
597
|
+
return (
|
|
594
598
|
<th
|
|
595
599
|
key={col.key}
|
|
596
600
|
scope="col"
|
|
597
601
|
className={clsx(
|
|
598
602
|
tableHeaderCell(col.className, density),
|
|
599
603
|
col.align && tableAlignClasses[col.align],
|
|
600
|
-
|
|
604
|
+
isPinned && "sticky",
|
|
605
|
+
isStickyHeader && "sticky top-0 bg-secondary-50",
|
|
606
|
+
isPinned ? "z-[15]" : isStickyHeader && "z-10",
|
|
607
|
+
isPinned && (col.pinned === "left" ? "it-table-pinned-left" : "it-table-pinned-right")
|
|
601
608
|
)}
|
|
602
609
|
style={
|
|
603
|
-
col.minWidth != null || isStickyHeader
|
|
610
|
+
col.minWidth != null || isStickyHeader || isPinned
|
|
604
611
|
? {
|
|
605
612
|
...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
|
|
606
|
-
...(isStickyHeader
|
|
613
|
+
...(isStickyHeader || isPinned
|
|
607
614
|
? { backgroundColor: "var(--color-table-headerBg, #f8fafc)" }
|
|
608
615
|
: {}),
|
|
616
|
+
...(isPinned
|
|
617
|
+
? pin.side === "left"
|
|
618
|
+
? { left: pin.offset }
|
|
619
|
+
: { right: pin.offset }
|
|
620
|
+
: {}),
|
|
609
621
|
}
|
|
610
622
|
: undefined
|
|
611
623
|
}
|
|
@@ -631,7 +643,8 @@ export default function ITTable<T extends Record<string, unknown>>({
|
|
|
631
643
|
</div>
|
|
632
644
|
</div>
|
|
633
645
|
</th>
|
|
634
|
-
|
|
646
|
+
);
|
|
647
|
+
})}
|
|
635
648
|
</tr>
|
|
636
649
|
</thead>
|
|
637
650
|
<tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
|
|
@@ -664,14 +677,30 @@ export default function ITTable<T extends Record<string, unknown>>({
|
|
|
664
677
|
(typeof rawValue === "string" || typeof rawValue === "number")
|
|
665
678
|
? String(rawValue)
|
|
666
679
|
: undefined;
|
|
680
|
+
const pin = pinnedMeta[col.key];
|
|
681
|
+
const isPinned = pin != null;
|
|
667
682
|
return (
|
|
668
683
|
<td
|
|
669
684
|
key={`${absoluteIndex}-${col.key}`}
|
|
670
685
|
className={clsx(
|
|
671
686
|
tableCell(col.className, density),
|
|
672
|
-
col.align && tableAlignClasses[col.align]
|
|
687
|
+
col.align && tableAlignClasses[col.align],
|
|
688
|
+
isPinned && "sticky bg-[var(--color-table-rowBg,white)] group-hover:bg-[var(--color-table-rowHover,var(--color-secondary-50))]",
|
|
689
|
+
isPinned && (col.pinned === "left" ? "it-table-pinned-left" : "it-table-pinned-right"),
|
|
690
|
+
isPinned && "z-[1]"
|
|
673
691
|
)}
|
|
674
|
-
style={
|
|
692
|
+
style={
|
|
693
|
+
col.minWidth != null || isPinned
|
|
694
|
+
? {
|
|
695
|
+
...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
|
|
696
|
+
...(isPinned
|
|
697
|
+
? pin.side === "left"
|
|
698
|
+
? { left: pin.offset }
|
|
699
|
+
: { right: pin.offset }
|
|
700
|
+
: {}),
|
|
701
|
+
}
|
|
702
|
+
: undefined
|
|
703
|
+
}
|
|
675
704
|
title={truncateTitle}
|
|
676
705
|
>
|
|
677
706
|
{col.type === "actions" ? (
|
|
@@ -11,7 +11,7 @@ Advanced server-side data table with automatic pagination, column sorting, and f
|
|
|
11
11
|
|
|
12
12
|
| Prop | Type | Default | Description |
|
|
13
13
|
|------|------|---------|-------------|
|
|
14
|
-
| `columns` | `Column<T>[]` | — | Column definitions with key, label, type, sortable, filter, render, plus layout fields: `width` (`number` px or CSS `string`), `minWidth` (px), `align` (`"left" \| "center" \| "right"`),
|
|
14
|
+
| `columns` | `Column<T>[]` | — | Column definitions with key, label, type, sortable, filter, render, plus layout fields: `width` (`number` px or CSS `string`), `minWidth` (px), `align` (`"left" \| "center" \| "right"`), `truncate` (ellipsis + native `title`), and `pinned` (`"left" \| "right"`). |
|
|
15
15
|
| `fetchData` | `(params: ITDataTableFetchParams) => Promise<ITDataTableResponse<T>>` | — | Async callback triggered on pagination, filter, or sort change. Must return `{ data, total }`. |
|
|
16
16
|
| `debounceMs` | `number` | `500` | Delay in ms before firing fetch after filter state changes. |
|
|
17
17
|
| `externalFilters` | `ColumnFilters` | `{}` | Filters managed outside the table (e.g., date pickers) merged with internal column filters. Values may be scalars, a `Date`, or a `[start, end]` tuple. |
|
|
@@ -76,6 +76,18 @@ import { ITDataTable } from '@axzydev/axzy_ui_system';
|
|
|
76
76
|
layout="fixed"
|
|
77
77
|
density="compact"
|
|
78
78
|
/>
|
|
79
|
+
|
|
80
|
+
// Pinned columns: freeze ID left and the last column right during horizontal scroll
|
|
81
|
+
<ITDataTable
|
|
82
|
+
columns={[
|
|
83
|
+
{ key: 'id', label: 'ID', type: 'number', width: 72, pinned: 'left' },
|
|
84
|
+
{ key: 'name', label: 'Name', type: 'string', width: 240, truncate: true },
|
|
85
|
+
{ key: 'active', label: 'Active', type: 'boolean', width: 120, pinned: 'right' },
|
|
86
|
+
]}
|
|
87
|
+
fetchData={async (params) => api.fetchItems(params)}
|
|
88
|
+
layout="fixed"
|
|
89
|
+
density="compact"
|
|
90
|
+
/>
|
|
79
91
|
```
|
|
80
92
|
|
|
81
93
|
## Notes
|
|
@@ -88,6 +100,7 @@ import { ITDataTable } from '@axzydev/axzy_ui_system';
|
|
|
88
100
|
- `onRowClick` fires in **both** the table and cards views. The event is a `MouseEvent` for pointer interaction and a `KeyboardEvent` for Enter/Space activation. It is not fired while `isLoading`, and interactions that start inside an interactive child (button, link, input, or `[data-row-click-ignore]`) are ignored.
|
|
89
101
|
- `density` controls padding/row height (`compact` 33px / `normal` 45px / `comfortable` 53px at `size="md"`); `size` changes font-size and shifts the row height by the line box (sm 16px / md 20px / lg 28px).
|
|
90
102
|
- **Anti horizontal scroll recipe:** `layout="fixed"` + a `width` on every column + `truncate` on long text + `density="compact"`. `width` accepts a `number` (px) or any CSS length string (`"20%"`, `"12rem"`). A `<colgroup>` is only emitted when at least one column defines a `width`.
|
|
103
|
+
- **Pinned columns:** set `columns[].pinned: "left" | "right"` to freeze a column to that edge during horizontal scroll (`position: sticky`; the wrapper needs no `relative`). Omitting `pinned` renders exactly as before. Sticky offsets accumulate from each preceding pinned column's pixel `width` / `minWidth`; a non-px width contributes `minWidth ?? 0`, so give every pinned column of a side a pixel `width`. The pinned cell gets an opaque background (`--color-table-rowBg`, row hover `--color-table-rowHover`) and a 1px separator shadow on its frozen edge. Z-layering: pinned body cell `z-[1]` < sticky header `z-10` < pinned header `z-[15]` < the loading overlay `z-20`; it composes with `stickyHeader` and `virtualized`. One pinned column per side is the supported contract. Known limitation: with `variant="striped"` the opaque pinned background covers that column's stripe tint.
|
|
91
104
|
- With the default `layout="auto"`, `min-w-max` stays active so column widths can still reflow as different cell content enters the window during vertical scroll. Use `layout="fixed"` + column `width` for stable widths.
|
|
92
105
|
- `autoCardBreakpoint` swaps to the cards view when the container (not the viewport) is narrower than the given px value; the Table toggle is disabled while forced, the Cards toggle stays enabled.
|
|
93
106
|
- **Virtualization** applies to the **table view only** — the cards view is never virtualized. It only pays off with a large page size: raise `defaultItemsPerPage` and `itemsPerPageOptions` (defaults are `10` and `[5, 10, 20]`). Rows are assumed to have a uniform `rowHeight`; use `truncate` + `layout="fixed"` for predictable heights.
|
|
@@ -161,6 +161,31 @@ export const FixedLayoutCompact: Story = {
|
|
|
161
161
|
},
|
|
162
162
|
};
|
|
163
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
|
+
|
|
164
189
|
export const VirtualizedLargeDataset: Story = {
|
|
165
190
|
args: {
|
|
166
191
|
title: 'Virtualized · 100 rows',
|
|
@@ -12,7 +12,7 @@ import ITSelect from "@/components/molecules/select/select";
|
|
|
12
12
|
import ITSearchSelect from "@/components/molecules/search-select/search-select";
|
|
13
13
|
import ITDatePicker from "@/components/molecules/date-picker/datePicker";
|
|
14
14
|
import { Column } from "@/components/molecules/table/table.props";
|
|
15
|
-
import { formatCurrencyMX, isInteractiveTarget } from "@/utils/table.utils";
|
|
15
|
+
import { formatCurrencyMX, getPinnedColumnMeta, isInteractiveTarget } from "@/utils/table.utils";
|
|
16
16
|
import { ITDataTableProps } from "./dataTable.props";
|
|
17
17
|
import ITText from "@/components/atoms/text/text";
|
|
18
18
|
import {
|
|
@@ -105,6 +105,7 @@ export default function ITDataTable<T extends Record<string, unknown>>({
|
|
|
105
105
|
const isStickyHeader = stickyHeader && isTableVirtual;
|
|
106
106
|
const resolvedRowHeight = rowHeight ?? getRowHeight(size, density);
|
|
107
107
|
const columnCount = columns.length;
|
|
108
|
+
const pinnedMeta = React.useMemo(() => getPinnedColumnMeta(columns), [columns]);
|
|
108
109
|
const scrollOuterClass = isTableVirtual
|
|
109
110
|
? "relative min-h-[200px]"
|
|
110
111
|
: "overflow-x-auto relative min-h-[200px]";
|
|
@@ -441,22 +442,33 @@ export default function ITDataTable<T extends Record<string, unknown>>({
|
|
|
441
442
|
className={clsx(tableHeaderRow, "dark:text-slate-200")}
|
|
442
443
|
aria-rowindex={isTableVirtual ? 1 : undefined}
|
|
443
444
|
>
|
|
444
|
-
{columns.map((col) =>
|
|
445
|
+
{columns.map((col) => {
|
|
446
|
+
const pin = pinnedMeta[col.key];
|
|
447
|
+
const isPinned = pin != null;
|
|
448
|
+
return (
|
|
445
449
|
<th
|
|
446
450
|
key={col.key}
|
|
447
451
|
scope="col"
|
|
448
452
|
className={clsx(
|
|
449
453
|
tableHeaderCell(col.className, density),
|
|
450
454
|
col.align && tableAlignClasses[col.align],
|
|
451
|
-
|
|
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")
|
|
452
459
|
)}
|
|
453
460
|
style={
|
|
454
|
-
col.minWidth != null || isStickyHeader
|
|
461
|
+
col.minWidth != null || isStickyHeader || isPinned
|
|
455
462
|
? {
|
|
456
463
|
...(col.minWidth != null ? { minWidth: `${col.minWidth}px` } : {}),
|
|
457
|
-
...(isStickyHeader
|
|
464
|
+
...(isStickyHeader || isPinned
|
|
458
465
|
? { backgroundColor: "var(--color-table-headerBg, #f8fafc)" }
|
|
459
466
|
: {}),
|
|
467
|
+
...(isPinned
|
|
468
|
+
? pin.side === "left"
|
|
469
|
+
? { left: pin.offset }
|
|
470
|
+
: { right: pin.offset }
|
|
471
|
+
: {}),
|
|
460
472
|
}
|
|
461
473
|
: undefined
|
|
462
474
|
}
|
|
@@ -483,7 +495,8 @@ export default function ITDataTable<T extends Record<string, unknown>>({
|
|
|
483
495
|
<div className="w-full">{col.filter ? renderFilterInput(col) : null}</div>
|
|
484
496
|
</div>
|
|
485
497
|
</th>
|
|
486
|
-
|
|
498
|
+
);
|
|
499
|
+
})}
|
|
487
500
|
</tr>
|
|
488
501
|
</thead>
|
|
489
502
|
<tbody className={clsx(tableBody, "dark:divide-slate-700/30")}>
|
|
@@ -516,14 +529,30 @@ export default function ITDataTable<T extends Record<string, unknown>>({
|
|
|
516
529
|
(typeof rawValue === "string" || typeof rawValue === "number")
|
|
517
530
|
? String(rawValue)
|
|
518
531
|
: undefined;
|
|
532
|
+
const pin = pinnedMeta[col.key];
|
|
533
|
+
const isPinned = pin != null;
|
|
519
534
|
return (
|
|
520
535
|
<td
|
|
521
536
|
key={`${absoluteIndex}-${col.key}`}
|
|
522
537
|
className={clsx(
|
|
523
538
|
tableCell(col.className, density),
|
|
524
|
-
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]"
|
|
525
543
|
)}
|
|
526
|
-
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
|
+
}
|
|
527
556
|
title={truncateTitle}
|
|
528
557
|
>
|
|
529
558
|
{col.type === "actions" ? (
|
|
@@ -13,13 +13,13 @@ Vertical navigation sidebar with collapsible submenus, tooltips when collapsed,
|
|
|
13
13
|
|------|------|---------|-------------|
|
|
14
14
|
| `navigationItems` | `ITNavigationItem[]` | — | Navigation structure: top-level items with optional sub-items. |
|
|
15
15
|
| `isCollapsed` | `boolean` | `false` | Whether the sidebar is collapsed to icon-only mode. |
|
|
16
|
-
| `onToggleCollapse` | `() => void` | — |
|
|
17
|
-
| `visibleOnMobile` | `boolean` | `false` |
|
|
16
|
+
| `onToggleCollapse` | `() => void` | — | Accepted for API compatibility but never invoked by `ITSidebar`: the component renders no collapse toggle button (the rail expands on pointer hover). `ITLayout` still passes it through. |
|
|
17
|
+
| `visibleOnMobile` | `boolean` | `false` | When `true`, the sidebar is always fully expanded (the collapsed 88px rail is disabled) and visible at every breakpoint. |
|
|
18
18
|
| `onItemClick` | `(item: ITNavigationItem) => void` | — | Callback when a top-level navigation item is clicked. |
|
|
19
19
|
| `onSubItemClick` | `(subitem: ITNavigationSubItem) => void` | — | Callback when a sub-navigation item is clicked. |
|
|
20
20
|
| `subitemConnector` | `"dot" \| "\|" \| "none"` | `"dot"` | Visual connector style for sub-items. |
|
|
21
21
|
| `className` | `string` | — | Additional CSS classes on the sidebar `<aside>`. |
|
|
22
|
-
| `header` | `ReactNode` | — | Optional content rendered above the navigation list (e.g. a wordmark). Hidden while the sidebar is collapsed,
|
|
22
|
+
| `header` | `ReactNode` | — | Optional content rendered above the navigation list (e.g. a wordmark). Hidden while the sidebar is collapsed, so it only appears when the hover-expanded rail is shown. |
|
|
23
23
|
|
|
24
24
|
### ITNavigationItem
|
|
25
25
|
|
|
@@ -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[]` | Array of entries: plain sub-items or titled groups (`ITNavigationSubItemGroup`). |
|
|
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
|
|
@@ -65,24 +77,50 @@ const items = [
|
|
|
65
77
|
<ITSidebar
|
|
66
78
|
navigationItems={items}
|
|
67
79
|
isCollapsed={false}
|
|
68
|
-
onToggleCollapse={toggleCollapse}
|
|
69
80
|
subitemConnector="dot"
|
|
70
81
|
/>
|
|
71
82
|
```
|
|
72
83
|
|
|
84
|
+
To group sub-items under a heading, replace a plain sub-item with a group object (`label` + `items`):
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
const items = [
|
|
88
|
+
{
|
|
89
|
+
id: 'sales',
|
|
90
|
+
label: 'Sales',
|
|
91
|
+
subitems: [
|
|
92
|
+
{ id: 'summary', label: 'Summary', action: () => {} },
|
|
93
|
+
{
|
|
94
|
+
id: 'gestion',
|
|
95
|
+
label: 'Management',
|
|
96
|
+
items: [
|
|
97
|
+
{ id: 'orders', label: 'Orders', action: () => {} },
|
|
98
|
+
{ id: 'invoices', label: 'Invoices', action: () => {} },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Discriminate entries with `"items" in entry`. The heading style (uppercase, `0.68rem`, weight 700, `0.08em` tracking, `var(--it-sidebar-icon-color, var(--color-secondary-400))`) is applied by the component and cannot be overridden per group.
|
|
107
|
+
|
|
73
108
|
## Notes
|
|
74
109
|
|
|
75
|
-
-
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
- Collapsed mode shrinks to icon-only; tooltips show labels on hover with glassmorphism style.
|
|
79
|
-
- Submenus expand/collapse with smooth transitions when a parent item is clicked.
|
|
110
|
+
- There is no collapse toggle button: the collapsed 88px rail expands and collapses automatically on pointer hover (300 ms leave delay).
|
|
111
|
+
- Collapsed mode shrinks to icon-only; a glassmorphism panel shows the item's sub-items on hover. For an item whose `subitems` contain groups, that panel **flattens** every group into one plain list and renders **no** group headings.
|
|
112
|
+
- Submenus expand/collapse with smooth transitions when a parent item is clicked; the expanded submenu is animated with a `max-h` transition (cap `1000px`).
|
|
80
113
|
- Active item highlighting uses a colored left-border accent and subtle background tint.
|
|
81
114
|
- Badge support renders a notification-style counter on the item.
|
|
82
|
-
- `subitemConnector` styles the visual link between parent and sub-items: `dot` (default), `|` (vertical bar), or `none`.
|
|
83
|
-
-
|
|
115
|
+
- `subitemConnector` styles the visual link between parent and sub-items: `dot` (default), `|` (vertical bar), or `none`. Grouping does not change it: the `|` guide is a single `border-left` on the outer submenu `<ul>` only.
|
|
116
|
+
- An active sub-item inside a group still auto-expands its parent, because auto-expand flattens groups the same way the collapsed flyout does.
|
|
117
|
+
- `visibleOnMobile` forces the sidebar visible at every breakpoint and disables the collapsed rail, so it stays fully expanded.
|
|
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
|
+
};
|