@dashforge/tw 0.7.0-beta → 0.8.0-beta

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/dist/index.esm.js +1092 -74
  3. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
  4. package/dist/src/components/DataGrid/dataGrid.types.d.ts +43 -0
  5. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +6 -0
  7. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -1
  8. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
  9. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
  11. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
  12. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
  13. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
  14. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
  15. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
  16. package/dist/src/components/Table/Table.d.ts.map +1 -1
  17. package/dist/src/components/Table/table.types.d.ts +69 -5
  18. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  19. package/dist/src/components/_shared/data/useTableFilter.d.ts +30 -7
  20. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -1
  21. package/dist/src/index.d.ts +1 -1
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/components/DataGrid/DataGrid.test.tsx +422 -1
  25. package/src/components/DataGrid/DataGrid.tsx +421 -53
  26. package/src/components/DataGrid/dataGrid.types.ts +50 -1
  27. package/src/components/DataGrid/dataGrid.variants.ts +9 -1
  28. package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
  29. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
  30. package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
  31. package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
  32. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
  33. package/src/components/Table/Table.tsx +14 -0
  34. package/src/components/Table/table.types.ts +74 -5
  35. package/src/components/_shared/data/useTableFilter.test.ts +166 -0
  36. package/src/components/_shared/data/useTableFilter.ts +97 -16
  37. package/src/index.ts +2 -0
@@ -1,4 +1,9 @@
1
- import { useMemo, type ReactNode, type MouseEvent } from 'react';
1
+ import {
2
+ useMemo,
3
+ type ReactNode,
4
+ type MouseEvent,
5
+ type PointerEvent as RPointerEvent,
6
+ } from 'react';
2
7
  import { cn } from '../../utils/cn.js';
3
8
  import { useAccessState } from '../../hooks/useAccessState.js';
4
9
  import { Skeleton } from '../Skeleton/Skeleton.js';
@@ -20,11 +25,20 @@ import {
20
25
  } from '../_shared/data/useColumnAutoDetect.js';
21
26
  import type {
22
27
  TableColumn,
28
+ TableColumnInferredType,
29
+ TableFilterType,
23
30
  TableSortModel,
24
31
  TableFilterModel,
25
32
  TableLabels,
26
33
  } from '../Table/table.types.js';
27
34
  import type { DataGridProps } from './dataGrid.types.js';
35
+ import { ColumnFilterTrigger } from './filters/ColumnFilters.js';
36
+ import { ColumnVisibilityTrigger } from './visibility/ColumnVisibilityDialog.js';
37
+ import { useColumnResize } from './resize/useColumnResize.js';
38
+ import {
39
+ useColumnReorder,
40
+ type ColumnReorderHandlers,
41
+ } from './reorder/useColumnReorder.js';
28
42
 
29
43
  const DEFAULT_LABELS: Required<TableLabels> = {
30
44
  searchPlaceholder: 'Search...',
@@ -42,10 +56,24 @@ const DEFAULT_LABELS: Required<TableLabels> = {
42
56
  filterColumn: 'Filter',
43
57
  filterApply: 'Apply',
44
58
  filterClear: 'Clear',
59
+ filterMin: 'Min',
60
+ filterMax: 'Max',
61
+ filterFrom: 'From',
62
+ filterTo: 'To',
63
+ filterAll: 'All',
64
+ filterTrue: 'True',
65
+ filterFalse: 'False',
45
66
  density: 'Density',
46
67
  densityCompact: 'Compact',
47
68
  densityComfortable: 'Comfortable',
48
69
  densitySpacious: 'Spacious',
70
+ columnsButton: 'Columns',
71
+ columnsTitle: 'Manage columns',
72
+ columnsDescription:
73
+ 'Show or hide columns. Required columns cannot be toggled.',
74
+ columnsShowAll: 'Show all',
75
+ columnsHideAll: 'Hide all',
76
+ columnsDone: 'Done',
49
77
  };
50
78
 
51
79
  /**
@@ -101,6 +129,18 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
101
129
  onFilterChange,
102
130
  serverSideFilter = false,
103
131
 
132
+ hiddenColumns: hiddenColumnsProp,
133
+ onHiddenColumnsChange,
134
+ enableColumnVisibility = true,
135
+
136
+ columnWidths: columnWidthsProp,
137
+ onColumnWidthsChange,
138
+ enableColumnResize = true,
139
+
140
+ columnOrder: columnOrderProp,
141
+ onColumnOrderChange,
142
+ enableColumnReorder = true,
143
+
104
144
  rowSelection = 'none',
105
145
  selectedRowIds: selectedRowIdsProp,
106
146
  onSelectionChange,
@@ -147,24 +187,116 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
147
187
  defaultValue: '',
148
188
  onChange: onSearchQueryChange,
149
189
  });
150
- // filterModel: read-only in v1 (no in-table filter UI; deferred to
151
- // v1-bis). Consumer wires it via filterModelProp + onFilterChange.
152
- void onFilterChange;
153
- const filterModel: TableFilterModel = filterModelProp ?? [];
190
+ // filterModel: in v1-bis the per-column header UI commits filter
191
+ // items to this controllable state. Consumer can lift it via
192
+ // filterModelProp + onFilterChange to drive server-side filtering.
193
+ const [filterModel, setFilterModel] = useControllableState<TableFilterModel>({
194
+ controlledValue: filterModelProp,
195
+ defaultValue: [],
196
+ onChange: onFilterChange,
197
+ });
154
198
  const [selectedRowIds, setSelectedRowIds] = useControllableState<string[]>({
155
199
  controlledValue: selectedRowIdsProp,
156
200
  defaultValue: [],
157
201
  onChange: onSelectionChange,
158
202
  });
159
203
 
160
- // ───── RBAC: filter columns ─────
161
- const visibleCols = useMemo(
204
+ // ───── Column visibility ─────
205
+ // Seed default hidden set from `defaultHidden` flag on columns.
206
+ // Memoized to avoid the seed shifting on every render — the
207
+ // controllable state keeps internal state stable thereafter.
208
+ const defaultHiddenColumns = useMemo(
162
209
  () =>
163
- cols.filter(
164
- (col) => !col.access || col.access.onUnauthorized !== 'hide',
165
- ),
210
+ cols
211
+ .filter((c) => c.defaultHidden === true)
212
+ .map((c) => c.field as string),
166
213
  [cols],
167
214
  );
215
+ const [hiddenColumns, setHiddenColumns] = useControllableState<string[]>({
216
+ controlledValue: hiddenColumnsProp,
217
+ defaultValue: defaultHiddenColumns,
218
+ onChange: onHiddenColumnsChange,
219
+ });
220
+
221
+ // ───── Column widths (resize state) ─────
222
+ // Seed from any `col.width` declared statically. Internal state is
223
+ // applied only when the user actually starts dragging; until then
224
+ // we fall back to the static width from the column def.
225
+ const defaultColumnWidths = useMemo(() => {
226
+ const map: Record<string, number> = {};
227
+ for (const c of cols) {
228
+ if (typeof c.width === 'number') {
229
+ map[c.field as string] = c.width;
230
+ }
231
+ }
232
+ return map;
233
+ }, [cols]);
234
+ const [columnWidths, setColumnWidths] = useControllableState<
235
+ Record<string, number>
236
+ >({
237
+ controlledValue: columnWidthsProp,
238
+ defaultValue: defaultColumnWidths,
239
+ onChange: onColumnWidthsChange,
240
+ });
241
+ const { startResize } = useColumnResize({
242
+ widths: columnWidths,
243
+ onChange: setColumnWidths,
244
+ });
245
+
246
+ // ───── Column order (reorder state) ─────
247
+ const defaultColumnOrder = useMemo(
248
+ () => cols.map((c) => c.field as string),
249
+ [cols],
250
+ );
251
+ const [columnOrder, setColumnOrder] = useControllableState<string[]>({
252
+ controlledValue: columnOrderProp,
253
+ defaultValue: defaultColumnOrder,
254
+ onChange: onColumnOrderChange,
255
+ });
256
+ const reorderHandlers = useColumnReorder({
257
+ order: columnOrder,
258
+ onChange: setColumnOrder,
259
+ });
260
+
261
+ // Apply the active order to the columns list. Cols not in the
262
+ // order array (e.g. newly-added columns at runtime) are appended
263
+ // in their original position from `cols`.
264
+ const orderedCols = useMemo(() => {
265
+ if (!columnOrder || columnOrder.length === 0) return cols;
266
+ const byField = new Map(
267
+ cols.map((c) => [c.field as string, c] as const),
268
+ );
269
+ const seen = new Set<string>();
270
+ const result: typeof cols = [];
271
+ for (const field of columnOrder) {
272
+ const c = byField.get(field);
273
+ if (c) {
274
+ result.push(c);
275
+ seen.add(field);
276
+ }
277
+ }
278
+ for (const c of cols) {
279
+ if (!seen.has(c.field as string)) result.push(c);
280
+ }
281
+ return result;
282
+ }, [cols, columnOrder]);
283
+
284
+ // ───── RBAC + visibility + reorder: derive final column list ─────
285
+ // - `access.onUnauthorized === 'hide'` removes a column entirely.
286
+ // - `hiddenColumns` is the user-toggled hidden set from the
287
+ // column-visibility dialog.
288
+ // - `orderedCols` is the user-reordered version of `cols`.
289
+ // NOTE: filtering happens on `orderedCols` so the active order is
290
+ // preserved through visibility changes.
291
+ const visibleCols = useMemo(
292
+ () =>
293
+ orderedCols.filter((col) => {
294
+ if (col.access && col.access.onUnauthorized === 'hide') return false;
295
+ if (hiddenColumns.includes(col.field as string)) return false;
296
+ return true;
297
+ }),
298
+ [orderedCols, hiddenColumns],
299
+ );
168
300
 
169
301
  // ───── Smart-default column types ─────
170
302
  const columnTypes = useColumnAutoDetect(rows, visibleCols);
@@ -257,25 +389,47 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
257
389
  data-disabled={!isInteractive ? 'true' : undefined}
258
390
  >
259
391
  {/* ───── Toolbar ───── */}
260
- {enableSearch && (
392
+ {(enableSearch || enableColumnVisibility) && (
261
393
  <div className={cn(v.toolbar(), slotProps?.toolbar?.className)}>
262
- <label className={cn(v.search(), slotProps?.search?.className)}>
263
- <span className="sr-only">{labels.searchPlaceholder}</span>
264
- <SearchIcon className="ml-2 shrink-0 text-neutral-500" />
265
- <input
266
- type="text"
267
- value={searchQuery}
268
- onChange={(e) => setSearchQuery(e.target.value)}
269
- placeholder={searchPlaceholder ?? labels.searchPlaceholder}
270
- disabled={!isInteractive}
271
- className={cn(
272
- 'flex-1 bg-transparent border-0 outline-none',
273
- 'px-2 py-1.5',
274
- 'placeholder:text-neutral-400',
275
- 'disabled:opacity-50 disabled:cursor-not-allowed',
394
+ {enableSearch && (
395
+ <label className={cn(v.search(), slotProps?.search?.className)}>
396
+ <span className="sr-only">{labels.searchPlaceholder}</span>
397
+ <SearchIcon className="ml-2 shrink-0 text-neutral-500" />
398
+ <input
399
+ type="text"
400
+ value={searchQuery}
401
+ onChange={(e) => setSearchQuery(e.target.value)}
402
+ placeholder={searchPlaceholder ?? labels.searchPlaceholder}
403
+ disabled={!isInteractive}
404
+ className={cn(
405
+ 'flex-1 bg-transparent border-0 outline-none',
406
+ 'px-2 py-1.5',
407
+ 'placeholder:text-neutral-400',
408
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
409
+ )}
410
+ />
411
+ </label>
412
+ )}
413
+ {enableColumnVisibility && (
414
+ <ColumnVisibilityTrigger
415
+ cols={cols.filter(
416
+ (c) =>
417
+ // RBAC-hidden columns are never offered in the dialog.
418
+ !c.access || c.access.onUnauthorized !== 'hide',
276
419
  )}
420
+ hiddenColumns={hiddenColumns}
421
+ onChange={setHiddenColumns}
422
+ disabled={!isInteractive}
423
+ labels={{
424
+ columnsButton: labels.columnsButton,
425
+ columnsTitle: labels.columnsTitle,
426
+ columnsDescription: labels.columnsDescription,
427
+ columnsShowAll: labels.columnsShowAll,
428
+ columnsHideAll: labels.columnsHideAll,
429
+ columnsDone: labels.columnsDone,
430
+ }}
277
431
  />
278
- </label>
432
+ )}
279
433
  </div>
280
434
  )}
281
435
 
@@ -336,10 +490,22 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
336
490
  handleSortClick(col, e, sortModel, setSortModel)
337
491
  }
338
492
  inferredType={columnTypes.get(col.field as string)}
493
+ filterModel={filterModel}
494
+ onFilterChange={setFilterModel}
495
+ effectiveWidth={resolveColumnWidth(col, columnWidths)}
496
+ resizable={
497
+ enableColumnResize && col.resizable !== false
498
+ }
499
+ startResize={startResize}
500
+ reorderable={
501
+ enableColumnReorder && col.reorderable !== false
502
+ }
503
+ reorderHandlers={reorderHandlers}
339
504
  labels={labels}
340
505
  className={cn(
341
506
  v.headerCell(),
342
507
  col.sticky === 'left' && v.stickyLeftHeaderCell(),
508
+ col.sticky === 'right' && v.stickyRightHeaderCell(),
343
509
  slotProps?.headerCell?.className,
344
510
  )}
345
511
  buttonClassName={v.headerCellButton()}
@@ -389,6 +555,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
389
555
  renderedRows,
390
556
  visibleCols,
391
557
  columnTypes,
558
+ columnWidths,
392
559
  selection,
393
560
  rowSelection,
394
561
  showSelectionColumn,
@@ -409,6 +576,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
409
576
  slotProps?.selectionCell?.className,
410
577
  ),
411
578
  stickyLeftCell: v.stickyLeftCell(),
579
+ stickyRightCell: v.stickyRightCell(),
412
580
  rowActionsCell: cn(
413
581
  v.cell(),
414
582
  v.rowActionsCell(),
@@ -475,6 +643,18 @@ function HeaderCell<T extends object>(props: {
475
643
  sortModel: TableSortModel;
476
644
  onSortClick: (e: MouseEvent<HTMLButtonElement>) => void;
477
645
  inferredType: string | undefined;
646
+ filterModel: TableFilterModel;
647
+ onFilterChange: (model: TableFilterModel) => void;
648
+ effectiveWidth: number | undefined;
649
+ resizable: boolean;
650
+ startResize: (
651
+ field: string,
652
+ startWidth: number,
653
+ clampMin: number,
654
+ clampMax: number,
655
+ ) => (e: RPointerEvent<HTMLElement>) => void;
656
+ reorderable: boolean;
657
+ reorderHandlers: ColumnReorderHandlers;
478
658
  labels: Required<TableLabels>;
479
659
  className: string;
480
660
  buttonClassName: string;
@@ -485,6 +665,13 @@ function HeaderCell<T extends object>(props: {
485
665
  sortModel,
486
666
  onSortClick,
487
667
  inferredType,
668
+ filterModel,
669
+ onFilterChange,
670
+ effectiveWidth,
671
+ resizable,
672
+ startResize,
673
+ reorderable,
674
+ reorderHandlers,
488
675
  labels,
489
676
  className,
490
677
  buttonClassName,
@@ -507,50 +694,213 @@ function HeaderCell<T extends object>(props: {
507
694
  typeof col.header === 'function' ? col.header() : col.header;
508
695
 
509
696
  const cellStyle = {
510
- width: col.width != null ? `${col.width}px` : undefined,
697
+ width: effectiveWidth != null ? `${effectiveWidth}px` : undefined,
511
698
  minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
512
- flex: col.flex,
699
+ flex: effectiveWidth != null ? undefined : col.flex,
700
+ position: resizable || reorderable ? ('relative' as const) : undefined,
513
701
  };
514
702
 
703
+ // Drop indicator for reorder: a 2px vertical bar at the LEFT or
704
+ // RIGHT edge of this th when it's the active drop target.
705
+ const dropTarget = reorderHandlers.dropTarget;
706
+ const dropIndicatorClass =
707
+ dropTarget != null && dropTarget.field === (col.field as string)
708
+ ? dropTarget.side === 'left'
709
+ ? 'before:absolute before:top-0 before:bottom-0 before:left-0 before:w-0.5 before:bg-primary-500'
710
+ : 'after:absolute after:top-0 after:bottom-0 after:right-0 after:w-0.5 after:bg-primary-500'
711
+ : '';
712
+
713
+ const isDragging = reorderHandlers.draggingField === (col.field as string);
714
+
715
+ // Drag attributes only attach when reorderable; we DO NOT make the
716
+ // entire th `draggable` if it isn't — keeps the keyboard focus
717
+ // behavior unchanged.
718
+ const dragAttrs = reorderable
719
+ ? {
720
+ draggable: true,
721
+ onDragStart: reorderHandlers.dragStart(col.field as string),
722
+ onDragOver: reorderHandlers.dragOver(col.field as string),
723
+ onDrop: reorderHandlers.drop(col.field as string),
724
+ onDragEnd: reorderHandlers.dragEnd,
725
+ }
726
+ : {};
727
+
728
+ const resizeHandle = resizable ? (
729
+ <span
730
+ role="separator"
731
+ aria-orientation="vertical"
732
+ aria-label={`Resize ${typeof col.header === 'string' ? col.header : col.field}`}
733
+ onPointerDown={(e) => {
734
+ const handle = e.currentTarget;
735
+ const th = handle.parentElement as HTMLElement | null;
736
+ const startWidth =
737
+ effectiveWidth ?? th?.getBoundingClientRect().width ?? 120;
738
+ const min = col.minWidth ?? 40;
739
+ const max = col.maxWidth ?? 1200;
740
+ startResize(col.field as string, startWidth, min, max)(e);
741
+ }}
742
+ className={cn(
743
+ 'absolute top-0 bottom-0 right-0 w-1.5',
744
+ 'cursor-col-resize select-none touch-none',
745
+ 'hover:bg-primary-300 active:bg-primary-400',
746
+ 'transition-colors motion-reduce:transition-none',
747
+ )}
748
+ />
749
+ ) : null;
750
+
751
+ const filterType = col.filterable
752
+ ? resolveFilterType(col, inferredType as TableColumnInferredType | undefined)
753
+ : null;
754
+
755
+ const filterTrigger =
756
+ col.filterable && filterType ? (
757
+ <ColumnFilterTrigger
758
+ field={col.field as string}
759
+ filterType={filterType}
760
+ filterModel={filterModel}
761
+ onFilterChange={onFilterChange}
762
+ disabled={disabled}
763
+ labels={{
764
+ filterColumn: labels.filterColumn,
765
+ filterApply: labels.filterApply,
766
+ filterClear: labels.filterClear,
767
+ filterMin: labels.filterMin,
768
+ filterMax: labels.filterMax,
769
+ filterFrom: labels.filterFrom,
770
+ filterTo: labels.filterTo,
771
+ filterAll: labels.filterAll,
772
+ filterTrue: labels.filterTrue,
773
+ filterFalse: labels.filterFalse,
774
+ }}
775
+ />
776
+ ) : null;
777
+
778
+ // Layout strategy:
779
+ // - sortable only → sort button is the cell content
780
+ // - filterable only → header text + filter trigger in a flex row
781
+ // - both → sort button + filter trigger in a flex row
782
+ // - neither → plain header text
783
+ const wrapperJustify =
784
+ align === 'right'
785
+ ? 'justify-end'
786
+ : align === 'center'
787
+ ? 'justify-center'
788
+ : 'justify-start';
789
+
515
790
  if (col.sortable) {
791
+ const sortButton = (
792
+ <button
793
+ type="button"
794
+ onClick={onSortClick}
795
+ disabled={disabled}
796
+ aria-label={
797
+ ariaSort === 'ascending'
798
+ ? labels.ariaSortAscending
799
+ : ariaSort === 'descending'
800
+ ? labels.ariaSortDescending
801
+ : labels.ariaSortNone
802
+ }
803
+ className={cn(
804
+ buttonClassName,
805
+ align === 'right' && 'flex-row-reverse',
806
+ 'disabled:cursor-not-allowed disabled:opacity-50',
807
+ )}
808
+ >
809
+ <span>{headerContent}</span>
810
+ <SortIcon direction={sortEntry?.direction} />
811
+ </button>
812
+ );
516
813
  return (
517
814
  <th
518
815
  scope="col"
519
816
  aria-sort={ariaSort}
520
- className={cn(className, alignClass)}
817
+ className={cn(
818
+ className,
819
+ alignClass,
820
+ dropIndicatorClass,
821
+ isDragging && 'opacity-50',
822
+ )}
521
823
  style={cellStyle}
824
+ {...dragAttrs}
522
825
  >
523
- <button
524
- type="button"
525
- onClick={onSortClick}
526
- disabled={disabled}
527
- aria-label={
528
- ariaSort === 'ascending'
529
- ? labels.ariaSortAscending
530
- : ariaSort === 'descending'
531
- ? labels.ariaSortDescending
532
- : labels.ariaSortNone
533
- }
534
- className={cn(
535
- buttonClassName,
536
- align === 'right' && 'flex-row-reverse',
537
- 'disabled:cursor-not-allowed disabled:opacity-50',
538
- )}
539
- >
540
- <span>{headerContent}</span>
541
- <SortIcon direction={sortEntry?.direction} />
542
- </button>
826
+ {filterTrigger ? (
827
+ <div className={cn('flex items-center gap-1', wrapperJustify)}>
828
+ {sortButton}
829
+ {filterTrigger}
830
+ </div>
831
+ ) : (
832
+ sortButton
833
+ )}
834
+ {resizeHandle}
543
835
  </th>
544
836
  );
545
837
  }
546
838
 
547
839
  return (
548
- <th scope="col" className={cn(className, alignClass)} style={cellStyle}>
549
- {headerContent}
840
+ <th
841
+ scope="col"
842
+ className={cn(
843
+ className,
844
+ alignClass,
845
+ dropIndicatorClass,
846
+ isDragging && 'opacity-50',
847
+ )}
848
+ style={cellStyle}
849
+ {...dragAttrs}
850
+ >
851
+ {filterTrigger ? (
852
+ <div className={cn('flex items-center gap-1', wrapperJustify)}>
853
+ <span>{headerContent}</span>
854
+ {filterTrigger}
855
+ </div>
856
+ ) : (
857
+ headerContent
858
+ )}
859
+ {resizeHandle}
550
860
  </th>
551
861
  );
552
862
  }
553
863
 
864
+ /**
865
+ * Resolve the effective width (px) for a column. User-driven resize
866
+ * (`columnWidths[field]`) wins over the static `col.width`. Returns
867
+ * `undefined` if neither source has a width set — the consumer falls
868
+ * back to the column's `flex` value or browser auto-layout.
869
+ */
870
+ function resolveColumnWidth<T extends object>(
871
+ col: TableColumn<T>,
872
+ columnWidths: Record<string, number>,
873
+ ): number | undefined {
874
+ const resized = columnWidths[col.field as string];
875
+ if (resized != null) return resized;
876
+ return col.width;
877
+ }
878
+
879
+ /**
880
+ * Resolve the filter UI type for a column. Explicit `col.filterType`
881
+ * wins; otherwise we map the auto-detected primitive type:
882
+ * - `number` → number range
883
+ * - `boolean` → true/false/all select
884
+ * - `date` → date range
885
+ * - anything else (string / unknown) → text contains
886
+ */
887
+ function resolveFilterType<T extends object>(
888
+ col: TableColumn<T>,
889
+ inferred: TableColumnInferredType | undefined,
890
+ ): TableFilterType {
891
+ if (col.filterType) return col.filterType;
892
+ switch (inferred) {
893
+ case 'number':
894
+ return 'number';
895
+ case 'boolean':
896
+ return 'boolean';
897
+ case 'date':
898
+ return 'date';
899
+ default:
900
+ return 'text';
901
+ }
902
+ }
903
+
554
904
  // ───── Virtualized body ─────
555
905
 
556
906
  function renderVirtualizedBody<T extends object>(args: {
@@ -559,6 +909,7 @@ function renderVirtualizedBody<T extends object>(args: {
559
909
  renderedRows: T[];
560
910
  visibleCols: TableColumn<T>[];
561
911
  columnTypes: Map<string, string>;
912
+ columnWidths: Record<string, number>;
562
913
  selection: ReturnType<typeof useTableSelection>;
563
914
  rowSelection: 'none' | 'single' | 'multiple';
564
915
  showSelectionColumn: boolean;
@@ -573,6 +924,7 @@ function renderVirtualizedBody<T extends object>(args: {
573
924
  cell: string;
574
925
  selectionCell: string;
575
926
  stickyLeftCell: string;
927
+ stickyRightCell: string;
576
928
  rowActionsCell: string;
577
929
  };
578
930
  }): ReactNode {
@@ -581,6 +933,7 @@ function renderVirtualizedBody<T extends object>(args: {
581
933
  renderedRows,
582
934
  visibleCols,
583
935
  columnTypes,
936
+ columnWidths,
584
937
  selection,
585
938
  rowSelection,
586
939
  showSelectionColumn,
@@ -628,8 +981,10 @@ function renderVirtualizedBody<T extends object>(args: {
628
981
  rowIndex={absoluteIdx}
629
982
  isSelected={isSelected}
630
983
  inferredType={columnTypes.get(col.field as string)}
984
+ effectiveWidth={resolveColumnWidth(col, columnWidths)}
631
985
  cellClass={classes.cell}
632
986
  stickyLeftClass={classes.stickyLeftCell}
987
+ stickyRightClass={classes.stickyRightCell}
633
988
  />
634
989
  ))}
635
990
  {showRowActionsColumn && rowActions && (
@@ -653,10 +1008,22 @@ function DataCell<T extends object>(props: {
653
1008
  rowIndex: number;
654
1009
  isSelected: boolean;
655
1010
  inferredType: string | undefined;
1011
+ effectiveWidth: number | undefined;
656
1012
  cellClass: string;
657
1013
  stickyLeftClass: string;
1014
+ stickyRightClass: string;
658
1015
  }) {
659
- const { col, row, rowIndex, isSelected, inferredType, cellClass, stickyLeftClass } = props;
1016
+ const {
1017
+ col,
1018
+ row,
1019
+ rowIndex,
1020
+ isSelected,
1021
+ inferredType,
1022
+ effectiveWidth,
1023
+ cellClass,
1024
+ stickyLeftClass,
1025
+ stickyRightClass,
1026
+ } = props;
660
1027
  const value = getNestedValue(row, col.field as string);
661
1028
  const align = resolveAlign(col, inferredType as never);
662
1029
  const tabularNums = resolveTabularNums(col, inferredType as never);
@@ -691,9 +1058,10 @@ function DataCell<T extends object>(props: {
691
1058
  tabularNums && 'tabular-nums',
692
1059
  monospace && 'font-mono',
693
1060
  col.sticky === 'left' && stickyLeftClass,
1061
+ col.sticky === 'right' && stickyRightClass,
694
1062
  )}
695
1063
  style={{
696
- width: col.width != null ? `${col.width}px` : undefined,
1064
+ width: effectiveWidth != null ? `${effectiveWidth}px` : undefined,
697
1065
  minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
698
1066
  }}
699
1067
  >