@alaarab/ogrid-core 1.0.0 → 1.2.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.
- package/README.md +12 -0
- package/dist/esm/components/GridContextMenu.js +25 -0
- package/dist/esm/components/OGridLayout.js +16 -0
- package/dist/esm/components/StatusBar.js +6 -0
- package/dist/esm/hooks/index.js +16 -0
- package/dist/esm/hooks/useActiveCell.js +28 -0
- package/dist/esm/hooks/useCellEditing.js +11 -0
- package/dist/esm/hooks/useCellSelection.js +86 -0
- package/dist/esm/hooks/useClipboard.js +129 -0
- package/dist/esm/hooks/useColumnChooserState.js +62 -0
- package/dist/esm/hooks/useColumnHeaderFilterState.js +207 -0
- package/dist/esm/hooks/useColumnResize.js +48 -0
- package/dist/esm/hooks/useContextMenu.js +16 -0
- package/dist/esm/hooks/useDataGridState.js +273 -0
- package/dist/esm/hooks/useDebounce.js +35 -0
- package/dist/esm/hooks/useFillHandle.js +93 -0
- package/dist/esm/hooks/useInlineCellEditorState.js +42 -0
- package/dist/esm/hooks/useKeyboardNavigation.js +364 -0
- package/dist/esm/hooks/useOGrid.js +356 -0
- package/dist/esm/hooks/useRowSelection.js +68 -0
- package/dist/esm/hooks/useUndoRedo.js +52 -0
- package/dist/esm/index.js +7 -3
- package/dist/esm/storybook/index.js +1 -0
- package/dist/esm/storybook/mockData.js +73 -0
- package/dist/esm/types/dataGridTypes.js +17 -0
- package/dist/esm/types/index.js +1 -1
- package/dist/esm/utils/cellValue.js +8 -0
- package/dist/esm/utils/columnUtils.js +19 -0
- package/dist/esm/utils/dataGridStatusBar.js +15 -0
- package/dist/esm/utils/dataGridViewModel.js +121 -0
- package/dist/esm/utils/exportToCsv.js +6 -1
- package/dist/esm/utils/gridContextMenuHelpers.js +31 -0
- package/dist/esm/utils/index.js +8 -0
- package/dist/esm/utils/ogridHelpers.js +49 -0
- package/dist/esm/utils/paginationHelpers.js +52 -0
- package/dist/esm/utils/statusBarHelpers.js +18 -0
- package/dist/types/components/GridContextMenu.d.ts +14 -0
- package/dist/types/components/OGridLayout.d.ts +25 -0
- package/dist/types/components/StatusBar.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +31 -0
- package/dist/types/hooks/useActiveCell.d.ts +13 -0
- package/dist/types/hooks/useCellEditing.d.ts +11 -0
- package/dist/types/hooks/useCellSelection.d.ts +14 -0
- package/dist/types/hooks/useClipboard.d.ts +18 -0
- package/dist/types/hooks/useColumnChooserState.d.ts +27 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +64 -0
- package/dist/types/hooks/useColumnResize.d.ts +16 -0
- package/dist/types/hooks/useContextMenu.d.ts +14 -0
- package/dist/types/hooks/useDataGridState.d.ts +95 -0
- package/dist/types/hooks/useDebounce.d.ts +9 -0
- package/dist/types/hooks/useFillHandle.d.ts +25 -0
- package/dist/types/hooks/useInlineCellEditorState.d.ts +24 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +33 -0
- package/dist/types/hooks/useOGrid.d.ts +24 -0
- package/dist/types/hooks/useRowSelection.d.ts +17 -0
- package/dist/types/hooks/useUndoRedo.d.ts +17 -0
- package/dist/types/index.d.ts +12 -6
- package/dist/types/storybook/index.d.ts +2 -0
- package/dist/types/storybook/mockData.d.ts +37 -0
- package/dist/types/types/columnTypes.d.ts +49 -1
- package/dist/types/types/dataGridTypes.d.ts +171 -4
- package/dist/types/types/index.d.ts +3 -3
- package/dist/types/utils/cellValue.d.ts +5 -0
- package/dist/types/utils/columnUtils.d.ts +6 -0
- package/dist/types/utils/dataGridStatusBar.d.ts +6 -0
- package/dist/types/utils/dataGridViewModel.d.ts +93 -0
- package/dist/types/utils/gridContextMenuHelpers.d.ts +23 -0
- package/dist/types/utils/index.d.ts +12 -0
- package/dist/types/utils/ogridHelpers.d.ts +9 -0
- package/dist/types/utils/paginationHelpers.d.ts +23 -0
- package/dist/types/utils/statusBarHelpers.d.ts +18 -0
- package/package.json +32 -32
package/README.md
CHANGED
|
@@ -23,10 +23,22 @@ npm install @alaarab/ogrid-core
|
|
|
23
23
|
|
|
24
24
|
### Hooks
|
|
25
25
|
|
|
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
|
|
26
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)
|
|
32
|
+
|
|
33
|
+
### Components
|
|
34
|
+
|
|
35
|
+
- `OGridLayout` -- Layout structure for OGrid: toolbar row (title, toolbar, columnChooser), grid area, pagination. Accepts `containerComponent` and `gap` so Fluent/Material/Radix supply their Container (div or Box).
|
|
27
36
|
|
|
28
37
|
### Utilities
|
|
29
38
|
|
|
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
|
|
30
42
|
- `toDataGridFilterProps(filters)` -- Splits `IFilters` into `multiSelectFilters`, `textFilters`, `peopleFilters`
|
|
31
43
|
- `toUserLike(user)` -- Converts a user-like object to `UserLike`
|
|
32
44
|
- `exportToCsv(items, columns, getValue, filename)` -- Full CSV export
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { GRID_CONTEXT_MENU_ITEMS, getContextMenuHandlers } from '../utils/gridContextMenuHelpers';
|
|
4
|
+
export function GridContextMenu(props) {
|
|
5
|
+
const { x, y, hasSelection, onClose, classNames } = props;
|
|
6
|
+
const ref = React.useRef(null);
|
|
7
|
+
const handlers = React.useMemo(() => getContextMenuHandlers(props), [props]);
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const handleClickOutside = (e) => {
|
|
10
|
+
if (ref.current && !ref.current.contains(e.target))
|
|
11
|
+
onClose();
|
|
12
|
+
};
|
|
13
|
+
const handleKeyDown = (e) => {
|
|
14
|
+
if (e.key === 'Escape')
|
|
15
|
+
onClose();
|
|
16
|
+
};
|
|
17
|
+
document.addEventListener('mousedown', handleClickOutside, true);
|
|
18
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
19
|
+
return () => {
|
|
20
|
+
document.removeEventListener('mousedown', handleClickOutside, true);
|
|
21
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
22
|
+
};
|
|
23
|
+
}, [onClose]);
|
|
24
|
+
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.id === 'selectAll' && _jsx("div", { className: classNames?.contextMenuDivider }), _jsx("button", { type: "button", className: classNames?.contextMenuItem, onClick: handlers[item.id], disabled: item.disabledWhenNoSelection ? !hasSelection : false, children: item.label })] }, item.id))) }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
const defaultGap = 16;
|
|
3
|
+
/**
|
|
4
|
+
* Renders OGrid layout: [toolbar row | title, toolbar, columnChooser] [grid] [pagination].
|
|
5
|
+
* Inner structure uses divs; only the root uses Container so UIs can use Box/div and pass gap.
|
|
6
|
+
*/
|
|
7
|
+
export function OGridLayout(props) {
|
|
8
|
+
const { containerComponent: Container = 'div', containerProps = {}, gap = defaultGap, className, title, toolbar, columnChooser, children, pagination, } = props;
|
|
9
|
+
// Always apply flex layout; merge with any containerProps styles
|
|
10
|
+
const rootStyle = {
|
|
11
|
+
display: 'flex',
|
|
12
|
+
flexDirection: 'column',
|
|
13
|
+
gap: typeof gap === 'number' ? `${gap}px` : gap,
|
|
14
|
+
};
|
|
15
|
+
return (_jsxs(Container, { className: className, style: rootStyle, ...containerProps, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, minHeight: 40 }, children: [title != null ? _jsx("div", { style: { margin: 0 }, children: title }) : null, toolbar, columnChooser] }), _jsx("div", { style: { width: '100%', minWidth: 0, flex: 1 }, children: children }), pagination] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { getStatusBarParts } from '../utils/statusBarHelpers';
|
|
3
|
+
export function StatusBar({ classNames, ...rest }) {
|
|
4
|
+
const parts = getStatusBarParts(rest);
|
|
5
|
+
return (_jsx("div", { className: classNames?.statusBar, role: "status", "aria-live": "polite", children: parts.map((p) => (_jsxs("span", { className: classNames?.statusBarItem, children: [_jsx("span", { className: classNames?.statusBarLabel, children: p.label }), _jsx("span", { className: classNames?.statusBarValue, children: p.value.toLocaleString() })] }, p.key))) }));
|
|
6
|
+
}
|
package/dist/esm/hooks/index.js
CHANGED
|
@@ -1 +1,17 @@
|
|
|
1
1
|
export { useFilterOptions } from './useFilterOptions';
|
|
2
|
+
export { useOGrid } from './useOGrid';
|
|
3
|
+
export { useActiveCell } from './useActiveCell';
|
|
4
|
+
export { useCellEditing } from './useCellEditing';
|
|
5
|
+
export { useContextMenu } from './useContextMenu';
|
|
6
|
+
export { useCellSelection } from './useCellSelection';
|
|
7
|
+
export { useClipboard } from './useClipboard';
|
|
8
|
+
export { useRowSelection } from './useRowSelection';
|
|
9
|
+
export { useKeyboardNavigation } from './useKeyboardNavigation';
|
|
10
|
+
export { useUndoRedo } from './useUndoRedo';
|
|
11
|
+
export { useDebounce } from './useDebounce';
|
|
12
|
+
export { useFillHandle } from './useFillHandle';
|
|
13
|
+
export { useDataGridState } from './useDataGridState';
|
|
14
|
+
export { useColumnHeaderFilterState } from './useColumnHeaderFilterState';
|
|
15
|
+
export { useColumnChooserState } from './useColumnChooserState';
|
|
16
|
+
export { useInlineCellEditorState } from './useInlineCellEditorState';
|
|
17
|
+
export { useColumnResize } from './useColumnResize';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useState, useLayoutEffect } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Tracks the active cell for keyboard navigation.
|
|
4
|
+
* When wrapperRef and editingCell are provided, scrolls the active cell into view when it changes (and not editing).
|
|
5
|
+
*/
|
|
6
|
+
export function useActiveCell(wrapperRef, editingCell) {
|
|
7
|
+
const [activeCell, setActiveCell] = useState(null);
|
|
8
|
+
// useLayoutEffect ensures focus moves synchronously before the browser can
|
|
9
|
+
// reset focus to body (fixes left/right arrow navigation losing focus)
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
if (activeCell == null ||
|
|
12
|
+
wrapperRef?.current == null ||
|
|
13
|
+
editingCell != null)
|
|
14
|
+
return;
|
|
15
|
+
const { rowIndex, columnIndex } = activeCell;
|
|
16
|
+
const selector = `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`;
|
|
17
|
+
const cell = wrapperRef.current.querySelector(selector);
|
|
18
|
+
if (cell) {
|
|
19
|
+
if (typeof cell.scrollIntoView === 'function') {
|
|
20
|
+
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
21
|
+
}
|
|
22
|
+
if (document.activeElement !== cell && typeof cell.focus === 'function') {
|
|
23
|
+
cell.focus();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}, [activeCell, editingCell, wrapperRef]);
|
|
27
|
+
return { activeCell, setActiveCell };
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
export function useCellEditing() {
|
|
3
|
+
const [editingCell, setEditingCell] = useState(null);
|
|
4
|
+
const [pendingEditorValue, setPendingEditorValue] = useState(undefined);
|
|
5
|
+
return {
|
|
6
|
+
editingCell,
|
|
7
|
+
setEditingCell,
|
|
8
|
+
pendingEditorValue,
|
|
9
|
+
setPendingEditorValue,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import { normalizeSelectionRange } from '../types';
|
|
3
|
+
export function useCellSelection(params) {
|
|
4
|
+
const { colOffset, rowCount, visibleColCount, setActiveCell } = params;
|
|
5
|
+
const [selectionRange, setSelectionRange] = useState(null);
|
|
6
|
+
const isDraggingRef = useRef(false);
|
|
7
|
+
const dragStartRef = useRef(null);
|
|
8
|
+
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
9
|
+
if (globalColIndex < colOffset)
|
|
10
|
+
return;
|
|
11
|
+
// Prevent native text selection during cell drag
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
const dataColIndex = globalColIndex - colOffset;
|
|
14
|
+
if (e.shiftKey && selectionRange != null) {
|
|
15
|
+
setSelectionRange(normalizeSelectionRange({
|
|
16
|
+
startRow: selectionRange.startRow,
|
|
17
|
+
startCol: selectionRange.startCol,
|
|
18
|
+
endRow: rowIndex,
|
|
19
|
+
endCol: dataColIndex,
|
|
20
|
+
}));
|
|
21
|
+
setActiveCell({ rowIndex, columnIndex: globalColIndex });
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
dragStartRef.current = { row: rowIndex, col: dataColIndex };
|
|
25
|
+
setSelectionRange({
|
|
26
|
+
startRow: rowIndex,
|
|
27
|
+
startCol: dataColIndex,
|
|
28
|
+
endRow: rowIndex,
|
|
29
|
+
endCol: dataColIndex,
|
|
30
|
+
});
|
|
31
|
+
setActiveCell({ rowIndex, columnIndex: globalColIndex });
|
|
32
|
+
isDraggingRef.current = true;
|
|
33
|
+
}
|
|
34
|
+
}, [colOffset, selectionRange, setActiveCell]);
|
|
35
|
+
const handleSelectAllCells = useCallback(() => {
|
|
36
|
+
if (rowCount === 0 || visibleColCount === 0)
|
|
37
|
+
return;
|
|
38
|
+
setSelectionRange({
|
|
39
|
+
startRow: 0,
|
|
40
|
+
startCol: 0,
|
|
41
|
+
endRow: rowCount - 1,
|
|
42
|
+
endCol: visibleColCount - 1,
|
|
43
|
+
});
|
|
44
|
+
setActiveCell({ rowIndex: 0, columnIndex: colOffset });
|
|
45
|
+
}, [rowCount, visibleColCount, colOffset, setActiveCell]);
|
|
46
|
+
// Window mouse move/up for drag selection
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onMove = (e) => {
|
|
49
|
+
if (!isDraggingRef.current || !dragStartRef.current)
|
|
50
|
+
return;
|
|
51
|
+
const target = document.elementFromPoint(e.clientX, e.clientY);
|
|
52
|
+
const cell = target?.closest?.('[data-row-index][data-col-index]');
|
|
53
|
+
if (!cell)
|
|
54
|
+
return;
|
|
55
|
+
const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
|
|
56
|
+
const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
|
|
57
|
+
if (Number.isNaN(r) || Number.isNaN(c) || c < colOffset)
|
|
58
|
+
return;
|
|
59
|
+
const dataCol = c - colOffset;
|
|
60
|
+
const start = dragStartRef.current;
|
|
61
|
+
setSelectionRange(normalizeSelectionRange({
|
|
62
|
+
startRow: start.row,
|
|
63
|
+
startCol: start.col,
|
|
64
|
+
endRow: r,
|
|
65
|
+
endCol: dataCol,
|
|
66
|
+
}));
|
|
67
|
+
setActiveCell({ rowIndex: r, columnIndex: c });
|
|
68
|
+
};
|
|
69
|
+
const onUp = () => {
|
|
70
|
+
isDraggingRef.current = false;
|
|
71
|
+
dragStartRef.current = null;
|
|
72
|
+
};
|
|
73
|
+
window.addEventListener('mousemove', onMove, true);
|
|
74
|
+
window.addEventListener('mouseup', onUp, true);
|
|
75
|
+
return () => {
|
|
76
|
+
window.removeEventListener('mousemove', onMove, true);
|
|
77
|
+
window.removeEventListener('mouseup', onUp, true);
|
|
78
|
+
};
|
|
79
|
+
}, [colOffset, setActiveCell]);
|
|
80
|
+
return {
|
|
81
|
+
selectionRange,
|
|
82
|
+
setSelectionRange,
|
|
83
|
+
handleCellMouseDown,
|
|
84
|
+
handleSelectAllCells,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { getCellValue } from '../utils';
|
|
3
|
+
import { normalizeSelectionRange } from '../types';
|
|
4
|
+
export function useClipboard(params) {
|
|
5
|
+
const { items, visibleCols, colOffset, selectionRange, activeCell, onCellValueChanged, } = params;
|
|
6
|
+
const cutRangeRef = useRef(null);
|
|
7
|
+
const [cutRange, setCutRange] = useState(null);
|
|
8
|
+
/** In-page clipboard fallback when system clipboard is unavailable. */
|
|
9
|
+
const internalClipboardRef = useRef(null);
|
|
10
|
+
const handleCopy = useCallback(() => {
|
|
11
|
+
const range = selectionRange ??
|
|
12
|
+
(activeCell != null
|
|
13
|
+
? {
|
|
14
|
+
startRow: activeCell.rowIndex,
|
|
15
|
+
startCol: activeCell.columnIndex - colOffset,
|
|
16
|
+
endRow: activeCell.rowIndex,
|
|
17
|
+
endCol: activeCell.columnIndex - colOffset,
|
|
18
|
+
}
|
|
19
|
+
: null);
|
|
20
|
+
if (range == null)
|
|
21
|
+
return;
|
|
22
|
+
const norm = normalizeSelectionRange(range);
|
|
23
|
+
const rows = [];
|
|
24
|
+
for (let r = norm.startRow; r <= norm.endRow; r++) {
|
|
25
|
+
const cells = [];
|
|
26
|
+
for (let c = norm.startCol; c <= norm.endCol; c++) {
|
|
27
|
+
if (r >= items.length || c >= visibleCols.length)
|
|
28
|
+
break;
|
|
29
|
+
const item = items[r];
|
|
30
|
+
const col = visibleCols[c];
|
|
31
|
+
const val = getCellValue(item, col);
|
|
32
|
+
cells.push(val != null && val !== '' ? String(val).replace(/\t/g, ' ').replace(/\n/g, ' ') : '');
|
|
33
|
+
}
|
|
34
|
+
rows.push(cells.join('\t'));
|
|
35
|
+
}
|
|
36
|
+
const tsv = rows.join('\r\n');
|
|
37
|
+
internalClipboardRef.current = tsv;
|
|
38
|
+
void navigator.clipboard.writeText(tsv).catch(() => { });
|
|
39
|
+
}, [selectionRange, activeCell, colOffset, items, visibleCols]);
|
|
40
|
+
const handleCut = useCallback(() => {
|
|
41
|
+
const range = selectionRange ??
|
|
42
|
+
(activeCell != null
|
|
43
|
+
? {
|
|
44
|
+
startRow: activeCell.rowIndex,
|
|
45
|
+
startCol: activeCell.columnIndex - colOffset,
|
|
46
|
+
endRow: activeCell.rowIndex,
|
|
47
|
+
endCol: activeCell.columnIndex - colOffset,
|
|
48
|
+
}
|
|
49
|
+
: null);
|
|
50
|
+
if (range == null || onCellValueChanged == null)
|
|
51
|
+
return;
|
|
52
|
+
const norm = normalizeSelectionRange(range);
|
|
53
|
+
cutRangeRef.current = norm;
|
|
54
|
+
setCutRange(norm);
|
|
55
|
+
handleCopy();
|
|
56
|
+
}, [selectionRange, activeCell, colOffset, handleCopy, onCellValueChanged]);
|
|
57
|
+
const handlePaste = useCallback(async () => {
|
|
58
|
+
if (onCellValueChanged == null)
|
|
59
|
+
return;
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = await navigator.clipboard.readText();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
text = '';
|
|
66
|
+
}
|
|
67
|
+
if (!text.trim() && internalClipboardRef.current != null) {
|
|
68
|
+
text = internalClipboardRef.current;
|
|
69
|
+
}
|
|
70
|
+
if (!text.trim())
|
|
71
|
+
return;
|
|
72
|
+
const norm = selectionRange ??
|
|
73
|
+
(activeCell != null
|
|
74
|
+
? {
|
|
75
|
+
startRow: activeCell.rowIndex,
|
|
76
|
+
startCol: activeCell.columnIndex - colOffset,
|
|
77
|
+
endRow: activeCell.rowIndex,
|
|
78
|
+
endCol: activeCell.columnIndex - colOffset,
|
|
79
|
+
}
|
|
80
|
+
: null);
|
|
81
|
+
const anchorRow = norm ? norm.startRow : 0;
|
|
82
|
+
const anchorCol = norm ? norm.startCol : 0;
|
|
83
|
+
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
|
84
|
+
for (let r = 0; r < lines.length; r++) {
|
|
85
|
+
const cells = lines[r].split('\t');
|
|
86
|
+
for (let c = 0; c < cells.length; c++) {
|
|
87
|
+
const targetRow = anchorRow + r;
|
|
88
|
+
const targetCol = anchorCol + c;
|
|
89
|
+
if (targetRow >= items.length || targetCol >= visibleCols.length)
|
|
90
|
+
continue;
|
|
91
|
+
const item = items[targetRow];
|
|
92
|
+
const col = visibleCols[targetCol];
|
|
93
|
+
const newValue = cells[c] ?? '';
|
|
94
|
+
const oldValue = getCellValue(item, col);
|
|
95
|
+
onCellValueChanged({
|
|
96
|
+
item,
|
|
97
|
+
columnId: col.columnId,
|
|
98
|
+
field: col.columnId,
|
|
99
|
+
oldValue,
|
|
100
|
+
newValue,
|
|
101
|
+
rowIndex: targetRow,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cutRangeRef.current) {
|
|
106
|
+
const cut = cutRangeRef.current;
|
|
107
|
+
for (let r = cut.startRow; r <= cut.endRow; r++) {
|
|
108
|
+
for (let c = cut.startCol; c <= cut.endCol; c++) {
|
|
109
|
+
if (r >= items.length || c >= visibleCols.length)
|
|
110
|
+
continue;
|
|
111
|
+
const item = items[r];
|
|
112
|
+
const col = visibleCols[c];
|
|
113
|
+
const oldValue = getCellValue(item, col);
|
|
114
|
+
onCellValueChanged({
|
|
115
|
+
item,
|
|
116
|
+
columnId: col.columnId,
|
|
117
|
+
field: col.columnId,
|
|
118
|
+
oldValue,
|
|
119
|
+
newValue: '',
|
|
120
|
+
rowIndex: r,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
cutRangeRef.current = null;
|
|
125
|
+
setCutRange(null);
|
|
126
|
+
}
|
|
127
|
+
}, [selectionRange, activeCell, colOffset, items, visibleCols, onCellValueChanged]);
|
|
128
|
+
return { handleCopy, handleCut, handlePaste, cutRangeRef, cutRange };
|
|
129
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless column chooser state and handlers for Fluent, Material, and Radix.
|
|
3
|
+
* UI packages use this hook and render only trigger + popover (checkboxes, Select All, Clear All).
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* Returns open/setOpen, handleToggle, handleClose (Escape handled in hook),
|
|
8
|
+
* handleCheckboxChange(columnKey)(visible), handleSelectAll, handleClearAll,
|
|
9
|
+
* visibleCount, totalCount. UI renders trigger + popover and wires handlers.
|
|
10
|
+
*/
|
|
11
|
+
export function useColumnChooserState(params) {
|
|
12
|
+
const { columns, visibleColumns, onVisibilityChange } = params;
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!open)
|
|
16
|
+
return;
|
|
17
|
+
const handleKeyDown = (event) => {
|
|
18
|
+
if (event.key === 'Escape') {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
setOpen(false);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
24
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
25
|
+
}, [open]);
|
|
26
|
+
const handleToggle = useCallback(() => {
|
|
27
|
+
setOpen((prev) => !prev);
|
|
28
|
+
}, []);
|
|
29
|
+
const handleClose = useCallback(() => {
|
|
30
|
+
setOpen(false);
|
|
31
|
+
}, []);
|
|
32
|
+
const handleCheckboxChange = useCallback((columnKey) => (visible) => {
|
|
33
|
+
onVisibilityChange(columnKey, visible);
|
|
34
|
+
}, [onVisibilityChange]);
|
|
35
|
+
const handleSelectAll = useCallback(() => {
|
|
36
|
+
columns.forEach((col) => {
|
|
37
|
+
if (!visibleColumns.has(col.columnId)) {
|
|
38
|
+
onVisibilityChange(col.columnId, true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}, [columns, visibleColumns, onVisibilityChange]);
|
|
42
|
+
const handleClearAll = useCallback(() => {
|
|
43
|
+
columns.forEach((col) => {
|
|
44
|
+
if (!col.required && visibleColumns.has(col.columnId)) {
|
|
45
|
+
onVisibilityChange(col.columnId, false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}, [columns, visibleColumns, onVisibilityChange]);
|
|
49
|
+
const visibleCount = visibleColumns.size;
|
|
50
|
+
const totalCount = columns.length;
|
|
51
|
+
return {
|
|
52
|
+
open,
|
|
53
|
+
setOpen,
|
|
54
|
+
handleToggle,
|
|
55
|
+
handleClose,
|
|
56
|
+
handleCheckboxChange,
|
|
57
|
+
handleSelectAll,
|
|
58
|
+
handleClearAll,
|
|
59
|
+
visibleCount,
|
|
60
|
+
totalCount,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless column header filter state and handlers for Fluent, Material, and Radix.
|
|
3
|
+
* UI packages use this hook and render only presentation (popover, inputs, buttons).
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback, useRef, useEffect, useMemo, } from 'react';
|
|
6
|
+
import { useDebounce } from './useDebounce';
|
|
7
|
+
const SEARCH_DEBOUNCE_MS = 150;
|
|
8
|
+
const EMPTY_OPTIONS = [];
|
|
9
|
+
export function useColumnHeaderFilterState(params) {
|
|
10
|
+
const { filterType, onSort, selectedValues, onFilterChange, options, textValue = '', onTextChange, selectedUser, onUserChange, peopleSearch, } = params;
|
|
11
|
+
const safeSelectedValues = selectedValues ?? EMPTY_OPTIONS;
|
|
12
|
+
const safeOptions = options ?? EMPTY_OPTIONS;
|
|
13
|
+
const headerRef = useRef(null);
|
|
14
|
+
const popoverRef = useRef(null);
|
|
15
|
+
const peopleInputRef = useRef(null);
|
|
16
|
+
const peopleSearchTimeoutRef = useRef(undefined);
|
|
17
|
+
const [isFilterOpen, setFilterOpen] = useState(false);
|
|
18
|
+
const [tempSelected, setTempSelected] = useState(() => new Set(safeSelectedValues));
|
|
19
|
+
const [tempTextValue, setTempTextValue] = useState(textValue);
|
|
20
|
+
const [searchText, setSearchText] = useState('');
|
|
21
|
+
const debouncedSearchText = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
|
|
22
|
+
const [peopleSuggestions, setPeopleSuggestions] = useState([]);
|
|
23
|
+
const [isPeopleLoading, setIsPeopleLoading] = useState(false);
|
|
24
|
+
const [peopleSearchText, setPeopleSearchText] = useState('');
|
|
25
|
+
const [popoverPosition, setPopoverPosition] = useState(null);
|
|
26
|
+
// Sync temp state when popover opens; compute position
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (isFilterOpen) {
|
|
29
|
+
setTempSelected(new Set(safeSelectedValues));
|
|
30
|
+
setTempTextValue(textValue);
|
|
31
|
+
setSearchText('');
|
|
32
|
+
setPeopleSearchText('');
|
|
33
|
+
setPeopleSuggestions([]);
|
|
34
|
+
const t = setTimeout(() => {
|
|
35
|
+
if (headerRef.current) {
|
|
36
|
+
const rect = headerRef.current.getBoundingClientRect();
|
|
37
|
+
setPopoverPosition({ top: rect.bottom + 4, left: rect.left });
|
|
38
|
+
}
|
|
39
|
+
if (filterType === 'people') {
|
|
40
|
+
setTimeout(() => peopleInputRef.current?.focus(), 50);
|
|
41
|
+
}
|
|
42
|
+
}, 0);
|
|
43
|
+
return () => clearTimeout(t);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
setPopoverPosition(null);
|
|
47
|
+
}
|
|
48
|
+
}, [isFilterOpen, filterType, safeSelectedValues, textValue]);
|
|
49
|
+
// Click outside and Escape to close
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!isFilterOpen)
|
|
52
|
+
return;
|
|
53
|
+
const handleClickOutside = (e) => {
|
|
54
|
+
const target = e.target;
|
|
55
|
+
if (popoverRef.current &&
|
|
56
|
+
!popoverRef.current.contains(target) &&
|
|
57
|
+
headerRef.current &&
|
|
58
|
+
!headerRef.current.contains(target)) {
|
|
59
|
+
setFilterOpen(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const handleKeyDown = (e) => {
|
|
63
|
+
if (e.key === 'Escape' || e.key === 'Esc') {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
setFilterOpen(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const timeoutId = setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0);
|
|
70
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
71
|
+
return () => {
|
|
72
|
+
clearTimeout(timeoutId);
|
|
73
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
74
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
75
|
+
};
|
|
76
|
+
}, [isFilterOpen]);
|
|
77
|
+
// Filtered options for multiSelect (search within options)
|
|
78
|
+
const filteredOptions = useMemo(() => {
|
|
79
|
+
if (!debouncedSearchText.trim())
|
|
80
|
+
return safeOptions;
|
|
81
|
+
const searchLower = debouncedSearchText.toLowerCase().trim();
|
|
82
|
+
return safeOptions.filter((opt) => opt.toLowerCase().includes(searchLower));
|
|
83
|
+
}, [safeOptions, debouncedSearchText]);
|
|
84
|
+
// People search
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!peopleSearch || !isFilterOpen || filterType !== 'people')
|
|
87
|
+
return;
|
|
88
|
+
if (peopleSearchTimeoutRef.current)
|
|
89
|
+
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
90
|
+
if (!peopleSearchText.trim()) {
|
|
91
|
+
setPeopleSuggestions([]);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setIsPeopleLoading(true);
|
|
95
|
+
peopleSearchTimeoutRef.current = window.setTimeout(async () => {
|
|
96
|
+
try {
|
|
97
|
+
const results = await peopleSearch(peopleSearchText);
|
|
98
|
+
setPeopleSuggestions(results.slice(0, 10));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
setPeopleSuggestions([]);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
setIsPeopleLoading(false);
|
|
105
|
+
}
|
|
106
|
+
}, 300);
|
|
107
|
+
return () => {
|
|
108
|
+
if (peopleSearchTimeoutRef.current)
|
|
109
|
+
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
110
|
+
};
|
|
111
|
+
}, [peopleSearchText, peopleSearch, isFilterOpen, filterType]);
|
|
112
|
+
const handleFilterIconClick = useCallback((e) => {
|
|
113
|
+
e.stopPropagation();
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
setFilterOpen((prev) => !prev);
|
|
116
|
+
}, []);
|
|
117
|
+
const handleSortClick = useCallback((e) => {
|
|
118
|
+
e.stopPropagation();
|
|
119
|
+
onSort?.();
|
|
120
|
+
}, [onSort]);
|
|
121
|
+
const handleCheckboxChange = useCallback((option, checked) => {
|
|
122
|
+
setTempSelected((prev) => {
|
|
123
|
+
const next = new Set(prev);
|
|
124
|
+
if (checked)
|
|
125
|
+
next.add(option);
|
|
126
|
+
else
|
|
127
|
+
next.delete(option);
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
130
|
+
}, []);
|
|
131
|
+
const handleSelectAll = useCallback(() => {
|
|
132
|
+
setTempSelected(new Set(filteredOptions));
|
|
133
|
+
}, [filteredOptions]);
|
|
134
|
+
const handleClearSelection = useCallback(() => setTempSelected(new Set()), []);
|
|
135
|
+
const handleApplyMultiSelect = useCallback(() => {
|
|
136
|
+
onFilterChange?.(Array.from(tempSelected));
|
|
137
|
+
setFilterOpen(false);
|
|
138
|
+
}, [onFilterChange, tempSelected]);
|
|
139
|
+
const handleTextApply = useCallback(() => {
|
|
140
|
+
onTextChange?.(tempTextValue.trim());
|
|
141
|
+
setFilterOpen(false);
|
|
142
|
+
}, [onTextChange, tempTextValue]);
|
|
143
|
+
const handleTextClear = useCallback(() => setTempTextValue(''), []);
|
|
144
|
+
const handleUserSelect = useCallback((user) => {
|
|
145
|
+
onUserChange?.(user);
|
|
146
|
+
setFilterOpen(false);
|
|
147
|
+
}, [onUserChange]);
|
|
148
|
+
const handleClearUser = useCallback(() => {
|
|
149
|
+
onUserChange?.(undefined);
|
|
150
|
+
setFilterOpen(false);
|
|
151
|
+
}, [onUserChange]);
|
|
152
|
+
const handlePopoverClick = useCallback((e) => e.stopPropagation(), []);
|
|
153
|
+
const handleInputFocus = useCallback((e) => e.stopPropagation(), []);
|
|
154
|
+
const handleInputMouseDown = useCallback((e) => e.stopPropagation(), []);
|
|
155
|
+
const handleInputClick = useCallback((e) => e.stopPropagation(), []);
|
|
156
|
+
const handleInputKeyDown = useCallback((e) => {
|
|
157
|
+
if (e.key !== 'Escape' && e.key !== 'Esc')
|
|
158
|
+
e.stopPropagation();
|
|
159
|
+
}, []);
|
|
160
|
+
const hasActiveFilter = useMemo(() => {
|
|
161
|
+
if (filterType === 'multiSelect')
|
|
162
|
+
return safeSelectedValues.length > 0;
|
|
163
|
+
if (filterType === 'text')
|
|
164
|
+
return !!textValue.trim();
|
|
165
|
+
if (filterType === 'people')
|
|
166
|
+
return !!selectedUser;
|
|
167
|
+
return false;
|
|
168
|
+
}, [filterType, safeSelectedValues, textValue, selectedUser]);
|
|
169
|
+
return {
|
|
170
|
+
headerRef,
|
|
171
|
+
popoverRef,
|
|
172
|
+
peopleInputRef,
|
|
173
|
+
isFilterOpen,
|
|
174
|
+
setFilterOpen,
|
|
175
|
+
tempSelected,
|
|
176
|
+
setTempSelected,
|
|
177
|
+
tempTextValue,
|
|
178
|
+
setTempTextValue,
|
|
179
|
+
searchText,
|
|
180
|
+
setSearchText,
|
|
181
|
+
debouncedSearchText,
|
|
182
|
+
filteredOptions,
|
|
183
|
+
peopleSuggestions,
|
|
184
|
+
isPeopleLoading,
|
|
185
|
+
peopleSearchText,
|
|
186
|
+
setPeopleSearchText,
|
|
187
|
+
hasActiveFilter,
|
|
188
|
+
popoverPosition,
|
|
189
|
+
handlers: {
|
|
190
|
+
handleFilterIconClick,
|
|
191
|
+
handleApplyMultiSelect,
|
|
192
|
+
handleTextApply,
|
|
193
|
+
handleTextClear,
|
|
194
|
+
handleUserSelect,
|
|
195
|
+
handleClearUser,
|
|
196
|
+
handleCheckboxChange,
|
|
197
|
+
handleSelectAll,
|
|
198
|
+
handleClearSelection,
|
|
199
|
+
handlePopoverClick,
|
|
200
|
+
handleInputFocus,
|
|
201
|
+
handleInputMouseDown,
|
|
202
|
+
handleInputClick,
|
|
203
|
+
handleInputKeyDown,
|
|
204
|
+
handleSortClick,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, }) {
|
|
3
|
+
const resizingRef = useRef(null);
|
|
4
|
+
const handleResizeStart = useCallback((e, col) => {
|
|
5
|
+
e.preventDefault();
|
|
6
|
+
e.stopPropagation();
|
|
7
|
+
const currentWidth = columnSizingOverrides[col.columnId]?.widthPx
|
|
8
|
+
?? col.idealWidth
|
|
9
|
+
?? col.defaultWidth
|
|
10
|
+
?? defaultWidth;
|
|
11
|
+
resizingRef.current = {
|
|
12
|
+
columnId: col.columnId,
|
|
13
|
+
startX: e.clientX,
|
|
14
|
+
startWidth: currentWidth,
|
|
15
|
+
};
|
|
16
|
+
}, [columnSizingOverrides, defaultWidth]);
|
|
17
|
+
const handleResizeMove = useCallback((e) => {
|
|
18
|
+
if (!resizingRef.current)
|
|
19
|
+
return;
|
|
20
|
+
const { columnId, startX, startWidth } = resizingRef.current;
|
|
21
|
+
const deltaX = e.clientX - startX;
|
|
22
|
+
const newWidth = Math.max(minWidth, startWidth + deltaX);
|
|
23
|
+
setColumnSizingOverrides((prev) => ({
|
|
24
|
+
...prev,
|
|
25
|
+
[columnId]: { widthPx: newWidth },
|
|
26
|
+
}));
|
|
27
|
+
}, [setColumnSizingOverrides, minWidth]);
|
|
28
|
+
const handleResizeEnd = useCallback(() => {
|
|
29
|
+
resizingRef.current = null;
|
|
30
|
+
}, []);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const handleMouseMove = (e) => handleResizeMove(e);
|
|
33
|
+
const handleMouseUp = () => handleResizeEnd();
|
|
34
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
35
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
36
|
+
return () => {
|
|
37
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
38
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
39
|
+
};
|
|
40
|
+
}, [handleResizeMove, handleResizeEnd]);
|
|
41
|
+
const getColumnWidth = useCallback((col) => {
|
|
42
|
+
return columnSizingOverrides[col.columnId]?.widthPx
|
|
43
|
+
?? col.idealWidth
|
|
44
|
+
?? col.defaultWidth
|
|
45
|
+
?? defaultWidth;
|
|
46
|
+
}, [columnSizingOverrides, defaultWidth]);
|
|
47
|
+
return { handleResizeStart, getColumnWidth };
|
|
48
|
+
}
|