@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
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,23 @@ 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: 'Manage columns',
8679
+ columnsDescription: 'Show or hide columns. Required columns cannot be toggled.',
8680
+ columnsShowAll: 'Show all',
8681
+ columnsHideAll: 'Hide all',
8682
+ columnsDone: 'Done'
8599
8683
  };
8600
8684
  const DEFAULT_GET_ROW_ID = (_row, index)=>String(index);
8601
8685
  /**
@@ -9536,7 +9620,7 @@ function RowActionMenuItem(props) {
9536
9620
  'border-t border-neutral-200',
9537
9621
  'bg-neutral-50 shadow-md'
9538
9622
  ],
9539
- // Sticky left column — uses `position: sticky; left: 0` + z-index
9623
+ // Sticky LEFT column — uses `position: sticky; left: 0` + z-index
9540
9624
  // so the column stays pinned during horizontal scroll. The
9541
9625
  // `bg-neutral-50` ensures the column doesn't bleed when content
9542
9626
  // scrolls underneath.
@@ -9544,6 +9628,12 @@ function RowActionMenuItem(props) {
9544
9628
  // Sticky left × header intersection (top-left corner): higher
9545
9629
  // z-index than both header (`z-10`) and sticky col (`z-[1]`).
9546
9630
  stickyLeftHeaderCell: 'sticky left-0 z-20 bg-neutral-50',
9631
+ // Sticky RIGHT column — mirror of stickyLeftCell. The first
9632
+ // right-sticky column gets a `border-l` to separate it visually
9633
+ // from the scrollable area (the consumer can override via
9634
+ // slotProps if they don't want it).
9635
+ stickyRightCell: 'sticky right-0 z-[1] bg-neutral-50 border-l border-neutral-200',
9636
+ stickyRightHeaderCell: 'sticky right-0 z-20 bg-neutral-50 border-l border-neutral-200',
9547
9637
  paginationFooter: 'flex justify-end mt-3'
9548
9638
  },
9549
9639
  variants: {
@@ -9748,6 +9838,690 @@ function RowActionMenuItem(props) {
9748
9838
  };
9749
9839
  }
9750
9840
 
9841
+ /**
9842
+ * Per-column filter UI for DataGrid.
9843
+ *
9844
+ * `<ColumnFilterTrigger>` renders the filter icon button in the
9845
+ * column header — when clicked, opens a Popover with the
9846
+ * appropriate filter UI for the column type:
9847
+ *
9848
+ * - `text` → `<TextFilter>` with single input (op: `contains`)
9849
+ * - `number` → `<NumberRangeFilter>` with min/max inputs (op: `between`)
9850
+ * - `boolean` → `<BooleanFilter>` with true/false/all radio (op: `equals`)
9851
+ * - `date` → `<DateRangeFilter>` with min/max date inputs (op: `between`)
9852
+ *
9853
+ * Each filter UI has Apply + Clear actions; Apply commits the
9854
+ * filter to the model, Clear removes any existing filter for that
9855
+ * column.
9856
+ */ // ───── Helper: read/write filter model for a single column ─────
9857
+ function findFilter(model, field) {
9858
+ return model.find((f)=>f.field === field);
9859
+ }
9860
+ function setFilter(model, field, next) {
9861
+ const rest = model.filter((f)=>f.field !== field);
9862
+ if (next == null) return rest;
9863
+ return [
9864
+ ...rest,
9865
+ next
9866
+ ];
9867
+ }
9868
+ const DEFAULT_FILTER_LABELS = {
9869
+ filterColumn: 'Filter',
9870
+ filterApply: 'Apply',
9871
+ filterClear: 'Clear',
9872
+ filterMin: 'Min',
9873
+ filterMax: 'Max',
9874
+ filterFrom: 'From',
9875
+ filterTo: 'To',
9876
+ filterAll: 'All',
9877
+ filterTrue: 'True',
9878
+ filterFalse: 'False'
9879
+ };
9880
+ function ColumnFilterTrigger(props) {
9881
+ const { field, filterType, filterModel, onFilterChange, disabled, labels: labelsProp } = props;
9882
+ const labels = _extends({}, DEFAULT_FILTER_LABELS, labelsProp);
9883
+ const [open, setOpen] = useState(false);
9884
+ const active = findFilter(filterModel, field) != null;
9885
+ return jsx(Popover, {
9886
+ open: open,
9887
+ onOpenChange: setOpen,
9888
+ side: "bottom",
9889
+ align: "end",
9890
+ content: jsx(FilterPanel, {
9891
+ field: field,
9892
+ filterType: filterType,
9893
+ filterModel: filterModel,
9894
+ onFilterChange: (next)=>{
9895
+ onFilterChange(next);
9896
+ setOpen(false);
9897
+ },
9898
+ labels: labels
9899
+ }),
9900
+ children: jsx("button", {
9901
+ type: "button",
9902
+ "aria-label": labels.filterColumn,
9903
+ "aria-pressed": active,
9904
+ disabled: disabled,
9905
+ 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'),
9906
+ children: jsx(FilterIcon, {})
9907
+ })
9908
+ });
9909
+ }
9910
+ // ───── Panel — dispatches to the right filter UI ─────
9911
+ function FilterPanel(props) {
9912
+ const { field, filterType, filterModel, onFilterChange, labels } = props;
9913
+ const current = findFilter(filterModel, field);
9914
+ const commit = (next)=>{
9915
+ onFilterChange(setFilter(filterModel, field, next));
9916
+ };
9917
+ switch(filterType){
9918
+ case 'number':
9919
+ return jsx(NumberRangeFilter, {
9920
+ field: field,
9921
+ current: current,
9922
+ onCommit: commit,
9923
+ labels: labels
9924
+ });
9925
+ case 'boolean':
9926
+ return jsx(BooleanFilter, {
9927
+ field: field,
9928
+ current: current,
9929
+ onCommit: commit,
9930
+ labels: labels
9931
+ });
9932
+ case 'date':
9933
+ return jsx(DateRangeFilter, {
9934
+ field: field,
9935
+ current: current,
9936
+ onCommit: commit,
9937
+ labels: labels
9938
+ });
9939
+ case 'text':
9940
+ default:
9941
+ return jsx(TextFilter, {
9942
+ field: field,
9943
+ current: current,
9944
+ onCommit: commit,
9945
+ labels: labels
9946
+ });
9947
+ }
9948
+ }
9949
+ // ───── Text filter ─────
9950
+ function TextFilter(props) {
9951
+ const { field, current, onCommit, labels } = props;
9952
+ const [value, setValue] = useState(typeof (current == null ? void 0 : current.value) === 'string' ? current.value : '');
9953
+ return jsxs("div", {
9954
+ className: "flex flex-col gap-2 min-w-[200px]",
9955
+ children: [
9956
+ jsx("input", {
9957
+ type: "text",
9958
+ value: value,
9959
+ onChange: (e)=>setValue(e.target.value),
9960
+ placeholder: "\u2026",
9961
+ autoFocus: true,
9962
+ 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"
9963
+ }),
9964
+ jsx(FilterActions, {
9965
+ onApply: ()=>onCommit(value.trim() ? {
9966
+ field,
9967
+ op: 'contains',
9968
+ value
9969
+ } : null),
9970
+ onClear: ()=>{
9971
+ setValue('');
9972
+ onCommit(null);
9973
+ },
9974
+ labels: labels
9975
+ })
9976
+ ]
9977
+ });
9978
+ }
9979
+ // ───── Number range filter ─────
9980
+ function NumberRangeFilter(props) {
9981
+ const { field, current, onCommit, labels } = props;
9982
+ const currentValue = current == null ? void 0 : current.value;
9983
+ const currentTuple = Array.isArray(currentValue) ? currentValue : [
9984
+ null,
9985
+ null
9986
+ ];
9987
+ const [min, setMin] = useState(currentTuple[0] != null ? String(currentTuple[0]) : '');
9988
+ const [max, setMax] = useState(currentTuple[1] != null ? String(currentTuple[1]) : '');
9989
+ const apply = ()=>{
9990
+ const minN = min.trim() ? Number(min) : null;
9991
+ const maxN = max.trim() ? Number(max) : null;
9992
+ if ((minN == null || Number.isFinite(minN)) && (maxN == null || Number.isFinite(maxN))) {
9993
+ if (minN == null && maxN == null) {
9994
+ onCommit(null);
9995
+ } else {
9996
+ onCommit({
9997
+ field,
9998
+ op: 'between',
9999
+ value: [
10000
+ minN,
10001
+ maxN
10002
+ ]
10003
+ });
10004
+ }
10005
+ }
10006
+ };
10007
+ return jsxs("div", {
10008
+ className: "flex flex-col gap-2 min-w-[220px]",
10009
+ children: [
10010
+ jsxs("div", {
10011
+ className: "grid grid-cols-2 gap-2",
10012
+ children: [
10013
+ jsxs("label", {
10014
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10015
+ children: [
10016
+ jsx("span", {
10017
+ children: labels.filterMin
10018
+ }),
10019
+ jsx("input", {
10020
+ type: "number",
10021
+ value: min,
10022
+ onChange: (e)=>setMin(e.target.value),
10023
+ autoFocus: true,
10024
+ 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"
10025
+ })
10026
+ ]
10027
+ }),
10028
+ jsxs("label", {
10029
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10030
+ children: [
10031
+ jsx("span", {
10032
+ children: labels.filterMax
10033
+ }),
10034
+ jsx("input", {
10035
+ type: "number",
10036
+ value: max,
10037
+ onChange: (e)=>setMax(e.target.value),
10038
+ 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"
10039
+ })
10040
+ ]
10041
+ })
10042
+ ]
10043
+ }),
10044
+ jsx(FilterActions, {
10045
+ onApply: apply,
10046
+ onClear: ()=>{
10047
+ setMin('');
10048
+ setMax('');
10049
+ onCommit(null);
10050
+ },
10051
+ labels: labels
10052
+ })
10053
+ ]
10054
+ });
10055
+ }
10056
+ // ───── Boolean filter ─────
10057
+ function BooleanFilter(props) {
10058
+ const { field, current, onCommit, labels } = props;
10059
+ const initial = (current == null ? void 0 : current.value) === true ? 'true' : (current == null ? void 0 : current.value) === false ? 'false' : 'all';
10060
+ const [value, setValue] = useState(initial);
10061
+ const apply = ()=>{
10062
+ if (value === 'all') onCommit(null);
10063
+ else onCommit({
10064
+ field,
10065
+ op: 'equals',
10066
+ value: value === 'true'
10067
+ });
10068
+ };
10069
+ return jsxs("div", {
10070
+ className: "flex flex-col gap-2 min-w-[160px]",
10071
+ children: [
10072
+ jsx("fieldset", {
10073
+ className: "flex flex-col gap-1",
10074
+ children: [
10075
+ 'all',
10076
+ 'true',
10077
+ 'false'
10078
+ ].map((opt)=>jsxs("label", {
10079
+ className: "flex items-center gap-2 text-sm cursor-pointer",
10080
+ children: [
10081
+ jsx("input", {
10082
+ type: "radio",
10083
+ name: `filter-${field}`,
10084
+ value: opt,
10085
+ checked: value === opt,
10086
+ onChange: ()=>setValue(opt),
10087
+ className: "cursor-pointer"
10088
+ }),
10089
+ jsx("span", {
10090
+ children: opt === 'all' ? labels.filterAll : opt === 'true' ? labels.filterTrue : labels.filterFalse
10091
+ })
10092
+ ]
10093
+ }, opt))
10094
+ }),
10095
+ jsx(FilterActions, {
10096
+ onApply: apply,
10097
+ onClear: ()=>{
10098
+ setValue('all');
10099
+ onCommit(null);
10100
+ },
10101
+ labels: labels
10102
+ })
10103
+ ]
10104
+ });
10105
+ }
10106
+ // ───── Date range filter ─────
10107
+ function DateRangeFilter(props) {
10108
+ const { field, current, onCommit, labels } = props;
10109
+ const currentValue = current == null ? void 0 : current.value;
10110
+ const currentTuple = Array.isArray(currentValue) ? currentValue : [
10111
+ null,
10112
+ null
10113
+ ];
10114
+ const [from, setFrom] = useState(typeof currentTuple[0] === 'string' ? currentTuple[0] : '');
10115
+ const [to, setTo] = useState(typeof currentTuple[1] === 'string' ? currentTuple[1] : '');
10116
+ const apply = ()=>{
10117
+ if (!from && !to) {
10118
+ onCommit(null);
10119
+ } else {
10120
+ onCommit({
10121
+ field,
10122
+ op: 'between',
10123
+ value: [
10124
+ from || null,
10125
+ to || null
10126
+ ]
10127
+ });
10128
+ }
10129
+ };
10130
+ return jsxs("div", {
10131
+ className: "flex flex-col gap-2 min-w-[220px]",
10132
+ children: [
10133
+ jsxs("div", {
10134
+ className: "grid grid-cols-2 gap-2",
10135
+ children: [
10136
+ jsxs("label", {
10137
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10138
+ children: [
10139
+ jsx("span", {
10140
+ children: labels.filterFrom
10141
+ }),
10142
+ jsx("input", {
10143
+ type: "date",
10144
+ value: from,
10145
+ onChange: (e)=>setFrom(e.target.value),
10146
+ autoFocus: true,
10147
+ 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"
10148
+ })
10149
+ ]
10150
+ }),
10151
+ jsxs("label", {
10152
+ className: "flex flex-col gap-1 text-xs text-neutral-700",
10153
+ children: [
10154
+ jsx("span", {
10155
+ children: labels.filterTo
10156
+ }),
10157
+ jsx("input", {
10158
+ type: "date",
10159
+ value: to,
10160
+ onChange: (e)=>setTo(e.target.value),
10161
+ 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"
10162
+ })
10163
+ ]
10164
+ })
10165
+ ]
10166
+ }),
10167
+ jsx(FilterActions, {
10168
+ onApply: apply,
10169
+ onClear: ()=>{
10170
+ setFrom('');
10171
+ setTo('');
10172
+ onCommit(null);
10173
+ },
10174
+ labels: labels
10175
+ })
10176
+ ]
10177
+ });
10178
+ }
10179
+ // ───── Apply + Clear actions ─────
10180
+ function FilterActions(props) {
10181
+ return jsxs("div", {
10182
+ className: "flex justify-end gap-2 pt-1 border-t border-neutral-200",
10183
+ children: [
10184
+ jsx("button", {
10185
+ type: "button",
10186
+ onClick: props.onClear,
10187
+ 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",
10188
+ children: props.labels.filterClear
10189
+ }),
10190
+ jsx("button", {
10191
+ type: "button",
10192
+ onClick: props.onApply,
10193
+ 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",
10194
+ children: props.labels.filterApply
10195
+ })
10196
+ ]
10197
+ });
10198
+ }
10199
+ // ───── Icon ─────
10200
+ function FilterIcon() {
10201
+ return jsx("svg", {
10202
+ width: "0.875em",
10203
+ height: "0.875em",
10204
+ viewBox: "0 0 16 16",
10205
+ fill: "none",
10206
+ stroke: "currentColor",
10207
+ strokeWidth: "1.5",
10208
+ strokeLinecap: "round",
10209
+ strokeLinejoin: "round",
10210
+ "aria-hidden": "true",
10211
+ children: jsx("path", {
10212
+ d: "M2 3h12M4 8h8M6 13h4"
10213
+ })
10214
+ });
10215
+ }
10216
+
10217
+ const DEFAULT_VISIBILITY_LABELS = {
10218
+ columnsButton: 'Columns',
10219
+ columnsTitle: 'Manage columns',
10220
+ columnsDescription: 'Show or hide columns. Required columns cannot be toggled.',
10221
+ columnsShowAll: 'Show all',
10222
+ columnsHideAll: 'Hide all',
10223
+ columnsDone: 'Done'
10224
+ };
10225
+ function ColumnVisibilityTrigger(props) {
10226
+ const { cols, hiddenColumns, onChange, disabled, labels: labelsProp } = props;
10227
+ const labels = _extends({}, DEFAULT_VISIBILITY_LABELS, labelsProp);
10228
+ const [open, setOpen] = useState(false);
10229
+ // Hideable cols are the only ones the user can toggle. `hideable`
10230
+ // defaults to `true` — a column is hideable unless explicitly opted
10231
+ // out with `hideable: false` (structurally required column).
10232
+ const hideableCols = cols.filter((c)=>c.hideable !== false);
10233
+ return jsxs(Fragment$1, {
10234
+ children: [
10235
+ jsxs("button", {
10236
+ type: "button",
10237
+ onClick: ()=>setOpen(true),
10238
+ disabled: disabled,
10239
+ 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'),
10240
+ children: [
10241
+ jsx(ColumnsIcon, {}),
10242
+ jsx("span", {
10243
+ children: labels.columnsButton
10244
+ })
10245
+ ]
10246
+ }),
10247
+ jsx(Dialog, {
10248
+ open: open,
10249
+ onOpenChange: setOpen,
10250
+ title: labels.columnsTitle,
10251
+ description: labels.columnsDescription,
10252
+ size: "sm",
10253
+ children: jsx(ColumnVisibilityBody, {
10254
+ cols: hideableCols,
10255
+ hiddenColumns: hiddenColumns,
10256
+ onChange: onChange,
10257
+ onDone: ()=>setOpen(false),
10258
+ labels: labels
10259
+ })
10260
+ })
10261
+ ]
10262
+ });
10263
+ }
10264
+ // ───── Dialog body ─────
10265
+ function ColumnVisibilityBody(props) {
10266
+ const { cols, hiddenColumns, onChange, onDone, labels } = props;
10267
+ // Local draft so the user can stage changes; commit on Done.
10268
+ const [draft, setDraft] = useState(hiddenColumns);
10269
+ // Re-sync when the dialog re-opens with a different external state.
10270
+ useEffect(()=>{
10271
+ setDraft(hiddenColumns);
10272
+ }, [
10273
+ hiddenColumns
10274
+ ]);
10275
+ const toggle = (field)=>{
10276
+ setDraft((prev)=>prev.includes(field) ? prev.filter((f)=>f !== field) : [
10277
+ ...prev,
10278
+ field
10279
+ ]);
10280
+ };
10281
+ const showAll = ()=>setDraft([]);
10282
+ const hideAll = ()=>setDraft(cols.map((c)=>c.field));
10283
+ return jsxs("div", {
10284
+ className: "flex flex-col gap-3",
10285
+ children: [
10286
+ jsxs("div", {
10287
+ className: "flex items-center gap-2 pb-2 border-b border-neutral-200",
10288
+ children: [
10289
+ jsx("button", {
10290
+ type: "button",
10291
+ onClick: showAll,
10292
+ className: "text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline",
10293
+ children: labels.columnsShowAll
10294
+ }),
10295
+ jsx("span", {
10296
+ className: "text-neutral-300",
10297
+ children: "\u00B7"
10298
+ }),
10299
+ jsx("button", {
10300
+ type: "button",
10301
+ onClick: hideAll,
10302
+ className: "text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline",
10303
+ children: labels.columnsHideAll
10304
+ })
10305
+ ]
10306
+ }),
10307
+ jsx("ul", {
10308
+ className: "flex flex-col gap-1.5 max-h-72 overflow-auto",
10309
+ children: cols.map((col)=>{
10310
+ const field = col.field;
10311
+ const hidden = draft.includes(field);
10312
+ const header = typeof col.header === 'function' ? col.header() : col.header;
10313
+ return jsx("li", {
10314
+ children: jsxs("label", {
10315
+ className: "flex items-center gap-2 cursor-pointer py-1 px-1 rounded hover:bg-neutral-100",
10316
+ children: [
10317
+ jsx("input", {
10318
+ type: "checkbox",
10319
+ checked: !hidden,
10320
+ onChange: ()=>toggle(field),
10321
+ className: "cursor-pointer"
10322
+ }),
10323
+ jsx("span", {
10324
+ className: "text-sm text-neutral-900",
10325
+ children: header
10326
+ })
10327
+ ]
10328
+ })
10329
+ }, field);
10330
+ })
10331
+ }),
10332
+ jsx("div", {
10333
+ className: "flex justify-end gap-2 pt-2 border-t border-neutral-200",
10334
+ children: jsx("button", {
10335
+ type: "button",
10336
+ onClick: ()=>{
10337
+ onChange(draft);
10338
+ onDone();
10339
+ },
10340
+ className: "text-sm 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.5",
10341
+ children: labels.columnsDone
10342
+ })
10343
+ })
10344
+ ]
10345
+ });
10346
+ }
10347
+ // ───── Icon ─────
10348
+ function ColumnsIcon() {
10349
+ return jsxs("svg", {
10350
+ width: "0.875em",
10351
+ height: "0.875em",
10352
+ viewBox: "0 0 16 16",
10353
+ fill: "none",
10354
+ stroke: "currentColor",
10355
+ strokeWidth: "1.5",
10356
+ strokeLinecap: "round",
10357
+ strokeLinejoin: "round",
10358
+ "aria-hidden": "true",
10359
+ children: [
10360
+ jsx("rect", {
10361
+ x: "2",
10362
+ y: "3",
10363
+ width: "3.5",
10364
+ height: "10",
10365
+ rx: "0.5"
10366
+ }),
10367
+ jsx("rect", {
10368
+ x: "6.25",
10369
+ y: "3",
10370
+ width: "3.5",
10371
+ height: "10",
10372
+ rx: "0.5"
10373
+ }),
10374
+ jsx("rect", {
10375
+ x: "10.5",
10376
+ y: "3",
10377
+ width: "3.5",
10378
+ height: "10",
10379
+ rx: "0.5"
10380
+ })
10381
+ ]
10382
+ });
10383
+ }
10384
+
10385
+ function useColumnResize(args) {
10386
+ const { widths, onChange, minWidth = 40, maxWidth = 1200 } = args;
10387
+ // We need the LATEST widths inside the pointermove closure. Stick
10388
+ // them in a ref so the closure captured at pointerdown stays valid
10389
+ // for the duration of the drag.
10390
+ const widthsRef = useRef(widths);
10391
+ widthsRef.current = widths;
10392
+ const startResize = useCallback((field, startWidth, clampMin, clampMax)=>{
10393
+ return (e)=>{
10394
+ // Only respond to the primary mouse button (or touch / pen).
10395
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
10396
+ e.preventDefault();
10397
+ e.stopPropagation();
10398
+ const startX = e.clientX;
10399
+ const target = e.currentTarget;
10400
+ try {
10401
+ target.setPointerCapture(e.pointerId);
10402
+ } catch (unused) {
10403
+ // jsdom or older browsers — fine to fall back to global listeners.
10404
+ }
10405
+ const effectiveMin = Math.max(minWidth, clampMin);
10406
+ const effectiveMax = Math.min(maxWidth, clampMax);
10407
+ const handleMove = (ev)=>{
10408
+ const delta = ev.clientX - startX;
10409
+ const next = clamp(startWidth + delta, effectiveMin, effectiveMax);
10410
+ onChange(_extends({}, widthsRef.current, {
10411
+ [field]: next
10412
+ }));
10413
+ };
10414
+ const handleEnd = (ev)=>{
10415
+ try {
10416
+ target.releasePointerCapture(ev.pointerId);
10417
+ } catch (unused) {
10418
+ /* noop */ }
10419
+ target.removeEventListener('pointermove', handleMove);
10420
+ target.removeEventListener('pointerup', handleEnd);
10421
+ target.removeEventListener('pointercancel', handleEnd);
10422
+ };
10423
+ target.addEventListener('pointermove', handleMove);
10424
+ target.addEventListener('pointerup', handleEnd);
10425
+ target.addEventListener('pointercancel', handleEnd);
10426
+ };
10427
+ }, [
10428
+ onChange,
10429
+ minWidth,
10430
+ maxWidth
10431
+ ]);
10432
+ return {
10433
+ startResize
10434
+ };
10435
+ }
10436
+ function clamp(n, min, max) {
10437
+ if (n < min) return min;
10438
+ if (n > max) return max;
10439
+ return n;
10440
+ }
10441
+
10442
+ function useColumnReorder(args) {
10443
+ const { order, onChange } = args;
10444
+ const sourceRef = useRef(null);
10445
+ const [dropTarget, setDropTarget] = useState(null);
10446
+ const [draggingField, setDraggingField] = useState(null);
10447
+ const dragStart = useCallback((field)=>(e)=>{
10448
+ sourceRef.current = field;
10449
+ setDraggingField(field);
10450
+ try {
10451
+ e.dataTransfer.effectAllowed = 'move';
10452
+ // Some browsers refuse to start a drag if no data is set.
10453
+ e.dataTransfer.setData('text/plain', field);
10454
+ } catch (unused) {
10455
+ /* noop — jsdom doesn't implement dataTransfer fully. */ }
10456
+ }, []);
10457
+ const dragOver = useCallback((field)=>(e)=>{
10458
+ if (sourceRef.current == null) return;
10459
+ if (field === sourceRef.current) return;
10460
+ e.preventDefault();
10461
+ e.dataTransfer.dropEffect = 'move';
10462
+ // Determine LEFT vs RIGHT half of the target.
10463
+ const rect = e.currentTarget.getBoundingClientRect();
10464
+ const midpoint = rect.left + rect.width / 2;
10465
+ const side = e.clientX < midpoint ? 'left' : 'right';
10466
+ setDropTarget((prev)=>prev && prev.field === field && prev.side === side ? prev : {
10467
+ field,
10468
+ side
10469
+ });
10470
+ }, []);
10471
+ const drop = useCallback((field)=>(e)=>{
10472
+ e.preventDefault();
10473
+ const source = sourceRef.current;
10474
+ sourceRef.current = null;
10475
+ setDraggingField(null);
10476
+ const target = dropTarget;
10477
+ setDropTarget(null);
10478
+ if (!source || source === field) return;
10479
+ const side = (target == null ? void 0 : target.field) === field ? target.side : 'right';
10480
+ const next = moveColumn(order, source, field, side);
10481
+ if (!arraysEqual(next, order)) onChange(next);
10482
+ }, [
10483
+ order,
10484
+ onChange,
10485
+ dropTarget
10486
+ ]);
10487
+ const dragEnd = useCallback(()=>{
10488
+ sourceRef.current = null;
10489
+ setDraggingField(null);
10490
+ setDropTarget(null);
10491
+ }, []);
10492
+ return {
10493
+ dropTarget,
10494
+ draggingField,
10495
+ dragStart,
10496
+ dragOver,
10497
+ drop,
10498
+ dragEnd
10499
+ };
10500
+ }
10501
+ /**
10502
+ * Move `source` next to `target` in the `order` list.
10503
+ * `side='left'` inserts before; `side='right'` inserts after.
10504
+ * Exposed for unit testing.
10505
+ */ function moveColumn(order, source, target, side) {
10506
+ const withoutSource = order.filter((f)=>f !== source);
10507
+ const targetIndex = withoutSource.indexOf(target);
10508
+ if (targetIndex === -1) return order;
10509
+ const insertAt = side === 'left' ? targetIndex : targetIndex + 1;
10510
+ return [
10511
+ ...withoutSource.slice(0, insertAt),
10512
+ source,
10513
+ ...withoutSource.slice(insertAt)
10514
+ ];
10515
+ }
10516
+ function arraysEqual(a, b) {
10517
+ if (a === b) return true;
10518
+ if (a.length !== b.length) return false;
10519
+ for(let i = 0; i < a.length; i++){
10520
+ if (a[i] !== b[i]) return false;
10521
+ }
10522
+ return true;
10523
+ }
10524
+
9751
10525
  const DEFAULT_LABELS = {
9752
10526
  searchPlaceholder: 'Search...',
9753
10527
  noData: 'No data',
@@ -9764,10 +10538,23 @@ const DEFAULT_LABELS = {
9764
10538
  filterColumn: 'Filter',
9765
10539
  filterApply: 'Apply',
9766
10540
  filterClear: 'Clear',
10541
+ filterMin: 'Min',
10542
+ filterMax: 'Max',
10543
+ filterFrom: 'From',
10544
+ filterTo: 'To',
10545
+ filterAll: 'All',
10546
+ filterTrue: 'True',
10547
+ filterFalse: 'False',
9767
10548
  density: 'Density',
9768
10549
  densityCompact: 'Compact',
9769
10550
  densityComfortable: 'Comfortable',
9770
- densitySpacious: 'Spacious'
10551
+ densitySpacious: 'Spacious',
10552
+ columnsButton: 'Columns',
10553
+ columnsTitle: 'Manage columns',
10554
+ columnsDescription: 'Show or hide columns. Required columns cannot be toggled.',
10555
+ columnsShowAll: 'Show all',
10556
+ columnsHideAll: 'Hide all',
10557
+ columnsDone: 'Done'
9771
10558
  };
9772
10559
  /**
9773
10560
  * Dashforge TW `<DataGrid>` — virtualized data table.
@@ -9796,7 +10583,7 @@ const DEFAULT_LABELS = {
9796
10583
  * - `'visible'` — toggles only the current viewport window
9797
10584
  */ function DataGrid(props) {
9798
10585
  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;
10586
+ 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
10587
  const labels = _extends({}, DEFAULT_LABELS, labelsProp);
9801
10588
  const gridAccessState = useAccessState(gridAccess);
9802
10589
  // ───── Controllable state ─────
@@ -9810,16 +10597,109 @@ const DEFAULT_LABELS = {
9810
10597
  defaultValue: '',
9811
10598
  onChange: onSearchQueryChange
9812
10599
  });
9813
- const filterModel = filterModelProp != null ? filterModelProp : [];
10600
+ // filterModel: in v1-bis the per-column header UI commits filter
10601
+ // items to this controllable state. Consumer can lift it via
10602
+ // filterModelProp + onFilterChange to drive server-side filtering.
10603
+ const [filterModel, setFilterModel] = useControllableState({
10604
+ controlledValue: filterModelProp,
10605
+ defaultValue: [],
10606
+ onChange: onFilterChange
10607
+ });
9814
10608
  const [selectedRowIds, setSelectedRowIds] = useControllableState({
9815
10609
  controlledValue: selectedRowIdsProp,
9816
10610
  defaultValue: [],
9817
10611
  onChange: onSelectionChange
9818
10612
  });
9819
- // ───── RBAC: filter columns ─────
9820
- const visibleCols = useMemo(()=>cols.filter((col)=>!col.access || col.access.onUnauthorized !== 'hide'), [
10613
+ // ───── Column visibility ─────
10614
+ // Seed default hidden set from `defaultHidden` flag on columns.
10615
+ // Memoized to avoid the seed shifting on every render — the
10616
+ // controllable state keeps internal state stable thereafter.
10617
+ const defaultHiddenColumns = useMemo(()=>cols.filter((c)=>c.defaultHidden === true).map((c)=>c.field), [
9821
10618
  cols
9822
10619
  ]);
10620
+ const [hiddenColumns, setHiddenColumns] = useControllableState({
10621
+ controlledValue: hiddenColumnsProp,
10622
+ defaultValue: defaultHiddenColumns,
10623
+ onChange: onHiddenColumnsChange
10624
+ });
10625
+ // ───── Column widths (resize state) ─────
10626
+ // Seed from any `col.width` declared statically. Internal state is
10627
+ // applied only when the user actually starts dragging; until then
10628
+ // we fall back to the static width from the column def.
10629
+ const defaultColumnWidths = useMemo(()=>{
10630
+ const map = {};
10631
+ for (const c of cols){
10632
+ if (typeof c.width === 'number') {
10633
+ map[c.field] = c.width;
10634
+ }
10635
+ }
10636
+ return map;
10637
+ }, [
10638
+ cols
10639
+ ]);
10640
+ const [columnWidths, setColumnWidths] = useControllableState({
10641
+ controlledValue: columnWidthsProp,
10642
+ defaultValue: defaultColumnWidths,
10643
+ onChange: onColumnWidthsChange
10644
+ });
10645
+ const { startResize } = useColumnResize({
10646
+ widths: columnWidths,
10647
+ onChange: setColumnWidths
10648
+ });
10649
+ // ───── Column order (reorder state) ─────
10650
+ const defaultColumnOrder = useMemo(()=>cols.map((c)=>c.field), [
10651
+ cols
10652
+ ]);
10653
+ const [columnOrder, setColumnOrder] = useControllableState({
10654
+ controlledValue: columnOrderProp,
10655
+ defaultValue: defaultColumnOrder,
10656
+ onChange: onColumnOrderChange
10657
+ });
10658
+ const reorderHandlers = useColumnReorder({
10659
+ order: columnOrder,
10660
+ onChange: setColumnOrder
10661
+ });
10662
+ // Apply the active order to the columns list. Cols not in the
10663
+ // order array (e.g. newly-added columns at runtime) are appended
10664
+ // in their original position from `cols`.
10665
+ const orderedCols = useMemo(()=>{
10666
+ if (!columnOrder || columnOrder.length === 0) return cols;
10667
+ const byField = new Map(cols.map((c)=>[
10668
+ c.field,
10669
+ c
10670
+ ]));
10671
+ const seen = new Set();
10672
+ const result = [];
10673
+ for (const field of columnOrder){
10674
+ const c = byField.get(field);
10675
+ if (c) {
10676
+ result.push(c);
10677
+ seen.add(field);
10678
+ }
10679
+ }
10680
+ for (const c of cols){
10681
+ if (!seen.has(c.field)) result.push(c);
10682
+ }
10683
+ return result;
10684
+ }, [
10685
+ cols,
10686
+ columnOrder
10687
+ ]);
10688
+ // ───── RBAC + visibility + reorder: derive final column list ─────
10689
+ // - `access.onUnauthorized === 'hide'` removes a column entirely.
10690
+ // - `hiddenColumns` is the user-toggled hidden set from the
10691
+ // column-visibility dialog.
10692
+ // - `orderedCols` is the user-reordered version of `cols`.
10693
+ // NOTE: filtering happens on `orderedCols` so the active order is
10694
+ // preserved through visibility changes.
10695
+ const visibleCols = useMemo(()=>orderedCols.filter((col)=>{
10696
+ if (col.access && col.access.onUnauthorized === 'hide') return false;
10697
+ if (hiddenColumns.includes(col.field)) return false;
10698
+ return true;
10699
+ }), [
10700
+ orderedCols,
10701
+ hiddenColumns
10702
+ ]);
9823
10703
  // ───── Smart-default column types ─────
9824
10704
  const columnTypes = useColumnAutoDetect(rows, visibleCols);
9825
10705
  // ───── Pipeline: search → filter → sort (skip when server-side) ─────
@@ -9901,28 +10781,45 @@ const DEFAULT_LABELS = {
9901
10781
  className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
9902
10782
  "data-disabled": !isInteractive ? 'true' : undefined,
9903
10783
  children: [
9904
- enableSearch && jsx("div", {
10784
+ (enableSearch || enableColumnVisibility) && jsxs("div", {
9905
10785
  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
- })
10786
+ children: [
10787
+ enableSearch && jsxs("label", {
10788
+ className: cn(v.search(), slotProps == null ? void 0 : (_slotProps_search = slotProps.search) == null ? void 0 : _slotProps_search.className),
10789
+ children: [
10790
+ jsx("span", {
10791
+ className: "sr-only",
10792
+ children: labels.searchPlaceholder
10793
+ }),
10794
+ jsx(SearchIcon, {
10795
+ className: "ml-2 shrink-0 text-neutral-500"
10796
+ }),
10797
+ jsx("input", {
10798
+ type: "text",
10799
+ value: searchQuery,
10800
+ onChange: (e)=>setSearchQuery(e.target.value),
10801
+ placeholder: searchPlaceholder != null ? searchPlaceholder : labels.searchPlaceholder,
10802
+ disabled: !isInteractive,
10803
+ 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')
10804
+ })
10805
+ ]
10806
+ }),
10807
+ enableColumnVisibility && jsx(ColumnVisibilityTrigger, {
10808
+ cols: cols.filter((c)=>// RBAC-hidden columns are never offered in the dialog.
10809
+ !c.access || c.access.onUnauthorized !== 'hide'),
10810
+ hiddenColumns: hiddenColumns,
10811
+ onChange: setHiddenColumns,
10812
+ disabled: !isInteractive,
10813
+ labels: {
10814
+ columnsButton: labels.columnsButton,
10815
+ columnsTitle: labels.columnsTitle,
10816
+ columnsDescription: labels.columnsDescription,
10817
+ columnsShowAll: labels.columnsShowAll,
10818
+ columnsHideAll: labels.columnsHideAll,
10819
+ columnsDone: labels.columnsDone
10820
+ }
10821
+ })
10822
+ ]
9926
10823
  }),
9927
10824
  jsx("div", {
9928
10825
  ref: scrollRef,
@@ -9965,8 +10862,15 @@ const DEFAULT_LABELS = {
9965
10862
  sortModel: sortModel,
9966
10863
  onSortClick: (e)=>handleSortClick(col, e, sortModel, setSortModel),
9967
10864
  inferredType: columnTypes.get(col.field),
10865
+ filterModel: filterModel,
10866
+ onFilterChange: setFilterModel,
10867
+ effectiveWidth: resolveColumnWidth(col, columnWidths),
10868
+ resizable: enableColumnResize && col.resizable !== false,
10869
+ startResize: startResize,
10870
+ reorderable: enableColumnReorder && col.reorderable !== false,
10871
+ reorderHandlers: reorderHandlers,
9968
10872
  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),
10873
+ 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
10874
  buttonClassName: v.headerCellButton(),
9971
10875
  disabled: !isInteractive
9972
10876
  }, col.field);
@@ -9999,6 +10903,7 @@ const DEFAULT_LABELS = {
9999
10903
  renderedRows,
10000
10904
  visibleCols,
10001
10905
  columnTypes,
10906
+ columnWidths,
10002
10907
  selection,
10003
10908
  rowSelection,
10004
10909
  showSelectionColumn,
@@ -10012,6 +10917,7 @@ const DEFAULT_LABELS = {
10012
10917
  cell: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell1 = slotProps.cell) == null ? void 0 : _slotProps_cell1.className),
10013
10918
  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
10919
  stickyLeftCell: v.stickyLeftCell(),
10920
+ stickyRightCell: v.stickyRightCell(),
10015
10921
  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
10922
  }
10017
10923
  })
@@ -10058,50 +10964,160 @@ const DEFAULT_LABELS = {
10058
10964
  }
10059
10965
  // ───── Header cell ─────
10060
10966
  function HeaderCell(props) {
10061
- const { col, sortModel, onSortClick, inferredType, labels, className, buttonClassName, disabled } = props;
10967
+ const { col, sortModel, onSortClick, inferredType, filterModel, onFilterChange, effectiveWidth, resizable, startResize, reorderable, reorderHandlers, labels, className, buttonClassName, disabled } = props;
10062
10968
  const align = resolveAlign(col, inferredType);
10063
10969
  const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
10064
10970
  const sortEntry = sortModel.find((s)=>s.field === col.field);
10065
10971
  const ariaSort = (sortEntry == null ? void 0 : sortEntry.direction) === 'asc' ? 'ascending' : (sortEntry == null ? void 0 : sortEntry.direction) === 'desc' ? 'descending' : 'none';
10066
10972
  const headerContent = typeof col.header === 'function' ? col.header() : col.header;
10067
10973
  const cellStyle = {
10068
- width: col.width != null ? `${col.width}px` : undefined,
10974
+ width: effectiveWidth != null ? `${effectiveWidth}px` : undefined,
10069
10975
  minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
10070
- flex: col.flex
10976
+ flex: effectiveWidth != null ? undefined : col.flex,
10977
+ position: resizable || reorderable ? 'relative' : undefined
10071
10978
  };
10979
+ // Drop indicator for reorder: a 2px vertical bar at the LEFT or
10980
+ // RIGHT edge of this th when it's the active drop target.
10981
+ const dropTarget = reorderHandlers.dropTarget;
10982
+ 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' : '';
10983
+ const isDragging = reorderHandlers.draggingField === col.field;
10984
+ // Drag attributes only attach when reorderable; we DO NOT make the
10985
+ // entire th `draggable` if it isn't — keeps the keyboard focus
10986
+ // behavior unchanged.
10987
+ const dragAttrs = reorderable ? {
10988
+ draggable: true,
10989
+ onDragStart: reorderHandlers.dragStart(col.field),
10990
+ onDragOver: reorderHandlers.dragOver(col.field),
10991
+ onDrop: reorderHandlers.drop(col.field),
10992
+ onDragEnd: reorderHandlers.dragEnd
10993
+ } : {};
10994
+ const resizeHandle = resizable ? jsx("span", {
10995
+ role: "separator",
10996
+ "aria-orientation": "vertical",
10997
+ "aria-label": `Resize ${typeof col.header === 'string' ? col.header : col.field}`,
10998
+ onPointerDown: (e)=>{
10999
+ var _ref, _col_minWidth, _col_maxWidth;
11000
+ const handle = e.currentTarget;
11001
+ const th = handle.parentElement;
11002
+ const startWidth = (_ref = effectiveWidth != null ? effectiveWidth : th == null ? void 0 : th.getBoundingClientRect().width) != null ? _ref : 120;
11003
+ const min = (_col_minWidth = col.minWidth) != null ? _col_minWidth : 40;
11004
+ const max = (_col_maxWidth = col.maxWidth) != null ? _col_maxWidth : 1200;
11005
+ startResize(col.field, startWidth, min, max)(e);
11006
+ },
11007
+ 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')
11008
+ }) : null;
11009
+ const filterType = col.filterable ? resolveFilterType(col, inferredType) : null;
11010
+ const filterTrigger = col.filterable && filterType ? jsx(ColumnFilterTrigger, {
11011
+ field: col.field,
11012
+ filterType: filterType,
11013
+ filterModel: filterModel,
11014
+ onFilterChange: onFilterChange,
11015
+ disabled: disabled,
11016
+ labels: {
11017
+ filterColumn: labels.filterColumn,
11018
+ filterApply: labels.filterApply,
11019
+ filterClear: labels.filterClear,
11020
+ filterMin: labels.filterMin,
11021
+ filterMax: labels.filterMax,
11022
+ filterFrom: labels.filterFrom,
11023
+ filterTo: labels.filterTo,
11024
+ filterAll: labels.filterAll,
11025
+ filterTrue: labels.filterTrue,
11026
+ filterFalse: labels.filterFalse
11027
+ }
11028
+ }) : null;
11029
+ // Layout strategy:
11030
+ // - sortable only → sort button is the cell content
11031
+ // - filterable only → header text + filter trigger in a flex row
11032
+ // - both → sort button + filter trigger in a flex row
11033
+ // - neither → plain header text
11034
+ const wrapperJustify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start';
10072
11035
  if (col.sortable) {
10073
- return jsx("th", {
11036
+ const sortButton = jsxs("button", {
11037
+ type: "button",
11038
+ onClick: onSortClick,
11039
+ disabled: disabled,
11040
+ "aria-label": ariaSort === 'ascending' ? labels.ariaSortAscending : ariaSort === 'descending' ? labels.ariaSortDescending : labels.ariaSortNone,
11041
+ className: cn(buttonClassName, align === 'right' && 'flex-row-reverse', 'disabled:cursor-not-allowed disabled:opacity-50'),
11042
+ children: [
11043
+ jsx("span", {
11044
+ children: headerContent
11045
+ }),
11046
+ jsx(SortIcon, {
11047
+ direction: sortEntry == null ? void 0 : sortEntry.direction
11048
+ })
11049
+ ]
11050
+ });
11051
+ return jsxs("th", _extends({
10074
11052
  scope: "col",
10075
11053
  "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'),
11054
+ className: cn(className, alignClass, dropIndicatorClass, isDragging && 'opacity-50'),
11055
+ style: cellStyle
11056
+ }, dragAttrs, {
11057
+ children: [
11058
+ filterTrigger ? jsxs("div", {
11059
+ className: cn('flex items-center gap-1', wrapperJustify),
11060
+ children: [
11061
+ sortButton,
11062
+ filterTrigger
11063
+ ]
11064
+ }) : sortButton,
11065
+ resizeHandle
11066
+ ]
11067
+ }));
11068
+ }
11069
+ return jsxs("th", _extends({
11070
+ scope: "col",
11071
+ className: cn(className, alignClass, dropIndicatorClass, isDragging && 'opacity-50'),
11072
+ style: cellStyle
11073
+ }, dragAttrs, {
11074
+ children: [
11075
+ filterTrigger ? jsxs("div", {
11076
+ className: cn('flex items-center gap-1', wrapperJustify),
10084
11077
  children: [
10085
11078
  jsx("span", {
10086
11079
  children: headerContent
10087
11080
  }),
10088
- jsx(SortIcon, {
10089
- direction: sortEntry == null ? void 0 : sortEntry.direction
10090
- })
11081
+ filterTrigger
10091
11082
  ]
10092
- })
10093
- });
11083
+ }) : headerContent,
11084
+ resizeHandle
11085
+ ]
11086
+ }));
11087
+ }
11088
+ /**
11089
+ * Resolve the effective width (px) for a column. User-driven resize
11090
+ * (`columnWidths[field]`) wins over the static `col.width`. Returns
11091
+ * `undefined` if neither source has a width set — the consumer falls
11092
+ * back to the column's `flex` value or browser auto-layout.
11093
+ */ function resolveColumnWidth(col, columnWidths) {
11094
+ const resized = columnWidths[col.field];
11095
+ if (resized != null) return resized;
11096
+ return col.width;
11097
+ }
11098
+ /**
11099
+ * Resolve the filter UI type for a column. Explicit `col.filterType`
11100
+ * wins; otherwise we map the auto-detected primitive type:
11101
+ * - `number` → number range
11102
+ * - `boolean` → true/false/all select
11103
+ * - `date` → date range
11104
+ * - anything else (string / unknown) → text contains
11105
+ */ function resolveFilterType(col, inferred) {
11106
+ if (col.filterType) return col.filterType;
11107
+ switch(inferred){
11108
+ case 'number':
11109
+ return 'number';
11110
+ case 'boolean':
11111
+ return 'boolean';
11112
+ case 'date':
11113
+ return 'date';
11114
+ default:
11115
+ return 'text';
10094
11116
  }
10095
- return jsx("th", {
10096
- scope: "col",
10097
- className: cn(className, alignClass),
10098
- style: cellStyle,
10099
- children: headerContent
10100
- });
10101
11117
  }
10102
11118
  // ───── Virtualized body ─────
10103
11119
  function renderVirtualizedBody(args) {
10104
- const { vWindow, renderedRows, visibleCols, columnTypes, selection, rowSelection, showSelectionColumn, showRowActionsColumn, rowActions, getRowId, isInteractive, labels, classes } = args;
11120
+ const { vWindow, renderedRows, visibleCols, columnTypes, columnWidths, selection, rowSelection, showSelectionColumn, showRowActionsColumn, rowActions, getRowId, isInteractive, labels, classes } = args;
10105
11121
  return jsxs(Fragment$1, {
10106
11122
  children: [
10107
11123
  vWindow.paddingTop > 0 && jsx("tr", {
@@ -10136,8 +11152,10 @@ function renderVirtualizedBody(args) {
10136
11152
  rowIndex: absoluteIdx,
10137
11153
  isSelected: isSelected,
10138
11154
  inferredType: columnTypes.get(col.field),
11155
+ effectiveWidth: resolveColumnWidth(col, columnWidths),
10139
11156
  cellClass: classes.cell,
10140
- stickyLeftClass: classes.stickyLeftCell
11157
+ stickyLeftClass: classes.stickyLeftCell,
11158
+ stickyRightClass: classes.stickyRightCell
10141
11159
  }, col.field)),
10142
11160
  showRowActionsColumn && rowActions && jsx("td", {
10143
11161
  className: classes.rowActionsCell,
@@ -10157,7 +11175,7 @@ function renderVirtualizedBody(args) {
10157
11175
  }
10158
11176
  // ───── Data cell ─────
10159
11177
  function DataCell(props) {
10160
- const { col, row, rowIndex, isSelected, inferredType, cellClass, stickyLeftClass } = props;
11178
+ const { col, row, rowIndex, isSelected, inferredType, effectiveWidth, cellClass, stickyLeftClass, stickyRightClass } = props;
10161
11179
  const value = getNestedValue(row, col.field);
10162
11180
  const align = resolveAlign(col, inferredType);
10163
11181
  const tabularNums = resolveTabularNums(col, inferredType);
@@ -10177,9 +11195,9 @@ function DataCell(props) {
10177
11195
  access: columnAccessState
10178
11196
  }) : renderDefaultCell(value);
10179
11197
  return jsx("td", {
10180
- className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono', col.sticky === 'left' && stickyLeftClass),
11198
+ className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono', col.sticky === 'left' && stickyLeftClass, col.sticky === 'right' && stickyRightClass),
10181
11199
  style: {
10182
- width: col.width != null ? `${col.width}px` : undefined,
11200
+ width: effectiveWidth != null ? `${effectiveWidth}px` : undefined,
10183
11201
  minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined
10184
11202
  },
10185
11203
  children: content