@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.
Files changed (72) hide show
  1. package/README.md +12 -0
  2. package/dist/esm/components/GridContextMenu.js +25 -0
  3. package/dist/esm/components/OGridLayout.js +16 -0
  4. package/dist/esm/components/StatusBar.js +6 -0
  5. package/dist/esm/hooks/index.js +16 -0
  6. package/dist/esm/hooks/useActiveCell.js +28 -0
  7. package/dist/esm/hooks/useCellEditing.js +11 -0
  8. package/dist/esm/hooks/useCellSelection.js +86 -0
  9. package/dist/esm/hooks/useClipboard.js +129 -0
  10. package/dist/esm/hooks/useColumnChooserState.js +62 -0
  11. package/dist/esm/hooks/useColumnHeaderFilterState.js +207 -0
  12. package/dist/esm/hooks/useColumnResize.js +48 -0
  13. package/dist/esm/hooks/useContextMenu.js +16 -0
  14. package/dist/esm/hooks/useDataGridState.js +273 -0
  15. package/dist/esm/hooks/useDebounce.js +35 -0
  16. package/dist/esm/hooks/useFillHandle.js +93 -0
  17. package/dist/esm/hooks/useInlineCellEditorState.js +42 -0
  18. package/dist/esm/hooks/useKeyboardNavigation.js +364 -0
  19. package/dist/esm/hooks/useOGrid.js +356 -0
  20. package/dist/esm/hooks/useRowSelection.js +68 -0
  21. package/dist/esm/hooks/useUndoRedo.js +52 -0
  22. package/dist/esm/index.js +7 -3
  23. package/dist/esm/storybook/index.js +1 -0
  24. package/dist/esm/storybook/mockData.js +73 -0
  25. package/dist/esm/types/dataGridTypes.js +17 -0
  26. package/dist/esm/types/index.js +1 -1
  27. package/dist/esm/utils/cellValue.js +8 -0
  28. package/dist/esm/utils/columnUtils.js +19 -0
  29. package/dist/esm/utils/dataGridStatusBar.js +15 -0
  30. package/dist/esm/utils/dataGridViewModel.js +121 -0
  31. package/dist/esm/utils/exportToCsv.js +6 -1
  32. package/dist/esm/utils/gridContextMenuHelpers.js +31 -0
  33. package/dist/esm/utils/index.js +8 -0
  34. package/dist/esm/utils/ogridHelpers.js +49 -0
  35. package/dist/esm/utils/paginationHelpers.js +52 -0
  36. package/dist/esm/utils/statusBarHelpers.js +18 -0
  37. package/dist/types/components/GridContextMenu.d.ts +14 -0
  38. package/dist/types/components/OGridLayout.d.ts +25 -0
  39. package/dist/types/components/StatusBar.d.ts +15 -0
  40. package/dist/types/hooks/index.d.ts +31 -0
  41. package/dist/types/hooks/useActiveCell.d.ts +13 -0
  42. package/dist/types/hooks/useCellEditing.d.ts +11 -0
  43. package/dist/types/hooks/useCellSelection.d.ts +14 -0
  44. package/dist/types/hooks/useClipboard.d.ts +18 -0
  45. package/dist/types/hooks/useColumnChooserState.d.ts +27 -0
  46. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +64 -0
  47. package/dist/types/hooks/useColumnResize.d.ts +16 -0
  48. package/dist/types/hooks/useContextMenu.d.ts +14 -0
  49. package/dist/types/hooks/useDataGridState.d.ts +95 -0
  50. package/dist/types/hooks/useDebounce.d.ts +9 -0
  51. package/dist/types/hooks/useFillHandle.d.ts +25 -0
  52. package/dist/types/hooks/useInlineCellEditorState.d.ts +24 -0
  53. package/dist/types/hooks/useKeyboardNavigation.d.ts +33 -0
  54. package/dist/types/hooks/useOGrid.d.ts +24 -0
  55. package/dist/types/hooks/useRowSelection.d.ts +17 -0
  56. package/dist/types/hooks/useUndoRedo.d.ts +17 -0
  57. package/dist/types/index.d.ts +12 -6
  58. package/dist/types/storybook/index.d.ts +2 -0
  59. package/dist/types/storybook/mockData.d.ts +37 -0
  60. package/dist/types/types/columnTypes.d.ts +49 -1
  61. package/dist/types/types/dataGridTypes.d.ts +171 -4
  62. package/dist/types/types/index.d.ts +3 -3
  63. package/dist/types/utils/cellValue.d.ts +5 -0
  64. package/dist/types/utils/columnUtils.d.ts +6 -0
  65. package/dist/types/utils/dataGridStatusBar.d.ts +6 -0
  66. package/dist/types/utils/dataGridViewModel.d.ts +93 -0
  67. package/dist/types/utils/gridContextMenuHelpers.d.ts +23 -0
  68. package/dist/types/utils/index.d.ts +12 -0
  69. package/dist/types/utils/ogridHelpers.d.ts +9 -0
  70. package/dist/types/utils/paginationHelpers.d.ts +23 -0
  71. package/dist/types/utils/statusBarHelpers.d.ts +18 -0
  72. 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
+ }
@@ -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
+ }