@alaarab/ogrid-core 1.8.2 → 1.9.0

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 (71) hide show
  1. package/README.md +42 -31
  2. package/dist/esm/components/BaseInlineCellEditor.js +112 -0
  3. package/dist/esm/components/CellErrorBoundary.js +43 -0
  4. package/dist/esm/components/EmptyState.js +19 -0
  5. package/dist/esm/components/GridContextMenu.js +4 -3
  6. package/dist/esm/components/MarchingAntsOverlay.js +6 -5
  7. package/dist/esm/components/OGridLayout.js +7 -6
  8. package/dist/esm/components/SideBar.js +66 -44
  9. package/dist/esm/constants.js +11 -0
  10. package/dist/esm/hooks/index.js +6 -0
  11. package/dist/esm/hooks/useActiveCell.js +25 -9
  12. package/dist/esm/hooks/useCellEditing.js +4 -0
  13. package/dist/esm/hooks/useCellSelection.js +7 -1
  14. package/dist/esm/hooks/useClipboard.js +36 -36
  15. package/dist/esm/hooks/useColumnHeaderFilterState.js +71 -119
  16. package/dist/esm/hooks/useColumnResize.js +27 -4
  17. package/dist/esm/hooks/useContextMenu.js +9 -5
  18. package/dist/esm/hooks/useDataGridState.js +110 -162
  19. package/dist/esm/hooks/useDateFilterState.js +34 -0
  20. package/dist/esm/hooks/useFillHandle.js +7 -2
  21. package/dist/esm/hooks/useFilterOptions.js +5 -5
  22. package/dist/esm/hooks/useKeyboardNavigation.js +18 -34
  23. package/dist/esm/hooks/useLatestRef.js +11 -0
  24. package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
  25. package/dist/esm/hooks/useOGrid.js +71 -18
  26. package/dist/esm/hooks/usePeopleFilterState.js +68 -0
  27. package/dist/esm/hooks/useRichSelectState.js +5 -0
  28. package/dist/esm/hooks/useRowSelection.js +14 -4
  29. package/dist/esm/hooks/useSideBarState.js +5 -0
  30. package/dist/esm/hooks/useTableLayout.js +77 -0
  31. package/dist/esm/hooks/useTextFilterState.js +25 -0
  32. package/dist/esm/hooks/useUndoRedo.js +6 -5
  33. package/dist/esm/index.js +7 -2
  34. package/dist/esm/utils/clientSideData.js +25 -12
  35. package/dist/esm/utils/columnUtils.js +6 -0
  36. package/dist/esm/utils/gridRowComparator.js +68 -0
  37. package/dist/esm/utils/index.js +1 -0
  38. package/dist/esm/utils/ogridHelpers.js +2 -1
  39. package/dist/esm/utils/paginationHelpers.js +7 -1
  40. package/dist/types/components/BaseInlineCellEditor.d.ts +33 -0
  41. package/dist/types/components/CellErrorBoundary.d.ts +25 -0
  42. package/dist/types/components/EmptyState.d.ts +26 -0
  43. package/dist/types/constants.d.ts +11 -0
  44. package/dist/types/hooks/index.d.ts +12 -1
  45. package/dist/types/hooks/useCellEditing.d.ts +4 -0
  46. package/dist/types/hooks/useCellSelection.d.ts +5 -0
  47. package/dist/types/hooks/useClipboard.d.ts +5 -0
  48. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +1 -0
  49. package/dist/types/hooks/useColumnResize.d.ts +5 -0
  50. package/dist/types/hooks/useContextMenu.d.ts +6 -2
  51. package/dist/types/hooks/useDataGridState.d.ts +1 -0
  52. package/dist/types/hooks/useDateFilterState.d.ts +19 -0
  53. package/dist/types/hooks/useFillHandle.d.ts +5 -0
  54. package/dist/types/hooks/useKeyboardNavigation.d.ts +38 -25
  55. package/dist/types/hooks/useLatestRef.d.ts +6 -0
  56. package/dist/types/hooks/useMultiSelectFilterState.d.ts +24 -0
  57. package/dist/types/hooks/useOGrid.d.ts +30 -9
  58. package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
  59. package/dist/types/hooks/useRichSelectState.d.ts +5 -0
  60. package/dist/types/hooks/useRowSelection.d.ts +5 -0
  61. package/dist/types/hooks/useSideBarState.d.ts +5 -0
  62. package/dist/types/hooks/useTableLayout.d.ts +27 -0
  63. package/dist/types/hooks/useTextFilterState.d.ts +16 -0
  64. package/dist/types/hooks/useUndoRedo.d.ts +3 -1
  65. package/dist/types/index.d.ts +11 -4
  66. package/dist/types/types/columnTypes.d.ts +2 -3
  67. package/dist/types/types/dataGridTypes.d.ts +32 -4
  68. package/dist/types/types/index.d.ts +1 -1
  69. package/dist/types/utils/gridRowComparator.d.ts +49 -0
  70. package/dist/types/utils/index.d.ts +2 -0
  71. package/package.json +1 -1
package/README.md CHANGED
@@ -1,48 +1,59 @@
1
- # @alaarab/ogrid-core
1
+ <p align="center">
2
+ <strong>OGrid Core</strong> — Headless types, hooks, and utilities for OGrid data grids.
3
+ </p>
2
4
 
3
- Framework-agnostic types, hooks, and utilities for [OGrid](https://github.com/alaarab/ogrid) data tables.
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@alaarab/ogrid-core"><img src="https://img.shields.io/npm/v/@alaarab/ogrid-core?color=%23217346&label=npm" alt="npm version" /></a>
7
+ <a href="https://github.com/alaarab/ogrid/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" /></a>
8
+ <img src="https://img.shields.io/badge/React-17%20%7C%2018%20%7C%2019-blue" alt="React 17, 18, 19" />
9
+ <img src="https://img.shields.io/badge/TypeScript-strict-blue" alt="TypeScript strict" />
10
+ </p>
4
11
 
5
- This package is the shared foundation used by `@alaarab/ogrid-fluent` and `@alaarab/ogrid-material`. You typically don't need to install it directly -- both framework packages re-export everything from core.
12
+ <p align="center">
13
+ <a href="https://alaarab.github.io/ogrid/">Documentation</a> · <a href="https://alaarab.github.io/ogrid/docs/getting-started/overview">Getting Started</a> · <a href="https://alaarab.github.io/ogrid/docs/api/ogrid-props">API Reference</a>
14
+ </p>
6
15
 
7
- ## Install
16
+ ---
8
17
 
9
- ```bash
10
- npm install @alaarab/ogrid-core
11
- ```
18
+ Framework-agnostic foundation for [OGrid](https://github.com/alaarab/ogrid) data grids. You typically don't need to install this directly — the UI packages ([`@alaarab/ogrid`](https://www.npmjs.com/package/@alaarab/ogrid), [`@alaarab/ogrid-fluent`](https://www.npmjs.com/package/@alaarab/ogrid-fluent), [`@alaarab/ogrid-material`](https://www.npmjs.com/package/@alaarab/ogrid-material)) re-export everything from core.
12
19
 
13
- ## What's Included
20
+ ## What's Inside
21
+
22
+ ### Hooks
23
+
24
+ - `useOGrid` — Orchestrator: pagination, sorting, filtering, visibility, editing, row selection, status bar, grid API
25
+ - `useDataGridState` — All DataGridTable state: layout, selection, editing, interaction, context menu, view models
26
+ - `useColumnHeaderFilterState` — Filter popover (open, temp values, apply/clear, people search debounce)
27
+ - `useColumnChooserState` — Column visibility dropdown
28
+ - `useInlineCellEditorState` — Inline cell editor
29
+ - `useRichSelectState` — Searchable rich select dropdown
30
+ - `useSideBarState` — Side bar panel management
31
+ - `useActiveCell`, `useCellSelection`, `useCellEditing`, `useRowSelection`, `useKeyboardNavigation`, `useClipboard`, `useFillHandle`, `useUndoRedo`, `useContextMenu`, `useColumnResize`, `useFilterOptions`, `useDebounce`
14
32
 
15
33
  ### Types
16
34
 
17
- - `IColumnDef<T>` -- Column definition with sorting, filtering, and rendering
18
- - `IDataSource<T>` -- Server-side data source interface
19
- - `IFetchParams` -- Parameters for `fetchPage()`
20
- - `IFilters` -- Unified filter values (text, multi-select, people)
21
- - `UserLike` -- Minimal user shape for people picker
22
- - `IColumnFilterDef`, `IColumnMeta`, `IPageResult`, `ColumnFilterType`
35
+ `IColumnDef<T>` · `IColumnGroupDef` · `IDataSource<T>` · `IFilters` · `FilterValue` · `IDateFilterValue` · `UserLike` · `IOGridApi<T>` · `IOGridProps<T>` · `IOGridDataGridProps<T>` · `ICellEditorProps<T>` · `IGridColumnState` · `ISideBarDef` · `ColumnFilterType` · `IColumnMeta`
23
36
 
24
- ### Hooks
37
+ ### Utilities
25
38
 
26
- - `useOGrid(props, ref)` -- Page/sort/filter/visibleColumns + `dataGridProps` for DataGridTable (used by OGrid wrappers)
27
- - `useDataGridState({ props, wrapperRef })` -- Orchestrator for grid state, selection, editing, clipboard, keyboard, fill handle, status bar
28
- - `useFilterOptions(dataSource, fields)` -- Loads filter options for multi-select columns
29
- - `useColumnHeaderFilterState(params)` -- Headless filter popover state (open, temp values, apply/clear, people search)
30
- - `useColumnChooserState({ columns, visibleColumns, onVisibilityChange })` -- Column visibility dropdown (open, Escape, select all/clear)
31
- - `useInlineCellEditorState({ value, editorType, onCommit, onCancel })` -- Inline cell editor (localValue, keydown, blur/commit)
39
+ `processClientSideData` · `exportToCsv` · `getCellValue` · `flattenColumns` · `buildHeaderRows` · `getPaginationViewModel` · `getHeaderFilterConfig` · `getCellRenderDescriptor` · `computeAggregations` · `formatShortcut` · `GRID_CONTEXT_MENU_ITEMS`
32
40
 
33
- ### Components
41
+ ### Headless Components
34
42
 
35
- - `OGridLayout` -- Unified bordered layout: toolbar strip (custom content + column chooser), optional secondary toolbar row, sidebar, grid area, and footer strip (pagination).
43
+ `OGridLayout` · `StatusBar` · `GridContextMenu` · `SideBar` · `MarchingAntsOverlay`
36
44
 
37
- ### Utilities
45
+ ## Install
46
+
47
+ ```bash
48
+ npm install @alaarab/ogrid-core
49
+ ```
50
+
51
+ Only peer dep is `react ^17 || ^18 || ^19`.
52
+
53
+ ## Documentation
38
54
 
39
- - `getPaginationViewModel(...)` -- Page numbers, ellipsis, start/end item for PaginationControls
40
- - `getHeaderFilterConfig(col, input)` -- ColumnHeaderFilter props from column + filter/sort state
41
- - `getCellRenderDescriptor(item, col, rowIndex, colIdx, input)` -- Cell mode (editing-inline / editing-popover / display) and flags for DataGridTable
42
- - `toUserLike(user)` -- Converts a user-like object to `UserLike`
43
- - `exportToCsv(items, columns, getValue, filename)` -- Full CSV export
44
- - `buildCsvHeader`, `buildCsvRows`, `triggerCsvDownload`, `escapeCsvValue` -- Low-level CSV helpers
55
+ Full docs at **[alaarab.github.io/ogrid](https://alaarab.github.io/ogrid/)**.
45
56
 
46
57
  ## License
47
58
 
48
- MIT
59
+ MIT — Free forever.
@@ -0,0 +1,112 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { useInlineCellEditorState, useRichSelectState } from '../hooks';
4
+ // ── Shared editor style constants (used across all 3 UI packages) ──
5
+ export const editorWrapperStyle = {
6
+ width: '100%',
7
+ height: '100%',
8
+ display: 'flex',
9
+ alignItems: 'center',
10
+ padding: '6px 10px',
11
+ boxSizing: 'border-box',
12
+ overflow: 'hidden',
13
+ minWidth: 0,
14
+ };
15
+ export const editorInputStyle = {
16
+ width: '100%',
17
+ padding: 0,
18
+ border: 'none',
19
+ background: 'transparent',
20
+ color: 'inherit',
21
+ font: 'inherit',
22
+ fontSize: '13px',
23
+ outline: 'none',
24
+ minWidth: 0,
25
+ };
26
+ export const richSelectWrapperStyle = {
27
+ ...editorWrapperStyle,
28
+ position: 'relative',
29
+ };
30
+ export const richSelectDropdownStyle = {
31
+ position: 'absolute',
32
+ top: '100%',
33
+ left: 0,
34
+ right: 0,
35
+ maxHeight: 200,
36
+ overflowY: 'auto',
37
+ background: 'var(--ogrid-bg, #fff)',
38
+ border: '1px solid var(--ogrid-border, #ccc)',
39
+ zIndex: 10,
40
+ boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
41
+ };
42
+ export const richSelectOptionStyle = {
43
+ padding: '6px 8px',
44
+ cursor: 'pointer',
45
+ color: 'var(--ogrid-fg, #242424)',
46
+ };
47
+ export const richSelectOptionHighlightedStyle = {
48
+ ...richSelectOptionStyle,
49
+ background: 'var(--ogrid-bg-hover, #e8f0fe)',
50
+ };
51
+ export const richSelectNoMatchesStyle = {
52
+ padding: '6px 8px',
53
+ color: 'var(--ogrid-muted, #999)',
54
+ };
55
+ export const selectEditorStyle = {
56
+ width: '100%',
57
+ padding: 0,
58
+ border: 'none',
59
+ background: 'transparent',
60
+ color: 'inherit',
61
+ font: 'inherit',
62
+ fontSize: '13px',
63
+ cursor: 'pointer',
64
+ outline: 'none',
65
+ };
66
+ /**
67
+ * Base inline cell editor with shared logic for all editor types except checkbox and select
68
+ * (which are framework-specific). Used by all 3 UI packages to avoid duplication.
69
+ *
70
+ * Usage:
71
+ * - Radix: Pass Radix Checkbox/native select via render props
72
+ * - Fluent: Pass Fluent Checkbox/Select via render props
73
+ * - Material: Pass MUI Checkbox/Select via render props
74
+ */
75
+ export function BaseInlineCellEditor(props) {
76
+ const { value, column, editorType, onCommit, onCancel, renderCheckbox, renderSelect } = props;
77
+ const wrapperRef = React.useRef(null);
78
+ const { localValue, setLocalValue, handleKeyDown, handleBlur, commit, cancel } = useInlineCellEditorState({ value, editorType, onCommit, onCancel });
79
+ const richSelectValues = column.cellEditorParams?.values ?? [];
80
+ const richSelectFormatValue = column.cellEditorParams?.formatValue;
81
+ const richSelect = useRichSelectState({
82
+ values: richSelectValues,
83
+ formatValue: richSelectFormatValue,
84
+ initialValue: value,
85
+ onCommit,
86
+ onCancel,
87
+ });
88
+ React.useEffect(() => {
89
+ const input = wrapperRef.current?.querySelector('input');
90
+ input?.focus();
91
+ }, []);
92
+ // Rich select (shared across all frameworks)
93
+ if (editorType === 'richSelect') {
94
+ return (_jsxs("div", { ref: wrapperRef, style: richSelectWrapperStyle, children: [_jsx("input", { type: "text", value: richSelect.searchText, onChange: (e) => richSelect.setSearchText(e.target.value), onKeyDown: richSelect.handleKeyDown, placeholder: "Search...", autoFocus: true, style: editorInputStyle }), _jsxs("div", { style: richSelectDropdownStyle, role: "listbox", children: [richSelect.filteredValues.map((v, i) => (_jsx("div", { role: "option", "aria-selected": i === richSelect.highlightedIndex, onClick: () => richSelect.selectValue(v), style: i === richSelect.highlightedIndex ? richSelectOptionHighlightedStyle : richSelectOptionStyle, children: richSelect.getDisplayText(v) }, String(v)))), richSelect.filteredValues.length === 0 && (_jsx("div", { style: richSelectNoMatchesStyle, children: "No matches" }))] })] }));
95
+ }
96
+ // Checkbox (framework-specific)
97
+ if (editorType === 'checkbox') {
98
+ const checked = value === true;
99
+ return _jsx(_Fragment, { children: renderCheckbox(checked, (val) => commit(val), cancel) });
100
+ }
101
+ // Select (framework-specific)
102
+ if (editorType === 'select') {
103
+ const values = column.cellEditorParams?.values ?? [];
104
+ return _jsx(_Fragment, { children: renderSelect(value, values, commit, cancel) });
105
+ }
106
+ // Date editor (shared across all frameworks)
107
+ if (editorType === 'date') {
108
+ return (_jsx("div", { ref: wrapperRef, style: editorWrapperStyle, children: _jsx("input", { type: "date", value: localValue, onChange: (e) => setLocalValue(e.target.value), onBlur: handleBlur, onKeyDown: handleKeyDown, style: editorInputStyle, autoFocus: true }) }));
109
+ }
110
+ // Text editor (default, shared across all frameworks)
111
+ return (_jsx("div", { ref: wrapperRef, style: editorWrapperStyle, children: _jsx("input", { type: "text", value: localValue, onChange: (e) => setLocalValue(e.target.value), onBlur: handleBlur, onKeyDown: handleKeyDown, style: editorInputStyle, autoFocus: true }) }));
112
+ }
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ const DEFAULT_FALLBACK_STYLE = {
4
+ color: '#d32f2f',
5
+ fontSize: '0.75rem',
6
+ padding: '2px 4px',
7
+ };
8
+ /**
9
+ * Error boundary for cell renderers and custom editors.
10
+ * Prevents a runtime error in a cell from crashing the entire grid.
11
+ */
12
+ export class CellErrorBoundary extends React.Component {
13
+ constructor(props) {
14
+ super(props);
15
+ this.state = { hasError: false };
16
+ }
17
+ static getDerivedStateFromError() {
18
+ return { hasError: true };
19
+ }
20
+ componentDidCatch(error, errorInfo) {
21
+ if (this.props.onError) {
22
+ this.props.onError(error, errorInfo);
23
+ }
24
+ }
25
+ componentDidUpdate(prevProps) {
26
+ // Reset error state when children change (e.g., navigating to a different cell)
27
+ if (prevProps.children !== this.props.children && this.state.hasError) {
28
+ this.setState({ hasError: false });
29
+ }
30
+ }
31
+ resetErrorBoundary() {
32
+ this.setState({ hasError: false });
33
+ }
34
+ render() {
35
+ if (this.state.hasError) {
36
+ if (this.props.fallback !== undefined) {
37
+ return this.props.fallback;
38
+ }
39
+ return _jsx("span", { style: DEFAULT_FALLBACK_STYLE, children: "\u26A0 Error" });
40
+ }
41
+ return this.props.children;
42
+ }
43
+ }
@@ -0,0 +1,19 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const clearButtonStyle = { background: 'none', border: 'none', color: 'inherit', textDecoration: 'underline', cursor: 'pointer', padding: 0, font: 'inherit' };
3
+ /**
4
+ * Headless empty state component with default rendering logic.
5
+ * Framework-specific wrappers provide styling.
6
+ *
7
+ * Default behavior:
8
+ * - Shows "No results found" title
9
+ * - If hasActiveFilters=true: shows "clear all filters" link
10
+ * - If message provided: shows custom message
11
+ * - If render provided: uses custom renderer
12
+ */
13
+ export function EmptyState(props) {
14
+ const { message, hasActiveFilters, onClearAll, render } = props;
15
+ if (render) {
16
+ return _jsx(_Fragment, { children: render() });
17
+ }
18
+ return (_jsx(_Fragment, { children: message != null ? (message) : hasActiveFilters ? (_jsxs(_Fragment, { children: ["No items match your current filters. Try adjusting your search or", ' ', _jsx("button", { type: "button", onClick: onClearAll, style: clearButtonStyle, children: "clear all filters" }), ' ', "to see all items."] })) : ('There are no items available at this time.') }));
19
+ }
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
3
  import { GRID_CONTEXT_MENU_ITEMS, getContextMenuHandlers, formatShortcut } from '../utils/gridContextMenuHelpers';
4
+ const menuPositionStyle = (x, y) => ({ left: x, top: y });
4
5
  export function GridContextMenu(props) {
5
- const { x, y, hasSelection, canUndo, canRedo, onClose, classNames } = props;
6
+ const { x, y, hasSelection, canUndo, canRedo, onClose, onCopy, onCut, onPaste, onSelectAll, onUndo, onRedo, classNames } = props;
6
7
  const ref = React.useRef(null);
7
- const handlers = React.useMemo(() => getContextMenuHandlers(props), [props]);
8
+ const handlers = React.useMemo(() => getContextMenuHandlers({ onCopy, onCut, onPaste, onSelectAll, onUndo, onRedo, onClose }), [onCopy, onCut, onPaste, onSelectAll, onUndo, onRedo, onClose]);
8
9
  const isDisabled = React.useCallback((item) => {
9
10
  if (item.disabledWhenNoSelection && !hasSelection)
10
11
  return true;
@@ -30,5 +31,5 @@ export function GridContextMenu(props) {
30
31
  document.removeEventListener('keydown', handleKeyDown, true);
31
32
  };
32
33
  }, [onClose]);
33
- return (_jsx("div", { ref: ref, className: classNames?.contextMenu, role: "menu", style: { left: x, top: y }, "aria-label": "Grid context menu", children: GRID_CONTEXT_MENU_ITEMS.map((item) => (_jsxs(React.Fragment, { children: [item.dividerBefore && _jsx("div", { className: classNames?.contextMenuDivider }), _jsxs("button", { type: "button", className: classNames?.contextMenuItem, onClick: handlers[item.id], disabled: isDisabled(item), children: [_jsx("span", { className: classNames?.contextMenuItemLabel, children: item.label }), item.shortcut && (_jsx("span", { className: classNames?.contextMenuItemShortcut, children: formatShortcut(item.shortcut) }))] })] }, item.id))) }));
34
+ return (_jsx("div", { ref: ref, className: classNames?.contextMenu, role: "menu", style: menuPositionStyle(x, y), "aria-label": "Grid context menu", children: GRID_CONTEXT_MENU_ITEMS.map((item) => (_jsxs(React.Fragment, { children: [item.dividerBefore && _jsx("div", { className: classNames?.contextMenuDivider }), _jsxs("button", { type: "button", className: classNames?.contextMenuItem, onClick: handlers[item.id], disabled: isDisabled(item), children: [_jsx("span", { className: classNames?.contextMenuItemLabel, children: item.label }), item.shortcut && (_jsx("span", { className: classNames?.contextMenuItemShortcut, children: formatShortcut(item.shortcut) }))] })] }, item.id))) }));
34
35
  }
@@ -8,17 +8,18 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
8
8
  * Uses SVG rects positioned via cell data-attribute measurements.
9
9
  */
10
10
  import { useEffect, useRef, useState, useCallback } from 'react';
11
- // Inject the @keyframes rule once into <head>
12
- let styleInjected = false;
11
+ const MARCHING_ANTS_ANIMATION = { animation: 'ogrid-marching-ants 0.5s linear infinite' };
12
+ // Inject the @keyframes rule once into <head> (deduplicates across multiple OGrid instances / module copies)
13
13
  function ensureKeyframes() {
14
- if (styleInjected || typeof document === 'undefined')
14
+ if (typeof document === 'undefined')
15
+ return;
16
+ if (document.getElementById('ogrid-marching-ants-keyframes'))
15
17
  return;
16
18
  const style = document.createElement('style');
17
19
  style.id = 'ogrid-marching-ants-keyframes';
18
20
  style.textContent =
19
21
  '@keyframes ogrid-marching-ants{to{stroke-dashoffset:-8}}';
20
22
  document.head.appendChild(style);
21
- styleInjected = true;
22
23
  }
23
24
  /** Measure the bounding rect of a range within a container. */
24
25
  function measureRange(container, range, colOffset) {
@@ -105,5 +106,5 @@ export function MarchingAntsOverlay({ containerRef, selectionRange, copyRange, c
105
106
  pointerEvents: 'none',
106
107
  zIndex: 5,
107
108
  overflow: 'visible',
108
- }, "aria-hidden": "true", children: _jsx("rect", { x: "1", y: "1", width: Math.max(0, clipRect.width - 2), height: Math.max(0, clipRect.height - 2), fill: "none", stroke: "var(--ogrid-selection, #217346)", strokeWidth: "2", strokeDasharray: "4 4", style: { animation: 'ogrid-marching-ants 0.5s linear infinite' } }) }))] }));
109
+ }, "aria-hidden": "true", children: _jsx("rect", { x: "1", y: "1", width: Math.max(0, clipRect.width - 2), height: Math.max(0, clipRect.height - 2), fill: "none", stroke: "var(--ogrid-selection, #217346)", strokeWidth: "2", strokeDasharray: "4 4", style: MARCHING_ANTS_ANIMATION }) }))] }));
109
110
  }
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { SideBar } from './SideBar';
3
+ import { GRID_BORDER_RADIUS } from '../constants';
3
4
  // Stable style objects (avoid re-creating on every render)
4
5
  const borderedContainerStyle = {
5
6
  border: '1px solid var(--ogrid-border, #e0e0e0)',
6
- borderRadius: 6,
7
+ borderRadius: GRID_BORDER_RADIUS,
7
8
  overflow: 'hidden',
8
9
  display: 'flex',
9
10
  flexDirection: 'column',
@@ -66,6 +67,11 @@ const gridChildStyle = {
66
67
  display: 'flex',
67
68
  flexDirection: 'column',
68
69
  };
70
+ const rootStyle = {
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ height: '100%',
74
+ };
69
75
  /**
70
76
  * Renders OGrid layout as a unified bordered container:
71
77
  * ┌────────────────────────────────────┐
@@ -81,10 +87,5 @@ export function OGridLayout(props) {
81
87
  const hasSideBar = sideBar != null;
82
88
  const sideBarPosition = sideBar?.position ?? 'right';
83
89
  const hasToolbar = toolbar != null || toolbarEnd != null;
84
- const rootStyle = {
85
- display: 'flex',
86
- flexDirection: 'column',
87
- height: '100%',
88
- };
89
90
  return (_jsx(Container, { className: className, style: rootStyle, ...containerProps, children: _jsxs("div", { style: borderedContainerStyle, children: [hasToolbar && (_jsxs("div", { style: toolbarBelow ? toolbarStripNoBorderStyle : toolbarStripStyle, children: [_jsx("div", { style: toolbarSectionStyle, children: toolbar }), _jsx("div", { style: toolbarSectionStyle, children: toolbarEnd })] })), toolbarBelow && (_jsx("div", { style: toolbarBelowStyle, children: toolbarBelow })), hasSideBar ? (_jsxs("div", { style: gridAreaFlexStyle, children: [sideBarPosition === 'left' && _jsx(SideBar, { ...sideBar }), _jsx("div", { style: gridChildStyle, children: children }), sideBarPosition !== 'left' && _jsx(SideBar, { ...sideBar })] })) : (_jsx("div", { style: gridAreaSoloStyle, children: children })), pagination && (_jsx("div", { style: footerStripStyle, children: pagination }))] }) }));
90
91
  }
@@ -5,50 +5,72 @@ const PANEL_LABELS = {
5
5
  columns: 'Columns',
6
6
  filters: 'Filters',
7
7
  };
8
+ // --- Stable style objects (avoid re-creating on every render) ---
9
+ const tabStripBaseStyle = {
10
+ display: 'flex',
11
+ flexDirection: 'column',
12
+ width: TAB_WIDTH,
13
+ background: 'var(--ogrid-header-bg, #f5f5f5)',
14
+ };
15
+ const tabStripBorderLeft = { ...tabStripBaseStyle, borderLeft: '1px solid var(--ogrid-border, #e0e0e0)' };
16
+ const tabStripBorderRight = { ...tabStripBaseStyle, borderRight: '1px solid var(--ogrid-border, #e0e0e0)' };
17
+ const tabButtonBase = {
18
+ width: TAB_WIDTH,
19
+ height: TAB_WIDTH,
20
+ border: 'none',
21
+ cursor: 'pointer',
22
+ color: 'var(--ogrid-fg, #242424)',
23
+ fontSize: 14,
24
+ display: 'flex',
25
+ alignItems: 'center',
26
+ justifyContent: 'center',
27
+ };
28
+ const tabButtonActive = { ...tabButtonBase, background: 'var(--ogrid-bg, #fff)', fontWeight: 'bold' };
29
+ const tabButtonInactive = { ...tabButtonBase, background: 'transparent', fontWeight: 'normal' };
30
+ const panelContainerBase = {
31
+ width: PANEL_WIDTH,
32
+ display: 'flex',
33
+ flexDirection: 'column',
34
+ overflow: 'hidden',
35
+ background: 'var(--ogrid-bg, #fff)',
36
+ color: 'var(--ogrid-fg, #242424)',
37
+ };
38
+ const panelContainerBorderLeft = { ...panelContainerBase, borderLeft: '1px solid var(--ogrid-border, #e0e0e0)' };
39
+ const panelContainerBorderRight = { ...panelContainerBase, borderRight: '1px solid var(--ogrid-border, #e0e0e0)' };
40
+ const panelHeaderStyle = {
41
+ display: 'flex',
42
+ justifyContent: 'space-between',
43
+ alignItems: 'center',
44
+ padding: '8px 12px',
45
+ borderBottom: '1px solid var(--ogrid-border, #e0e0e0)',
46
+ fontWeight: 600,
47
+ };
48
+ const closeButtonStyle = { border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 16, color: 'var(--ogrid-fg, #242424)' };
49
+ const panelBodyStyle = { flex: 1, overflowY: 'auto', padding: '8px 12px' };
50
+ const sideBarRootStyle = { display: 'flex', flexDirection: 'row', flexShrink: 0 };
51
+ const buttonRowStyle = { display: 'flex', gap: 8, marginBottom: 8 };
52
+ const actionButtonStyle = { flex: 1, cursor: 'pointer', background: 'var(--ogrid-bg-subtle, #f3f2f1)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4, padding: '4px 8px' };
53
+ const checkboxLabelStyle = { display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0', cursor: 'pointer' };
54
+ const noFilterStyle = { color: 'var(--ogrid-muted, #999)', fontStyle: 'italic' };
55
+ const filterGroupStyle = { marginBottom: 12 };
56
+ const filterLabelStyle = { fontWeight: 500, marginBottom: 4, fontSize: 13 };
57
+ const textInputStyle = { width: '100%', boxSizing: 'border-box', padding: '4px 6px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 };
58
+ const dateContainerStyle = { display: 'flex', flexDirection: 'column', gap: 4 };
59
+ const dateLabelStyle = { display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 };
60
+ const dateInputStyle = { flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 };
61
+ const multiSelectContainerStyle = { maxHeight: 120, overflowY: 'auto' };
62
+ const multiSelectLabelStyle = { display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0', cursor: 'pointer', fontSize: 13 };
8
63
  export function SideBar(props) {
9
64
  const { activePanel, onPanelChange, panels, position, columns, visibleColumns, onVisibilityChange, onSetVisibleColumns, filterableColumns, filters, onFilterChange, filterOptions, } = props;
10
65
  const isOpen = activePanel !== null;
11
66
  const handleTabClick = (panel) => {
12
67
  onPanelChange(activePanel === panel ? null : panel);
13
68
  };
14
- const tabStrip = (_jsx("div", { style: {
15
- display: 'flex',
16
- flexDirection: 'column',
17
- width: TAB_WIDTH,
18
- borderLeft: position === 'right' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
19
- borderRight: position === 'left' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
20
- background: 'var(--ogrid-header-bg, #f5f5f5)',
21
- }, role: "tablist", "aria-label": "Side bar tabs", children: panels.map((panel) => (_jsx("button", { role: "tab", "aria-selected": activePanel === panel, "aria-label": PANEL_LABELS[panel], onClick: () => handleTabClick(panel), title: PANEL_LABELS[panel], style: {
22
- width: TAB_WIDTH,
23
- height: TAB_WIDTH,
24
- border: 'none',
25
- cursor: 'pointer',
26
- background: activePanel === panel ? 'var(--ogrid-bg, #fff)' : 'transparent',
27
- color: 'var(--ogrid-fg, #242424)',
28
- fontWeight: activePanel === panel ? 'bold' : 'normal',
29
- fontSize: 14,
30
- display: 'flex',
31
- alignItems: 'center',
32
- justifyContent: 'center',
33
- }, children: panel === 'columns' ? '\u2261' : '\u2A65' }, panel))) }));
34
- const panelContent = isOpen ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: {
35
- width: PANEL_WIDTH,
36
- display: 'flex',
37
- flexDirection: 'column',
38
- borderLeft: position === 'right' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
39
- borderRight: position === 'left' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
40
- overflow: 'hidden',
41
- background: 'var(--ogrid-bg, #fff)',
42
- color: 'var(--ogrid-fg, #242424)',
43
- }, children: [_jsxs("div", { style: {
44
- display: 'flex',
45
- justifyContent: 'space-between',
46
- alignItems: 'center',
47
- padding: '8px 12px',
48
- borderBottom: '1px solid var(--ogrid-border, #e0e0e0)',
49
- fontWeight: 600,
50
- }, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: { border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 16, color: 'var(--ogrid-fg, #242424)' }, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: { flex: 1, overflowY: 'auto', padding: '8px 12px' }, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, filters: filters, onFilterChange: onFilterChange, filterOptions: filterOptions }))] })] })) : null;
51
- return (_jsxs("div", { style: { display: 'flex', flexDirection: 'row', flexShrink: 0 }, role: "complementary", "aria-label": "Side bar", children: [position === 'left' && tabStrip, position === 'left' && panelContent, position === 'right' && panelContent, position === 'right' && tabStrip] }));
69
+ const tabStripStyle = position === 'right' ? tabStripBorderLeft : tabStripBorderRight;
70
+ const panelContainerStyle = position === 'right' ? panelContainerBorderLeft : panelContainerBorderRight;
71
+ const tabStrip = (_jsx("div", { style: tabStripStyle, role: "tablist", "aria-label": "Side bar tabs", children: panels.map((panel) => (_jsx("button", { role: "tab", "aria-selected": activePanel === panel, "aria-label": PANEL_LABELS[panel], onClick: () => handleTabClick(panel), title: PANEL_LABELS[panel], style: activePanel === panel ? tabButtonActive : tabButtonInactive, children: panel === 'columns' ? '\u2261' : '\u2A65' }, panel))) }));
72
+ const panelContent = isOpen ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: panelContainerStyle, children: [_jsxs("div", { style: panelHeaderStyle, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: closeButtonStyle, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: panelBodyStyle, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, filters: filters, onFilterChange: onFilterChange, filterOptions: filterOptions }))] })] })) : null;
73
+ return (_jsxs("div", { style: sideBarRootStyle, role: "complementary", "aria-label": "Side bar", children: [position === 'left' && tabStrip, position === 'left' && panelContent, position === 'right' && panelContent, position === 'right' && tabStrip] }));
52
74
  }
53
75
  // --- Internal sub-components ---
54
76
  function ColumnsPanel(props) {
@@ -67,28 +89,28 @@ function ColumnsPanel(props) {
67
89
  });
68
90
  onSetVisibleColumns(next);
69
91
  };
70
- return (_jsxs(_Fragment, { children: [_jsxs("div", { style: { display: 'flex', gap: 8, marginBottom: 8 }, children: [_jsx("button", { onClick: handleSelectAll, disabled: allVisible, style: { flex: 1, cursor: 'pointer', background: 'var(--ogrid-bg-subtle, #f3f2f1)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4, padding: '4px 8px' }, children: "Select All" }), _jsx("button", { onClick: handleClearAll, style: { flex: 1, cursor: 'pointer', background: 'var(--ogrid-bg-subtle, #f3f2f1)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4, padding: '4px 8px' }, children: "Clear All" })] }), columns.map((col) => (_jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0', cursor: 'pointer' }, children: [_jsx("input", { type: "checkbox", checked: visibleColumns.has(col.columnId), onChange: (e) => onVisibilityChange(col.columnId, e.target.checked), disabled: col.required }), _jsx("span", { children: col.name })] }, col.columnId)))] }));
92
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { style: buttonRowStyle, children: [_jsx("button", { onClick: handleSelectAll, disabled: allVisible, style: actionButtonStyle, children: "Select All" }), _jsx("button", { onClick: handleClearAll, style: actionButtonStyle, children: "Clear All" })] }), columns.map((col) => (_jsxs("label", { style: checkboxLabelStyle, children: [_jsx("input", { type: "checkbox", checked: visibleColumns.has(col.columnId), onChange: (e) => onVisibilityChange(col.columnId, e.target.checked), disabled: col.required }), _jsx("span", { children: col.name })] }, col.columnId)))] }));
71
93
  }
72
94
  function FiltersPanel(props) {
73
95
  const { filterableColumns, filters, onFilterChange, filterOptions } = props;
74
96
  if (filterableColumns.length === 0) {
75
- return _jsx("div", { style: { color: 'var(--ogrid-muted, #999)', fontStyle: 'italic' }, children: "No filterable columns" });
97
+ return _jsx("div", { style: noFilterStyle, children: "No filterable columns" });
76
98
  }
77
99
  return (_jsx(_Fragment, { children: filterableColumns.map((col) => {
78
100
  const filterKey = col.filterField;
79
- return (_jsxs("div", { style: { marginBottom: 12 }, children: [_jsx("div", { style: { fontWeight: 500, marginBottom: 4, fontSize: 13 }, children: col.name }), col.filterType === 'text' && (_jsx("input", { type: "text", value: filters[filterKey]?.type === 'text' ? filters[filterKey].value : '', onChange: (e) => onFilterChange(filterKey, e.target.value ? { type: 'text', value: e.target.value } : undefined), placeholder: `Filter ${col.name}...`, "aria-label": `Filter ${col.name}`, style: { width: '100%', boxSizing: 'border-box', padding: '4px 6px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 } })), col.filterType === 'date' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }, children: ["From:", _jsx("input", { type: "date", value: filters[filterKey]?.type === 'date' ? (filters[filterKey].value.from ?? '') : '', onChange: (e) => {
101
+ return (_jsxs("div", { style: filterGroupStyle, children: [_jsx("div", { style: filterLabelStyle, children: col.name }), col.filterType === 'text' && (_jsx("input", { type: "text", value: filters[filterKey]?.type === 'text' ? filters[filterKey].value : '', onChange: (e) => onFilterChange(filterKey, e.target.value ? { type: 'text', value: e.target.value } : undefined), placeholder: `Filter ${col.name}...`, "aria-label": `Filter ${col.name}`, style: textInputStyle })), col.filterType === 'date' && (_jsxs("div", { style: dateContainerStyle, children: [_jsxs("label", { style: dateLabelStyle, children: ["From:", _jsx("input", { type: "date", value: filters[filterKey]?.type === 'date' ? (filters[filterKey].value.from ?? '') : '', onChange: (e) => {
80
102
  const from = e.target.value || undefined;
81
103
  const existingValue = filters[filterKey]?.type === 'date' ? filters[filterKey].value : {};
82
104
  const to = existingValue.to;
83
105
  onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
84
- }, "aria-label": `${col.name} from date`, style: { flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 } })] }), _jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }, children: ["To:", _jsx("input", { type: "date", value: filters[filterKey]?.type === 'date' ? (filters[filterKey].value.to ?? '') : '', onChange: (e) => {
106
+ }, "aria-label": `${col.name} from date`, style: dateInputStyle })] }), _jsxs("label", { style: dateLabelStyle, children: ["To:", _jsx("input", { type: "date", value: filters[filterKey]?.type === 'date' ? (filters[filterKey].value.to ?? '') : '', onChange: (e) => {
85
107
  const to = e.target.value || undefined;
86
108
  const existingValue = filters[filterKey]?.type === 'date' ? filters[filterKey].value : {};
87
109
  const from = existingValue.from;
88
110
  onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
89
- }, "aria-label": `${col.name} to date`, style: { flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 } })] })] })), col.filterType === 'multiSelect' && (_jsx("div", { style: { maxHeight: 120, overflowY: 'auto' }, role: "group", "aria-label": `${col.name} options`, children: (filterOptions[filterKey] ?? []).map((opt) => {
111
+ }, "aria-label": `${col.name} to date`, style: dateInputStyle })] })] })), col.filterType === 'multiSelect' && (_jsx("div", { style: multiSelectContainerStyle, role: "group", "aria-label": `${col.name} options`, children: (filterOptions[filterKey] ?? []).map((opt) => {
90
112
  const selected = filters[filterKey]?.type === 'multiSelect' ? filters[filterKey].value.includes(opt) : false;
91
- return (_jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0', cursor: 'pointer', fontSize: 13 }, children: [_jsx("input", { type: "checkbox", checked: selected, onChange: (e) => {
113
+ return (_jsxs("label", { style: multiSelectLabelStyle, children: [_jsx("input", { type: "checkbox", checked: selected, onChange: (e) => {
92
114
  const current = filters[filterKey]?.type === 'multiSelect' ? filters[filterKey].value : [];
93
115
  const next = e.target.checked
94
116
  ? [...current, opt]
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Core OGrid constants — magic numbers centralized for consistency and maintainability.
3
+ */
4
+ /** Width of the row selection checkbox column in pixels. */
5
+ export const CHECKBOX_COLUMN_WIDTH = 48;
6
+ /** Default minimum width for resizable columns in pixels. */
7
+ export const DEFAULT_MIN_COLUMN_WIDTH = 80;
8
+ /** Horizontal padding inside cells, used for width calculations. */
9
+ export const CELL_PADDING = 16;
10
+ /** Border radius for the grid container in pixels. */
11
+ export const GRID_BORDER_RADIUS = 6;
@@ -12,8 +12,14 @@ export { useDebounce } from './useDebounce';
12
12
  export { useFillHandle } from './useFillHandle';
13
13
  export { useDataGridState } from './useDataGridState';
14
14
  export { useColumnHeaderFilterState } from './useColumnHeaderFilterState';
15
+ export { useTextFilterState } from './useTextFilterState';
16
+ export { useMultiSelectFilterState } from './useMultiSelectFilterState';
17
+ export { usePeopleFilterState } from './usePeopleFilterState';
18
+ export { useDateFilterState } from './useDateFilterState';
15
19
  export { useColumnChooserState } from './useColumnChooserState';
16
20
  export { useInlineCellEditorState } from './useInlineCellEditorState';
17
21
  export { useColumnResize } from './useColumnResize';
18
22
  export { useRichSelectState } from './useRichSelectState';
19
23
  export { useSideBarState } from './useSideBarState';
24
+ export { useTableLayout } from './useTableLayout';
25
+ export { useLatestRef } from './useLatestRef';
@@ -29,18 +29,34 @@ export function useActiveCell(wrapperRef, editingCell) {
29
29
  const selector = `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`;
30
30
  const cell = wrapperRef.current.querySelector(selector);
31
31
  if (cell) {
32
- if (typeof cell.scrollIntoView === 'function') {
33
- // Account for sticky <thead> so scrollIntoView doesn't leave
34
- // the cell hidden behind the header.
35
- const thead = wrapperRef.current.querySelector('thead');
36
- const headerHeight = thead ? thead.getBoundingClientRect().height : 0;
37
- cell.style.scrollMarginTop = `${headerHeight}px`;
38
- cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
32
+ // Scroll the cell into view within the table wrapper only — do NOT
33
+ // use native scrollIntoView() which scrolls all ancestor containers
34
+ // including the page, causing an unwanted viewport jump.
35
+ const wrapper = wrapperRef.current;
36
+ const thead = wrapper.querySelector('thead');
37
+ const headerHeight = thead ? thead.getBoundingClientRect().height : 0;
38
+ const wrapperRect = wrapper.getBoundingClientRect();
39
+ const cellRect = cell.getBoundingClientRect();
40
+ // Vertical scroll (account for sticky thead)
41
+ const visibleTop = wrapperRect.top + headerHeight;
42
+ if (cellRect.top < visibleTop) {
43
+ wrapper.scrollTop -= visibleTop - cellRect.top;
44
+ }
45
+ else if (cellRect.bottom > wrapperRect.bottom) {
46
+ wrapper.scrollTop += cellRect.bottom - wrapperRect.bottom;
47
+ }
48
+ // Horizontal scroll
49
+ if (cellRect.left < wrapperRect.left) {
50
+ wrapper.scrollLeft -= wrapperRect.left - cellRect.left;
51
+ }
52
+ else if (cellRect.right > wrapperRect.right) {
53
+ wrapper.scrollLeft += cellRect.right - wrapperRect.right;
39
54
  }
40
55
  if (document.activeElement !== cell && typeof cell.focus === 'function') {
41
- cell.focus();
56
+ cell.focus({ preventScroll: true });
42
57
  }
43
58
  }
44
- }, [activeCell, editingCell, wrapperRef]);
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
45
61
  return { activeCell, setActiveCell };
46
62
  }
@@ -1,4 +1,8 @@
1
1
  import { useState } from 'react';
2
+ /**
3
+ * Manages cell editing state: which cell is being edited and its pending value.
4
+ * @returns Current editing cell, setter, pending editor value, and setter.
5
+ */
2
6
  export function useCellEditing() {
3
7
  const [editingCell, setEditingCell] = useState(null);
4
8
  const [pendingEditorValue, setPendingEditorValue] = useState(undefined);
@@ -21,6 +21,11 @@ function autoScrollSpeed(distance) {
21
21
  const t = Math.min(distance / AUTO_SCROLL_EDGE, 1);
22
22
  return AUTO_SCROLL_MIN_SPEED + t * (AUTO_SCROLL_MAX_SPEED - AUTO_SCROLL_MIN_SPEED);
23
23
  }
24
+ /**
25
+ * Manages cell selection range with drag-to-select and select-all support.
26
+ * @param params - Row/col counts, active cell setter, and wrapper ref for auto-scroll.
27
+ * @returns Selection range, setters, mouse/keyboard handlers, and drag state.
28
+ */
24
29
  export function useCellSelection(params) {
25
30
  const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
26
31
  const [selectionRange, _setSelectionRange] = useState(null);
@@ -307,7 +312,8 @@ export function useCellSelection(params) {
307
312
  cancelAnimationFrame(rafRef.current);
308
313
  stopAutoScroll();
309
314
  };
310
- }, [colOffset, setActiveCell, wrapperRef]);
315
+ // eslint-disable-next-line react-hooks/exhaustive-deps
316
+ }, [colOffset, setActiveCell]); // wrapperRef excluded — refs are stable across renders
311
317
  return {
312
318
  selectionRange,
313
319
  setSelectionRange,