@optilogic/core 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optilogic/core",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Core UI components for Optilogic - A professional React component library",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -0,0 +1,340 @@
1
+ import * as React from "react";
2
+ import { Check, ChevronDown, X } from "lucide-react";
3
+
4
+ import { cn } from "../utils/cn";
5
+ import { Popover, PopoverAnchor, PopoverContent } from "./popover";
6
+ import type { AutocompleteOption } from "./autocomplete";
7
+
8
+ export interface ComboboxProps {
9
+ /** Array of options to display */
10
+ options: AutocompleteOption[];
11
+ /** Currently selected value */
12
+ value?: string;
13
+ /** Callback when selection changes */
14
+ onChange?: (value: string | undefined) => void;
15
+ /** Callback when the input text changes (for controlled/async filtering) */
16
+ onInputChange?: (input: string) => void;
17
+ /** Placeholder text when empty */
18
+ placeholder?: string;
19
+ /** Text to show when no options match */
20
+ emptyText?: string;
21
+ /** Whether the combobox is disabled */
22
+ disabled?: boolean;
23
+ /** Additional class name for the trigger */
24
+ className?: string;
25
+ /** Whether to allow clearing the selection */
26
+ clearable?: boolean;
27
+ /** Whether to allow custom values not in the options list */
28
+ allowCustomValue?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Combobox component - a searchable input that also allows custom values
33
+ *
34
+ * Unlike Autocomplete (button trigger, select-only), Combobox uses an
35
+ * inline text input as the trigger. Users can type to filter options
36
+ * AND optionally commit custom values not in the list.
37
+ *
38
+ * Features:
39
+ * - Inline text input trigger
40
+ * - Dropdown opens on focus/typing
41
+ * - Grouped options with descriptions
42
+ * - Custom value support (allowCustomValue)
43
+ * - Clearable selection
44
+ * - Async filtering via onInputChange
45
+ *
46
+ * @example
47
+ * <Combobox
48
+ * options={[
49
+ * { value: 'react', label: 'React' },
50
+ * { value: 'vue', label: 'Vue' },
51
+ * ]}
52
+ * value={selected}
53
+ * onChange={setSelected}
54
+ * placeholder="Type or select..."
55
+ * allowCustomValue
56
+ * />
57
+ */
58
+ export function Combobox({
59
+ options,
60
+ value,
61
+ onChange,
62
+ onInputChange,
63
+ placeholder = "Type or select...",
64
+ emptyText = "No options found.",
65
+ disabled = false,
66
+ className,
67
+ clearable = false,
68
+ allowCustomValue = true,
69
+ }: ComboboxProps) {
70
+ const [open, setOpen] = React.useState(false);
71
+ const [inputValue, setInputValue] = React.useState("");
72
+ const inputRef = React.useRef<HTMLInputElement>(null);
73
+ const wrapperRef = React.useRef<HTMLDivElement>(null);
74
+
75
+ // Sync input display with selected value
76
+ const selectedOption = React.useMemo(
77
+ () => options.find((opt) => opt.value === value),
78
+ [options, value]
79
+ );
80
+
81
+ // When value changes externally, update input display
82
+ React.useEffect(() => {
83
+ if (!open) {
84
+ setInputValue(selectedOption?.label ?? value ?? "");
85
+ }
86
+ }, [value, selectedOption, open]);
87
+
88
+ // Filter options based on input
89
+ const filteredOptions = React.useMemo(() => {
90
+ if (!inputValue.trim()) return options;
91
+ const searchLower = inputValue.toLowerCase();
92
+ return options.filter(
93
+ (opt) =>
94
+ opt.label.toLowerCase().includes(searchLower) ||
95
+ opt.description?.toLowerCase().includes(searchLower)
96
+ );
97
+ }, [options, inputValue]);
98
+
99
+ // Group options
100
+ const groupedOptions = React.useMemo(() => {
101
+ const groups: Record<string, AutocompleteOption[]> = {};
102
+ const ungrouped: AutocompleteOption[] = [];
103
+
104
+ filteredOptions.forEach((opt) => {
105
+ if (opt.group) {
106
+ if (!groups[opt.group]) groups[opt.group] = [];
107
+ groups[opt.group]!.push(opt);
108
+ } else {
109
+ ungrouped.push(opt);
110
+ }
111
+ });
112
+
113
+ return { groups, ungrouped };
114
+ }, [filteredOptions]);
115
+
116
+ const hasGroups = Object.keys(groupedOptions.groups).length > 0;
117
+
118
+ const handleInputChange = React.useCallback(
119
+ (e: React.ChangeEvent<HTMLInputElement>) => {
120
+ const newValue = e.target.value;
121
+ setInputValue(newValue);
122
+ onInputChange?.(newValue);
123
+ if (!open) setOpen(true);
124
+ },
125
+ [onInputChange, open]
126
+ );
127
+
128
+ const handleSelect = React.useCallback(
129
+ (optionValue: string) => {
130
+ const option = options.find((o) => o.value === optionValue);
131
+ onChange?.(optionValue);
132
+ setInputValue(option?.label ?? optionValue);
133
+ setOpen(false);
134
+ },
135
+ [onChange, options]
136
+ );
137
+
138
+ const handleClear = React.useCallback(
139
+ (e: React.MouseEvent) => {
140
+ e.stopPropagation();
141
+ e.preventDefault();
142
+ onChange?.(undefined);
143
+ setInputValue("");
144
+ inputRef.current?.focus();
145
+ },
146
+ [onChange]
147
+ );
148
+
149
+ const handleFocus = React.useCallback(() => {
150
+ setOpen(true);
151
+ // Select all text on focus for easy replacement
152
+ inputRef.current?.select();
153
+ }, []);
154
+
155
+ const handleBlur = React.useCallback(() => {
156
+ // Delay to allow click events on options to fire
157
+ setTimeout(() => {
158
+ // If focus moved outside the wrapper entirely, close
159
+ if (!wrapperRef.current?.contains(document.activeElement)) {
160
+ setOpen(false);
161
+ }
162
+
163
+ if (allowCustomValue && inputValue.trim()) {
164
+ // Check if input matches an existing option label
165
+ const matchingOption = options.find(
166
+ (opt) => opt.label.toLowerCase() === inputValue.toLowerCase()
167
+ );
168
+ if (matchingOption) {
169
+ onChange?.(matchingOption.value);
170
+ setInputValue(matchingOption.label);
171
+ } else {
172
+ // Commit custom value
173
+ onChange?.(inputValue.trim());
174
+ }
175
+ } else if (!allowCustomValue) {
176
+ // Reset to selected option or clear
177
+ setInputValue(selectedOption?.label ?? "");
178
+ }
179
+ }, 200);
180
+ }, [allowCustomValue, inputValue, options, onChange, selectedOption]);
181
+
182
+ const handleKeyDown = React.useCallback(
183
+ (e: React.KeyboardEvent) => {
184
+ if (e.key === "Escape") {
185
+ setOpen(false);
186
+ setInputValue(selectedOption?.label ?? value ?? "");
187
+ inputRef.current?.blur();
188
+ } else if (e.key === "Enter" && open) {
189
+ e.preventDefault();
190
+ // If there's exactly one filtered option, select it
191
+ if (filteredOptions.length === 1) {
192
+ handleSelect(filteredOptions[0]!.value);
193
+ } else if (allowCustomValue && inputValue.trim()) {
194
+ // Check for exact match first
195
+ const exactMatch = filteredOptions.find(
196
+ (opt) => opt.label.toLowerCase() === inputValue.toLowerCase()
197
+ );
198
+ if (exactMatch) {
199
+ handleSelect(exactMatch.value);
200
+ } else {
201
+ onChange?.(inputValue.trim());
202
+ setOpen(false);
203
+ }
204
+ }
205
+ }
206
+ },
207
+ [
208
+ open,
209
+ filteredOptions,
210
+ handleSelect,
211
+ allowCustomValue,
212
+ inputValue,
213
+ onChange,
214
+ selectedOption,
215
+ value,
216
+ ]
217
+ );
218
+
219
+ const renderOption = (option: AutocompleteOption) => (
220
+ <button
221
+ key={option.value}
222
+ type="button"
223
+ disabled={option.disabled}
224
+ onMouseDown={(e) => e.preventDefault()}
225
+ onClick={() => handleSelect(option.value)}
226
+ className={cn(
227
+ "relative flex w-full cursor-pointer select-none items-start gap-2 rounded-sm px-2 py-1.5 text-sm outline-none",
228
+ "hover:bg-accent hover:text-accent-foreground",
229
+ "focus:bg-accent focus:text-accent-foreground",
230
+ option.disabled && "pointer-events-none opacity-50",
231
+ value === option.value && "bg-accent/50"
232
+ )}
233
+ >
234
+ <span className="flex h-4 w-4 items-center justify-center flex-shrink-0 mt-0.5">
235
+ {value === option.value && <Check className="h-4 w-4" />}
236
+ </span>
237
+ <div className="flex-1 min-w-0">
238
+ <div className="truncate">{option.label}</div>
239
+ {option.description && (
240
+ <div className="text-xs text-muted-foreground truncate">
241
+ {option.description}
242
+ </div>
243
+ )}
244
+ </div>
245
+ </button>
246
+ );
247
+
248
+ return (
249
+ <Popover open={open} onOpenChange={setOpen}>
250
+ <PopoverAnchor asChild>
251
+ <div
252
+ ref={wrapperRef}
253
+ className={cn(
254
+ "flex h-9 w-full items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm shadow-sm ring-offset-background",
255
+ "hover:border-input-hover",
256
+ "focus-within:outline-none focus-within:ring-1 focus-within:ring-ring",
257
+ disabled &&
258
+ "cursor-not-allowed opacity-50 hover:border-input",
259
+ className
260
+ )}
261
+ >
262
+ <input
263
+ ref={inputRef}
264
+ type="text"
265
+ value={inputValue}
266
+ onChange={handleInputChange}
267
+ onFocus={handleFocus}
268
+ onBlur={handleBlur}
269
+ onKeyDown={handleKeyDown}
270
+ placeholder={placeholder}
271
+ disabled={disabled}
272
+ className="flex-1 min-w-0 bg-transparent py-2 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
273
+ role="combobox"
274
+ aria-expanded={open}
275
+ aria-haspopup="listbox"
276
+ aria-autocomplete="list"
277
+ />
278
+ <div className="flex items-center gap-1 flex-shrink-0">
279
+ {clearable && value && (
280
+ <span
281
+ role="button"
282
+ tabIndex={-1}
283
+ onMouseDown={(e) => e.preventDefault()}
284
+ onClick={handleClear}
285
+ className="rounded-sm hover:bg-muted p-0.5"
286
+ >
287
+ <X className="h-3.5 w-3.5 text-muted-foreground" />
288
+ </span>
289
+ )}
290
+ <ChevronDown className="h-4 w-4 opacity-50" />
291
+ </div>
292
+ </div>
293
+ </PopoverAnchor>
294
+ <PopoverContent
295
+ className="p-0"
296
+ style={{ width: wrapperRef.current?.offsetWidth }}
297
+ align="start"
298
+ sideOffset={4}
299
+ onOpenAutoFocus={(e) => e.preventDefault()}
300
+ onFocusOutside={(e) => {
301
+ if (wrapperRef.current?.contains(e.target as Node)) {
302
+ e.preventDefault();
303
+ }
304
+ }}
305
+ onInteractOutside={(e) => {
306
+ if (wrapperRef.current?.contains(e.target as Node)) {
307
+ e.preventDefault();
308
+ }
309
+ }}
310
+ >
311
+ {/* Options list */}
312
+ <div className="max-h-[300px] overflow-y-auto p-1">
313
+ {filteredOptions.length === 0 ? (
314
+ <div className="py-6 text-center text-sm text-muted-foreground">
315
+ {emptyText}
316
+ </div>
317
+ ) : hasGroups ? (
318
+ <>
319
+ {groupedOptions.ungrouped.map(renderOption)}
320
+ {Object.entries(groupedOptions.groups).map(([group, opts]) => (
321
+ <div key={group}>
322
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
323
+ {group}
324
+ </div>
325
+ {opts.map(renderOption)}
326
+ </div>
327
+ ))}
328
+ </>
329
+ ) : (
330
+ filteredOptions.map(renderOption)
331
+ )}
332
+ </div>
333
+ </PopoverContent>
334
+ </Popover>
335
+ );
336
+ }
337
+
338
+ Combobox.displayName = "Combobox";
339
+
340
+ export default Combobox;
@@ -89,6 +89,7 @@ export function DataGrid<T = Record<string, CellValue>>({
89
89
  tooltipMinLength = 30,
90
90
  enableKeyboardNavigation = true,
91
91
  showColumnBorders = true,
92
+ fillWidth,
92
93
  enableInternalSorting = true,
93
94
  enableInternalFiltering = true,
94
95
 
@@ -163,6 +164,19 @@ export function DataGrid<T = Record<string, CellValue>>({
163
164
  y: number;
164
165
  } | null>(null);
165
166
 
167
+ // Measure container width for fillWidth mode
168
+ const [containerWidth, setContainerWidth] = React.useState(0);
169
+
170
+ React.useLayoutEffect(() => {
171
+ if (!fillWidth || !parentRef.current) return;
172
+ const observer = new ResizeObserver((entries) => {
173
+ const w = entries[0]?.contentRect.width;
174
+ if (w && w > 0) setContainerWidth(w);
175
+ });
176
+ observer.observe(parentRef.current);
177
+ return () => observer.disconnect();
178
+ }, [fillWidth]);
179
+
166
180
  // Filter visible columns
167
181
  const visibleColumns = React.useMemo(
168
182
  () => columns.filter((col) => !col.hidden),
@@ -232,16 +246,6 @@ export function DataGrid<T = Record<string, CellValue>>({
232
246
  // Keep the state hook's ref in sync so editing resolves the correct row
233
247
  processedDataRef.current = processedData;
234
248
 
235
- // Use column resize manager
236
- const { resizingColumn, getResizeProps } = useColumnResizeManager({
237
- columns: visibleColumns,
238
- columnWidths: state.columnWidths,
239
- resizableColumns,
240
- onColumnResize: actions.setColumnWidth,
241
- onColumnResizeStart,
242
- onColumnResizeEnd,
243
- });
244
-
245
249
  // Auto-enable virtualization for large datasets
246
250
  const shouldVirtualize = virtualized ?? processedData.length > 100;
247
251
 
@@ -261,13 +265,47 @@ export function DataGrid<T = Record<string, CellValue>>({
261
265
  enabled: shouldVirtualize,
262
266
  });
263
267
 
264
- // Calculate table width - prefer explicit widths, fall back to estimated width from header
265
- const tableWidth = React.useMemo(() => {
266
- return visibleColumns.reduce((acc, col) => {
267
- const width = state.columnWidths[col.key] || col.width || estimateColumnWidth(col);
268
- return acc + width;
269
- }, 0);
270
- }, [visibleColumns, state.columnWidths]);
268
+ // Calculate table width and effective column widths (with fillWidth scaling)
269
+ const { tableWidth, effectiveColumnWidths } = React.useMemo(() => {
270
+ // 1. Collect raw widths
271
+ const rawWidths: Record<string, number> = {};
272
+ let rawTotal = 0;
273
+ for (const col of visibleColumns) {
274
+ const w = state.columnWidths[col.key] || col.width || estimateColumnWidth(col);
275
+ rawWidths[col.key] = w;
276
+ rawTotal += w;
277
+ }
278
+
279
+ // 2. If fillWidth off or container not measured, use raw widths
280
+ if (!fillWidth || !containerWidth || rawTotal >= containerWidth) {
281
+ return { tableWidth: rawTotal, effectiveColumnWidths: rawWidths };
282
+ }
283
+
284
+ // 3. Scale proportionally to fill container, respecting minWidth/maxWidth
285
+ const scale = containerWidth / rawTotal;
286
+ const scaled: Record<string, number> = {};
287
+ for (const col of visibleColumns) {
288
+ let w = Math.floor(rawWidths[col.key] * scale);
289
+ if (col.minWidth) w = Math.max(w, col.minWidth);
290
+ if (col.maxWidth) w = Math.min(w, col.maxWidth);
291
+ scaled[col.key] = w;
292
+ }
293
+
294
+ return { tableWidth: containerWidth, effectiveColumnWidths: scaled };
295
+ }, [visibleColumns, state.columnWidths, fillWidth, containerWidth]);
296
+
297
+ // Use column resize manager
298
+ const { resizingColumn, getResizeProps } = useColumnResizeManager({
299
+ columns: visibleColumns,
300
+ columnWidths: state.columnWidths,
301
+ resizableColumns,
302
+ onColumnResize: actions.setColumnWidth,
303
+ onColumnResizeStart,
304
+ onColumnResizeEnd,
305
+ fillWidth,
306
+ containerWidth,
307
+ effectiveColumnWidths,
308
+ });
271
309
 
272
310
  // Get visible row count for keyboard navigation
273
311
  const visibleRowCount = React.useMemo(() => {
@@ -577,7 +615,7 @@ export function DataGrid<T = Record<string, CellValue>>({
577
615
  role="row"
578
616
  >
579
617
  {visibleColumns.map((column, colIndex) => {
580
- const width = state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
618
+ const width = effectiveColumnWidths[column.key];
581
619
  const resizeProps = getResizeProps(column.key);
582
620
 
583
621
  return (
@@ -591,6 +629,7 @@ export function DataGrid<T = Record<string, CellValue>>({
591
629
  isResizable={
592
630
  resizableColumns && column.resizable !== false
593
631
  }
632
+ fillWidth={fillWidth}
594
633
  onSort={() => handleSort(column.key)}
595
634
  onFilterChange={(filter) =>
596
635
  handleFilterChange(column.key, filter)
@@ -646,7 +685,7 @@ export function DataGrid<T = Record<string, CellValue>>({
646
685
  >
647
686
  {visibleColumns.map((column, colIndex) => {
648
687
  const width =
649
- state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
688
+ effectiveColumnWidths[column.key];
650
689
  const isEditingThisCell =
651
690
  state.editingCell?.rowIndex === virtualRow.index &&
652
691
  state.editingCell?.columnKey === column.key;
@@ -671,7 +710,8 @@ export function DataGrid<T = Record<string, CellValue>>({
671
710
  <div
672
711
  key={column.key}
673
712
  className={cn(
674
- "flex-shrink-0 px-3 py-2 text-sm overflow-hidden",
713
+ "px-3 py-2 text-sm overflow-hidden",
714
+ !fillWidth && "flex-shrink-0",
675
715
  showColumnBorders && "border-r border-border last:border-r-0",
676
716
  isFocused && !isEditingThisCell && "ring-2 ring-inset ring-primary",
677
717
  isEditingThisCell && "ring-2 ring-inset ring-primary bg-background",
@@ -799,7 +839,7 @@ export function DataGrid<T = Record<string, CellValue>>({
799
839
  </div>
800
840
  )}
801
841
 
802
- <div className="flex-1 overflow-auto min-h-0">
842
+ <div ref={parentRef} className="flex-1 overflow-auto min-h-0">
803
843
  {/* Header row using HeaderCell for consistent features */}
804
844
  <div
805
845
  className={cn(
@@ -810,7 +850,7 @@ export function DataGrid<T = Record<string, CellValue>>({
810
850
  role="row"
811
851
  >
812
852
  {visibleColumns.map((column, colIndex) => {
813
- const width = state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
853
+ const width = effectiveColumnWidths[column.key];
814
854
  const resizeProps = getResizeProps(column.key);
815
855
 
816
856
  return (
@@ -822,6 +862,7 @@ export function DataGrid<T = Record<string, CellValue>>({
822
862
  sorting={getColumnSort(column.key)}
823
863
  filter={getColumnFilter(column.key)}
824
864
  isResizable={resizableColumns && column.resizable !== false}
865
+ fillWidth={fillWidth}
825
866
  onSort={() => handleSort(column.key)}
826
867
  onFilterChange={(filter) => handleFilterChange(column.key, filter)}
827
868
  onResizeMouseDown={resizeProps.handleMouseDown}
@@ -853,7 +894,7 @@ export function DataGrid<T = Record<string, CellValue>>({
853
894
  role="row"
854
895
  >
855
896
  {visibleColumns.map((column) => {
856
- const width = state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
897
+ const width = effectiveColumnWidths[column.key];
857
898
  const isEditingThisCell =
858
899
  state.editingCell?.rowIndex === rowIndex &&
859
900
  state.editingCell?.columnKey === column.key;
@@ -865,7 +906,8 @@ export function DataGrid<T = Record<string, CellValue>>({
865
906
  <div
866
907
  key={column.key}
867
908
  className={cn(
868
- "flex-shrink-0 px-3 py-2 text-sm overflow-hidden",
909
+ "px-3 py-2 text-sm overflow-hidden",
910
+ !fillWidth && "flex-shrink-0",
869
911
  showColumnBorders && "border-r border-border last:border-r-0",
870
912
  isFocused && !isEditingThisCell && "ring-2 ring-inset ring-primary",
871
913
  isEditingThisCell && "ring-2 ring-inset ring-primary bg-background",
@@ -28,6 +28,8 @@ export interface HeaderCellProps<T = any> {
28
28
  filter?: FilterConfig;
29
29
  /** Whether this column is resizable */
30
30
  isResizable: boolean;
31
+ /** Whether fillWidth mode is active */
32
+ fillWidth?: boolean;
31
33
  /** Callback when sort is toggled */
32
34
  onSort?: () => void;
33
35
  /** Callback when filter changes */
@@ -50,6 +52,7 @@ export function HeaderCell<T = any>({
50
52
  sorting,
51
53
  filter,
52
54
  isResizable,
55
+ fillWidth,
53
56
  onSort,
54
57
  onFilterChange,
55
58
  onResizeMouseDown,
@@ -101,7 +104,8 @@ export function HeaderCell<T = any>({
101
104
  return (
102
105
  <div
103
106
  className={cn(
104
- "relative flex-shrink-0 border-r border-border last:border-r-0",
107
+ "relative border-r border-border last:border-r-0",
108
+ !fillWidth && "flex-shrink-0",
105
109
  "bg-muted select-none",
106
110
  isResizing && "bg-accent/20"
107
111
  )}
@@ -212,6 +212,12 @@ export interface UseColumnResizeManagerOptions<T = any> {
212
212
  onColumnResizeStart?: (columnKey: string) => void;
213
213
  /** Callback when resize ends */
214
214
  onColumnResizeEnd?: (columnKey: string, width: number) => void;
215
+ /** Whether fillWidth mode is active */
216
+ fillWidth?: boolean;
217
+ /** Measured container width for fillWidth redistribution */
218
+ containerWidth?: number;
219
+ /** Effective column widths (after fillWidth scaling) */
220
+ effectiveColumnWidths?: Record<string, number>;
215
221
  }
216
222
 
217
223
  export interface UseColumnResizeManagerReturn {
@@ -238,11 +244,15 @@ export function useColumnResizeManager<T = any>(
238
244
  onColumnResize,
239
245
  onColumnResizeStart,
240
246
  onColumnResizeEnd,
247
+ fillWidth,
248
+ containerWidth,
249
+ effectiveColumnWidths,
241
250
  } = options;
242
251
 
243
252
  const [resizingColumn, setResizingColumn] = useState<string | null>(null);
244
253
  const startXRef = useRef(0);
245
254
  const startWidthRef = useRef(0);
255
+ const startEffectiveWidthsRef = useRef<Record<string, number>>({});
246
256
 
247
257
  /**
248
258
  * Get column by key
@@ -280,9 +290,22 @@ export function useColumnResizeManager<T = any>(
280
290
  startWidthRef.current + deltaX
281
291
  );
282
292
 
293
+ if (fillWidth && containerWidth) {
294
+ const otherCols = columns.filter(c => c.key !== resizingColumn);
295
+ const startEffective = startEffectiveWidthsRef.current;
296
+ const otherTotal = otherCols.reduce((s, c) => s + (startEffective[c.key] || 0), 0);
297
+ const remaining = containerWidth - newWidth;
298
+
299
+ for (const col of otherCols) {
300
+ const proportion = (startEffective[col.key] || 0) / otherTotal;
301
+ const adjusted = clampWidth(col.key, Math.floor(remaining * proportion));
302
+ onColumnResize(col.key, adjusted);
303
+ }
304
+ }
305
+
283
306
  onColumnResize(resizingColumn, newWidth);
284
307
  },
285
- [resizingColumn, clampWidth, onColumnResize]
308
+ [resizingColumn, clampWidth, onColumnResize, fillWidth, containerWidth, columns]
286
309
  );
287
310
 
288
311
  /**
@@ -336,6 +359,10 @@ export function useColumnResizeManager<T = any>(
336
359
  document.body.style.cursor = "col-resize";
337
360
 
338
361
  onColumnResizeStart?.(columnKey);
362
+
363
+ if (fillWidth && effectiveColumnWidths) {
364
+ startEffectiveWidthsRef.current = { ...effectiveColumnWidths };
365
+ }
339
366
  };
340
367
 
341
368
  const handleDoubleClick = (event: React.MouseEvent) => {
@@ -368,6 +395,8 @@ export function useColumnResizeManager<T = any>(
368
395
  onColumnResize,
369
396
  onColumnResizeStart,
370
397
  onColumnResizeEnd,
398
+ fillWidth,
399
+ effectiveColumnWidths,
371
400
  ]
372
401
  );
373
402
 
@@ -282,6 +282,10 @@ export interface DataGridProps<T = Record<string, CellValue>> {
282
282
  tooltipMinLength?: number;
283
283
  /** Show column dividing borders between cells (default: true) */
284
284
  showColumnBorders?: boolean;
285
+ /** When true, columns scale proportionally to fill the container width.
286
+ * Columns shrink/grow to keep total width = container width.
287
+ * Horizontal scroll only kicks in if columns hit their minWidth constraints. */
288
+ fillWidth?: boolean;
285
289
  /** Enable internal sorting when in uncontrolled mode (default: true) */
286
290
  enableInternalSorting?: boolean;
287
291
  /** Enable internal filtering when in uncontrolled mode (default: true) */
@@ -436,6 +440,7 @@ export interface HeaderCellProps<T = Record<string, CellValue>> {
436
440
  sorting?: SortConfig;
437
441
  filter?: FilterConfig;
438
442
  isResizable: boolean;
443
+ fillWidth?: boolean;
439
444
  onSort?: () => void;
440
445
  onFilterChange?: (filter: FilterConfig | null) => void;
441
446
  onResize?: (width: number) => void;