@axzydev/axzy_ui_system 1.3.4 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.cjs +216 -124
- 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 +42 -3
- package/dist/index.d.ts +42 -3
- package/dist/index.js +216 -124
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
- 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 +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/index.css +10 -0
- package/src/index.ts +2 -1
- package/src/showcases/DataShowcases.tsx +61 -0
- package/src/showcases/StructureShowcases.tsx +33 -5
- package/src/utils/table.utils.ts +83 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"packageManager": "pnpm@10.34.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"version": "1.3.
|
|
7
|
+
"version": "1.3.5",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"sideEffects": [
|
|
10
10
|
"*.css"
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
"bundle": "tsup src/index.ts && node scripts/build-css.mjs",
|
|
39
39
|
"watch": "tsup src/index.ts --watch",
|
|
40
40
|
"storybook": "storybook dev -p 6006",
|
|
41
|
-
"build-storybook": "storybook build"
|
|
41
|
+
"build-storybook": "storybook build",
|
|
42
|
+
"ext:catalog": "node vscode-extension/scripts/generate-catalog.mjs",
|
|
43
|
+
"ext:build": "node vscode-extension/esbuild.mjs",
|
|
44
|
+
"ext:typecheck": "tsc --noEmit -p vscode-extension/tsconfig.json",
|
|
45
|
+
"ext:package": "node vscode-extension/esbuild.mjs && cd vscode-extension && npx --yes @vscode/vsce package --no-dependencies"
|
|
42
46
|
},
|
|
43
47
|
"dependencies": {
|
|
44
48
|
"clsx": "^2.1.1",
|
|
@@ -81,6 +85,8 @@
|
|
|
81
85
|
"vitest": "^4.0.18",
|
|
82
86
|
"playwright": "^1.58.0",
|
|
83
87
|
"@vitest/browser-playwright": "^4.0.18",
|
|
84
|
-
"@vitest/coverage-v8": "^4.0.18"
|
|
88
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
89
|
+
"@types/vscode": "1.85.0",
|
|
90
|
+
"esbuild": "^0.25.0"
|
|
85
91
|
}
|
|
86
92
|
}
|
|
@@ -11,7 +11,7 @@ Basic data table with column definitions, sorting, and configurable visual varia
|
|
|
11
11
|
|
|
12
12
|
| Prop | Type | Default | Description |
|
|
13
13
|
|------|------|---------|-------------|
|
|
14
|
-
| `columns` | `Column<T>[]` | — | Column definitions: key, label, type, sortable, filter, render, actions, plus layout fields `width` (`number` px or CSS `string`), `minWidth` (px), `align` (`"left" \| "center" \| "right"`),
|
|
14
|
+
| `columns` | `Column<T>[]` | — | Column definitions: key, label, type, sortable, filter, render, actions, 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
|
| `data` | `T[]` | `[]` | Row data array. |
|
|
16
16
|
| `variant` | `"default" \| "striped" \| "bordered"` | `"default"` | Visual style variant. |
|
|
17
17
|
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Font-size preset (does not change padding). |
|
|
@@ -42,6 +42,7 @@ Basic data table with column definitions, sorting, and configurable visual varia
|
|
|
42
42
|
| `minWidth` | `number` | — | Minimum column width in px, applied to header and body cells via inline style. |
|
|
43
43
|
| `align` | `"left" \| "center" \| "right"` | `"left"` | Horizontal alignment of the column's header and cells. |
|
|
44
44
|
| `truncate` | `boolean` | `false` | Truncates overflowing cell content with an ellipsis and exposes the raw string/number via the native `title` attribute. |
|
|
45
|
+
| `pinned` | `"left" \| "right"` | `undefined` | Freezes the column to the given horizontal edge while the table scrolls sideways. `"left"` keeps it flush to the left edge, `"right"` to the right edge. Omit to leave the column unpinned (identical to the previous behavior). Offsets accumulate from each preceding pinned column's pixel `width` / `minWidth`, so give every pinned column of a side a pixel `width`. The pinned cell gets an opaque background (row background + row hover) and a 1px separator shadow on its frozen edge. One pinned column per side is the supported contract. |
|
|
45
46
|
| `filter` | `true \| "catalog" \| "search" \| "date" \| "date-range"` | `false` | Filter control rendered in the header. `true` = text/number input (or boolean toggle when `type: "boolean"`); `"catalog"` = `ITSelect` fed by `catalogOptions.data`; `"search"` = `ITSearchSelect` typeahead fed by `catalogOptions.data` (optional `catalogOptions.onSearch` for server-side search); `"date"` / `"date-range"` = `ITDatePicker` single/range, bounded by `dateFilterOptions`. |
|
|
46
47
|
|
|
47
48
|
## Usage
|
|
@@ -85,6 +86,15 @@ const fixedColumns = [
|
|
|
85
86
|
];
|
|
86
87
|
|
|
87
88
|
<ITTable columns={fixedColumns} data={data} layout="fixed" density="compact" />
|
|
89
|
+
|
|
90
|
+
// Pinned columns: freeze the first column left and the last one right
|
|
91
|
+
const pinnedColumns = [
|
|
92
|
+
{ key: 'id', label: 'ID', type: 'number', width: 72, pinned: 'left' },
|
|
93
|
+
{ key: 'name', label: 'Name', type: 'string', width: 240, truncate: true },
|
|
94
|
+
{ key: 'actions', label: 'Actions', type: 'actions', width: 104, pinned: 'right' },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
<ITTable columns={pinnedColumns} data={data} layout="fixed" density="compact" />
|
|
88
98
|
```
|
|
89
99
|
|
|
90
100
|
## Notes
|
|
@@ -97,6 +107,7 @@ const fixedColumns = [
|
|
|
97
107
|
- When `onRowClick` is omitted no `cursor-pointer`, focus ring, `tabIndex`, or `role` is added.
|
|
98
108
|
- `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).
|
|
99
109
|
- **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`.
|
|
110
|
+
- **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]`; 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.
|
|
100
111
|
- 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.
|
|
101
112
|
- `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.
|
|
102
113
|
- **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.
|
|
@@ -90,6 +90,23 @@ export interface Column<T = any> {
|
|
|
90
90
|
* @default false
|
|
91
91
|
*/
|
|
92
92
|
truncate?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Pins this column to a horizontal edge so it stays visible while the table
|
|
95
|
+
* scrolls sideways.
|
|
96
|
+
*
|
|
97
|
+
* - `"left"` — freezes the column flush to the left edge.
|
|
98
|
+
* - `"right"` — freezes the column flush to the right edge.
|
|
99
|
+
*
|
|
100
|
+
* Omit to leave the column unpinned. When more than one column shares a side,
|
|
101
|
+
* sticky offsets accumulate from each preceding pinned column's `width` /
|
|
102
|
+
* `minWidth`; a column without a pixel width resolves to `0px`, so declare a
|
|
103
|
+
* pixel `width` on every pinned column of that side to avoid overlap. The
|
|
104
|
+
* pinned cell renders an opaque background (row background + row hover) and a
|
|
105
|
+
* 1px separator shadow on its frozen edge.
|
|
106
|
+
*
|
|
107
|
+
* @default undefined (column not pinned)
|
|
108
|
+
*/
|
|
109
|
+
pinned?: "left" | "right";
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
export interface ITTableProps<T> {
|
|
@@ -272,6 +272,51 @@ export const FixedLayoutWithWidths: Story = {
|
|
|
272
272
|
} as any,
|
|
273
273
|
};
|
|
274
274
|
|
|
275
|
+
// Pixel widths force the total past the 520px wrapper, so the ID (left) and
|
|
276
|
+
// Actions (right) columns stay frozen while the middle scrolls sideways.
|
|
277
|
+
const pinnedColumns: Column<User>[] = [
|
|
278
|
+
{ key: "id", label: "ID", type: "number", width: 72, minWidth: 56, align: "center", pinned: "left" },
|
|
279
|
+
{ key: "name", label: "Name", type: "string", width: 240, truncate: true },
|
|
280
|
+
{ key: "email", label: "Email", type: "string", width: 280, truncate: true },
|
|
281
|
+
{ key: "balance", label: "Balance", type: "number", width: 160, currencyMX: true },
|
|
282
|
+
{ key: "lastLogin", label: "Last login", type: "string", width: 160, truncate: true },
|
|
283
|
+
{
|
|
284
|
+
key: "isActive",
|
|
285
|
+
label: "Status",
|
|
286
|
+
type: "boolean",
|
|
287
|
+
width: 104,
|
|
288
|
+
align: "center",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
key: "actions",
|
|
292
|
+
label: "Actions",
|
|
293
|
+
type: "actions",
|
|
294
|
+
width: 104,
|
|
295
|
+
pinned: "right",
|
|
296
|
+
actions: () => (
|
|
297
|
+
<ITButton size="sm" variant="text" color="danger" ariaLabel="Delete">
|
|
298
|
+
<FaTrash />
|
|
299
|
+
</ITButton>
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
export const PinnedColumns: Story = {
|
|
305
|
+
render: (args: any) => (
|
|
306
|
+
<div style={{ width: 520 }} className="rounded-lg border border-dashed border-slate-300">
|
|
307
|
+
<ITTable {...args} />
|
|
308
|
+
</div>
|
|
309
|
+
),
|
|
310
|
+
args: {
|
|
311
|
+
columns: pinnedColumns,
|
|
312
|
+
data: mockData.slice(0, 6),
|
|
313
|
+
title: "Pinned left (ID) & right (Actions)",
|
|
314
|
+
layout: "fixed",
|
|
315
|
+
density: "compact",
|
|
316
|
+
defaultItemsPerPage: 6,
|
|
317
|
+
} as any,
|
|
318
|
+
};
|
|
319
|
+
|
|
275
320
|
export const AutoCardsNarrowContainer: Story = {
|
|
276
321
|
render: (args: any) => (
|
|
277
322
|
<div style={{ width: 420 }} className="rounded-lg border border-dashed border-slate-300">
|
|
@@ -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" ? (
|
|
@@ -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
|
+
};
|