@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
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { FaChevronDown } from "react-icons/fa";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ITNavigationItem,
|
|
5
|
+
ITNavigationSubItem,
|
|
6
|
+
ITNavigationSubItemEntry,
|
|
7
|
+
ITNavigationSubItemGroup,
|
|
8
|
+
ITSidebarProps,
|
|
9
|
+
} from "./sidebar.props";
|
|
4
10
|
import ITText from "@/components/atoms/text/text";
|
|
5
11
|
|
|
12
|
+
/** Narrows a submenu entry to a titled group. */
|
|
13
|
+
function isSubItemGroup(
|
|
14
|
+
entry: ITNavigationSubItemEntry,
|
|
15
|
+
): entry is ITNavigationSubItemGroup {
|
|
16
|
+
return "items" in entry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Flattens a parent's entries (plain items + groups) into a single list. */
|
|
20
|
+
function flattenSubItems(
|
|
21
|
+
entries: ITNavigationSubItemEntry[],
|
|
22
|
+
): ITNavigationSubItem[] {
|
|
23
|
+
return entries.flatMap((entry) =>
|
|
24
|
+
isSubItemGroup(entry) ? entry.items : [entry],
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
/**
|
|
7
29
|
* Vertical navigation sidebar with submenu expand/collapse, hover tooltips in collapsed mode,
|
|
8
30
|
* and glassmorphism styling. The collapsed rail expands automatically on pointer hover — there
|
|
@@ -76,7 +98,7 @@ export default function ITSidebar({
|
|
|
76
98
|
useEffect(() => {
|
|
77
99
|
const activeParents = new Set<string>();
|
|
78
100
|
navigationItems.forEach(item => {
|
|
79
|
-
if (item.subitems && item.subitems.some(sub => sub.isActive)) {
|
|
101
|
+
if (item.subitems && flattenSubItems(item.subitems).some(sub => sub.isActive)) {
|
|
80
102
|
activeParents.add(item.id);
|
|
81
103
|
}
|
|
82
104
|
});
|
|
@@ -115,6 +137,86 @@ export default function ITSidebar({
|
|
|
115
137
|
const isSidebarCollapsed = visibleOnMobile ? false : (!isHovering && isCollapsed);
|
|
116
138
|
const sidebarWidth = isSidebarCollapsed ? "w-[88px]" : "w-[280px]";
|
|
117
139
|
|
|
140
|
+
const renderSubItem = (subitem: ITNavigationSubItem) => (
|
|
141
|
+
<li key={subitem.id} className="relative">
|
|
142
|
+
<button
|
|
143
|
+
aria-current={subitem.isActive ? "page" : undefined}
|
|
144
|
+
onClick={() => {
|
|
145
|
+
if (subitem.action) subitem.action();
|
|
146
|
+
if (onSubItemClick) onSubItemClick(subitem);
|
|
147
|
+
}}
|
|
148
|
+
className={`flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-xl transition-all duration-300`}
|
|
149
|
+
style={{
|
|
150
|
+
color: subitem.isActive ? "var(--it-sidebar-active-color, var(--color-secondary-900))" : "var(--it-sidebar-label-color, var(--color-secondary-600))",
|
|
151
|
+
backgroundColor: subitem.isActive ? "var(--it-sidebar-active-bg, var(--color-secondary-50))" : 'transparent',
|
|
152
|
+
fontSize: '0.78rem',
|
|
153
|
+
fontWeight: subitem.isActive ? 600 : 500,
|
|
154
|
+
letterSpacing: '0.01em',
|
|
155
|
+
marginLeft: subitemConnector === '|' ? '-1px' : '0',
|
|
156
|
+
}}
|
|
157
|
+
onMouseEnter={(e) => {
|
|
158
|
+
if (!subitem.isActive) {
|
|
159
|
+
e.currentTarget.style.backgroundColor = "var(--it-sidebar-hover-bg, var(--color-secondary-100))";
|
|
160
|
+
e.currentTarget.style.transform = 'translateX(3px)';
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
onMouseLeave={(e) => {
|
|
164
|
+
if (!subitem.isActive) {
|
|
165
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
166
|
+
e.currentTarget.style.transform = 'translateX(0)';
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{subitem.isActive && subitemConnector === '|' && (
|
|
171
|
+
<div
|
|
172
|
+
className="absolute left-0 top-1/3 bottom-1/3 w-[2.5px] rounded-r-full transition-all"
|
|
173
|
+
style={{
|
|
174
|
+
backgroundColor: "var(--it-sidebar-active-icon, var(--color-primary-500))",
|
|
175
|
+
boxShadow: "0 0 6px color-mix(in srgb, var(--it-sidebar-active-icon, var(--color-primary-500)) 25%, transparent)",
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
{subitemConnector === 'dot' && (
|
|
180
|
+
<span
|
|
181
|
+
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 transition-all duration-300 ${subitem.isActive ? 'scale-125' : ''}`}
|
|
182
|
+
style={{
|
|
183
|
+
backgroundColor: subitem.isActive
|
|
184
|
+
? "var(--it-sidebar-active-icon, var(--color-primary-500))"
|
|
185
|
+
: "var(--it-sidebar-icon-color, var(--color-secondary-400))"
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
<ITText as="span" className="truncate">{subitem.label}</ITText>
|
|
190
|
+
</button>
|
|
191
|
+
</li>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const renderSubItemGroup = (group: ITNavigationSubItemGroup, itemId: string) => {
|
|
195
|
+
const headingId = `it-sidebar-subgroup-${itemId}-${group.id}`;
|
|
196
|
+
return (
|
|
197
|
+
<li key={group.id} className="relative">
|
|
198
|
+
<div id={headingId} className="px-3 pt-2.5 pb-1 mt-1">
|
|
199
|
+
<ITText
|
|
200
|
+
as="span"
|
|
201
|
+
className="block"
|
|
202
|
+
style={{
|
|
203
|
+
color: "var(--it-sidebar-icon-color, var(--color-secondary-400))",
|
|
204
|
+
fontSize: "0.68rem",
|
|
205
|
+
fontWeight: 700,
|
|
206
|
+
letterSpacing: "0.08em",
|
|
207
|
+
textTransform: "uppercase",
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{group.label}
|
|
211
|
+
</ITText>
|
|
212
|
+
</div>
|
|
213
|
+
<ul aria-labelledby={headingId} className="flex flex-col gap-0">
|
|
214
|
+
{group.items.map(renderSubItem)}
|
|
215
|
+
</ul>
|
|
216
|
+
</li>
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
118
220
|
return (
|
|
119
221
|
<aside
|
|
120
222
|
ref={sidebarRef}
|
|
@@ -256,7 +358,7 @@ export default function ITSidebar({
|
|
|
256
358
|
|
|
257
359
|
{item.subitems && item.subitems.length > 0 ? (
|
|
258
360
|
<div className="py-2">
|
|
259
|
-
{item.subitems.map((subitem) => (
|
|
361
|
+
{flattenSubItems(item.subitems).map((subitem) => (
|
|
260
362
|
<div
|
|
261
363
|
key={subitem.id}
|
|
262
364
|
className={`px-5 py-2.5 text-sm flex items-center gap-3 transition-colors relative`}
|
|
@@ -283,7 +385,7 @@ export default function ITSidebar({
|
|
|
283
385
|
|
|
284
386
|
{/* Submenu - smooth height/opacity when not collapsed */}
|
|
285
387
|
{!isSidebarCollapsed && item.subitems && item.subitems.length > 0 && (
|
|
286
|
-
<div id={submenuId} role="group" aria-label={item.label} className={`overflow-hidden transition-all duration-400 ease-[cubic-bezier(0.2,0,0,1)] ${expandedItems.has(item.id) ? "max-h-[
|
|
388
|
+
<div id={submenuId} role="group" aria-label={item.label} className={`overflow-hidden transition-all duration-400 ease-[cubic-bezier(0.2,0,0,1)] ${expandedItems.has(item.id) ? "max-h-[1000px] opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
|
|
287
389
|
<ul
|
|
288
390
|
className="ml-4 flex flex-col gap-0 py-0.5"
|
|
289
391
|
style={{
|
|
@@ -292,59 +394,11 @@ export default function ITSidebar({
|
|
|
292
394
|
: 'none'
|
|
293
395
|
}}
|
|
294
396
|
>
|
|
295
|
-
{item.subitems.map((
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (subitem.action) subitem.action();
|
|
301
|
-
if (onSubItemClick) onSubItemClick(subitem);
|
|
302
|
-
}}
|
|
303
|
-
className={`flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-xl transition-all duration-300`}
|
|
304
|
-
style={{
|
|
305
|
-
color: subitem.isActive ? "var(--it-sidebar-active-color, var(--color-secondary-900))" : "var(--it-sidebar-label-color, var(--color-secondary-600))",
|
|
306
|
-
backgroundColor: subitem.isActive ? "var(--it-sidebar-active-bg, var(--color-secondary-50))" : 'transparent',
|
|
307
|
-
fontSize: '0.78rem',
|
|
308
|
-
fontWeight: subitem.isActive ? 600 : 500,
|
|
309
|
-
letterSpacing: '0.01em',
|
|
310
|
-
marginLeft: subitemConnector === '|' ? '-1px' : '0',
|
|
311
|
-
}}
|
|
312
|
-
onMouseEnter={(e) => {
|
|
313
|
-
if (!subitem.isActive) {
|
|
314
|
-
e.currentTarget.style.backgroundColor = "var(--it-sidebar-hover-bg, var(--color-secondary-100))";
|
|
315
|
-
e.currentTarget.style.transform = 'translateX(3px)';
|
|
316
|
-
}
|
|
317
|
-
}}
|
|
318
|
-
onMouseLeave={(e) => {
|
|
319
|
-
if (!subitem.isActive) {
|
|
320
|
-
e.currentTarget.style.backgroundColor = 'transparent';
|
|
321
|
-
e.currentTarget.style.transform = 'translateX(0)';
|
|
322
|
-
}
|
|
323
|
-
}}
|
|
324
|
-
>
|
|
325
|
-
{subitem.isActive && subitemConnector === '|' && (
|
|
326
|
-
<div
|
|
327
|
-
className="absolute left-0 top-1/3 bottom-1/3 w-[2.5px] rounded-r-full transition-all"
|
|
328
|
-
style={{
|
|
329
|
-
backgroundColor: "var(--it-sidebar-active-icon, var(--color-primary-500))",
|
|
330
|
-
boxShadow: "0 0 6px color-mix(in srgb, var(--it-sidebar-active-icon, var(--color-primary-500)) 25%, transparent)",
|
|
331
|
-
}}
|
|
332
|
-
/>
|
|
333
|
-
)}
|
|
334
|
-
{subitemConnector === 'dot' && (
|
|
335
|
-
<span
|
|
336
|
-
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 transition-all duration-300 ${subitem.isActive ? 'scale-125' : ''}`}
|
|
337
|
-
style={{
|
|
338
|
-
backgroundColor: subitem.isActive
|
|
339
|
-
? "var(--it-sidebar-active-icon, var(--color-primary-500))"
|
|
340
|
-
: "var(--it-sidebar-icon-color, var(--color-secondary-400))"
|
|
341
|
-
}}
|
|
342
|
-
/>
|
|
343
|
-
)}
|
|
344
|
-
<ITText as="span" className="truncate">{subitem.label}</ITText>
|
|
345
|
-
</button>
|
|
346
|
-
</li>
|
|
347
|
-
))}
|
|
397
|
+
{item.subitems.map((entry) =>
|
|
398
|
+
isSubItemGroup(entry)
|
|
399
|
+
? renderSubItemGroup(entry, item.id)
|
|
400
|
+
: renderSubItem(entry),
|
|
401
|
+
)}
|
|
348
402
|
</ul>
|
|
349
403
|
</div>
|
|
350
404
|
)}
|
package/src/index.css
CHANGED
|
@@ -160,4 +160,14 @@
|
|
|
160
160
|
.it-stepper-slide-in-left {
|
|
161
161
|
animation: it-slide-in-left 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
162
162
|
}
|
|
163
|
+
|
|
164
|
+
/* Pinned (frozen) table columns: 1px edge separator on the frozen side.
|
|
165
|
+
Background + hover live in the component as Tailwind arbitrary utilities so
|
|
166
|
+
the cell matches --color-table-rowBg / --color-table-rowHover. */
|
|
167
|
+
:where(.it-table-pinned-left) {
|
|
168
|
+
box-shadow: inset -1px 0 0 0 var(--color-secondary-200);
|
|
169
|
+
}
|
|
170
|
+
:where(.it-table-pinned-right) {
|
|
171
|
+
box-shadow: inset 1px 0 0 0 var(--color-secondary-200);
|
|
172
|
+
}
|
|
163
173
|
}
|
package/src/index.ts
CHANGED
|
@@ -101,7 +101,7 @@ import type { ITSearchSelectProps } from "./components/molecules/search-select/s
|
|
|
101
101
|
import type { ITSelectProps } from "./components/molecules/select/select.props";
|
|
102
102
|
import type { ITSearchTableProps } from "./components/organisms/searchTable/searchTable.props";
|
|
103
103
|
import type { ITSegmentedControlProps } from "./components/atoms/segmented-control/segmented-control.props";
|
|
104
|
-
import type { ITSidebarProps, ITNavigationItem, ITNavigationSubItem } from "./components/organisms/sidebar/sidebar.props";
|
|
104
|
+
import type { ITSidebarProps, ITNavigationItem, ITNavigationSubItem, ITNavigationSubItemEntry, ITNavigationSubItemGroup } from "./components/organisms/sidebar/sidebar.props";
|
|
105
105
|
import type { ITSkeletonProps } from "./components/atoms/skeleton/skeleton.props";
|
|
106
106
|
import type { ITSlideToggleProps } from "./components/atoms/slide/slide.props";
|
|
107
107
|
import type { ITSliderProps } from "./components/atoms/slider/slider.props";
|
|
@@ -173,6 +173,7 @@ export {
|
|
|
173
173
|
ITLayoutProps, ITLoaderProps, ITNavbarProps,
|
|
174
174
|
ITPageProps, ITPageHeaderProps,
|
|
175
175
|
ITNavigationItem, ITNavigationSubItem,
|
|
176
|
+
ITNavigationSubItemEntry, ITNavigationSubItemGroup,
|
|
176
177
|
ITPaginationProps, ITPopoverProps, ITProgressProps,
|
|
177
178
|
ITRadioGroupProps, ITRadioOption, ITSelectProps, ITSearchSelectProps,
|
|
178
179
|
ITSearchTableProps,
|
|
@@ -248,6 +248,15 @@ export const TableShowcase = () => {
|
|
|
248
248
|
truncate: col.key === "email" || col.key === "name",
|
|
249
249
|
}));
|
|
250
250
|
|
|
251
|
+
// Pixel widths force horizontal overflow inside the 520px demo wrapper, so
|
|
252
|
+
// the first (Name) and last (Actions) columns can stay pinned while the
|
|
253
|
+
// middle ones scroll sideways.
|
|
254
|
+
const pinnedColumns: any = fixedColumns.map((col: any, i: number) => ({
|
|
255
|
+
...col,
|
|
256
|
+
width: [200, 240, 180, 160, 180, 96][i],
|
|
257
|
+
pinned: i === 0 ? "left" : i === fixedColumns.length - 1 ? "right" : undefined,
|
|
258
|
+
}));
|
|
259
|
+
|
|
251
260
|
// Single-line columns for virtualization: no avatar, no actions, no custom
|
|
252
261
|
// render, so every row is a deterministic one-line box.
|
|
253
262
|
const virtualColumns: any = [
|
|
@@ -344,6 +353,7 @@ export const TableShowcase = () => {
|
|
|
344
353
|
'<ITTable columns={columns} data={data} title="Users" />',
|
|
345
354
|
'<ITTable columns={columns} data={data} defaultView="cards" onRowClick={(row) => open(row)} />',
|
|
346
355
|
'<ITTable columns={fixedColumns} data={data} layout="fixed" density="compact" />',
|
|
356
|
+
'// Columna fija: pinned "left" | "right" (los offsets acumulan width en px)\n{ key: "name", label: "Name", type: "string", width: 200, pinned: "left" },\n{ key: "actions", label: "", type: "actions", width: 96, pinned: "right" }',
|
|
347
357
|
'<ITTable columns={cols} data={rows} virtualized stickyHeader layout="fixed" density="compact" defaultItemsPerPage={300} itemsPerPageOptions={[50, 100, 300]} />',
|
|
348
358
|
'// ITSearchSelect typeahead (server-side search opcional vía catalogOptions.onSearch)\n{ key: "role", label: "Role", type: "string", filter: "search", catalogOptions: { data: roles } }',
|
|
349
359
|
'// ITDatePicker fecha única\n{ key: "lastLogin", label: "Last login", type: "date", filter: "date", dateFilterOptions: { maxDate: new Date() } }',
|
|
@@ -377,6 +387,7 @@ export const TableShowcase = () => {
|
|
|
377
387
|
"onRowClick funciona en ambas vistas (tabla y cards).",
|
|
378
388
|
"Cuando se omite no se agrega cursor-pointer, foco, tabIndex ni role.",
|
|
379
389
|
"Column acepta width (number=px | string CSS), minWidth (px), align (left/center/right) y truncate (ellipsis + title nativo).",
|
|
390
|
+
"Columna fija: Column acepta pinned: \"left\" | \"right\" (opt-in; omitir = sin cambios). Los offsets sticky acumulan el width en px de cada columna pinneada previa del mismo lado; una columna sin width en px aporta 0px, así que declara width en px en todas las columnas pinneadas de un lado. La celda pinneada pinta fondo opaco (rowBg + rowHover) y un separador de 1px en el borde congelado. Una columna pinneada por lado es el contrato probado. Limitación: con variant=\"striped\" el fondo opaco tapa el tinte de la franja de esa columna.",
|
|
380
391
|
"Anti scroll horizontal: layout=\"fixed\" + width por columna + truncate + density.",
|
|
381
392
|
"Virtualización: solo vista tabla; rinde con page size grande; filas de alto uniforme; el scroll no se reinicia al cambiar filtros.",
|
|
382
393
|
"Virtualización exige alto de fila uniforme. El default es size-aware (getRowHeight(size, density)): con size=\"md\" el baseline es compact 33 / normal 45 / comfortable 53 (padding de celda + line box text-sm de 20px + 1px del divide-y); size lo desplaza por el line box (sm 16 / md 20 / lg 28 px, es decir −4 / 0 / +8 px). Una celda de dos líneas (avatar + texto) mide ~50px real, distinto de ese alto asumido, así que los spacers se desalinean: usar celdas de una sola línea o pasar un rowHeight explícito.",
|
|
@@ -483,6 +494,23 @@ export const TableShowcase = () => {
|
|
|
483
494
|
</div>
|
|
484
495
|
</div>
|
|
485
496
|
|
|
497
|
+
<div>
|
|
498
|
+
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Pinned columns — left (Name) & right (Actions)</h4>
|
|
499
|
+
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 520 }}>
|
|
500
|
+
<ITTable
|
|
501
|
+
columns={pinnedColumns}
|
|
502
|
+
data={TABLE_DATA}
|
|
503
|
+
title="Pinned columns"
|
|
504
|
+
layout="fixed"
|
|
505
|
+
density="compact"
|
|
506
|
+
defaultItemsPerPage={5}
|
|
507
|
+
/>
|
|
508
|
+
</div>
|
|
509
|
+
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
|
510
|
+
Scroll the table sideways: <code>pinned: "left"</code> freezes the Name column, <code>pinned: "right"</code> freezes the Actions column. Offsets accumulate from each preceding pinned column's pixel <code>width</code>.
|
|
511
|
+
</p>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
486
514
|
<div>
|
|
487
515
|
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Auto cards fallback (< 640px container)</h4>
|
|
488
516
|
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 420 }}>
|
|
@@ -598,6 +626,19 @@ export const DataTableShowcase = () => {
|
|
|
598
626
|
{ key: "active", label: "Active", type: "boolean" as const, filter: true }
|
|
599
627
|
], []);
|
|
600
628
|
|
|
629
|
+
// Pixel widths force horizontal overflow inside the 520px demo wrapper, so
|
|
630
|
+
// the first (ID) and last (Active) columns stay pinned while the middle
|
|
631
|
+
// ones scroll sideways.
|
|
632
|
+
const pinnedColumns = useMemo(
|
|
633
|
+
() =>
|
|
634
|
+
userColumns.map((col: any, i: number) => ({
|
|
635
|
+
...col,
|
|
636
|
+
width: [72, 220, 180, 180, 120][i],
|
|
637
|
+
pinned: i === 0 ? "left" : i === userColumns.length - 1 ? "right" : undefined,
|
|
638
|
+
})),
|
|
639
|
+
[userColumns]
|
|
640
|
+
);
|
|
641
|
+
|
|
601
642
|
const MOCK_USERS = useMemo(() =>
|
|
602
643
|
[
|
|
603
644
|
{ id: 1, name: "Sofía Castillo", role: "Admin", active: true },
|
|
@@ -875,6 +916,7 @@ export const DataTableShowcase = () => {
|
|
|
875
916
|
'<ITDataTable columns={columns} fetchData={api.fetchUsers} />',
|
|
876
917
|
'<ITDataTable columns={columns} fetchData={api.fetchUsers} onRowClick={(row) => open(row)} />',
|
|
877
918
|
'<ITDataTable columns={fixedColumns} fetchData={api.fetchUsers} layout="fixed" density="compact" />',
|
|
919
|
+
'// Columna fija: pinned "left" | "right" (los offsets acumulan width en px)\n{ key: "id", label: "ID", type: "number", width: 72, pinned: "left" },\n{ key: "active", label: "Active", type: "boolean", width: 120, pinned: "right" }',
|
|
878
920
|
'<ITDataTable columns={cols} fetchData={api.page} virtualized stickyHeader defaultItemsPerPage={200} itemsPerPageOptions={[50, 100, 200]} debounceMs={0} />',
|
|
879
921
|
'<ITDataTable columns={cols} fetchData={api.page} layout="fixed" density="compact" autoCardBreakpoint={640} />',
|
|
880
922
|
'// Filtro de columna tipo ITSearchSelect (typeahead)\n{ key: "role", label: "Role", type: "string", filter: "search", catalogOptions: { data: roles, onSearch: (q) => api.searchRoles(q) } }',
|
|
@@ -913,6 +955,7 @@ export const DataTableShowcase = () => {
|
|
|
913
955
|
"onRowClick funciona en ambas vistas (tabla y cards).",
|
|
914
956
|
"Interacciones que inician en botones, links o inputs no disparan onRowClick.",
|
|
915
957
|
"Column acepta width (number=px | string CSS), minWidth (px), align (left/center/right) y truncate (ellipsis + title nativo).",
|
|
958
|
+
"Columna fija: Column acepta pinned: \"left\" | \"right\" (opt-in; omitir = sin cambios). Los offsets sticky acumulan el width en px de cada columna pinneada previa del mismo lado; una columna sin width en px aporta 0px, así que declara width en px en todas las columnas pinneadas de un lado. La celda pinneada pinta fondo opaco (rowBg + rowHover) y un separador de 1px en el borde congelado. Una columna pinneada por lado es el contrato probado. Limitación: con variant=\"striped\" el fondo opaco tapa el tinte de la franja de esa columna.",
|
|
916
959
|
"Anti scroll horizontal: layout=\"fixed\" + width por columna + truncate + density.",
|
|
917
960
|
"Virtualización: solo vista tabla; rinde con page size grande; filas de alto uniforme; el scroll no se reinicia al cambiar filtros.",
|
|
918
961
|
"La virtualización server-side hace ventana sobre la PÁGINA ACTUAL (data.length), no sobre total: usar un defaultItemsPerPage grande (p. ej. 200) con itemsPerPageOptions que lo incluya, o el efecto es invisible.",
|
|
@@ -980,6 +1023,24 @@ export const DataTableShowcase = () => {
|
|
|
980
1023
|
</div>
|
|
981
1024
|
</div>
|
|
982
1025
|
|
|
1026
|
+
<div>
|
|
1027
|
+
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Pinned columns — left (ID) & right (Active)</h4>
|
|
1028
|
+
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden" style={{ maxWidth: 520 }}>
|
|
1029
|
+
<ITDataTable
|
|
1030
|
+
columns={pinnedColumns}
|
|
1031
|
+
fetchData={fetchData}
|
|
1032
|
+
title="Pinned columns"
|
|
1033
|
+
layout="fixed"
|
|
1034
|
+
density="compact"
|
|
1035
|
+
defaultItemsPerPage={4}
|
|
1036
|
+
debounceMs={0}
|
|
1037
|
+
/>
|
|
1038
|
+
</div>
|
|
1039
|
+
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
|
1040
|
+
Scroll the table sideways: <code>pinned: "left"</code> freezes the ID column, <code>pinned: "right"</code> freezes the Active column.
|
|
1041
|
+
</p>
|
|
1042
|
+
</div>
|
|
1043
|
+
|
|
983
1044
|
<div>
|
|
984
1045
|
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-200 mb-3">Server-side virtualization — 240 rows {dtVirtualized ? "(page 200)" : "(page 10)"}</h4>
|
|
985
1046
|
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
|
@@ -939,10 +939,23 @@ export const LayoutShowcase = () => {
|
|
|
939
939
|
id: "sales",
|
|
940
940
|
label: "Ventas",
|
|
941
941
|
icon: <FaShoppingCart />,
|
|
942
|
-
isActive: activeId === "sales" || activeId === "orders",
|
|
942
|
+
isActive: activeId === "sales" || activeId === "orders" || activeId === "quotes",
|
|
943
943
|
subitems: [
|
|
944
|
-
{
|
|
945
|
-
|
|
944
|
+
{
|
|
945
|
+
id: "sales-gestion",
|
|
946
|
+
label: "Gestión",
|
|
947
|
+
items: [
|
|
948
|
+
{ id: "orders", label: "Órdenes", action: () => setActiveId("orders") },
|
|
949
|
+
{ id: "invoices", label: "Facturas", action: () => setActiveId("invoices") },
|
|
950
|
+
],
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
id: "sales-catalogo",
|
|
954
|
+
label: "Catálogo",
|
|
955
|
+
items: [
|
|
956
|
+
{ id: "quotes", label: "Cotizaciones", action: () => setActiveId("quotes") },
|
|
957
|
+
],
|
|
958
|
+
},
|
|
946
959
|
],
|
|
947
960
|
},
|
|
948
961
|
{ id: "products", label: "Productos", icon: <FaBoxOpen />, isActive: activeId === "products", action: () => setActiveId("products"), badge: "3" },
|
|
@@ -968,7 +981,21 @@ export const LayoutShowcase = () => {
|
|
|
968
981
|
userMenu: { userName, userEmail, menuItems }
|
|
969
982
|
}}
|
|
970
983
|
sidebar={{
|
|
971
|
-
navigationItems: [
|
|
984
|
+
navigationItems: [
|
|
985
|
+
{ id: "dashboard", label: "Dashboard", icon: <FaChartLine />, isActive: true },
|
|
986
|
+
{
|
|
987
|
+
id: "sales", label: "Ventas", icon: <FaShoppingCart />,
|
|
988
|
+
subitems: [
|
|
989
|
+
{ id: "sales-gestion", label: "Gestión", items: [
|
|
990
|
+
{ id: "orders", label: "Órdenes" },
|
|
991
|
+
{ id: "invoices", label: "Facturas" },
|
|
992
|
+
]},
|
|
993
|
+
{ id: "sales-catalogo", label: "Catálogo", items: [
|
|
994
|
+
{ id: "quotes", label: "Cotizaciones" },
|
|
995
|
+
]},
|
|
996
|
+
]
|
|
997
|
+
},
|
|
998
|
+
],
|
|
972
999
|
isCollapsed: ${collapsed},
|
|
973
1000
|
// Slot opcional sobre la navegación (oculto al colapsar)
|
|
974
1001
|
header: <span>AXZY v1.3</span>
|
|
@@ -1114,13 +1141,14 @@ export const LayoutShowcase = () => {
|
|
|
1114
1141
|
],
|
|
1115
1142
|
props: [
|
|
1116
1143
|
{ name: "topBar", type: "ITTopBarProps", required: true, description: "Configuración de la barra superior." },
|
|
1117
|
-
{ name: "sidebar", type: "ITSidebarProps", required: true, description: "Configuración del sidebar (navigationItems, isCollapsed, header…)." },
|
|
1144
|
+
{ name: "sidebar", type: "ITSidebarProps", required: true, description: "Configuración del sidebar (navigationItems con subítems planos o agrupados, isCollapsed, header…)." },
|
|
1118
1145
|
{ name: "children", type: "React.ReactNode", required: true, description: "Contenido principal." },
|
|
1119
1146
|
{ name: "className", type: "string", description: "Clases del wrapper externo." },
|
|
1120
1147
|
{ name: "contentClassName", type: "string", description: "Clases del contenedor de contenido." },
|
|
1121
1148
|
{ name: "sidebar.header", type: "React.ReactNode", description: "Slot opcional sobre la navegación (ej. wordmark o filtro). Oculto al colapsar." },
|
|
1122
1149
|
],
|
|
1123
1150
|
notes: [
|
|
1151
|
+
"`subitems` acepta entradas planas o grupos con `label`+`items`; el título del grupo no es foco ni un tercer nivel.",
|
|
1124
1152
|
"El sidebar hereda aria-current/aria-expanded y acepta un slot header opcional.",
|
|
1125
1153
|
"Con `sidebar.isCollapsed` omitido el contenido reserva 88px (hover expande sobre el contenido). Si lo controlas a `false`, reserva 280px y no tapa el contenido.",
|
|
1126
1154
|
"El riel colapsado se expande automáticamente al pasar el cursor por encima; no hay botón de colapso.",
|
package/src/utils/table.utils.ts
CHANGED
|
@@ -39,3 +39,86 @@ export function isInteractiveTarget(
|
|
|
39
39
|
if (boundary instanceof Node && interactive === boundary) return false;
|
|
40
40
|
return true;
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// ──────────────────────────────────────────────
|
|
44
|
+
// PINNED (FROZEN) TABLE COLUMNS
|
|
45
|
+
// ──────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Horizontal edge a table column can be pinned to, used by `Column.pinned`. */
|
|
48
|
+
export type TablePinnedSide = "left" | "right";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Structural shape required to compute pinned offsets. Kept loose (not tied to
|
|
52
|
+
* `Column<T>`) so any column-like definition can be passed without importing
|
|
53
|
+
* the table props module.
|
|
54
|
+
*/
|
|
55
|
+
type PinnableColumn = {
|
|
56
|
+
/** Unique column key; also the map key in the returned metadata. */
|
|
57
|
+
key: string;
|
|
58
|
+
/** Edge the column is frozen to, if any. */
|
|
59
|
+
pinned?: "left" | "right";
|
|
60
|
+
/** Declared width; a pixel `number` / `"<n>px"` string drives the offset. */
|
|
61
|
+
width?: number | string;
|
|
62
|
+
/** Fallback offset width (px) when `width` is not a pixel value. */
|
|
63
|
+
minWidth?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Sticky metadata for one pinned column. */
|
|
67
|
+
export interface PinnedColumnMeta {
|
|
68
|
+
/** Edge the column is frozen to. */
|
|
69
|
+
side: TablePinnedSide;
|
|
70
|
+
/** Distance (px) from that edge to the column's sticky position. */
|
|
71
|
+
offset: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pixel width a pinned column contributes to the accumulated sticky offset.
|
|
76
|
+
* Numbers are pixels; `"<n>px"` strings are parsed; every other length
|
|
77
|
+
* (`%`, `rem`, `em`, …) falls back to `minWidth ?? 0`.
|
|
78
|
+
*/
|
|
79
|
+
function pinnedColumnWidth(col: PinnableColumn): number {
|
|
80
|
+
if (typeof col.width === "number") return col.width;
|
|
81
|
+
if (typeof col.width === "string" && /^\d+(\.\d+)?px$/.test(col.width.trim())) {
|
|
82
|
+
return parseFloat(col.width);
|
|
83
|
+
}
|
|
84
|
+
return col.minWidth ?? 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Computes the sticky `offset` for every pinned column in a table.
|
|
89
|
+
*
|
|
90
|
+
* Left-pinned columns accumulate forward in document order; right-pinned
|
|
91
|
+
* columns accumulate backward. Only pinned columns appear in the returned map,
|
|
92
|
+
* keyed by `col.key`. A column whose width is not resolvable to pixels
|
|
93
|
+
* contributes `minWidth ?? 0`, so declare a pixel `width` on every pinned
|
|
94
|
+
* column of the same side to keep offsets aligned.
|
|
95
|
+
*
|
|
96
|
+
* @param columns - Column definitions in render order.
|
|
97
|
+
* @returns Map of pinned column `key` → `{ side, offset }`.
|
|
98
|
+
*/
|
|
99
|
+
export function getPinnedColumnMeta<T extends PinnableColumn>(
|
|
100
|
+
columns: T[]
|
|
101
|
+
): Record<string, PinnedColumnMeta> {
|
|
102
|
+
const meta: Record<string, PinnedColumnMeta> = {};
|
|
103
|
+
|
|
104
|
+
// Left edge: walk forward, accumulating widths of preceding left-pinned columns.
|
|
105
|
+
let leftOffset = 0;
|
|
106
|
+
for (const col of columns) {
|
|
107
|
+
if (col.pinned === "left") {
|
|
108
|
+
meta[col.key] = { side: "left", offset: leftOffset };
|
|
109
|
+
leftOffset += pinnedColumnWidth(col);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Right edge: walk backward, accumulating widths of preceding right-pinned columns.
|
|
114
|
+
let rightOffset = 0;
|
|
115
|
+
for (let i = columns.length - 1; i >= 0; i--) {
|
|
116
|
+
const col = columns[i];
|
|
117
|
+
if (col.pinned === "right") {
|
|
118
|
+
meta[col.key] = { side: "right", offset: rightOffset };
|
|
119
|
+
rightOffset += pinnedColumnWidth(col);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return meta;
|
|
124
|
+
}
|