@alaarab/ogrid-material 1.0.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 ADDED
@@ -0,0 +1,59 @@
1
+ # @alaarab/ogrid-material
2
+
3
+ [OGrid](https://github.com/alaarab/ogrid) data table for [Material UI](https://mui.com/), powered by [MUI X DataGrid](https://mui.com/x/react-data-grid/). Sort, filter (text, multi-select, people), paginate, show/hide columns, and export to CSV. Use an in-memory array or plug in your own API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @alaarab/ogrid-material
9
+ ```
10
+
11
+ ### Peer Dependencies
12
+
13
+ ```
14
+ @mui/material ^6.0.0
15
+ @mui/icons-material ^6.0.0
16
+ @mui/x-data-grid ^7.0.0
17
+ @emotion/react ^11.0.0
18
+ @emotion/styled ^11.0.0
19
+ react ^17.0.0 || ^18.0.0
20
+ react-dom ^17.0.0 || ^18.0.0
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```tsx
26
+ import { OGrid, type IColumnDef } from '@alaarab/ogrid-material';
27
+
28
+ const columns: IColumnDef<Product>[] = [
29
+ { columnId: 'name', name: 'Name', sortable: true, filterable: { type: 'text' }, renderCell: (item) => <span>{item.name}</span> },
30
+ { columnId: 'category', name: 'Category', sortable: true, filterable: { type: 'multiSelect', filterField: 'category' }, renderCell: (item) => <span>{item.category}</span> },
31
+ ];
32
+
33
+ <OGrid<Product>
34
+ data={products}
35
+ columns={columns}
36
+ getRowId={(r) => r.id}
37
+ entityLabelPlural="products"
38
+ />
39
+ ```
40
+
41
+ ## Components
42
+
43
+ - **`OGrid<T>`** -- Full table with column chooser, filters, and pagination (Material UI implementation)
44
+ - **`DataGridTable<T>`** -- Lower-level grid for custom state management
45
+ - **`ColumnChooser`** -- Column visibility dropdown
46
+ - **`PaginationControls`** -- Pagination UI
47
+ - **`ColumnHeaderFilter`** -- Column header with sort/filter (used internally)
48
+
49
+ All core types, hooks, and utilities are re-exported from `@alaarab/ogrid-core` for convenience.
50
+
51
+ ## Storybook
52
+
53
+ ```bash
54
+ npm run storybook # port 6007
55
+ ```
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useRef, useEffect } from 'react';
3
+ import { Button, Popover, Checkbox, Box, Typography, FormControlLabel, } from '@mui/material';
4
+ import { ViewColumn as ViewColumnIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, } from '@mui/icons-material';
5
+ export const ColumnChooser = (props) => {
6
+ const { columns, visibleColumns, onVisibilityChange, className } = props;
7
+ const [anchorEl, setAnchorEl] = useState(null);
8
+ const buttonRef = useRef(null);
9
+ const isOpen = Boolean(anchorEl);
10
+ const handleToggle = useCallback((e) => {
11
+ setAnchorEl(anchorEl ? null : e.currentTarget);
12
+ }, [anchorEl]);
13
+ const handleClose = useCallback(() => {
14
+ setAnchorEl(null);
15
+ }, []);
16
+ useEffect(() => {
17
+ if (!isOpen)
18
+ return;
19
+ const handleKeyDown = (event) => {
20
+ if (event.key === 'Escape') {
21
+ event.preventDefault();
22
+ setAnchorEl(null);
23
+ }
24
+ };
25
+ document.addEventListener('keydown', handleKeyDown, true);
26
+ return () => document.removeEventListener('keydown', handleKeyDown, true);
27
+ }, [isOpen]);
28
+ const handleCheckboxChange = useCallback((columnKey) => {
29
+ return (ev) => {
30
+ ev.stopPropagation();
31
+ onVisibilityChange(columnKey, ev.target.checked);
32
+ };
33
+ }, [onVisibilityChange]);
34
+ const handleSelectAll = useCallback(() => {
35
+ columns.forEach((col) => {
36
+ if (!visibleColumns.has(col.columnId)) {
37
+ onVisibilityChange(col.columnId, true);
38
+ }
39
+ });
40
+ }, [columns, visibleColumns, onVisibilityChange]);
41
+ const handleClearAll = useCallback(() => {
42
+ columns.forEach((col) => {
43
+ if (!col.required && visibleColumns.has(col.columnId)) {
44
+ onVisibilityChange(col.columnId, false);
45
+ }
46
+ });
47
+ }, [columns, visibleColumns, onVisibilityChange]);
48
+ const visibleCount = visibleColumns.size;
49
+ const totalCount = columns.length;
50
+ return (_jsxs(Box, { className: className, sx: { display: 'inline-flex' }, children: [_jsxs(Button, { ref: buttonRef, variant: "outlined", size: "small", startIcon: _jsx(ViewColumnIcon, {}), endIcon: isOpen ? _jsx(ExpandLessIcon, {}) : _jsx(ExpandMoreIcon, {}), onClick: handleToggle, "aria-expanded": isOpen, "aria-haspopup": "listbox", sx: {
51
+ textTransform: 'none',
52
+ fontWeight: 600,
53
+ borderColor: isOpen ? 'primary.main' : 'divider',
54
+ }, children: ["Column Visibility (", visibleCount, " of ", totalCount, ")"] }), _jsxs(Popover, { open: isOpen, anchorEl: anchorEl, onClose: handleClose, anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, transformOrigin: { vertical: 'top', horizontal: 'right' }, slotProps: {
55
+ paper: {
56
+ sx: { mt: 0.5, minWidth: 220 },
57
+ },
58
+ }, children: [_jsx(Box, { sx: {
59
+ px: 1.5,
60
+ py: 1,
61
+ borderBottom: 1,
62
+ borderColor: 'divider',
63
+ bgcolor: 'grey.50',
64
+ }, children: _jsxs(Typography, { variant: "subtitle2", fontWeight: 600, children: ["Select Columns (", visibleCount, " of ", totalCount, ")"] }) }), _jsx(Box, { sx: { maxHeight: 320, overflowY: 'auto', py: 0.5 }, children: columns.map((column) => (_jsx(Box, { sx: { px: 1.5, minHeight: 32, display: 'flex', alignItems: 'center' }, children: _jsx(FormControlLabel, { control: _jsx(Checkbox, { size: "small", checked: visibleColumns.has(column.columnId), onChange: handleCheckboxChange(column.columnId) }), label: _jsx(Typography, { variant: "body2", children: column.name }), sx: { m: 0 } }) }, column.columnId))) }), _jsxs(Box, { sx: {
65
+ display: 'flex',
66
+ justifyContent: 'flex-end',
67
+ gap: 1,
68
+ px: 1.5,
69
+ py: 1,
70
+ borderTop: 1,
71
+ borderColor: 'divider',
72
+ bgcolor: 'grey.50',
73
+ }, children: [_jsx(Button, { size: "small", onClick: handleClearAll, sx: { textTransform: 'none' }, children: "Clear All" }), _jsx(Button, { size: "small", variant: "contained", onClick: handleSelectAll, sx: { textTransform: 'none' }, children: "Select All" })] })] })] }));
74
+ };
@@ -0,0 +1,231 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
4
+ import { TextField, Checkbox, Avatar, Popover, CircularProgress, Tooltip, IconButton, Box, Typography, Button, InputAdornment, FormControlLabel, } from '@mui/material';
5
+ import { Search as SearchIcon, ArrowUpward as ArrowUpwardIcon, ArrowDownward as ArrowDownwardIcon, SwapVert as SwapVertIcon, FilterList as FilterListIcon, Clear as ClearIcon, } from '@mui/icons-material';
6
+ const SEARCH_DEBOUNCE_MS = 150;
7
+ const EMPTY_ARRAY = [];
8
+ export const ColumnHeaderFilter = React.memo((props) => {
9
+ const { columnName, filterType, isSorted = false, isSortedDescending = false, onSort, selectedValues, onFilterChange, options, isLoadingOptions = false, textValue = '', onTextChange, selectedUser, onUserChange, peopleSearch, } = props;
10
+ const safeSelectedValues = selectedValues ?? EMPTY_ARRAY;
11
+ const safeOptions = options ?? EMPTY_ARRAY;
12
+ const [anchorEl, setAnchorEl] = useState(null);
13
+ const [tempSelected, setTempSelected] = useState(new Set(safeSelectedValues));
14
+ const [tempTextValue, setTempTextValue] = useState(textValue);
15
+ const [searchText, setSearchText] = useState('');
16
+ const [debouncedSearchText, setDebouncedSearchText] = useState('');
17
+ const [peopleSuggestions, setPeopleSuggestions] = useState([]);
18
+ const [isPeopleLoading, setIsPeopleLoading] = useState(false);
19
+ const [peopleSearchText, setPeopleSearchText] = useState('');
20
+ const searchDebounceRef = useRef();
21
+ const peopleSearchTimeoutRef = useRef(undefined);
22
+ const peopleInputRef = useRef(null);
23
+ const isFilterOpen = Boolean(anchorEl);
24
+ useEffect(() => {
25
+ if (isFilterOpen) {
26
+ setTempSelected(new Set(safeSelectedValues));
27
+ setTempTextValue(textValue);
28
+ setSearchText('');
29
+ setDebouncedSearchText('');
30
+ setPeopleSearchText('');
31
+ setPeopleSuggestions([]);
32
+ if (filterType === 'people') {
33
+ setTimeout(() => {
34
+ peopleInputRef.current?.focus();
35
+ }, 50);
36
+ }
37
+ }
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [isFilterOpen]);
40
+ useEffect(() => {
41
+ if (searchDebounceRef.current) {
42
+ clearTimeout(searchDebounceRef.current);
43
+ }
44
+ searchDebounceRef.current = setTimeout(() => {
45
+ setDebouncedSearchText(searchText);
46
+ }, SEARCH_DEBOUNCE_MS);
47
+ return () => {
48
+ if (searchDebounceRef.current) {
49
+ clearTimeout(searchDebounceRef.current);
50
+ }
51
+ };
52
+ }, [searchText]);
53
+ const filteredOptions = useMemo(() => {
54
+ if (!debouncedSearchText.trim()) {
55
+ return safeOptions;
56
+ }
57
+ const searchLower = debouncedSearchText.toLowerCase().trim();
58
+ return safeOptions.filter((opt) => opt.toLowerCase().includes(searchLower));
59
+ }, [safeOptions, debouncedSearchText]);
60
+ useEffect(() => {
61
+ if (!peopleSearch || !isFilterOpen || filterType !== 'people')
62
+ return;
63
+ if (peopleSearchTimeoutRef.current) {
64
+ window.clearTimeout(peopleSearchTimeoutRef.current);
65
+ }
66
+ if (!peopleSearchText.trim()) {
67
+ setPeopleSuggestions([]);
68
+ return;
69
+ }
70
+ setIsPeopleLoading(true);
71
+ peopleSearchTimeoutRef.current = window.setTimeout(async () => {
72
+ try {
73
+ const results = await peopleSearch(peopleSearchText);
74
+ setPeopleSuggestions(results.slice(0, 10));
75
+ }
76
+ catch (error) {
77
+ console.error('Error searching people:', error);
78
+ setPeopleSuggestions([]);
79
+ }
80
+ finally {
81
+ setIsPeopleLoading(false);
82
+ }
83
+ }, 300);
84
+ return () => {
85
+ if (peopleSearchTimeoutRef.current) {
86
+ window.clearTimeout(peopleSearchTimeoutRef.current);
87
+ }
88
+ };
89
+ }, [peopleSearchText, peopleSearch, isFilterOpen, filterType]);
90
+ const handleFilterIconClick = useCallback((e) => {
91
+ e.stopPropagation();
92
+ e.preventDefault();
93
+ setAnchorEl(anchorEl ? null : e.currentTarget);
94
+ }, [anchorEl]);
95
+ const handlePopoverClose = useCallback(() => {
96
+ setAnchorEl(null);
97
+ }, []);
98
+ const handleSortClick = useCallback((e) => {
99
+ e.stopPropagation();
100
+ if (onSort) {
101
+ onSort();
102
+ }
103
+ }, [onSort]);
104
+ const handleCheckboxChange = useCallback((option, checked) => {
105
+ setTempSelected((prev) => {
106
+ const newSet = new Set(prev);
107
+ if (checked) {
108
+ newSet.add(option);
109
+ }
110
+ else {
111
+ newSet.delete(option);
112
+ }
113
+ return newSet;
114
+ });
115
+ }, []);
116
+ const handleSelectAll = useCallback(() => {
117
+ setTempSelected(new Set(filteredOptions));
118
+ }, [filteredOptions]);
119
+ const handleClearSelection = useCallback(() => {
120
+ setTempSelected(new Set());
121
+ }, []);
122
+ const handleApplyMultiSelect = useCallback(() => {
123
+ if (onFilterChange) {
124
+ onFilterChange(Array.from(tempSelected));
125
+ }
126
+ setAnchorEl(null);
127
+ }, [onFilterChange, tempSelected]);
128
+ const handleTextApply = useCallback(() => {
129
+ if (onTextChange) {
130
+ onTextChange(tempTextValue.trim());
131
+ }
132
+ setAnchorEl(null);
133
+ }, [onTextChange, tempTextValue]);
134
+ const handleTextClear = useCallback(() => {
135
+ setTempTextValue('');
136
+ }, []);
137
+ const handleTextKeyDown = useCallback((event) => {
138
+ if (event.key === 'Enter') {
139
+ event.preventDefault();
140
+ handleTextApply();
141
+ }
142
+ }, [handleTextApply]);
143
+ const handleUserSelect = useCallback((user) => {
144
+ if (onUserChange) {
145
+ onUserChange(user);
146
+ }
147
+ setAnchorEl(null);
148
+ }, [onUserChange]);
149
+ const handleClearUser = useCallback(() => {
150
+ if (onUserChange) {
151
+ onUserChange(undefined);
152
+ }
153
+ setAnchorEl(null);
154
+ }, [onUserChange]);
155
+ const hasActiveFilter = useMemo(() => {
156
+ if (filterType === 'multiSelect') {
157
+ return safeSelectedValues.length > 0;
158
+ }
159
+ if (filterType === 'text') {
160
+ return !!textValue.trim();
161
+ }
162
+ if (filterType === 'people') {
163
+ return !!selectedUser;
164
+ }
165
+ return false;
166
+ }, [filterType, safeSelectedValues, textValue, selectedUser]);
167
+ const renderPopoverContent = () => {
168
+ if (filterType === 'multiSelect') {
169
+ return (_jsxs(Box, { sx: { width: 280 }, children: [_jsxs(Box, { sx: { p: 1.5, pb: 0.5 }, children: [_jsx(TextField, { placeholder: "Search...", value: searchText, onChange: (e) => setSearchText(e.target.value), onKeyDown: (e) => e.stopPropagation(), autoComplete: "off", size: "small", fullWidth: true, slotProps: {
170
+ input: {
171
+ startAdornment: (_jsx(InputAdornment, { position: "start", children: _jsx(SearchIcon, { fontSize: "small" }) })),
172
+ },
173
+ } }), _jsxs(Typography, { variant: "caption", color: "text.secondary", sx: { mt: 0.5, display: 'block' }, children: [filteredOptions.length, " of ", safeOptions.length, " options"] })] }), _jsxs(Box, { sx: { display: 'flex', justifyContent: 'space-between', px: 1.5, py: 0.5 }, children: [_jsxs(Button, { size: "small", onClick: handleSelectAll, children: ["Select All (", filteredOptions.length, ")"] }), _jsx(Button, { size: "small", onClick: handleClearSelection, children: "Clear" })] }), _jsx(Box, { sx: { maxHeight: 240, overflowY: 'auto', px: 0.5 }, children: isLoadingOptions ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 2 }, children: _jsx(CircularProgress, { size: 24 }) })) : filteredOptions.length === 0 ? (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { py: 2, textAlign: 'center' }, children: "No options found" })) : (filteredOptions.map((option) => (_jsx(FormControlLabel, { control: _jsx(Checkbox, { size: "small", checked: tempSelected.has(option), onChange: (e) => handleCheckboxChange(option, e.target.checked) }), label: _jsx(Typography, { variant: "body2", children: option }), sx: { display: 'flex', mx: 0, '& .MuiFormControlLabel-label': { flex: 1, minWidth: 0 } } }, option)))) }), _jsxs(Box, { sx: {
174
+ display: 'flex',
175
+ justifyContent: 'flex-end',
176
+ gap: 1,
177
+ p: 1.5,
178
+ pt: 1,
179
+ borderTop: 1,
180
+ borderColor: 'divider',
181
+ }, children: [_jsx(Button, { size: "small", onClick: handleClearSelection, children: "Clear" }), _jsx(Button, { size: "small", variant: "contained", onClick: handleApplyMultiSelect, children: "Apply" })] })] }));
182
+ }
183
+ if (filterType === 'text') {
184
+ return (_jsxs(Box, { sx: { width: 260 }, children: [_jsx(Box, { sx: { p: 1.5 }, children: _jsx(TextField, { placeholder: "Enter search term...", value: tempTextValue, onChange: (e) => setTempTextValue(e.target.value), onKeyDown: (e) => {
185
+ e.stopPropagation();
186
+ handleTextKeyDown(e);
187
+ }, autoComplete: "off", size: "small", fullWidth: true, slotProps: {
188
+ input: {
189
+ startAdornment: (_jsx(InputAdornment, { position: "start", children: _jsx(SearchIcon, { fontSize: "small" }) })),
190
+ },
191
+ } }) }), _jsxs(Box, { sx: {
192
+ display: 'flex',
193
+ justifyContent: 'flex-end',
194
+ gap: 1,
195
+ p: 1.5,
196
+ pt: 0,
197
+ }, children: [_jsx(Button, { size: "small", disabled: !tempTextValue, onClick: handleTextClear, children: "Clear" }), _jsx(Button, { size: "small", variant: "contained", onClick: handleTextApply, children: "Apply" })] })] }));
198
+ }
199
+ if (filterType === 'people') {
200
+ return (_jsxs(Box, { sx: { width: 300 }, children: [selectedUser && (_jsxs(Box, { sx: { p: 1.5, pb: 1, borderBottom: 1, borderColor: 'divider' }, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: "Currently filtered by:" }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }, children: [_jsx(Avatar, { src: selectedUser.photo, alt: selectedUser.displayName, sx: { width: 32, height: 32 }, children: selectedUser.displayName?.[0] }), _jsxs(Box, { sx: { flex: 1, minWidth: 0 }, children: [_jsx(Typography, { variant: "body2", noWrap: true, children: selectedUser.displayName }), _jsx(Typography, { variant: "caption", color: "text.secondary", noWrap: true, children: selectedUser.email })] }), _jsx(IconButton, { size: "small", onClick: handleClearUser, "aria-label": "Remove filter", children: _jsx(ClearIcon, { fontSize: "small" }) })] })] })), _jsx(Box, { sx: { p: 1.5, pb: 0.5 }, children: _jsx(TextField, { inputRef: peopleInputRef, placeholder: "Search for a person...", value: peopleSearchText, onChange: (e) => setPeopleSearchText(e.target.value), onKeyDown: (e) => e.stopPropagation(), autoComplete: "off", size: "small", fullWidth: true, slotProps: {
201
+ input: {
202
+ startAdornment: (_jsx(InputAdornment, { position: "start", children: _jsx(SearchIcon, { fontSize: "small" }) })),
203
+ },
204
+ } }) }), _jsx(Box, { sx: { maxHeight: 240, overflowY: 'auto' }, children: isPeopleLoading && peopleSearchText.trim() ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 2 }, children: _jsx(CircularProgress, { size: 24 }) })) : peopleSuggestions.length === 0 && peopleSearchText.trim() ? (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { py: 2, textAlign: 'center' }, children: "No results found" })) : peopleSearchText.trim() ? (peopleSuggestions.map((user) => (_jsxs(Box, { onClick: () => handleUserSelect(user), sx: {
205
+ display: 'flex',
206
+ alignItems: 'center',
207
+ gap: 1,
208
+ px: 1.5,
209
+ py: 1,
210
+ cursor: 'pointer',
211
+ '&:hover': { bgcolor: 'action.hover' },
212
+ }, children: [_jsx(Avatar, { src: user.photo, alt: user.displayName, sx: { width: 32, height: 32 }, children: user.displayName?.[0] }), _jsxs(Box, { sx: { flex: 1, minWidth: 0 }, children: [_jsx(Typography, { variant: "body2", noWrap: true, children: user.displayName }), _jsx(Typography, { variant: "caption", color: "text.secondary", noWrap: true, children: user.email })] })] }, user.id || user.email || user.displayName)))) : (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { py: 2, textAlign: 'center' }, children: "Type to search..." })) }), selectedUser && (_jsx(Box, { sx: { p: 1.5, pt: 1, borderTop: 1, borderColor: 'divider' }, children: _jsx(Button, { size: "small", fullWidth: true, onClick: handleClearUser, children: "Clear Filter" }) }))] }));
213
+ }
214
+ return null;
215
+ };
216
+ return (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 }, children: [_jsx(Box, { sx: { flex: 1, minWidth: 0, overflow: 'hidden' }, children: _jsx(Tooltip, { title: columnName, arrow: true, children: _jsx(Typography, { variant: "body2", fontWeight: 600, noWrap: true, "data-header-label": true, sx: { lineHeight: 1.4 }, children: columnName }) }) }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', ml: 0.5, flexShrink: 0 }, children: [onSort && (_jsx(IconButton, { size: "small", onClick: handleSortClick, "aria-label": `Sort by ${columnName}`, title: isSorted ? (isSortedDescending ? 'Sorted descending' : 'Sorted ascending') : 'Sort', color: isSorted ? 'primary' : 'default', sx: { p: 0.25 }, children: isSorted ? (isSortedDescending ? (_jsx(ArrowDownwardIcon, { sx: { fontSize: 16 } })) : (_jsx(ArrowUpwardIcon, { sx: { fontSize: 16 } }))) : (_jsx(SwapVertIcon, { sx: { fontSize: 16 } })) })), filterType !== 'none' && (_jsxs(IconButton, { size: "small", onClick: handleFilterIconClick, "aria-label": `Filter ${columnName}`, title: `Filter ${columnName}`, color: hasActiveFilter || isFilterOpen ? 'primary' : 'default', sx: { p: 0.25, position: 'relative' }, children: [_jsx(FilterListIcon, { sx: { fontSize: 16 } }), hasActiveFilter && (_jsx(Box, { sx: {
217
+ position: 'absolute',
218
+ top: 2,
219
+ right: 2,
220
+ width: 6,
221
+ height: 6,
222
+ borderRadius: '50%',
223
+ bgcolor: 'primary.main',
224
+ } }))] }))] }), _jsxs(Popover, { open: isFilterOpen, anchorEl: anchorEl, onClose: handlePopoverClose, anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' }, slotProps: {
225
+ paper: {
226
+ sx: { mt: 0.5, overflow: 'visible' },
227
+ onClick: (e) => e.stopPropagation(),
228
+ },
229
+ }, children: [_jsx(Box, { sx: { borderBottom: 1, borderColor: 'divider', px: 1.5, py: 1 }, children: _jsxs(Typography, { variant: "subtitle2", children: ["Filter: ", columnName] }) }), renderPopoverContent()] })] }));
230
+ });
231
+ ColumnHeaderFilter.displayName = 'ColumnHeaderFilter';
@@ -0,0 +1 @@
1
+ export { ColumnHeaderFilter } from './ColumnHeaderFilter';
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useCallback, useRef, useEffect, useState } from 'react';
3
+ import { DataGrid } from '@mui/x-data-grid';
4
+ import { Box, CircularProgress, Typography, Button } from '@mui/material';
5
+ import { ColumnHeaderFilter } from '../ColumnHeaderFilter';
6
+ export function DataGridTable(props) {
7
+ const { items, columns, getRowId, sortBy, sortDirection, onColumnSort, visibleColumns, isLoading = false, loadingMessage = 'Loading\u2026', multiSelectFilters, onMultiSelectFilterChange, textFilters = {}, onTextFilterChange, peopleFilters = {}, onPeopleFilterChange, filterOptions, loadingFilterOptions, peopleSearch, getUserByEmail, emptyState, layoutMode = 'content', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
8
+ const wrapperRef = useRef(null);
9
+ const [containerWidth, setContainerWidth] = useState(0);
10
+ useEffect(() => {
11
+ const el = wrapperRef.current;
12
+ if (!el)
13
+ return;
14
+ const measure = () => {
15
+ const rect = el.getBoundingClientRect();
16
+ setContainerWidth(Math.max(0, rect.width));
17
+ };
18
+ const ro = new ResizeObserver(measure);
19
+ ro.observe(el);
20
+ measure();
21
+ return () => ro.disconnect();
22
+ }, []);
23
+ const visibleCols = useMemo(() => {
24
+ return visibleColumns ? columns.filter((c) => visibleColumns.has(c.columnId)) : columns;
25
+ }, [columns, visibleColumns]);
26
+ const minTableWidth = useMemo(() => {
27
+ return visibleCols.reduce((sum, c) => sum + (c.minWidth ?? 80), 0);
28
+ }, [visibleCols]);
29
+ const createHeaderWithFilter = useCallback((col) => {
30
+ const filterable = col.filterable && typeof col.filterable === 'object' ? col.filterable : null;
31
+ const filterType = filterable?.type ?? 'none';
32
+ const filterField = filterable?.filterField ?? col.columnId;
33
+ const sortable = col.sortable !== false;
34
+ if (filterType === 'text') {
35
+ return (_jsx(ColumnHeaderFilter, { columnKey: col.columnId, columnName: col.name, filterType: "text", isSorted: sortBy === col.columnId, isSortedDescending: sortBy === col.columnId && sortDirection === 'desc', onSort: sortable ? () => onColumnSort(col.columnId) : undefined, textValue: textFilters[filterField] ?? '', onTextChange: onTextFilterChange ? (v) => onTextFilterChange(filterField, v) : undefined }));
36
+ }
37
+ if (filterType === 'people') {
38
+ return (_jsx(ColumnHeaderFilter, { columnKey: col.columnId, columnName: col.name, filterType: "people", isSorted: sortBy === col.columnId, isSortedDescending: sortBy === col.columnId && sortDirection === 'desc', onSort: sortable ? () => onColumnSort(col.columnId) : undefined, selectedUser: peopleFilters[filterField], onUserChange: onPeopleFilterChange ? (u) => onPeopleFilterChange(filterField, u) : undefined, peopleSearch: peopleSearch }));
39
+ }
40
+ if (filterType === 'multiSelect') {
41
+ return (_jsx(ColumnHeaderFilter, { columnKey: col.columnId, columnName: col.name, filterType: "multiSelect", isSorted: sortBy === col.columnId, isSortedDescending: sortBy === col.columnId && sortDirection === 'desc', onSort: sortable ? () => onColumnSort(col.columnId) : undefined, options: filterOptions[filterField] ?? [], isLoadingOptions: loadingFilterOptions[filterField] ?? false, selectedValues: multiSelectFilters[filterField] ?? [], onFilterChange: (values) => onMultiSelectFilterChange(filterField, values) }));
42
+ }
43
+ return (_jsx(ColumnHeaderFilter, { columnKey: col.columnId, columnName: col.name, filterType: "none", isSorted: sortBy === col.columnId, isSortedDescending: sortBy === col.columnId && sortDirection === 'desc', onSort: sortable ? () => onColumnSort(col.columnId) : undefined }));
44
+ }, [
45
+ sortBy,
46
+ sortDirection,
47
+ onColumnSort,
48
+ textFilters,
49
+ onTextFilterChange,
50
+ peopleFilters,
51
+ onPeopleFilterChange,
52
+ peopleSearch,
53
+ getUserByEmail,
54
+ filterOptions,
55
+ loadingFilterOptions,
56
+ multiSelectFilters,
57
+ onMultiSelectFilterChange,
58
+ ]);
59
+ const muiColumns = useMemo(() => {
60
+ return visibleCols.map((col) => {
61
+ const gridCol = {
62
+ field: col.columnId,
63
+ headerName: col.name,
64
+ minWidth: col.minWidth ?? 80,
65
+ width: col.idealWidth ?? col.defaultWidth ?? 120,
66
+ flex: layoutMode === 'fill' ? 1 : undefined,
67
+ sortable: false,
68
+ filterable: false,
69
+ disableColumnMenu: true,
70
+ renderHeader: () => createHeaderWithFilter(col),
71
+ renderCell: col.renderCell
72
+ ? (params) => col.renderCell(params.row)
73
+ : undefined,
74
+ };
75
+ return gridCol;
76
+ });
77
+ }, [visibleCols, createHeaderWithFilter, layoutMode]);
78
+ const rows = useMemo(() => {
79
+ return items.map((item) => ({
80
+ ...item,
81
+ id: getRowId(item),
82
+ }));
83
+ }, [items, getRowId]);
84
+ const showEmptyState = items.length === 0 && emptyState;
85
+ const allowOverflowX = containerWidth > 0 && minTableWidth > containerWidth;
86
+ const NoRowsOverlay = useCallback(() => {
87
+ if (!emptyState)
88
+ return null;
89
+ if (emptyState.render) {
90
+ return _jsx(Box, { sx: { py: 4 }, children: emptyState.render() });
91
+ }
92
+ return (_jsxs(Box, { sx: {
93
+ display: 'flex',
94
+ flexDirection: 'column',
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ py: 6,
98
+ px: 2,
99
+ }, children: [_jsx(Typography, { variant: "h2", sx: { mb: 1 }, "aria-hidden": true, children: "\uD83D\uDCCB" }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "No results found" }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { textAlign: 'center' }, children: emptyState.message != null ? (emptyState.message) : emptyState.hasActiveFilters ? (_jsxs(_Fragment, { children: ["No items match your current filters. Try adjusting your search or", ' ', _jsx(Button, { variant: "text", size: "small", onClick: emptyState.onClearAll, sx: { textTransform: 'none', p: 0, minWidth: 'auto', verticalAlign: 'baseline' }, children: "clear all filters" }), ' ', "to see all items."] })) : ('There are no items available at this time.') })] }));
100
+ }, [emptyState]);
101
+ const LoadingOverlay = useCallback(() => (_jsxs(Box, { sx: {
102
+ display: 'flex',
103
+ alignItems: 'center',
104
+ justifyContent: 'center',
105
+ height: '100%',
106
+ gap: 1,
107
+ }, children: [_jsx(CircularProgress, { size: 20 }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: loadingMessage })] })), [loadingMessage]);
108
+ return (_jsx(Box, { ref: wrapperRef, role: "region", "aria-label": ariaLabel ?? (ariaLabelledBy ? undefined : 'Data grid'), "aria-labelledby": ariaLabelledBy, sx: {
109
+ width: '100%',
110
+ overflowX: allowOverflowX ? 'auto' : 'hidden',
111
+ }, children: _jsx(Box, { sx: {
112
+ minWidth: allowOverflowX ? minTableWidth : undefined,
113
+ '& .MuiDataGrid-root': {
114
+ border: 1,
115
+ borderColor: 'divider',
116
+ borderRadius: 1,
117
+ },
118
+ '& .MuiDataGrid-columnHeader': {
119
+ '&:focus, &:focus-within': {
120
+ outline: 'none',
121
+ },
122
+ },
123
+ '& .MuiDataGrid-cell': {
124
+ '&:focus, &:focus-within': {
125
+ outline: 'none',
126
+ },
127
+ },
128
+ }, children: _jsx(DataGrid, { rows: rows, columns: muiColumns, loading: isLoading, autoHeight: !showEmptyState || items.length > 0, disableColumnMenu: true, disableColumnFilter: true, disableColumnSorting: true, disableRowSelectionOnClick: true, hideFooter: true, slots: {
129
+ noRowsOverlay: NoRowsOverlay,
130
+ loadingOverlay: LoadingOverlay,
131
+ }, sx: {
132
+ '& .MuiDataGrid-overlayWrapper': {
133
+ minHeight: showEmptyState ? 200 : undefined,
134
+ },
135
+ }, getRowId: (row) => row.id, "aria-label": ariaLabel }) }) }));
136
+ }
@@ -0,0 +1,280 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
3
+ import { Box } from '@mui/material';
4
+ import { DataGridTable } from '../DataGridTable/DataGridTable';
5
+ import { ColumnChooser } from '../ColumnChooser/ColumnChooser';
6
+ import { PaginationControls } from '../PaginationControls/PaginationControls';
7
+ import { useFilterOptions, toDataGridFilterProps, } from '@alaarab/ogrid-core';
8
+ const DEFAULT_PAGE_SIZE = 20;
9
+ function getFilterField(col) {
10
+ const f = col.filterable && typeof col.filterable === 'object' ? col.filterable : null;
11
+ return (f?.filterField ?? col.columnId);
12
+ }
13
+ function getRowValue(item, key) {
14
+ return item[key];
15
+ }
16
+ /** Merge a single filter change into a full IFilters object. */
17
+ function mergeFilter(prev, key, value) {
18
+ const next = { ...prev };
19
+ const isEmpty = value === undefined ||
20
+ (Array.isArray(value) && value.length === 0) ||
21
+ (typeof value === 'string' && value.trim() === '');
22
+ const isPlainObjectWithoutEmail = typeof value === 'object' && value !== null && !Array.isArray(value) && !('email' in value);
23
+ if (isEmpty || isPlainObjectWithoutEmail) {
24
+ delete next[key];
25
+ }
26
+ else {
27
+ next[key] = value;
28
+ }
29
+ return next;
30
+ }
31
+ /** Derive filter options for multiSelect columns from client-side data. */
32
+ function deriveFilterOptionsFromData(items, columns) {
33
+ const out = {};
34
+ columns.forEach((col) => {
35
+ const f = col.filterable && typeof col.filterable === 'object' ? col.filterable : null;
36
+ if (f?.type !== 'multiSelect')
37
+ return;
38
+ const field = getFilterField(col);
39
+ const values = new Set();
40
+ items.forEach((item) => {
41
+ const v = getRowValue(item, col.columnId);
42
+ if (v != null && v !== '')
43
+ values.add(String(v));
44
+ });
45
+ out[field] = Array.from(values).sort();
46
+ });
47
+ return out;
48
+ }
49
+ /** Get list of filter fields that use multiSelect (for useFilterOptions). */
50
+ function getMultiSelectFilterFields(columns) {
51
+ const fields = [];
52
+ columns.forEach((col) => {
53
+ const f = col.filterable && typeof col.filterable === 'object' ? col.filterable : null;
54
+ if (f?.type === 'multiSelect')
55
+ fields.push(getFilterField(col));
56
+ });
57
+ return fields;
58
+ }
59
+ export function OGrid(props) {
60
+ const { columns, getRowId, data, dataSource, page: controlledPage, pageSize: controlledPageSize, sort: controlledSort, filters: controlledFilters, visibleColumns: controlledVisibleColumns, onPageChange, onPageSizeChange, onSortChange, onFiltersChange, onVisibleColumnsChange, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, emptyState, entityLabelPlural = 'items', className, title, layoutMode = 'content', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
61
+ const isServerSide = dataSource != null;
62
+ const isClientSide = data != null;
63
+ if (!isServerSide && !isClientSide) {
64
+ throw new Error('MaterialDataTable requires either data (client-side) or dataSource (server-side).');
65
+ }
66
+ if (isServerSide && isClientSide) {
67
+ throw new Error('MaterialDataTable: pass either data or dataSource, not both.');
68
+ }
69
+ const defaultSortField = defaultSortBy ?? columns[0]?.columnId;
70
+ const [internalPage, setInternalPage] = useState(1);
71
+ const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
72
+ const [internalSort, setInternalSort] = useState({
73
+ field: defaultSortField,
74
+ direction: defaultSortDirection,
75
+ });
76
+ const [internalFilters, setInternalFilters] = useState({});
77
+ const [internalVisibleColumns, setInternalVisibleColumns] = useState(() => {
78
+ const visible = columns.filter((c) => c.defaultVisible !== false).map((c) => c.columnId);
79
+ return new Set(visible.length > 0 ? visible : columns.map((c) => c.columnId));
80
+ });
81
+ const page = controlledPage ?? internalPage;
82
+ const pageSize = controlledPageSize ?? internalPageSize;
83
+ const sort = controlledSort ?? internalSort;
84
+ const filters = controlledFilters ?? internalFilters;
85
+ const visibleColumns = controlledVisibleColumns ?? internalVisibleColumns;
86
+ const setPage = useCallback((p) => {
87
+ if (controlledPage === undefined)
88
+ setInternalPage(p);
89
+ onPageChange?.(p);
90
+ }, [controlledPage, onPageChange]);
91
+ const setPageSize = useCallback((size) => {
92
+ if (controlledPageSize === undefined)
93
+ setInternalPageSize(size);
94
+ onPageSizeChange?.(size);
95
+ setPage(1);
96
+ }, [controlledPageSize, onPageSizeChange, setPage]);
97
+ const setSort = useCallback((s) => {
98
+ if (controlledSort === undefined)
99
+ setInternalSort(s);
100
+ onSortChange?.(s);
101
+ setPage(1);
102
+ }, [controlledSort, onSortChange, setPage]);
103
+ const setFilters = useCallback((f) => {
104
+ if (controlledFilters === undefined)
105
+ setInternalFilters(f);
106
+ onFiltersChange?.(f);
107
+ setPage(1);
108
+ }, [controlledFilters, onFiltersChange, setPage]);
109
+ const setVisibleColumns = useCallback((cols) => {
110
+ if (controlledVisibleColumns === undefined)
111
+ setInternalVisibleColumns(cols);
112
+ onVisibleColumnsChange?.(cols);
113
+ }, [controlledVisibleColumns, onVisibleColumnsChange]);
114
+ const { multiSelectFilters, textFilters, peopleFilters } = useMemo(() => toDataGridFilterProps(filters), [filters]);
115
+ const handleSort = useCallback((columnKey) => {
116
+ setSort({
117
+ field: columnKey,
118
+ direction: sort.field === columnKey && sort.direction === 'asc' ? 'desc' : 'asc',
119
+ });
120
+ }, [sort, setSort]);
121
+ const handleMultiSelectFilterChange = useCallback((key, values) => {
122
+ setFilters(mergeFilter(filters, key, values.length ? values : undefined));
123
+ }, [filters, setFilters]);
124
+ const handleTextFilterChange = useCallback((key, value) => {
125
+ setFilters(mergeFilter(filters, key, value.trim() || undefined));
126
+ }, [filters, setFilters]);
127
+ const handlePeopleFilterChange = useCallback((key, user) => {
128
+ setFilters(mergeFilter(filters, key, user ?? undefined));
129
+ }, [filters, setFilters]);
130
+ const handleVisibilityChange = useCallback((columnKey, isVisible) => {
131
+ const next = new Set(visibleColumns);
132
+ if (isVisible)
133
+ next.add(columnKey);
134
+ else
135
+ next.delete(columnKey);
136
+ setVisibleColumns(next);
137
+ }, [visibleColumns, setVisibleColumns]);
138
+ const multiSelectFilterFields = useMemo(() => getMultiSelectFilterFields(columns), [columns]);
139
+ const filterOptionsSource = useMemo(() => dataSource ?? { fetchFilterOptions: undefined }, [dataSource]);
140
+ const { filterOptions: serverFilterOptions, loadingOptions: loadingFilterOptions } = useFilterOptions(filterOptionsSource, multiSelectFilterFields);
141
+ const clientFilterOptions = useMemo(() => {
142
+ if (dataSource != null && dataSource.fetchFilterOptions)
143
+ return serverFilterOptions;
144
+ return deriveFilterOptionsFromData(data ?? [], columns);
145
+ }, [dataSource, data, columns, serverFilterOptions]);
146
+ const clientItemsAndTotal = useMemo(() => {
147
+ if (!isClientSide || data == null)
148
+ return null;
149
+ let rows = data.slice();
150
+ columns.forEach((col) => {
151
+ const filterKey = getFilterField(col);
152
+ const f = col.filterable && typeof col.filterable === 'object' ? col.filterable : null;
153
+ const type = f?.type;
154
+ const val = filters[filterKey];
155
+ if (type === 'multiSelect' && Array.isArray(val) && val.length > 0) {
156
+ rows = rows.filter((r) => val.includes(String(getRowValue(r, col.columnId))));
157
+ }
158
+ else if (type === 'text' && typeof val === 'string' && val.trim()) {
159
+ const lower = val.trim().toLowerCase();
160
+ rows = rows.filter((r) => String(getRowValue(r, col.columnId) ?? '')
161
+ .toLowerCase()
162
+ .includes(lower));
163
+ }
164
+ else if (type === 'people' && val && typeof val === 'object' && 'email' in val) {
165
+ const email = val.email.toLowerCase();
166
+ rows = rows.filter((r) => String(getRowValue(r, col.columnId) ?? '').toLowerCase() === email);
167
+ }
168
+ });
169
+ if (sort.field) {
170
+ const col = columns.find((c) => c.columnId === sort.field);
171
+ const compare = col?.compare;
172
+ const dir = sort.direction === 'asc' ? 1 : -1;
173
+ rows.sort((a, b) => {
174
+ if (compare)
175
+ return compare(a, b) * dir;
176
+ const av = getRowValue(a, sort.field);
177
+ const bv = getRowValue(b, sort.field);
178
+ if (av == null && bv == null)
179
+ return 0;
180
+ if (av == null)
181
+ return -1 * dir;
182
+ if (bv == null)
183
+ return 1 * dir;
184
+ if (typeof av === 'number' && typeof bv === 'number')
185
+ return av === bv ? 0 : av > bv ? dir : -dir;
186
+ const as = String(av).toLowerCase();
187
+ const bs = String(bv).toLowerCase();
188
+ return as === bs ? 0 : as > bs ? dir : -dir;
189
+ });
190
+ }
191
+ const total = rows.length;
192
+ const start = (page - 1) * pageSize;
193
+ const paged = rows.slice(start, start + pageSize);
194
+ return { items: paged, totalCount: total };
195
+ }, [isClientSide, data, columns, filters, sort.field, sort.direction, page, pageSize]);
196
+ const [serverItems, setServerItems] = useState([]);
197
+ const [serverTotalCount, setServerTotalCount] = useState(0);
198
+ const [loading, setLoading] = useState(true);
199
+ const fetchIdRef = useRef(0);
200
+ useEffect(() => {
201
+ if (!isServerSide || !dataSource) {
202
+ if (!isServerSide)
203
+ setLoading(false);
204
+ return;
205
+ }
206
+ const id = ++fetchIdRef.current;
207
+ setLoading(true);
208
+ dataSource
209
+ .fetchPage({
210
+ page,
211
+ pageSize,
212
+ sort: { field: sort.field, direction: sort.direction },
213
+ filters,
214
+ })
215
+ .then((res) => {
216
+ if (id !== fetchIdRef.current)
217
+ return;
218
+ setServerItems(res.items);
219
+ setServerTotalCount(res.totalCount);
220
+ })
221
+ .catch((err) => {
222
+ if (id !== fetchIdRef.current)
223
+ return;
224
+ console.error('MaterialDataTable fetchPage error:', err);
225
+ setServerItems([]);
226
+ setServerTotalCount(0);
227
+ })
228
+ .finally(() => {
229
+ if (id === fetchIdRef.current)
230
+ setLoading(false);
231
+ });
232
+ }, [isServerSide, dataSource, page, pageSize, sort.field, sort.direction, filters]);
233
+ const displayItems = isClientSide && clientItemsAndTotal ? clientItemsAndTotal.items : serverItems;
234
+ const displayTotalCount = isClientSide && clientItemsAndTotal ? clientItemsAndTotal.totalCount : serverTotalCount;
235
+ const hasActiveFilters = useMemo(() => {
236
+ return Object.values(filters).some((v) => v !== undefined &&
237
+ (Array.isArray(v) ? v.length > 0 : typeof v === 'string' ? v.trim() !== '' : true));
238
+ }, [filters]);
239
+ const columnChooserColumns = useMemo(() => columns.map((c) => ({
240
+ columnId: c.columnId,
241
+ name: c.name,
242
+ required: c.required === true,
243
+ })), [columns]);
244
+ const dataGridProps = {
245
+ items: displayItems,
246
+ columns,
247
+ getRowId,
248
+ sortBy: sort.field,
249
+ sortDirection: sort.direction,
250
+ onColumnSort: handleSort,
251
+ visibleColumns,
252
+ isLoading: isServerSide && loading,
253
+ multiSelectFilters,
254
+ onMultiSelectFilterChange: handleMultiSelectFilterChange,
255
+ textFilters,
256
+ onTextFilterChange: handleTextFilterChange,
257
+ peopleFilters,
258
+ onPeopleFilterChange: handlePeopleFilterChange,
259
+ filterOptions: clientFilterOptions,
260
+ loadingFilterOptions: dataSource?.fetchFilterOptions ? loadingFilterOptions : {},
261
+ peopleSearch: dataSource?.searchPeople,
262
+ getUserByEmail: dataSource?.getUserByEmail,
263
+ layoutMode,
264
+ 'aria-label': ariaLabel,
265
+ 'aria-labelledby': ariaLabelledBy,
266
+ emptyState: {
267
+ hasActiveFilters,
268
+ onClearAll: () => setFilters({}),
269
+ message: emptyState?.message,
270
+ render: emptyState?.render,
271
+ },
272
+ };
273
+ return (_jsxs(Box, { className: className, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, children: [title != null ? _jsx(Box, { sx: { m: 0 }, children: title }) : null, toolbar, _jsx(ColumnChooser, { columns: columnChooserColumns, visibleColumns: visibleColumns, onVisibilityChange: handleVisibilityChange })] }), _jsx(DataGridTable, { ...dataGridProps }), _jsx(PaginationControls, { currentPage: page, pageSize: pageSize, totalCount: displayTotalCount, onPageChange: setPage, onPageSizeChange: (size) => {
274
+ setPageSize(size);
275
+ setPage(1);
276
+ }, entityLabelPlural: entityLabelPlural })] }));
277
+ }
278
+ OGrid.displayName = 'OGrid';
279
+ /** @deprecated Use OGrid and IOGridProps. Kept for backward compatibility. */
280
+ export const MaterialDataTable = OGrid;
@@ -0,0 +1 @@
1
+ export { OGrid, MaterialDataTable } from './MaterialDataTable';
@@ -0,0 +1,53 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { useMemo, useCallback } from 'react';
4
+ import { IconButton, Button, Select, MenuItem, Box, Typography, } from '@mui/material';
5
+ import { FirstPage as FirstPageIcon, LastPage as LastPageIcon, ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon, } from '@mui/icons-material';
6
+ const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
7
+ const MAX_PAGE_BUTTONS = 5;
8
+ export const PaginationControls = React.memo((props) => {
9
+ const { currentPage, pageSize, totalCount, onPageChange, onPageSizeChange, entityLabelPlural, className, } = props;
10
+ const labelPlural = entityLabelPlural ?? 'items';
11
+ const { pageNumbers, showStartEllipsis, showEndEllipsis } = useMemo(() => {
12
+ const totalPages = Math.ceil(totalCount / pageSize);
13
+ if (totalPages <= MAX_PAGE_BUTTONS) {
14
+ const numbers = [];
15
+ for (let i = 1; i <= totalPages; i++)
16
+ numbers.push(i);
17
+ return { pageNumbers: numbers, showStartEllipsis: false, showEndEllipsis: false };
18
+ }
19
+ let start = Math.max(1, currentPage - 2);
20
+ let end = Math.min(totalPages, currentPage + 2);
21
+ if (end - start + 1 < MAX_PAGE_BUTTONS) {
22
+ if (start === 1)
23
+ end = Math.min(totalPages, start + MAX_PAGE_BUTTONS - 1);
24
+ else if (end === totalPages)
25
+ start = Math.max(1, end - MAX_PAGE_BUTTONS + 1);
26
+ }
27
+ const numbers = [];
28
+ for (let i = start; i <= end; i++)
29
+ numbers.push(i);
30
+ return {
31
+ pageNumbers: numbers,
32
+ showStartEllipsis: start > 1,
33
+ showEndEllipsis: end < totalPages,
34
+ };
35
+ }, [currentPage, pageSize, totalCount]);
36
+ const totalPages = Math.ceil(totalCount / pageSize);
37
+ const handlePageSizeChange = useCallback((event) => {
38
+ onPageSizeChange(Number(event.target.value));
39
+ }, [onPageSizeChange]);
40
+ if (totalCount === 0) {
41
+ return null;
42
+ }
43
+ const startItem = Math.max(1, (currentPage - 1) * pageSize + 1);
44
+ const endItem = Math.min(currentPage * pageSize, totalCount);
45
+ return (_jsxs(Box, { className: className, role: "navigation", "aria-label": "Pagination", sx: {
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ justifyContent: 'space-between',
49
+ flexWrap: 'wrap',
50
+ gap: 2,
51
+ py: 1,
52
+ }, children: [_jsxs(Typography, { variant: "body2", color: "text.secondary", children: ["Showing ", startItem, " to ", endItem, " of ", totalCount.toLocaleString(), " ", labelPlural] }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 0.5 }, children: [_jsx(IconButton, { size: "small", onClick: () => onPageChange(1), disabled: currentPage === 1, "aria-label": "First page", children: _jsx(FirstPageIcon, { fontSize: "small" }) }), _jsx(IconButton, { size: "small", onClick: () => onPageChange(currentPage - 1), disabled: currentPage === 1, "aria-label": "Previous page", children: _jsx(ChevronLeftIcon, { fontSize: "small" }) }), showStartEllipsis && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outlined", size: "small", onClick: () => onPageChange(1), "aria-label": "Page 1", sx: { minWidth: 32, px: 0.5 }, children: "1" }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mx: 0.5 }, "aria-hidden": true, children: "\u2026" })] })), pageNumbers.map((pageNum) => (_jsx(Button, { variant: currentPage === pageNum ? 'contained' : 'outlined', size: "small", onClick: () => onPageChange(pageNum), "aria-label": `Page ${pageNum}`, "aria-current": currentPage === pageNum ? 'page' : undefined, sx: { minWidth: 32, px: 0.5 }, children: pageNum }, pageNum))), showEndEllipsis && (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mx: 0.5 }, "aria-hidden": true, children: "\u2026" }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => onPageChange(totalPages), "aria-label": `Page ${totalPages}`, sx: { minWidth: 32, px: 0.5 }, children: totalPages })] })), _jsx(IconButton, { size: "small", onClick: () => onPageChange(currentPage + 1), disabled: currentPage >= totalPages, "aria-label": "Next page", children: _jsx(ChevronRightIcon, { fontSize: "small" }) }), _jsx(IconButton, { size: "small", onClick: () => onPageChange(totalPages), disabled: currentPage >= totalPages, "aria-label": "Last page", children: _jsx(LastPageIcon, { fontSize: "small" }) })] }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(Typography, { variant: "body2", color: "text.secondary", children: "Rows" }), _jsx(Select, { value: pageSize, onChange: handlePageSizeChange, size: "small", "aria-label": "Rows per page", sx: { minWidth: 70 }, children: PAGE_SIZE_OPTIONS.map((n) => (_jsx(MenuItem, { value: n, children: n }, n))) })] })] }));
53
+ });
@@ -0,0 +1,10 @@
1
+ // Components
2
+ export { OGrid, MaterialDataTable } from './MaterialDataTable';
3
+ export { DataGridTable } from './DataGridTable/DataGridTable';
4
+ export { ColumnChooser } from './ColumnChooser/ColumnChooser';
5
+ export { ColumnHeaderFilter } from './ColumnHeaderFilter/ColumnHeaderFilter';
6
+ export { PaginationControls } from './PaginationControls/PaginationControls';
7
+ // Re-export everything from core for convenience
8
+ export {
9
+ // Utilities
10
+ toUserLike, toDataGridFilterProps, useFilterOptions, escapeCsvValue, buildCsvHeader, buildCsvRows, exportToCsv, triggerCsvDownload, } from '@alaarab/ogrid-core';
@@ -0,0 +1,10 @@
1
+ import * as React from 'react';
2
+ import type { IColumnDefinition } from '@alaarab/ogrid-core';
3
+ export type { IColumnDefinition };
4
+ export interface IColumnChooserProps {
5
+ columns: IColumnDefinition[];
6
+ visibleColumns: Set<string>;
7
+ onVisibilityChange: (columnKey: string, visible: boolean) => void;
8
+ className?: string;
9
+ }
10
+ export declare const ColumnChooser: React.FC<IColumnChooserProps>;
@@ -0,0 +1,20 @@
1
+ import * as React from 'react';
2
+ import type { UserLike, ColumnFilterType } from '@alaarab/ogrid-core';
3
+ export interface IColumnHeaderFilterProps {
4
+ columnKey: string;
5
+ columnName: string;
6
+ filterType: ColumnFilterType;
7
+ isSorted?: boolean;
8
+ isSortedDescending?: boolean;
9
+ onSort?: () => void;
10
+ selectedValues?: string[];
11
+ onFilterChange?: (values: string[]) => void;
12
+ options?: string[];
13
+ isLoadingOptions?: boolean;
14
+ textValue?: string;
15
+ onTextChange?: (value: string) => void;
16
+ selectedUser?: UserLike;
17
+ onUserChange?: (user: UserLike | undefined) => void;
18
+ peopleSearch?: (query: string) => Promise<UserLike[]>;
19
+ }
20
+ export declare const ColumnHeaderFilter: React.FC<IColumnHeaderFilterProps>;
@@ -0,0 +1 @@
1
+ export { ColumnHeaderFilter } from './ColumnHeaderFilter';
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ import type { IColumnDef, UserLike } from '@alaarab/ogrid-core';
3
+ export interface IDataGridTableProps<T> {
4
+ items: T[];
5
+ columns: IColumnDef<T>[];
6
+ getRowId: (item: T) => string;
7
+ sortBy?: string;
8
+ sortDirection: 'asc' | 'desc';
9
+ onColumnSort: (columnKey: string) => void;
10
+ visibleColumns?: Set<string>;
11
+ layoutMode?: 'content' | 'fill';
12
+ isLoading?: boolean;
13
+ loadingMessage?: string;
14
+ multiSelectFilters: Record<string, string[]>;
15
+ onMultiSelectFilterChange: (key: string, values: string[]) => void;
16
+ textFilters?: Record<string, string>;
17
+ onTextFilterChange?: (key: string, value: string) => void;
18
+ peopleFilters?: Record<string, UserLike | undefined>;
19
+ onPeopleFilterChange?: (key: string, user: UserLike | undefined) => void;
20
+ filterOptions: Record<string, string[]>;
21
+ loadingFilterOptions: Record<string, boolean>;
22
+ peopleSearch?: (query: string) => Promise<UserLike[]>;
23
+ getUserByEmail?: (email: string) => Promise<UserLike | undefined>;
24
+ emptyState?: {
25
+ onClearAll: () => void;
26
+ hasActiveFilters: boolean;
27
+ message?: React.ReactNode;
28
+ render?: () => React.ReactNode;
29
+ };
30
+ 'aria-label'?: string;
31
+ 'aria-labelledby'?: string;
32
+ }
33
+ export declare function DataGridTable<T>(props: IDataGridTableProps<T>): React.ReactElement;
@@ -0,0 +1,56 @@
1
+ import * as React from 'react';
2
+ import { type IColumnDef, type IFilters, type IDataSource } from '@alaarab/ogrid-core';
3
+ export interface IOGridProps<T> {
4
+ columns: IColumnDef<T>[];
5
+ getRowId: (item: T) => string;
6
+ /** Client-side: pass an array; grid filters/sorts/pages in memory. */
7
+ data?: T[];
8
+ /** Server-side: pass a data source; grid calls fetchPage with params. */
9
+ dataSource?: IDataSource<T>;
10
+ /** Controlled: current page (1-based). Omit for uncontrolled. */
11
+ page?: number;
12
+ /** Controlled: page size. Omit for uncontrolled. */
13
+ pageSize?: number;
14
+ /** Controlled: sort. Omit for uncontrolled. */
15
+ sort?: {
16
+ field: string;
17
+ direction: 'asc' | 'desc';
18
+ };
19
+ /** Controlled: unified filters. Omit for uncontrolled. */
20
+ filters?: IFilters;
21
+ /** Controlled: visible column ids. Omit for uncontrolled. */
22
+ visibleColumns?: Set<string>;
23
+ onPageChange?: (page: number) => void;
24
+ onPageSizeChange?: (size: number) => void;
25
+ onSortChange?: (sort: {
26
+ field: string;
27
+ direction: 'asc' | 'desc';
28
+ }) => void;
29
+ onFiltersChange?: (filters: IFilters) => void;
30
+ onVisibleColumnsChange?: (cols: Set<string>) => void;
31
+ /** Initial page size when uncontrolled (default 20). */
32
+ defaultPageSize?: number;
33
+ /** Initial sort field when uncontrolled. */
34
+ defaultSortBy?: string;
35
+ /** Initial sort direction when uncontrolled (default 'asc'). */
36
+ defaultSortDirection?: 'asc' | 'desc';
37
+ toolbar?: React.ReactNode;
38
+ emptyState?: {
39
+ message?: React.ReactNode;
40
+ render?: () => React.ReactNode;
41
+ };
42
+ entityLabelPlural?: string;
43
+ className?: string;
44
+ title?: React.ReactNode;
45
+ layoutMode?: 'content' | 'fill';
46
+ 'aria-label'?: string;
47
+ 'aria-labelledby'?: string;
48
+ }
49
+ export declare function OGrid<T>(props: IOGridProps<T>): React.ReactElement;
50
+ export declare namespace OGrid {
51
+ var displayName: string;
52
+ }
53
+ /** @deprecated Use OGrid and IOGridProps. Kept for backward compatibility. */
54
+ export declare const MaterialDataTable: typeof OGrid;
55
+ /** @deprecated Use IOGridProps. Kept for backward compatibility. */
56
+ export type IMaterialDataTableProps<T> = IOGridProps<T>;
@@ -0,0 +1 @@
1
+ export { OGrid, MaterialDataTable, type IOGridProps, type IMaterialDataTableProps } from './MaterialDataTable';
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ export interface IPaginationControlsProps {
3
+ currentPage: number;
4
+ pageSize: number;
5
+ totalCount: number;
6
+ onPageChange: (page: number) => void;
7
+ onPageSizeChange: (pageSize: number) => void;
8
+ entityLabelPlural?: string;
9
+ className?: string;
10
+ }
11
+ export declare const PaginationControls: React.FC<IPaginationControlsProps>;
@@ -0,0 +1,6 @@
1
+ export { OGrid, type IOGridProps, MaterialDataTable, type IMaterialDataTableProps } from './MaterialDataTable';
2
+ export { DataGridTable, type IDataGridTableProps } from './DataGridTable/DataGridTable';
3
+ export { ColumnChooser, type IColumnChooserProps } from './ColumnChooser/ColumnChooser';
4
+ export { ColumnHeaderFilter, type IColumnHeaderFilterProps } from './ColumnHeaderFilter/ColumnHeaderFilter';
5
+ export { PaginationControls, type IPaginationControlsProps } from './PaginationControls/PaginationControls';
6
+ export { type ColumnFilterType, type IColumnFilterDef, type IColumnMeta, type IColumnDef, type IColumnDefinition, type UserLike, type IFilters, type IFetchParams, type IPageResult, type IDataSource, toUserLike, toDataGridFilterProps, useFilterOptions, type UseFilterOptionsResult, escapeCsvValue, buildCsvHeader, buildCsvRows, exportToCsv, triggerCsvDownload, type CsvColumn, } from '@alaarab/ogrid-core';
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@alaarab/ogrid-material",
3
+ "version": "1.0.0",
4
+ "description": "OGrid Material UI implementation – MUI DataGrid-powered data table with sorting, filtering, pagination, column chooser, and CSV export.",
5
+ "main": "dist/esm/index.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/types/index.d.ts",
11
+ "import": "./dist/esm/index.js",
12
+ "require": "./dist/esm/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "rimraf dist && tsc -p tsconfig.build.json",
17
+ "test": "jest",
18
+ "storybook": "storybook dev -p 6007 --no-open",
19
+ "build-storybook": "storybook build"
20
+ },
21
+ "keywords": ["ogrid", "material-ui", "mui", "datatable", "react", "typescript", "grid"],
22
+ "author": "Ala Arab",
23
+ "license": "MIT",
24
+ "files": ["dist", "README.md", "LICENSE"],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "dependencies": {
29
+ "@alaarab/ogrid-core": "^1.0.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@emotion/react": "^11.0.0",
33
+ "@emotion/styled": "^11.0.0",
34
+ "@mui/icons-material": "^6.0.0",
35
+ "@mui/material": "^6.0.0",
36
+ "@mui/x-data-grid": "^7.0.0",
37
+ "react": "^17.0.0 || ^18.0.0",
38
+ "react-dom": "^17.0.0 || ^18.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@emotion/react": "^11.14.0",
42
+ "@emotion/styled": "^11.14.0",
43
+ "@mui/icons-material": "^6.4.0",
44
+ "@mui/material": "^6.4.0",
45
+ "@mui/x-data-grid": "^7.27.0",
46
+ "@storybook/react": "^8.5.3",
47
+ "@storybook/react-vite": "^8.5.3",
48
+ "storybook": "^8.5.3",
49
+ "vite": "^6.1.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }