@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
package/dist/index.esm.js CHANGED
@@ -8259,28 +8259,99 @@ const DEFAULT_PAGE_SIZE_OPTIONS = [
8259
8259
  }
8260
8260
 
8261
8261
  /**
8262
- * Filters `rows` by the per-column filter model. v1 only supports
8263
- * the `contains` operator (text substring) — additional ops
8264
- * (`equals`, `in`, `gte`, `lte`, `between`, `before`, `after`)
8265
- * land in v1-bis once the filter UI surface for each shape is
8266
- * fleshed out.
8267
- *
8268
- * AND semantics across columns: a row must match EVERY active
8269
- * filter. Within a single filter, the op decides the predicate.
8262
+ * Coerce a value to a number for range comparison. Returns
8263
+ * `null` if not coercible.
8264
+ */ function toNumber(value) {
8265
+ if (value == null) return null;
8266
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
8267
+ if (typeof value === 'bigint') return Number(value);
8268
+ if (typeof value === 'string') {
8269
+ const n = Number(value);
8270
+ return Number.isFinite(n) ? n : null;
8271
+ }
8272
+ return null;
8273
+ }
8274
+ /**
8275
+ * Coerce a value to a Date's epoch ms for date-range comparison.
8276
+ * Accepts `Date` instances + ISO 8601 strings. Returns `null` if
8277
+ * not coercible.
8278
+ */ function toDateMs(value) {
8279
+ if (value == null) return null;
8280
+ if (value instanceof Date) return value.getTime();
8281
+ if (typeof value === 'string') {
8282
+ const d = new Date(value);
8283
+ const t = d.getTime();
8284
+ return Number.isNaN(t) ? null : t;
8285
+ }
8286
+ return null;
8287
+ }
8288
+ /**
8289
+ * Test one row × one filter. Returns `true` if the row passes the
8290
+ * filter. The operator decides the predicate:
8291
+ *
8292
+ * - `contains` → substring match on stringified value (text filter)
8293
+ * - `equals` → strict equality (boolean filter mostly)
8294
+ * - `between` → `value: [min, max]` tuple. Each end can be `null`
8295
+ * for an open range. Used by number range + date range filters.
8296
+ *
8297
+ * Empty filter is a no-op (passes every row).
8298
+ */ function passesFilter(rowValue, op, filterValue) {
8299
+ switch(op){
8300
+ case 'contains':
8301
+ {
8302
+ const needle = typeof filterValue === 'string' ? filterValue.trim().toLowerCase() : '';
8303
+ if (!needle) return true;
8304
+ return stringifyForSearch(rowValue).toLowerCase().includes(needle);
8305
+ }
8306
+ case 'equals':
8307
+ {
8308
+ if (filterValue == null) return true;
8309
+ return rowValue === filterValue;
8310
+ }
8311
+ case 'between':
8312
+ {
8313
+ if (!Array.isArray(filterValue) || filterValue.length !== 2) return true;
8314
+ const [min, max] = filterValue;
8315
+ if (min == null && max == null) return true;
8316
+ // Try number range first.
8317
+ const rowNum = toNumber(rowValue);
8318
+ const minNum = min == null ? null : toNumber(min);
8319
+ const maxNum = max == null ? null : toNumber(max);
8320
+ if (rowNum != null && (minNum != null || maxNum != null)) {
8321
+ if (minNum != null && rowNum < minNum) return false;
8322
+ if (maxNum != null && rowNum > maxNum) return false;
8323
+ return true;
8324
+ }
8325
+ // Fall through to date range.
8326
+ const rowMs = toDateMs(rowValue);
8327
+ const minMs = min == null ? null : toDateMs(min);
8328
+ const maxMs = max == null ? null : toDateMs(max);
8329
+ if (rowMs != null && (minMs != null || maxMs != null)) {
8330
+ if (minMs != null && rowMs < minMs) return false;
8331
+ if (maxMs != null && rowMs > maxMs) return false;
8332
+ return true;
8333
+ }
8334
+ // If neither coercion worked, let the row pass (defensive).
8335
+ return true;
8336
+ }
8337
+ default:
8338
+ return true;
8339
+ }
8340
+ }
8341
+ /**
8342
+ * Filters `rows` by the per-column filter model. AND semantics
8343
+ * across columns — a row must pass EVERY active filter.
8344
+ *
8345
+ * Each filter item specifies an `op` (`'contains'` / `'equals'` /
8346
+ * `'between'`) and a `value` whose shape matches the operator
8347
+ * (string for contains; primitive for equals; `[min, max]` tuple
8348
+ * for between).
8270
8349
  */ function useTableFilter(rows, filterModel) {
8271
8350
  return useMemo(()=>{
8272
8351
  if (!filterModel || filterModel.length === 0) return rows;
8273
8352
  return rows.filter((row)=>filterModel.every((filter)=>{
8274
8353
  const value = getNestedValue(row, filter.field);
8275
- const stringValue = stringifyForSearch(value).toLowerCase();
8276
- const needle = filter.value.trim().toLowerCase();
8277
- if (!needle) return true; // empty filter == no-op
8278
- switch(filter.op){
8279
- case 'contains':
8280
- return stringValue.includes(needle);
8281
- default:
8282
- return true;
8283
- }
8354
+ return passesFilter(value, filter.op, filter.value);
8284
8355
  }));
8285
8356
  }, [
8286
8357
  rows,
@@ -8592,10 +8663,21 @@ const DEFAULT_LABELS$1 = {
8592
8663
  filterColumn: 'Filter',
8593
8664
  filterApply: 'Apply',
8594
8665
  filterClear: 'Clear',
8666
+ filterMin: 'Min',
8667
+ filterMax: 'Max',
8668
+ filterFrom: 'From',
8669
+ filterTo: 'To',
8670
+ filterAll: 'All',
8671
+ filterTrue: 'True',
8672
+ filterFalse: 'False',
8595
8673
  density: 'Density',
8596
8674
  densityCompact: 'Compact',
8597
8675
  densityComfortable: 'Comfortable',
8598
- densitySpacious: 'Spacious'
8676
+ densitySpacious: 'Spacious',
8677
+ columnsButton: 'Columns',
8678
+ columnsTitle: 'Toggle columns',
8679
+ columnsShowAll: 'Show all',
8680
+ columnsHideAll: 'Hide all'
8599
8681
  };
8600
8682
  const DEFAULT_GET_ROW_ID = (_row, index)=>String(index);
8601
8683
  /**
@@ -9536,7 +9618,7 @@ function RowActionMenuItem(props) {
9536
9618
  'border-t border-neutral-200',
9537
9619
  'bg-neutral-50 shadow-md'
9538
9620
  ],
9539
- // Sticky left column — uses `position: sticky; left: 0` + z-index
9621
+ // Sticky LEFT column — uses `position: sticky; left: 0` + z-index
9540
9622
  // so the column stays pinned during horizontal scroll. The
9541
9623
  // `bg-neutral-50` ensures the column doesn't bleed when content
9542
9624
  // scrolls underneath.
@@ -9544,6 +9626,12 @@ function RowActionMenuItem(props) {
9544
9626
  // Sticky left × header intersection (top-left corner): higher
9545
9627
  // z-index than both header (`z-10`) and sticky col (`z-[1]`).
9546
9628
  stickyLeftHeaderCell: 'sticky left-0 z-20 bg-neutral-50',
9629
+ // Sticky RIGHT column — mirror of stickyLeftCell. The first
9630
+ // right-sticky column gets a `border-l` to separate it visually
9631
+ // from the scrollable area (the consumer can override via
9632
+ // slotProps if they don't want it).
9633
+ stickyRightCell: 'sticky right-0 z-[1] bg-neutral-50 border-l border-neutral-200',
9634
+ stickyRightHeaderCell: 'sticky right-0 z-20 bg-neutral-50 border-l border-neutral-200',
9547
9635
  paginationFooter: 'flex justify-end mt-3'
9548
9636
  },
9549
9637
  variants: {
@@ -9748,6 +9836,661 @@ function RowActionMenuItem(props) {
9748
9836
  };
9749
9837
  }
9750
9838
 
9839
+ /**
9840
+ * Per-column filter UI for DataGrid.
9841
+ *
9842
+ * `<ColumnFilterTrigger>` renders the filter icon button in the
9843
+ * column header — when clicked, opens a Popover with the
9844
+ * appropriate filter UI for the column type:
9845
+ *
9846
+ * - `text` → `<TextFilter>` with single input (op: `contains`)
9847
+ * - `number` → `<NumberRangeFilter>` with min/max inputs (op: `between`)
9848
+ * - `boolean` → `<BooleanFilter>` with true/false/all radio (op: `equals`)
9849
+ * - `date` → `<DateRangeFilter>` with min/max date inputs (op: `between`)
9850
+ *
9851
+ * Each filter UI has Apply + Clear actions; Apply commits the
9852
+ * filter to the model, Clear removes any existing filter for that
9853
+ * column.
9854
+ */ // ───── Helper: read/write filter model for a single column ─────
9855
+ function findFilter(model, field) {
9856
+ return model.find((f)=>f.field === field);
9857
+ }
9858
+ function setFilter(model, field, next) {
9859
+ const rest = model.filter((f)=>f.field !== field);
9860
+ if (next == null) return rest;
9861
+ return [
9862
+ ...rest,
9863
+ next
9864
+ ];
9865
+ }
9866
+ const DEFAULT_FILTER_LABELS = {
9867
+ filterColumn: 'Filter',
9868
+ filterApply: 'Apply',
9869
+ filterClear: 'Clear',
9870
+ filterMin: 'Min',
9871
+ filterMax: 'Max',
9872
+ filterFrom: 'From',
9873
+ filterTo: 'To',
9874
+ filterAll: 'All',
9875
+ filterTrue: 'True',
9876
+ filterFalse: 'False'
9877
+ };
9878
+ function ColumnFilterTrigger(props) {
9879
+ const { field, filterType, filterModel, onFilterChange, disabled, labels: labelsProp } = props;
9880
+ const labels = _extends({}, DEFAULT_FILTER_LABELS, labelsProp);
9881
+ const [open, setOpen] = useState(false);
9882
+ const active = findFilter(filterModel, field) != null;
9883
+ return jsx(Popover, {
9884
+ open: open,
9885
+ onOpenChange: setOpen,
9886
+ side: "bottom",
9887
+ align: "end",
9888
+ content: jsx(FilterPanel, {
9889
+ field: field,
9890
+ filterType: filterType,
9891
+ filterModel: filterModel,
9892
+ onFilterChange: (next)=>{
9893
+ onFilterChange(next);
9894
+ setOpen(false);
9895
+ },
9896
+ labels: labels
9897
+ }),
9898
+ children: jsx("button", {
9899
+ type: "button",
9900
+ "aria-label": labels.filterColumn,
9901
+ "aria-pressed": active,
9902
+ disabled: disabled,
9903
+ className: cn('inline-flex items-center justify-center h-6 w-6 rounded', 'text-neutral-500 hover:text-neutral-900', active && 'text-primary-700', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'transition-colors motion-reduce:transition-none', 'disabled:opacity-50 disabled:cursor-not-allowed'),
9904
+ children: jsx(FilterIcon, {})
9905
+ })
9906
+ });
9907
+ }
9908
+ // ───── Panel — dispatches to the right filter UI ─────
9909
+ function FilterPanel(props) {
9910
+ const { field, filterType, filterModel, onFilterChange, labels } = props;
9911
+ const current = findFilter(filterModel, field);
9912
+ const commit = (next)=>{
9913
+ onFilterChange(setFilter(filterModel, field, next));
9914
+ };
9915
+ switch(filterType){
9916
+ case 'number':
9917
+ return jsx(NumberRangeFilter, {
9918
+ field: field,
9919
+ current: current,
9920
+ onCommit: commit,
9921
+ labels: labels
9922
+ });
9923
+ case 'boolean':
9924
+ return jsx(BooleanFilter, {
9925
+ field: field,
9926
+ current: current,
9927
+ onCommit: commit,
9928
+ labels: labels
9929
+ });
9930
+ case 'date':
9931
+ return jsx(DateRangeFilter, {
9932
+ field: field,
9933
+ current: current,
9934
+ onCommit: commit,
9935
+ labels: labels
9936
+ });
9937
+ case 'text':
9938
+ default:
9939
+ return jsx(TextFilter, {
9940
+ field: field,
9941
+ current: current,
9942
+ onCommit: commit,
9943
+ labels: labels
9944
+ });
9945
+ }
9946
+ }
9947
+ // ───── Text filter ─────
9948
+ function TextFilter(props) {
9949
+ const { field, current, onCommit, labels } = props;
9950
+ const [value, setValue] = useState(typeof (current == null ? void 0 : current.value) === 'string' ? current.value : '');
9951
+ return jsxs("div", {
9952
+ className: "flex flex-col gap-2 min-w-[200px]",
9953
+ children: [
9954
+ jsx("input", {
9955
+ type: "text",
9956
+ value: value,
9957
+ onChange: (e)=>setValue(e.target.value),
9958
+ placeholder: "\u2026",
9959
+ autoFocus: true,
9960
+ className: "rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
9961
+ }),
9962
+ jsx(FilterActions, {
9963
+ onApply: ()=>onCommit(value.trim() ? {
9964
+ field,
9965
+ op: 'contains',
9966
+ value
9967
+ } : null),
9968
+ onClear: ()=>{
9969
+ setValue('');
9970
+ onCommit(null);
9971
+ },
9972
+ labels: labels
9973
+ })
9974
+ ]
9975
+ });
9976
+ }
9977
+ // ───── Number range filter ─────
9978
+ function NumberRangeFilter(props) {
9979
+ const { field, current, onCommit, labels } = props;
9980
+ const currentValue = current == null ? void 0 : current.value;
9981
+ const currentTuple = Array.isArray(currentValue) ? currentValue : [
9982
+ null,
9983
+ null
9984
+ ];
9985
+ const [min, setMin] = useState(currentTuple[0] != null ? String(currentTuple[0]) : '');
9986
+ const [max, setMax] = useState(currentTuple[1] != null ? String(currentTuple[1]) : '');
9987
+ const apply = ()=>{
9988
+ const minN = min.trim() ? Number(min) : null;
9989
+ const maxN = max.trim() ? Number(max) : null;
9990
+ if ((minN == null || Number.isFinite(minN)) && (maxN == null || Number.isFinite(maxN))) {
9991
+ if (minN == null && maxN == null) {
9992
+ onCommit(null);
9993
+ } else {
9994
+ onCommit({
9995
+ field,
9996
+ op: 'between',
9997
+ value: [
9998
+ minN,
9999
+ maxN
10000
+ ]
10001
+ });
10002
+ }
10003
+ }
10004
+ };
10005
+ return jsxs("div", {
10006
+ className: "flex flex-col gap-2 min-w-[220px]",
10007
+ children: [
10008
+ jsxs("div", {
10009
+ className: "grid grid-cols-2 gap-2",
10010
+ children: [
10011
+ jsxs("label", {
10012
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10013
+ children: [
10014
+ jsx("span", {
10015
+ children: labels.filterMin
10016
+ }),
10017
+ jsx("input", {
10018
+ type: "number",
10019
+ value: min,
10020
+ onChange: (e)=>setMin(e.target.value),
10021
+ autoFocus: true,
10022
+ className: "rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
10023
+ })
10024
+ ]
10025
+ }),
10026
+ jsxs("label", {
10027
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10028
+ children: [
10029
+ jsx("span", {
10030
+ children: labels.filterMax
10031
+ }),
10032
+ jsx("input", {
10033
+ type: "number",
10034
+ value: max,
10035
+ onChange: (e)=>setMax(e.target.value),
10036
+ className: "rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
10037
+ })
10038
+ ]
10039
+ })
10040
+ ]
10041
+ }),
10042
+ jsx(FilterActions, {
10043
+ onApply: apply,
10044
+ onClear: ()=>{
10045
+ setMin('');
10046
+ setMax('');
10047
+ onCommit(null);
10048
+ },
10049
+ labels: labels
10050
+ })
10051
+ ]
10052
+ });
10053
+ }
10054
+ // ───── Boolean filter ─────
10055
+ function BooleanFilter(props) {
10056
+ const { field, current, onCommit, labels } = props;
10057
+ const initial = (current == null ? void 0 : current.value) === true ? 'true' : (current == null ? void 0 : current.value) === false ? 'false' : 'all';
10058
+ const [value, setValue] = useState(initial);
10059
+ const apply = ()=>{
10060
+ if (value === 'all') onCommit(null);
10061
+ else onCommit({
10062
+ field,
10063
+ op: 'equals',
10064
+ value: value === 'true'
10065
+ });
10066
+ };
10067
+ return jsxs("div", {
10068
+ className: "flex flex-col gap-2 min-w-[160px]",
10069
+ children: [
10070
+ jsx("fieldset", {
10071
+ className: "flex flex-col gap-1",
10072
+ children: [
10073
+ 'all',
10074
+ 'true',
10075
+ 'false'
10076
+ ].map((opt)=>jsxs("label", {
10077
+ className: "flex items-center gap-2 text-sm cursor-pointer",
10078
+ children: [
10079
+ jsx("input", {
10080
+ type: "radio",
10081
+ name: `filter-${field}`,
10082
+ value: opt,
10083
+ checked: value === opt,
10084
+ onChange: ()=>setValue(opt),
10085
+ className: "cursor-pointer"
10086
+ }),
10087
+ jsx("span", {
10088
+ children: opt === 'all' ? labels.filterAll : opt === 'true' ? labels.filterTrue : labels.filterFalse
10089
+ })
10090
+ ]
10091
+ }, opt))
10092
+ }),
10093
+ jsx(FilterActions, {
10094
+ onApply: apply,
10095
+ onClear: ()=>{
10096
+ setValue('all');
10097
+ onCommit(null);
10098
+ },
10099
+ labels: labels
10100
+ })
10101
+ ]
10102
+ });
10103
+ }
10104
+ // ───── Date range filter ─────
10105
+ function DateRangeFilter(props) {
10106
+ const { field, current, onCommit, labels } = props;
10107
+ const currentValue = current == null ? void 0 : current.value;
10108
+ const currentTuple = Array.isArray(currentValue) ? currentValue : [
10109
+ null,
10110
+ null
10111
+ ];
10112
+ const [from, setFrom] = useState(typeof currentTuple[0] === 'string' ? currentTuple[0] : '');
10113
+ const [to, setTo] = useState(typeof currentTuple[1] === 'string' ? currentTuple[1] : '');
10114
+ const apply = ()=>{
10115
+ if (!from && !to) {
10116
+ onCommit(null);
10117
+ } else {
10118
+ onCommit({
10119
+ field,
10120
+ op: 'between',
10121
+ value: [
10122
+ from || null,
10123
+ to || null
10124
+ ]
10125
+ });
10126
+ }
10127
+ };
10128
+ return jsxs("div", {
10129
+ className: "flex flex-col gap-2 min-w-[220px]",
10130
+ children: [
10131
+ jsxs("div", {
10132
+ className: "grid grid-cols-2 gap-2",
10133
+ children: [
10134
+ jsxs("label", {
10135
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10136
+ children: [
10137
+ jsx("span", {
10138
+ children: labels.filterFrom
10139
+ }),
10140
+ jsx("input", {
10141
+ type: "date",
10142
+ value: from,
10143
+ onChange: (e)=>setFrom(e.target.value),
10144
+ autoFocus: true,
10145
+ className: "rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
10146
+ })
10147
+ ]
10148
+ }),
10149
+ jsxs("label", {
10150
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10151
+ children: [
10152
+ jsx("span", {
10153
+ children: labels.filterTo
10154
+ }),
10155
+ jsx("input", {
10156
+ type: "date",
10157
+ value: to,
10158
+ onChange: (e)=>setTo(e.target.value),
10159
+ className: "rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
10160
+ })
10161
+ ]
10162
+ })
10163
+ ]
10164
+ }),
10165
+ jsx(FilterActions, {
10166
+ onApply: apply,
10167
+ onClear: ()=>{
10168
+ setFrom('');
10169
+ setTo('');
10170
+ onCommit(null);
10171
+ },
10172
+ labels: labels
10173
+ })
10174
+ ]
10175
+ });
10176
+ }
10177
+ // ───── Apply + Clear actions ─────
10178
+ function FilterActions(props) {
10179
+ return jsxs("div", {
10180
+ className: "flex justify-end gap-2 pt-1 border-t border-neutral-200",
10181
+ children: [
10182
+ jsx("button", {
10183
+ type: "button",
10184
+ onClick: props.onClear,
10185
+ className: "text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline px-2 py-1",
10186
+ children: props.labels.filterClear
10187
+ }),
10188
+ jsx("button", {
10189
+ type: "button",
10190
+ onClick: props.onApply,
10191
+ className: "text-xs bg-primary-500 text-white rounded-md hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 px-3 py-1",
10192
+ children: props.labels.filterApply
10193
+ })
10194
+ ]
10195
+ });
10196
+ }
10197
+ // ───── Icon ─────
10198
+ function FilterIcon() {
10199
+ return jsx("svg", {
10200
+ width: "0.875em",
10201
+ height: "0.875em",
10202
+ viewBox: "0 0 16 16",
10203
+ fill: "none",
10204
+ stroke: "currentColor",
10205
+ strokeWidth: "1.5",
10206
+ strokeLinecap: "round",
10207
+ strokeLinejoin: "round",
10208
+ "aria-hidden": "true",
10209
+ children: jsx("path", {
10210
+ d: "M2 3h12M4 8h8M6 13h4"
10211
+ })
10212
+ });
10213
+ }
10214
+
10215
+ const DEFAULT_VISIBILITY_LABELS = {
10216
+ columnsButton: 'Columns',
10217
+ columnsTitle: 'Toggle columns',
10218
+ columnsShowAll: 'Show all',
10219
+ columnsHideAll: 'Hide all'
10220
+ };
10221
+ function ColumnVisibilityTrigger(props) {
10222
+ const { cols, hiddenColumns, onChange, disabled, labels: labelsProp } = props;
10223
+ const labels = _extends({}, DEFAULT_VISIBILITY_LABELS, labelsProp);
10224
+ const [open, setOpen] = useState(false);
10225
+ // Hideable cols are the only ones the user can toggle. `hideable`
10226
+ // defaults to `true` — a column is hideable unless explicitly opted
10227
+ // out with `hideable: false` (structurally required column).
10228
+ const hideableCols = cols.filter((c)=>c.hideable !== false);
10229
+ const toggle = (field)=>{
10230
+ onChange(hiddenColumns.includes(field) ? hiddenColumns.filter((f)=>f !== field) : [
10231
+ ...hiddenColumns,
10232
+ field
10233
+ ]);
10234
+ };
10235
+ const showAll = ()=>onChange([]);
10236
+ const hideAll = ()=>onChange(hideableCols.map((c)=>c.field));
10237
+ return jsx(Popover, {
10238
+ open: open,
10239
+ onOpenChange: setOpen,
10240
+ side: "bottom",
10241
+ align: "end",
10242
+ content: jsxs("div", {
10243
+ className: "flex flex-col gap-2 min-w-[220px]",
10244
+ children: [
10245
+ jsxs("div", {
10246
+ className: "flex items-center justify-between pb-2 border-b border-neutral-200",
10247
+ children: [
10248
+ jsx("span", {
10249
+ className: "text-xs font-semibold text-neutral-700 uppercase tracking-wide",
10250
+ children: labels.columnsTitle
10251
+ }),
10252
+ jsxs("div", {
10253
+ className: "flex items-center gap-2",
10254
+ children: [
10255
+ jsx("button", {
10256
+ type: "button",
10257
+ onClick: showAll,
10258
+ className: "text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline",
10259
+ children: labels.columnsShowAll
10260
+ }),
10261
+ jsx("span", {
10262
+ className: "text-neutral-300",
10263
+ "aria-hidden": "true",
10264
+ children: "\u00B7"
10265
+ }),
10266
+ jsx("button", {
10267
+ type: "button",
10268
+ onClick: hideAll,
10269
+ className: "text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline",
10270
+ children: labels.columnsHideAll
10271
+ })
10272
+ ]
10273
+ })
10274
+ ]
10275
+ }),
10276
+ jsx("ul", {
10277
+ className: "flex flex-col gap-0.5 max-h-72 overflow-auto",
10278
+ children: hideableCols.map((col)=>{
10279
+ const field = col.field;
10280
+ const hidden = hiddenColumns.includes(field);
10281
+ const header = typeof col.header === 'function' ? col.header() : col.header;
10282
+ return jsx("li", {
10283
+ children: jsxs("label", {
10284
+ className: "flex items-center gap-2 cursor-pointer py-1 px-1 rounded hover:bg-neutral-100",
10285
+ children: [
10286
+ jsx("input", {
10287
+ type: "checkbox",
10288
+ checked: !hidden,
10289
+ onChange: ()=>toggle(field),
10290
+ className: "cursor-pointer"
10291
+ }),
10292
+ jsx("span", {
10293
+ className: "text-sm text-neutral-900",
10294
+ children: header
10295
+ })
10296
+ ]
10297
+ })
10298
+ }, field);
10299
+ })
10300
+ })
10301
+ ]
10302
+ }),
10303
+ children: jsxs("button", {
10304
+ type: "button",
10305
+ disabled: disabled,
10306
+ className: cn('inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md', 'text-sm text-neutral-700 hover:text-neutral-900', 'border border-neutral-300 bg-neutral-50 hover:bg-neutral-100', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'transition-colors motion-reduce:transition-none', 'disabled:opacity-50 disabled:cursor-not-allowed'),
10307
+ children: [
10308
+ jsx(ColumnsIcon, {}),
10309
+ jsx("span", {
10310
+ children: labels.columnsButton
10311
+ })
10312
+ ]
10313
+ })
10314
+ });
10315
+ }
10316
+ // ───── Icon ─────
10317
+ function ColumnsIcon() {
10318
+ return jsxs("svg", {
10319
+ width: "0.875em",
10320
+ height: "0.875em",
10321
+ viewBox: "0 0 16 16",
10322
+ fill: "none",
10323
+ stroke: "currentColor",
10324
+ strokeWidth: "1.5",
10325
+ strokeLinecap: "round",
10326
+ strokeLinejoin: "round",
10327
+ "aria-hidden": "true",
10328
+ children: [
10329
+ jsx("rect", {
10330
+ x: "2",
10331
+ y: "3",
10332
+ width: "3.5",
10333
+ height: "10",
10334
+ rx: "0.5"
10335
+ }),
10336
+ jsx("rect", {
10337
+ x: "6.25",
10338
+ y: "3",
10339
+ width: "3.5",
10340
+ height: "10",
10341
+ rx: "0.5"
10342
+ }),
10343
+ jsx("rect", {
10344
+ x: "10.5",
10345
+ y: "3",
10346
+ width: "3.5",
10347
+ height: "10",
10348
+ rx: "0.5"
10349
+ })
10350
+ ]
10351
+ });
10352
+ }
10353
+
10354
+ function useColumnResize(args) {
10355
+ const { widths, onChange, minWidth = 40, maxWidth = 1200 } = args;
10356
+ // We need the LATEST widths inside the pointermove closure. Stick
10357
+ // them in a ref so the closure captured at pointerdown stays valid
10358
+ // for the duration of the drag.
10359
+ const widthsRef = useRef(widths);
10360
+ widthsRef.current = widths;
10361
+ const startResize = useCallback((field, startWidth, clampMin, clampMax)=>{
10362
+ return (e)=>{
10363
+ // Only respond to the primary mouse button (or touch / pen).
10364
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
10365
+ e.preventDefault();
10366
+ e.stopPropagation();
10367
+ const startX = e.clientX;
10368
+ const target = e.currentTarget;
10369
+ try {
10370
+ target.setPointerCapture(e.pointerId);
10371
+ } catch (unused) {
10372
+ // jsdom or older browsers — fine to fall back to global listeners.
10373
+ }
10374
+ const effectiveMin = Math.max(minWidth, clampMin);
10375
+ const effectiveMax = Math.min(maxWidth, clampMax);
10376
+ const handleMove = (ev)=>{
10377
+ const delta = ev.clientX - startX;
10378
+ const next = clamp(startWidth + delta, effectiveMin, effectiveMax);
10379
+ onChange(_extends({}, widthsRef.current, {
10380
+ [field]: next
10381
+ }));
10382
+ };
10383
+ const handleEnd = (ev)=>{
10384
+ try {
10385
+ target.releasePointerCapture(ev.pointerId);
10386
+ } catch (unused) {
10387
+ /* noop */ }
10388
+ target.removeEventListener('pointermove', handleMove);
10389
+ target.removeEventListener('pointerup', handleEnd);
10390
+ target.removeEventListener('pointercancel', handleEnd);
10391
+ };
10392
+ target.addEventListener('pointermove', handleMove);
10393
+ target.addEventListener('pointerup', handleEnd);
10394
+ target.addEventListener('pointercancel', handleEnd);
10395
+ };
10396
+ }, [
10397
+ onChange,
10398
+ minWidth,
10399
+ maxWidth
10400
+ ]);
10401
+ return {
10402
+ startResize
10403
+ };
10404
+ }
10405
+ function clamp(n, min, max) {
10406
+ if (n < min) return min;
10407
+ if (n > max) return max;
10408
+ return n;
10409
+ }
10410
+
10411
+ function useColumnReorder(args) {
10412
+ const { order, onChange } = args;
10413
+ const sourceRef = useRef(null);
10414
+ const [dropTarget, setDropTarget] = useState(null);
10415
+ const [draggingField, setDraggingField] = useState(null);
10416
+ const dragStart = useCallback((field)=>(e)=>{
10417
+ sourceRef.current = field;
10418
+ setDraggingField(field);
10419
+ try {
10420
+ e.dataTransfer.effectAllowed = 'move';
10421
+ // Some browsers refuse to start a drag if no data is set.
10422
+ e.dataTransfer.setData('text/plain', field);
10423
+ } catch (unused) {
10424
+ /* noop — jsdom doesn't implement dataTransfer fully. */ }
10425
+ }, []);
10426
+ const dragOver = useCallback((field)=>(e)=>{
10427
+ if (sourceRef.current == null) return;
10428
+ if (field === sourceRef.current) return;
10429
+ e.preventDefault();
10430
+ e.dataTransfer.dropEffect = 'move';
10431
+ // Determine LEFT vs RIGHT half of the target.
10432
+ const rect = e.currentTarget.getBoundingClientRect();
10433
+ const midpoint = rect.left + rect.width / 2;
10434
+ const side = e.clientX < midpoint ? 'left' : 'right';
10435
+ setDropTarget((prev)=>prev && prev.field === field && prev.side === side ? prev : {
10436
+ field,
10437
+ side
10438
+ });
10439
+ }, []);
10440
+ const drop = useCallback((field)=>(e)=>{
10441
+ e.preventDefault();
10442
+ const source = sourceRef.current;
10443
+ sourceRef.current = null;
10444
+ setDraggingField(null);
10445
+ const target = dropTarget;
10446
+ setDropTarget(null);
10447
+ if (!source || source === field) return;
10448
+ const side = (target == null ? void 0 : target.field) === field ? target.side : 'right';
10449
+ const next = moveColumn(order, source, field, side);
10450
+ if (!arraysEqual(next, order)) onChange(next);
10451
+ }, [
10452
+ order,
10453
+ onChange,
10454
+ dropTarget
10455
+ ]);
10456
+ const dragEnd = useCallback(()=>{
10457
+ sourceRef.current = null;
10458
+ setDraggingField(null);
10459
+ setDropTarget(null);
10460
+ }, []);
10461
+ return {
10462
+ dropTarget,
10463
+ draggingField,
10464
+ dragStart,
10465
+ dragOver,
10466
+ drop,
10467
+ dragEnd
10468
+ };
10469
+ }
10470
+ /**
10471
+ * Move `source` next to `target` in the `order` list.
10472
+ * `side='left'` inserts before; `side='right'` inserts after.
10473
+ * Exposed for unit testing.
10474
+ */ function moveColumn(order, source, target, side) {
10475
+ const withoutSource = order.filter((f)=>f !== source);
10476
+ const targetIndex = withoutSource.indexOf(target);
10477
+ if (targetIndex === -1) return order;
10478
+ const insertAt = side === 'left' ? targetIndex : targetIndex + 1;
10479
+ return [
10480
+ ...withoutSource.slice(0, insertAt),
10481
+ source,
10482
+ ...withoutSource.slice(insertAt)
10483
+ ];
10484
+ }
10485
+ function arraysEqual(a, b) {
10486
+ if (a === b) return true;
10487
+ if (a.length !== b.length) return false;
10488
+ for(let i = 0; i < a.length; i++){
10489
+ if (a[i] !== b[i]) return false;
10490
+ }
10491
+ return true;
10492
+ }
10493
+
9751
10494
  const DEFAULT_LABELS = {
9752
10495
  searchPlaceholder: 'Search...',
9753
10496
  noData: 'No data',
@@ -9764,10 +10507,21 @@ const DEFAULT_LABELS = {
9764
10507
  filterColumn: 'Filter',
9765
10508
  filterApply: 'Apply',
9766
10509
  filterClear: 'Clear',
10510
+ filterMin: 'Min',
10511
+ filterMax: 'Max',
10512
+ filterFrom: 'From',
10513
+ filterTo: 'To',
10514
+ filterAll: 'All',
10515
+ filterTrue: 'True',
10516
+ filterFalse: 'False',
9767
10517
  density: 'Density',
9768
10518
  densityCompact: 'Compact',
9769
10519
  densityComfortable: 'Comfortable',
9770
- densitySpacious: 'Spacious'
10520
+ densitySpacious: 'Spacious',
10521
+ columnsButton: 'Columns',
10522
+ columnsTitle: 'Toggle columns',
10523
+ columnsShowAll: 'Show all',
10524
+ columnsHideAll: 'Hide all'
9771
10525
  };
9772
10526
  /**
9773
10527
  * Dashforge TW `<DataGrid>` — virtualized data table.
@@ -9796,7 +10550,7 @@ const DEFAULT_LABELS = {
9796
10550
  * - `'visible'` — toggles only the current viewport window
9797
10551
  */ function DataGrid(props) {
9798
10552
  var _slotProps_root, _slotProps_toolbar, _slotProps_search, _slotProps_scroll, _slotProps_table, _slotProps_thead, _slotProps_headerRow, _slotProps_headerCell, _slotProps_selectionCell, _slotProps_headerCell1, _slotProps_tbody, _slotProps_row, _slotProps_cell, _slotProps_emptyState, _slotProps_row1, _slotProps_cell1, _slotProps_cell2, _slotProps_selectionCell1, _slotProps_cell3, _slotProps_rowActionsCell, _slotProps_bulkActionFooter, _slotProps_pagination;
9799
- const { rows, cols, getRowId, totalCount: totalCountProp, rowHeight, overscan = 5, height, sortModel: sortModelProp, onSortChange, defaultSortModel, serverSideSort = false, enableSearch = false, searchQuery: searchQueryProp, onSearchQueryChange, searchPlaceholder, searchDebounceMs = 200, serverSideSearch = false, filterModel: filterModelProp, onFilterChange, serverSideFilter = false, rowSelection = 'none', selectedRowIds: selectedRowIdsProp, onSelectionChange, bulkActions, selectAllScope = 'allLoaded', pagination, serverSidePagination = false, rowActions, loading = false, loadingRowCount = 8, emptyState, stickyHeader = true, caption, showCaption = false, access: gridAccess, labels: labelsProp, variant, size, density, sx, slotProps } = props;
10553
+ const { rows, cols, getRowId, totalCount: totalCountProp, rowHeight, overscan = 5, height, sortModel: sortModelProp, onSortChange, defaultSortModel, serverSideSort = false, enableSearch = false, searchQuery: searchQueryProp, onSearchQueryChange, searchPlaceholder, searchDebounceMs = 200, serverSideSearch = false, filterModel: filterModelProp, onFilterChange, serverSideFilter = false, hiddenColumns: hiddenColumnsProp, onHiddenColumnsChange, enableColumnVisibility = true, columnWidths: columnWidthsProp, onColumnWidthsChange, enableColumnResize = true, columnOrder: columnOrderProp, onColumnOrderChange, enableColumnReorder = true, rowSelection = 'none', selectedRowIds: selectedRowIdsProp, onSelectionChange, bulkActions, selectAllScope = 'allLoaded', pagination, serverSidePagination = false, rowActions, loading = false, loadingRowCount = 8, emptyState, stickyHeader = true, caption, showCaption = false, access: gridAccess, labels: labelsProp, variant, size, density, sx, slotProps } = props;
9800
10554
  const labels = _extends({}, DEFAULT_LABELS, labelsProp);
9801
10555
  const gridAccessState = useAccessState(gridAccess);
9802
10556
  // ───── Controllable state ─────
@@ -9810,16 +10564,109 @@ const DEFAULT_LABELS = {
9810
10564
  defaultValue: '',
9811
10565
  onChange: onSearchQueryChange
9812
10566
  });
9813
- const filterModel = filterModelProp != null ? filterModelProp : [];
10567
+ // filterModel: in v1-bis the per-column header UI commits filter
10568
+ // items to this controllable state. Consumer can lift it via
10569
+ // filterModelProp + onFilterChange to drive server-side filtering.
10570
+ const [filterModel, setFilterModel] = useControllableState({
10571
+ controlledValue: filterModelProp,
10572
+ defaultValue: [],
10573
+ onChange: onFilterChange
10574
+ });
9814
10575
  const [selectedRowIds, setSelectedRowIds] = useControllableState({
9815
10576
  controlledValue: selectedRowIdsProp,
9816
10577
  defaultValue: [],
9817
10578
  onChange: onSelectionChange
9818
10579
  });
9819
- // ───── RBAC: filter columns ─────
9820
- const visibleCols = useMemo(()=>cols.filter((col)=>!col.access || col.access.onUnauthorized !== 'hide'), [
10580
+ // ───── Column visibility ─────
10581
+ // Seed default hidden set from `defaultHidden` flag on columns.
10582
+ // Memoized to avoid the seed shifting on every render — the
10583
+ // controllable state keeps internal state stable thereafter.
10584
+ const defaultHiddenColumns = useMemo(()=>cols.filter((c)=>c.defaultHidden === true).map((c)=>c.field), [
9821
10585
  cols
9822
10586
  ]);
10587
+ const [hiddenColumns, setHiddenColumns] = useControllableState({
10588
+ controlledValue: hiddenColumnsProp,
10589
+ defaultValue: defaultHiddenColumns,
10590
+ onChange: onHiddenColumnsChange
10591
+ });
10592
+ // ───── Column widths (resize state) ─────
10593
+ // Seed from any `col.width` declared statically. Internal state is
10594
+ // applied only when the user actually starts dragging; until then
10595
+ // we fall back to the static width from the column def.
10596
+ const defaultColumnWidths = useMemo(()=>{
10597
+ const map = {};
10598
+ for (const c of cols){
10599
+ if (typeof c.width === 'number') {
10600
+ map[c.field] = c.width;
10601
+ }
10602
+ }
10603
+ return map;
10604
+ }, [
10605
+ cols
10606
+ ]);
10607
+ const [columnWidths, setColumnWidths] = useControllableState({
10608
+ controlledValue: columnWidthsProp,
10609
+ defaultValue: defaultColumnWidths,
10610
+ onChange: onColumnWidthsChange
10611
+ });
10612
+ const { startResize } = useColumnResize({
10613
+ widths: columnWidths,
10614
+ onChange: setColumnWidths
10615
+ });
10616
+ // ───── Column order (reorder state) ─────
10617
+ const defaultColumnOrder = useMemo(()=>cols.map((c)=>c.field), [
10618
+ cols
10619
+ ]);
10620
+ const [columnOrder, setColumnOrder] = useControllableState({
10621
+ controlledValue: columnOrderProp,
10622
+ defaultValue: defaultColumnOrder,
10623
+ onChange: onColumnOrderChange
10624
+ });
10625
+ const reorderHandlers = useColumnReorder({
10626
+ order: columnOrder,
10627
+ onChange: setColumnOrder
10628
+ });
10629
+ // Apply the active order to the columns list. Cols not in the
10630
+ // order array (e.g. newly-added columns at runtime) are appended
10631
+ // in their original position from `cols`.
10632
+ const orderedCols = useMemo(()=>{
10633
+ if (!columnOrder || columnOrder.length === 0) return cols;
10634
+ const byField = new Map(cols.map((c)=>[
10635
+ c.field,
10636
+ c
10637
+ ]));
10638
+ const seen = new Set();
10639
+ const result = [];
10640
+ for (const field of columnOrder){
10641
+ const c = byField.get(field);
10642
+ if (c) {
10643
+ result.push(c);
10644
+ seen.add(field);
10645
+ }
10646
+ }
10647
+ for (const c of cols){
10648
+ if (!seen.has(c.field)) result.push(c);
10649
+ }
10650
+ return result;
10651
+ }, [
10652
+ cols,
10653
+ columnOrder
10654
+ ]);
10655
+ // ───── RBAC + visibility + reorder: derive final column list ─────
10656
+ // - `access.onUnauthorized === 'hide'` removes a column entirely.
10657
+ // - `hiddenColumns` is the user-toggled hidden set from the
10658
+ // column-visibility dialog.
10659
+ // - `orderedCols` is the user-reordered version of `cols`.
10660
+ // NOTE: filtering happens on `orderedCols` so the active order is
10661
+ // preserved through visibility changes.
10662
+ const visibleCols = useMemo(()=>orderedCols.filter((col)=>{
10663
+ if (col.access && col.access.onUnauthorized === 'hide') return false;
10664
+ if (hiddenColumns.includes(col.field)) return false;
10665
+ return true;
10666
+ }), [
10667
+ orderedCols,
10668
+ hiddenColumns
10669
+ ]);
9823
10670
  // ───── Smart-default column types ─────
9824
10671
  const columnTypes = useColumnAutoDetect(rows, visibleCols);
9825
10672
  // ───── Pipeline: search → filter → sort (skip when server-side) ─────
@@ -9901,28 +10748,43 @@ const DEFAULT_LABELS = {
9901
10748
  className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
9902
10749
  "data-disabled": !isInteractive ? 'true' : undefined,
9903
10750
  children: [
9904
- enableSearch && jsx("div", {
10751
+ (enableSearch || enableColumnVisibility) && jsxs("div", {
9905
10752
  className: cn(v.toolbar(), slotProps == null ? void 0 : (_slotProps_toolbar = slotProps.toolbar) == null ? void 0 : _slotProps_toolbar.className),
9906
- children: jsxs("label", {
9907
- className: cn(v.search(), slotProps == null ? void 0 : (_slotProps_search = slotProps.search) == null ? void 0 : _slotProps_search.className),
9908
- children: [
9909
- jsx("span", {
9910
- className: "sr-only",
9911
- children: labels.searchPlaceholder
9912
- }),
9913
- jsx(SearchIcon, {
9914
- className: "ml-2 shrink-0 text-neutral-500"
9915
- }),
9916
- jsx("input", {
9917
- type: "text",
9918
- value: searchQuery,
9919
- onChange: (e)=>setSearchQuery(e.target.value),
9920
- placeholder: searchPlaceholder != null ? searchPlaceholder : labels.searchPlaceholder,
9921
- disabled: !isInteractive,
9922
- className: cn('flex-1 bg-transparent border-0 outline-none', 'px-2 py-1.5', 'placeholder:text-neutral-400', 'disabled:opacity-50 disabled:cursor-not-allowed')
9923
- })
9924
- ]
9925
- })
10753
+ children: [
10754
+ enableSearch && jsxs("label", {
10755
+ className: cn(v.search(), slotProps == null ? void 0 : (_slotProps_search = slotProps.search) == null ? void 0 : _slotProps_search.className),
10756
+ children: [
10757
+ jsx("span", {
10758
+ className: "sr-only",
10759
+ children: labels.searchPlaceholder
10760
+ }),
10761
+ jsx(SearchIcon, {
10762
+ className: "ml-2 shrink-0 text-neutral-500"
10763
+ }),
10764
+ jsx("input", {
10765
+ type: "text",
10766
+ value: searchQuery,
10767
+ onChange: (e)=>setSearchQuery(e.target.value),
10768
+ placeholder: searchPlaceholder != null ? searchPlaceholder : labels.searchPlaceholder,
10769
+ disabled: !isInteractive,
10770
+ className: cn('flex-1 bg-transparent border-0 outline-none', 'px-2 py-1.5', 'placeholder:text-neutral-400', 'disabled:opacity-50 disabled:cursor-not-allowed')
10771
+ })
10772
+ ]
10773
+ }),
10774
+ enableColumnVisibility && jsx(ColumnVisibilityTrigger, {
10775
+ cols: cols.filter((c)=>// RBAC-hidden columns are never offered in the menu.
10776
+ !c.access || c.access.onUnauthorized !== 'hide'),
10777
+ hiddenColumns: hiddenColumns,
10778
+ onChange: setHiddenColumns,
10779
+ disabled: !isInteractive,
10780
+ labels: {
10781
+ columnsButton: labels.columnsButton,
10782
+ columnsTitle: labels.columnsTitle,
10783
+ columnsShowAll: labels.columnsShowAll,
10784
+ columnsHideAll: labels.columnsHideAll
10785
+ }
10786
+ })
10787
+ ]
9926
10788
  }),
9927
10789
  jsx("div", {
9928
10790
  ref: scrollRef,
@@ -9965,8 +10827,15 @@ const DEFAULT_LABELS = {
9965
10827
  sortModel: sortModel,
9966
10828
  onSortClick: (e)=>handleSortClick(col, e, sortModel, setSortModel),
9967
10829
  inferredType: columnTypes.get(col.field),
10830
+ filterModel: filterModel,
10831
+ onFilterChange: setFilterModel,
10832
+ effectiveWidth: resolveColumnWidth(col, columnWidths),
10833
+ resizable: enableColumnResize && col.resizable !== false,
10834
+ startResize: startResize,
10835
+ reorderable: enableColumnReorder && col.reorderable !== false,
10836
+ reorderHandlers: reorderHandlers,
9968
10837
  labels: labels,
9969
- className: cn(v.headerCell(), col.sticky === 'left' && v.stickyLeftHeaderCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className),
10838
+ className: cn(v.headerCell(), col.sticky === 'left' && v.stickyLeftHeaderCell(), col.sticky === 'right' && v.stickyRightHeaderCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className),
9970
10839
  buttonClassName: v.headerCellButton(),
9971
10840
  disabled: !isInteractive
9972
10841
  }, col.field);
@@ -9999,6 +10868,7 @@ const DEFAULT_LABELS = {
9999
10868
  renderedRows,
10000
10869
  visibleCols,
10001
10870
  columnTypes,
10871
+ columnWidths,
10002
10872
  selection,
10003
10873
  rowSelection,
10004
10874
  showSelectionColumn,
@@ -10012,6 +10882,7 @@ const DEFAULT_LABELS = {
10012
10882
  cell: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell1 = slotProps.cell) == null ? void 0 : _slotProps_cell1.className),
10013
10883
  selectionCell: cn(v.cell(), v.selectionCell(), v.stickyLeftCell(), slotProps == null ? void 0 : (_slotProps_cell2 = slotProps.cell) == null ? void 0 : _slotProps_cell2.className, slotProps == null ? void 0 : (_slotProps_selectionCell1 = slotProps.selectionCell) == null ? void 0 : _slotProps_selectionCell1.className),
10014
10884
  stickyLeftCell: v.stickyLeftCell(),
10885
+ stickyRightCell: v.stickyRightCell(),
10015
10886
  rowActionsCell: cn(v.cell(), v.rowActionsCell(), slotProps == null ? void 0 : (_slotProps_cell3 = slotProps.cell) == null ? void 0 : _slotProps_cell3.className, slotProps == null ? void 0 : (_slotProps_rowActionsCell = slotProps.rowActionsCell) == null ? void 0 : _slotProps_rowActionsCell.className)
10016
10887
  }
10017
10888
  })
@@ -10058,50 +10929,160 @@ const DEFAULT_LABELS = {
10058
10929
  }
10059
10930
  // ───── Header cell ─────
10060
10931
  function HeaderCell(props) {
10061
- const { col, sortModel, onSortClick, inferredType, labels, className, buttonClassName, disabled } = props;
10932
+ const { col, sortModel, onSortClick, inferredType, filterModel, onFilterChange, effectiveWidth, resizable, startResize, reorderable, reorderHandlers, labels, className, buttonClassName, disabled } = props;
10062
10933
  const align = resolveAlign(col, inferredType);
10063
10934
  const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
10064
10935
  const sortEntry = sortModel.find((s)=>s.field === col.field);
10065
10936
  const ariaSort = (sortEntry == null ? void 0 : sortEntry.direction) === 'asc' ? 'ascending' : (sortEntry == null ? void 0 : sortEntry.direction) === 'desc' ? 'descending' : 'none';
10066
10937
  const headerContent = typeof col.header === 'function' ? col.header() : col.header;
10067
10938
  const cellStyle = {
10068
- width: col.width != null ? `${col.width}px` : undefined,
10939
+ width: effectiveWidth != null ? `${effectiveWidth}px` : undefined,
10069
10940
  minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
10070
- flex: col.flex
10941
+ flex: effectiveWidth != null ? undefined : col.flex,
10942
+ position: resizable || reorderable ? 'relative' : undefined
10071
10943
  };
10944
+ // Drop indicator for reorder: a 2px vertical bar at the LEFT or
10945
+ // RIGHT edge of this th when it's the active drop target.
10946
+ const dropTarget = reorderHandlers.dropTarget;
10947
+ const dropIndicatorClass = dropTarget != null && dropTarget.field === col.field ? dropTarget.side === 'left' ? 'before:absolute before:top-0 before:bottom-0 before:left-0 before:w-0.5 before:bg-primary-500' : 'after:absolute after:top-0 after:bottom-0 after:right-0 after:w-0.5 after:bg-primary-500' : '';
10948
+ const isDragging = reorderHandlers.draggingField === col.field;
10949
+ // Drag attributes only attach when reorderable; we DO NOT make the
10950
+ // entire th `draggable` if it isn't — keeps the keyboard focus
10951
+ // behavior unchanged.
10952
+ const dragAttrs = reorderable ? {
10953
+ draggable: true,
10954
+ onDragStart: reorderHandlers.dragStart(col.field),
10955
+ onDragOver: reorderHandlers.dragOver(col.field),
10956
+ onDrop: reorderHandlers.drop(col.field),
10957
+ onDragEnd: reorderHandlers.dragEnd
10958
+ } : {};
10959
+ const resizeHandle = resizable ? jsx("span", {
10960
+ role: "separator",
10961
+ "aria-orientation": "vertical",
10962
+ "aria-label": `Resize ${typeof col.header === 'string' ? col.header : col.field}`,
10963
+ onPointerDown: (e)=>{
10964
+ var _ref, _col_minWidth, _col_maxWidth;
10965
+ const handle = e.currentTarget;
10966
+ const th = handle.parentElement;
10967
+ const startWidth = (_ref = effectiveWidth != null ? effectiveWidth : th == null ? void 0 : th.getBoundingClientRect().width) != null ? _ref : 120;
10968
+ const min = (_col_minWidth = col.minWidth) != null ? _col_minWidth : 40;
10969
+ const max = (_col_maxWidth = col.maxWidth) != null ? _col_maxWidth : 1200;
10970
+ startResize(col.field, startWidth, min, max)(e);
10971
+ },
10972
+ className: cn('absolute top-0 bottom-0 right-0 w-1.5', 'cursor-col-resize select-none touch-none', 'hover:bg-primary-300 active:bg-primary-400', 'transition-colors motion-reduce:transition-none')
10973
+ }) : null;
10974
+ const filterType = col.filterable ? resolveFilterType(col, inferredType) : null;
10975
+ const filterTrigger = col.filterable && filterType ? jsx(ColumnFilterTrigger, {
10976
+ field: col.field,
10977
+ filterType: filterType,
10978
+ filterModel: filterModel,
10979
+ onFilterChange: onFilterChange,
10980
+ disabled: disabled,
10981
+ labels: {
10982
+ filterColumn: labels.filterColumn,
10983
+ filterApply: labels.filterApply,
10984
+ filterClear: labels.filterClear,
10985
+ filterMin: labels.filterMin,
10986
+ filterMax: labels.filterMax,
10987
+ filterFrom: labels.filterFrom,
10988
+ filterTo: labels.filterTo,
10989
+ filterAll: labels.filterAll,
10990
+ filterTrue: labels.filterTrue,
10991
+ filterFalse: labels.filterFalse
10992
+ }
10993
+ }) : null;
10994
+ // Layout strategy:
10995
+ // - sortable only → sort button is the cell content
10996
+ // - filterable only → header text + filter trigger in a flex row
10997
+ // - both → sort button + filter trigger in a flex row
10998
+ // - neither → plain header text
10999
+ const wrapperJustify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start';
10072
11000
  if (col.sortable) {
10073
- return jsx("th", {
11001
+ const sortButton = jsxs("button", {
11002
+ type: "button",
11003
+ onClick: onSortClick,
11004
+ disabled: disabled,
11005
+ "aria-label": ariaSort === 'ascending' ? labels.ariaSortAscending : ariaSort === 'descending' ? labels.ariaSortDescending : labels.ariaSortNone,
11006
+ className: cn(buttonClassName, align === 'right' && 'flex-row-reverse', 'disabled:cursor-not-allowed disabled:opacity-50'),
11007
+ children: [
11008
+ jsx("span", {
11009
+ children: headerContent
11010
+ }),
11011
+ jsx(SortIcon, {
11012
+ direction: sortEntry == null ? void 0 : sortEntry.direction
11013
+ })
11014
+ ]
11015
+ });
11016
+ return jsxs("th", _extends({
10074
11017
  scope: "col",
10075
11018
  "aria-sort": ariaSort,
10076
- className: cn(className, alignClass),
10077
- style: cellStyle,
10078
- children: jsxs("button", {
10079
- type: "button",
10080
- onClick: onSortClick,
10081
- disabled: disabled,
10082
- "aria-label": ariaSort === 'ascending' ? labels.ariaSortAscending : ariaSort === 'descending' ? labels.ariaSortDescending : labels.ariaSortNone,
10083
- className: cn(buttonClassName, align === 'right' && 'flex-row-reverse', 'disabled:cursor-not-allowed disabled:opacity-50'),
11019
+ className: cn(className, alignClass, dropIndicatorClass, isDragging && 'opacity-50'),
11020
+ style: cellStyle
11021
+ }, dragAttrs, {
11022
+ children: [
11023
+ filterTrigger ? jsxs("div", {
11024
+ className: cn('flex items-center gap-1', wrapperJustify),
11025
+ children: [
11026
+ sortButton,
11027
+ filterTrigger
11028
+ ]
11029
+ }) : sortButton,
11030
+ resizeHandle
11031
+ ]
11032
+ }));
11033
+ }
11034
+ return jsxs("th", _extends({
11035
+ scope: "col",
11036
+ className: cn(className, alignClass, dropIndicatorClass, isDragging && 'opacity-50'),
11037
+ style: cellStyle
11038
+ }, dragAttrs, {
11039
+ children: [
11040
+ filterTrigger ? jsxs("div", {
11041
+ className: cn('flex items-center gap-1', wrapperJustify),
10084
11042
  children: [
10085
11043
  jsx("span", {
10086
11044
  children: headerContent
10087
11045
  }),
10088
- jsx(SortIcon, {
10089
- direction: sortEntry == null ? void 0 : sortEntry.direction
10090
- })
11046
+ filterTrigger
10091
11047
  ]
10092
- })
10093
- });
11048
+ }) : headerContent,
11049
+ resizeHandle
11050
+ ]
11051
+ }));
11052
+ }
11053
+ /**
11054
+ * Resolve the effective width (px) for a column. User-driven resize
11055
+ * (`columnWidths[field]`) wins over the static `col.width`. Returns
11056
+ * `undefined` if neither source has a width set — the consumer falls
11057
+ * back to the column's `flex` value or browser auto-layout.
11058
+ */ function resolveColumnWidth(col, columnWidths) {
11059
+ const resized = columnWidths[col.field];
11060
+ if (resized != null) return resized;
11061
+ return col.width;
11062
+ }
11063
+ /**
11064
+ * Resolve the filter UI type for a column. Explicit `col.filterType`
11065
+ * wins; otherwise we map the auto-detected primitive type:
11066
+ * - `number` → number range
11067
+ * - `boolean` → true/false/all select
11068
+ * - `date` → date range
11069
+ * - anything else (string / unknown) → text contains
11070
+ */ function resolveFilterType(col, inferred) {
11071
+ if (col.filterType) return col.filterType;
11072
+ switch(inferred){
11073
+ case 'number':
11074
+ return 'number';
11075
+ case 'boolean':
11076
+ return 'boolean';
11077
+ case 'date':
11078
+ return 'date';
11079
+ default:
11080
+ return 'text';
10094
11081
  }
10095
- return jsx("th", {
10096
- scope: "col",
10097
- className: cn(className, alignClass),
10098
- style: cellStyle,
10099
- children: headerContent
10100
- });
10101
11082
  }
10102
11083
  // ───── Virtualized body ─────
10103
11084
  function renderVirtualizedBody(args) {
10104
- const { vWindow, renderedRows, visibleCols, columnTypes, selection, rowSelection, showSelectionColumn, showRowActionsColumn, rowActions, getRowId, isInteractive, labels, classes } = args;
11085
+ const { vWindow, renderedRows, visibleCols, columnTypes, columnWidths, selection, rowSelection, showSelectionColumn, showRowActionsColumn, rowActions, getRowId, isInteractive, labels, classes } = args;
10105
11086
  return jsxs(Fragment$1, {
10106
11087
  children: [
10107
11088
  vWindow.paddingTop > 0 && jsx("tr", {
@@ -10136,8 +11117,10 @@ function renderVirtualizedBody(args) {
10136
11117
  rowIndex: absoluteIdx,
10137
11118
  isSelected: isSelected,
10138
11119
  inferredType: columnTypes.get(col.field),
11120
+ effectiveWidth: resolveColumnWidth(col, columnWidths),
10139
11121
  cellClass: classes.cell,
10140
- stickyLeftClass: classes.stickyLeftCell
11122
+ stickyLeftClass: classes.stickyLeftCell,
11123
+ stickyRightClass: classes.stickyRightCell
10141
11124
  }, col.field)),
10142
11125
  showRowActionsColumn && rowActions && jsx("td", {
10143
11126
  className: classes.rowActionsCell,
@@ -10157,7 +11140,7 @@ function renderVirtualizedBody(args) {
10157
11140
  }
10158
11141
  // ───── Data cell ─────
10159
11142
  function DataCell(props) {
10160
- const { col, row, rowIndex, isSelected, inferredType, cellClass, stickyLeftClass } = props;
11143
+ const { col, row, rowIndex, isSelected, inferredType, effectiveWidth, cellClass, stickyLeftClass, stickyRightClass } = props;
10161
11144
  const value = getNestedValue(row, col.field);
10162
11145
  const align = resolveAlign(col, inferredType);
10163
11146
  const tabularNums = resolveTabularNums(col, inferredType);
@@ -10177,9 +11160,9 @@ function DataCell(props) {
10177
11160
  access: columnAccessState
10178
11161
  }) : renderDefaultCell(value);
10179
11162
  return jsx("td", {
10180
- className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono', col.sticky === 'left' && stickyLeftClass),
11163
+ className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono', col.sticky === 'left' && stickyLeftClass, col.sticky === 'right' && stickyRightClass),
10181
11164
  style: {
10182
- width: col.width != null ? `${col.width}px` : undefined,
11165
+ width: effectiveWidth != null ? `${effectiveWidth}px` : undefined,
10183
11166
  minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined
10184
11167
  },
10185
11168
  children: content