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