@xeplr/ui-utils 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -6
- package/src/confirm/confirm.js +106 -0
- package/src/dataImport/dataImport.js +32 -0
- package/src/dataImport/designs/DataImportSample.jsx +71 -0
- package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
- package/src/dataImport/designs/dataImport.css +98 -0
- package/src/dataImport/designs/index.js +2 -0
- package/src/dataImport/pages.jsx +17 -0
- package/src/dataImport/useDataImportController.js +94 -0
- package/src/dataImport/useImportHistoryController.js +67 -0
- package/src/dataImport/validateDesign.js +49 -0
- package/src/dateField/dateField.js +71 -0
- package/src/dateField/designs/DateFieldSample.jsx +30 -0
- package/src/dateField/designs/dateField.css +60 -0
- package/src/dateField/designs/index.js +1 -0
- package/src/dateField/pages.jsx +10 -0
- package/src/dateField/useDateFieldController.js +77 -0
- package/src/dateField/validateDesign.js +3 -0
- package/src/dropdown/designs/DropdownSample.jsx +151 -0
- package/src/dropdown/designs/dropdown.css +226 -0
- package/src/dropdown/designs/index.js +1 -0
- package/src/dropdown/dropdown.js +75 -0
- package/src/dropdown/pages.jsx +10 -0
- package/src/dropdown/useDropdownController.js +254 -0
- package/src/dropdown/validateDesign.js +4 -0
- package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
- package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
- package/src/gridDisplayer/designs/index.js +1 -0
- package/src/gridDisplayer/gridDisplayer.js +44 -0
- package/src/gridDisplayer/pages.jsx +10 -0
- package/src/gridDisplayer/useGridDisplayerController.js +59 -0
- package/src/gridDisplayer/validateDesign.js +4 -0
- package/src/index.js +115 -1
- package/src/numberField/designs/NumberFieldSample.jsx +52 -0
- package/src/numberField/designs/index.js +1 -0
- package/src/numberField/designs/numberField.css +66 -0
- package/src/numberField/numberField.js +105 -0
- package/src/numberField/pages.jsx +10 -0
- package/src/numberField/useNumberFieldController.js +116 -0
- package/src/numberField/validateDesign.js +3 -0
- package/src/snackbar/snackbar.js +6 -3
- package/src/theme/default.theme.json +178 -0
- package/src/theme/index.js +75 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdown — pure model. No React.
|
|
3
|
+
*
|
|
4
|
+
* Two sources:
|
|
5
|
+
* - 'static' → caller passes `options` directly
|
|
6
|
+
* - 'dynamic' → caller passes `fetchData` (returns Promise<Array>)
|
|
7
|
+
*
|
|
8
|
+
* Two modes: 'single' or 'multi'.
|
|
9
|
+
*
|
|
10
|
+
* Items default to { id, name }. Other shapes are supported via the `keys`
|
|
11
|
+
* prop, an array where keys[0] is the id field, keys[1] is the display name
|
|
12
|
+
* (also used for sort + search), and the remainder are "extra" fields shown
|
|
13
|
+
* as metadata in each row (also searchable).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export var DROPDOWN_MODES = ['single', 'multi'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Turn a `keys` array (or undefined) into a structured shape.
|
|
20
|
+
* keys[0] = id, keys[1] = name, keys[2..] = extras.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeKeys(keys) {
|
|
23
|
+
var arr = Array.isArray(keys) && keys.length >= 2 ? keys : ['id', 'name'];
|
|
24
|
+
return {
|
|
25
|
+
id: arr[0],
|
|
26
|
+
name: arr[1],
|
|
27
|
+
extras: arr.slice(2)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sort items ascending by the named key, case-insensitive for strings.
|
|
33
|
+
*/
|
|
34
|
+
export function sortByKey(items, key) {
|
|
35
|
+
if (!Array.isArray(items)) return [];
|
|
36
|
+
var copy = items.slice();
|
|
37
|
+
copy.sort(function(a, b) {
|
|
38
|
+
var av = a == null ? '' : a[key];
|
|
39
|
+
var bv = b == null ? '' : b[key];
|
|
40
|
+
if (typeof av === 'string') av = av.toLowerCase();
|
|
41
|
+
if (typeof bv === 'string') bv = bv.toLowerCase();
|
|
42
|
+
if (av < bv) return -1;
|
|
43
|
+
if (av > bv) return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
});
|
|
46
|
+
return copy;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Filter items where any of `searchKeys` contains `query` (case-insensitive).
|
|
51
|
+
* Empty query returns all items.
|
|
52
|
+
*/
|
|
53
|
+
export function filterByQuery(items, query, searchKeys) {
|
|
54
|
+
if (!query) return items;
|
|
55
|
+
var q = String(query).toLowerCase();
|
|
56
|
+
return items.filter(function(item) {
|
|
57
|
+
for (var i = 0; i < searchKeys.length; i++) {
|
|
58
|
+
var v = item[searchKeys[i]];
|
|
59
|
+
if (v != null && String(v).toLowerCase().indexOf(q) !== -1) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find items whose ids match the given selection.
|
|
67
|
+
* `selection` is an array of ids; returns an array of items in the same order
|
|
68
|
+
* as the master list (preserves stability across reorders).
|
|
69
|
+
*/
|
|
70
|
+
export function pickSelected(items, selection, idKey) {
|
|
71
|
+
if (!selection || selection.length === 0) return [];
|
|
72
|
+
var set = {};
|
|
73
|
+
for (var i = 0; i < selection.length; i++) set[selection[i]] = true;
|
|
74
|
+
return items.filter(function(item) { return set[item[idKey]]; });
|
|
75
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useDropdownController } from './useDropdownController.js';
|
|
2
|
+
import DropdownSample from './designs/DropdownSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { DROPDOWN_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function DropdownPage(props) {
|
|
7
|
+
var controller = useDropdownController(props);
|
|
8
|
+
var ref = useDesignValidator('DropdownPage', DROPDOWN_RULES);
|
|
9
|
+
return <div ref={ref}><DropdownSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
DROPDOWN_MODES,
|
|
4
|
+
normalizeKeys,
|
|
5
|
+
sortByKey,
|
|
6
|
+
filterByQuery,
|
|
7
|
+
pickSelected
|
|
8
|
+
} from './dropdown.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} props
|
|
12
|
+
* @param {'single'|'multi'} [props.mode='single']
|
|
13
|
+
* @param {Array} [props.options] — static items
|
|
14
|
+
* @param {() => Promise<Array>} [props.fetchData] — dynamic loader
|
|
15
|
+
* @param {Array<string>} [props.keys] — [idKey, nameKey, ...extraKeys]; default ['id','name']
|
|
16
|
+
* @param {*} [props.value] — controlled selection (id, or id[])
|
|
17
|
+
* @param {*} [props.defaultValue] — uncontrolled initial selection
|
|
18
|
+
* @param {(value, items) => void} [props.onChange]
|
|
19
|
+
* @param {string} [props.placeholder='Select…']
|
|
20
|
+
* @param {boolean} [props.searchable=true]
|
|
21
|
+
* @param {boolean} [props.disabled]
|
|
22
|
+
* @param {(name:string) => Promise<*>} [props.onCreate] — create a new option from the typed
|
|
23
|
+
* name via your API; return the created item (or its id) and it is auto-selected.
|
|
24
|
+
* @param {boolean} [props.allowCreate=true] — show the "add new" affordance when onCreate is set
|
|
25
|
+
* @param {string} [props.createLabel='Add'] — label prefix on the create row (e.g. 'Add company')
|
|
26
|
+
*/
|
|
27
|
+
export function useDropdownController(props) {
|
|
28
|
+
props = props || {};
|
|
29
|
+
var mode = props.mode || 'single';
|
|
30
|
+
if (DROPDOWN_MODES.indexOf(mode) === -1) {
|
|
31
|
+
throw new Error('[xeplr-ui-utils:Dropdown] Unknown mode: ' + mode + '. Expected one of ' + DROPDOWN_MODES.join(', '));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var keys = useMemo(function() { return normalizeKeys(props.keys); }, [props.keys]);
|
|
35
|
+
var source = props.fetchData ? 'dynamic' : 'static';
|
|
36
|
+
var searchable = props.searchable !== false;
|
|
37
|
+
var canCreate = !!props.onCreate && props.allowCreate !== false;
|
|
38
|
+
|
|
39
|
+
var [dynamicItems, setDynamicItems] = useState([]);
|
|
40
|
+
var [loading, setLoading] = useState(false);
|
|
41
|
+
var [error, setError] = useState('');
|
|
42
|
+
var [query, setQuery] = useState('');
|
|
43
|
+
var [isOpen, setIsOpen] = useState(false);
|
|
44
|
+
var [creating, setCreating] = useState(false);
|
|
45
|
+
var [createError, setCreateError] = useState('');
|
|
46
|
+
|
|
47
|
+
var isControlled = props.value !== undefined;
|
|
48
|
+
var initialSelection = useMemo(function() {
|
|
49
|
+
var v = isControlled ? props.value : props.defaultValue;
|
|
50
|
+
if (v === undefined || v === null) return [];
|
|
51
|
+
return Array.isArray(v) ? v.slice() : [v];
|
|
52
|
+
}, [isControlled, props.value, props.defaultValue]);
|
|
53
|
+
|
|
54
|
+
var [selection, setSelection] = useState(initialSelection);
|
|
55
|
+
|
|
56
|
+
useEffect(function() {
|
|
57
|
+
if (isControlled) {
|
|
58
|
+
var v = props.value;
|
|
59
|
+
setSelection(v === null || v === undefined ? [] : (Array.isArray(v) ? v.slice() : [v]));
|
|
60
|
+
}
|
|
61
|
+
}, [isControlled, props.value]);
|
|
62
|
+
|
|
63
|
+
// Static items are derived (no state — avoids re-render loops if caller
|
|
64
|
+
// passes a new `options` array reference each render). Dynamic items come
|
|
65
|
+
// from the fetch effect below.
|
|
66
|
+
var items = useMemo(function() {
|
|
67
|
+
if (source === 'static') return sortByKey(props.options || [], keys.name);
|
|
68
|
+
return dynamicItems;
|
|
69
|
+
}, [source, props.options, dynamicItems, keys.name]);
|
|
70
|
+
|
|
71
|
+
// Load dynamic data on mount.
|
|
72
|
+
var fetchedRef = useRef(false);
|
|
73
|
+
useEffect(function() {
|
|
74
|
+
if (source !== 'dynamic' || fetchedRef.current) return;
|
|
75
|
+
fetchedRef.current = true;
|
|
76
|
+
setLoading(true);
|
|
77
|
+
setError('');
|
|
78
|
+
Promise.resolve()
|
|
79
|
+
.then(function() { return props.fetchData(); })
|
|
80
|
+
.then(function(data) {
|
|
81
|
+
setDynamicItems(sortByKey(Array.isArray(data) ? data : [], keys.name));
|
|
82
|
+
})
|
|
83
|
+
.catch(function(err) {
|
|
84
|
+
setError(err && err.message ? err.message : 'Failed to load options');
|
|
85
|
+
})
|
|
86
|
+
.finally(function() {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
});
|
|
89
|
+
}, [source]);
|
|
90
|
+
|
|
91
|
+
function refresh() {
|
|
92
|
+
if (source !== 'dynamic') return;
|
|
93
|
+
setLoading(true);
|
|
94
|
+
setError('');
|
|
95
|
+
Promise.resolve()
|
|
96
|
+
.then(function() { return props.fetchData(); })
|
|
97
|
+
.then(function(data) {
|
|
98
|
+
setDynamicItems(sortByKey(Array.isArray(data) ? data : [], keys.name));
|
|
99
|
+
})
|
|
100
|
+
.catch(function(err) {
|
|
101
|
+
setError(err && err.message ? err.message : 'Failed to load options');
|
|
102
|
+
})
|
|
103
|
+
.finally(function() {
|
|
104
|
+
setLoading(false);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
var searchKeys = useMemo(function() {
|
|
109
|
+
return [keys.name].concat(keys.extras);
|
|
110
|
+
}, [keys]);
|
|
111
|
+
|
|
112
|
+
var filteredItems = useMemo(function() {
|
|
113
|
+
return filterByQuery(items, query, searchKeys);
|
|
114
|
+
}, [items, query, searchKeys]);
|
|
115
|
+
|
|
116
|
+
var selectedItems = useMemo(function() {
|
|
117
|
+
return pickSelected(items, selection, keys.id);
|
|
118
|
+
}, [items, selection, keys.id]);
|
|
119
|
+
|
|
120
|
+
function open() { if (!props.disabled) setIsOpen(true); }
|
|
121
|
+
function close() { setIsOpen(false); setQuery(''); }
|
|
122
|
+
function toggle() { isOpen ? close() : open(); }
|
|
123
|
+
|
|
124
|
+
function emit(nextSelection, itemsOverride) {
|
|
125
|
+
var nextItems = pickSelected(itemsOverride || items, nextSelection, keys.id);
|
|
126
|
+
if (props.onChange) {
|
|
127
|
+
props.onChange(mode === 'single' ? (nextSelection[0] || null) : nextSelection, nextItems);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create a new option from the typed name via props.onCreate, then reload the
|
|
132
|
+
// (dynamic) options so the new row carries its real server id, and select it.
|
|
133
|
+
function createItem(rawName) {
|
|
134
|
+
var name = String(rawName != null ? rawName : query).trim();
|
|
135
|
+
if (!name || !props.onCreate) return Promise.resolve();
|
|
136
|
+
setCreating(true);
|
|
137
|
+
setCreateError('');
|
|
138
|
+
return Promise.resolve()
|
|
139
|
+
.then(function() { return props.onCreate(name); })
|
|
140
|
+
.then(function(created) {
|
|
141
|
+
if (source === 'dynamic' && props.fetchData) {
|
|
142
|
+
return Promise.resolve(props.fetchData()).then(function(data) {
|
|
143
|
+
var fresh = sortByKey(Array.isArray(data) ? data : [], keys.name);
|
|
144
|
+
setDynamicItems(fresh);
|
|
145
|
+
return { created: created, fresh: fresh };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return { created: created, fresh: items };
|
|
149
|
+
})
|
|
150
|
+
.then(function(ctx) {
|
|
151
|
+
var created = ctx.created;
|
|
152
|
+
var fresh = ctx.fresh;
|
|
153
|
+
var newId = null;
|
|
154
|
+
if (created && typeof created === 'object') newId = created[keys.id];
|
|
155
|
+
else if (created !== undefined && created !== null) newId = created;
|
|
156
|
+
if (newId == null) {
|
|
157
|
+
var match = fresh.filter(function(it) { return it[keys.name] === name; })[0];
|
|
158
|
+
if (match) newId = match[keys.id];
|
|
159
|
+
}
|
|
160
|
+
if (newId == null) return; // created, but couldn't resolve an id — leave unselected
|
|
161
|
+
var next = mode === 'single'
|
|
162
|
+
? [newId]
|
|
163
|
+
: (selection.indexOf(newId) === -1 ? selection.concat(newId) : selection);
|
|
164
|
+
if (!isControlled) setSelection(next);
|
|
165
|
+
emit(next, fresh);
|
|
166
|
+
if (mode === 'single') close();
|
|
167
|
+
})
|
|
168
|
+
.catch(function(err) {
|
|
169
|
+
setCreateError(err && err.message ? err.message : 'Failed to add');
|
|
170
|
+
})
|
|
171
|
+
.finally(function() {
|
|
172
|
+
setCreating(false);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function selectItem(itemId) {
|
|
177
|
+
var next;
|
|
178
|
+
if (mode === 'single') {
|
|
179
|
+
next = [itemId];
|
|
180
|
+
if (!isControlled) setSelection(next);
|
|
181
|
+
emit(next);
|
|
182
|
+
close();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
var has = selection.indexOf(itemId) !== -1;
|
|
186
|
+
next = has ? selection.filter(function(id) { return id !== itemId; }) : selection.concat(itemId);
|
|
187
|
+
if (!isControlled) setSelection(next);
|
|
188
|
+
emit(next);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeSelection(itemId) {
|
|
192
|
+
var next = selection.filter(function(id) { return id !== itemId; });
|
|
193
|
+
if (!isControlled) setSelection(next);
|
|
194
|
+
emit(next);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function clearSelection() {
|
|
198
|
+
if (!isControlled) setSelection([]);
|
|
199
|
+
emit([]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleSearch(e) {
|
|
203
|
+
setQuery(e.target.value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Click-outside support — design wires this onto its container ref.
|
|
207
|
+
var rootRef = useRef(null);
|
|
208
|
+
useEffect(function() {
|
|
209
|
+
if (!isOpen) return;
|
|
210
|
+
function onDocClick(e) {
|
|
211
|
+
if (rootRef.current && !rootRef.current.contains(e.target)) {
|
|
212
|
+
close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
document.addEventListener('mousedown', onDocClick);
|
|
216
|
+
return function() { document.removeEventListener('mousedown', onDocClick); };
|
|
217
|
+
}, [isOpen]);
|
|
218
|
+
|
|
219
|
+
var isSelected = useCallback(function(itemId) {
|
|
220
|
+
return selection.indexOf(itemId) !== -1;
|
|
221
|
+
}, [selection]);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
mode,
|
|
225
|
+
source,
|
|
226
|
+
keys,
|
|
227
|
+
searchable,
|
|
228
|
+
canCreate,
|
|
229
|
+
createLabel: props.createLabel || 'Add',
|
|
230
|
+
creating,
|
|
231
|
+
createError,
|
|
232
|
+
createItem,
|
|
233
|
+
placeholder: props.placeholder || 'Select…',
|
|
234
|
+
disabled: !!props.disabled,
|
|
235
|
+
items,
|
|
236
|
+
filteredItems,
|
|
237
|
+
selectedItems,
|
|
238
|
+
selection,
|
|
239
|
+
loading,
|
|
240
|
+
error,
|
|
241
|
+
query,
|
|
242
|
+
isOpen,
|
|
243
|
+
rootRef,
|
|
244
|
+
isSelected,
|
|
245
|
+
open,
|
|
246
|
+
close,
|
|
247
|
+
toggle,
|
|
248
|
+
selectItem,
|
|
249
|
+
removeSelection,
|
|
250
|
+
clearSelection,
|
|
251
|
+
handleSearch,
|
|
252
|
+
refresh
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import './gridDisplayer.css';
|
|
2
|
+
|
|
3
|
+
export default function GridDisplayerSample({
|
|
4
|
+
query, setQuery,
|
|
5
|
+
sortFields, sortValue, setSortValue,
|
|
6
|
+
displayRows, tileFormatComponent, onSelect, onAddNew
|
|
7
|
+
}) {
|
|
8
|
+
var TileFormatComponent = tileFormatComponent;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="xeplr-grid-displayer">
|
|
12
|
+
<div className="xeplr-grid-toolbar">
|
|
13
|
+
<input
|
|
14
|
+
type="text"
|
|
15
|
+
className="xeplr-grid-search"
|
|
16
|
+
placeholder="Search…"
|
|
17
|
+
value={query}
|
|
18
|
+
onChange={function(e) { setQuery(e.target.value); }}
|
|
19
|
+
/>
|
|
20
|
+
{sortFields.length > 0 && (
|
|
21
|
+
<select
|
|
22
|
+
className="xeplr-grid-sort-select"
|
|
23
|
+
value={sortValue || ''}
|
|
24
|
+
onChange={function(e) { setSortValue(e.target.value || null); }}
|
|
25
|
+
>
|
|
26
|
+
{sortFields.map(function(f) {
|
|
27
|
+
return <option key={f.value} value={f.value}>{f.label}</option>;
|
|
28
|
+
})}
|
|
29
|
+
</select>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div className="xeplr-grid">
|
|
34
|
+
{onAddNew && (
|
|
35
|
+
<div className="xeplr-grid-tile xeplr-grid-tile-add" onClick={onAddNew}>
|
|
36
|
+
<TileFormatComponent isAddTile />
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
{displayRows.map(function(row) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
key={row.id}
|
|
44
|
+
className="xeplr-grid-tile"
|
|
45
|
+
onClick={onSelect ? function() { onSelect(row); } : undefined}
|
|
46
|
+
>
|
|
47
|
+
<TileFormatComponent row={row} />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
|
|
52
|
+
{displayRows.length === 0 && !onAddNew && (
|
|
53
|
+
<div className="xeplr-grid-empty">No matches</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* Uses @xeplr/ui-account's --xeplr-* theme tokens when available (apply
|
|
2
|
+
.xeplr-theme-light/-dark on an ancestor); falls back to the original dark
|
|
3
|
+
hex values when that stylesheet isn't loaded. */
|
|
4
|
+
|
|
5
|
+
.xeplr-grid-displayer {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: 14px;
|
|
9
|
+
color: var(--xeplr-text-primary, #e0e0e0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* ─── Toolbar ─── */
|
|
13
|
+
|
|
14
|
+
.xeplr-grid-toolbar {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
gap: 10px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.xeplr-grid-search {
|
|
22
|
+
flex: 1;
|
|
23
|
+
max-width: 320px;
|
|
24
|
+
padding: 8px 12px;
|
|
25
|
+
border: 1px solid var(--xeplr-input-border, #3a3a3a);
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
background: var(--xeplr-input-bg, #141414);
|
|
28
|
+
color: var(--xeplr-input-text, #e0e0e0);
|
|
29
|
+
font-size: 14px;
|
|
30
|
+
}
|
|
31
|
+
.xeplr-grid-search:focus { outline: none; border-color: var(--xeplr-accent, #646cff); }
|
|
32
|
+
|
|
33
|
+
/* One dropdown carries both field + direction (e.g. "Name (A-Z)") — no
|
|
34
|
+
separate asc/desc icon button to keep the toolbar clean. */
|
|
35
|
+
.xeplr-grid-sort-select {
|
|
36
|
+
padding: 7px 10px;
|
|
37
|
+
border: 1px solid var(--xeplr-input-border, #3a3a3a);
|
|
38
|
+
border-radius: 8px;
|
|
39
|
+
background: var(--xeplr-input-bg, #141414);
|
|
40
|
+
color: var(--xeplr-input-text, #e0e0e0);
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ─── Grid + tiles ───
|
|
45
|
+
Netflix-style: a hovered tile scales up and lifts above its neighbors —
|
|
46
|
+
z-index only bumps on hover so it doesn't otherwise disturb grid stacking. */
|
|
47
|
+
|
|
48
|
+
.xeplr-grid {
|
|
49
|
+
display: grid;
|
|
50
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
51
|
+
gap: 16px;
|
|
52
|
+
/* room for hovered tiles to scale up without clipping against the container edge */
|
|
53
|
+
padding: 6px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.xeplr-grid-tile {
|
|
57
|
+
position: relative;
|
|
58
|
+
border-radius: 14px;
|
|
59
|
+
background: var(--xeplr-bg-tertiary, #1a1a1a);
|
|
60
|
+
border: 1px solid var(--xeplr-border-secondary, #2a2a2a);
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
64
|
+
transform-origin: center;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.xeplr-grid-tile:hover {
|
|
68
|
+
transform: scale(1.08);
|
|
69
|
+
z-index: 5;
|
|
70
|
+
border-color: var(--xeplr-accent, #646cff);
|
|
71
|
+
box-shadow: var(--xeplr-shadow-lg, 0 12px 28px rgba(0, 0, 0, 0.45));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.xeplr-grid-tile-add {
|
|
75
|
+
border-style: dashed;
|
|
76
|
+
border-color: var(--xeplr-border-primary, #444);
|
|
77
|
+
display: grid;
|
|
78
|
+
place-items: center;
|
|
79
|
+
min-height: 120px;
|
|
80
|
+
color: var(--xeplr-text-muted, #888);
|
|
81
|
+
}
|
|
82
|
+
.xeplr-grid-tile-add:hover { border-color: var(--xeplr-accent, #646cff); color: var(--xeplr-text-primary, #e0e0e0); }
|
|
83
|
+
|
|
84
|
+
.xeplr-grid-empty {
|
|
85
|
+
grid-column: 1 / -1;
|
|
86
|
+
padding: 32px;
|
|
87
|
+
text-align: center;
|
|
88
|
+
color: var(--xeplr-text-muted, #777);
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as GridDisplayerSample } from './GridDisplayerSample.jsx';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GridDisplayer — pure model. No React.
|
|
3
|
+
* Client-side search + sort over a plain `rows` array, same philosophy as
|
|
4
|
+
* CrudDisplayer/Dropdown — no fetch function, no server round-trip.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Substring match (case-insensitive) across `searchFields`. A field whose
|
|
9
|
+
* value is an array (e.g. tags) matches if ANY entry contains the query.
|
|
10
|
+
*/
|
|
11
|
+
export function filterRows(rows, query, searchFields) {
|
|
12
|
+
if (!query) return rows || [];
|
|
13
|
+
var q = String(query).toLowerCase();
|
|
14
|
+
return (rows || []).filter(function(row) {
|
|
15
|
+
for (var i = 0; i < searchFields.length; i++) {
|
|
16
|
+
var value = row[searchFields[i]];
|
|
17
|
+
if (value == null) continue;
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
if (value.some(function(v) { return String(v).toLowerCase().indexOf(q) !== -1; })) return true;
|
|
20
|
+
} else if (String(value).toLowerCase().indexOf(q) !== -1) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compareValues(a, b) {
|
|
29
|
+
if (a == null && b == null) return 0;
|
|
30
|
+
if (a == null) return -1;
|
|
31
|
+
if (b == null) return 1;
|
|
32
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
33
|
+
return String(a).toLowerCase().localeCompare(String(b).toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sortRows(rows, field, direction) {
|
|
37
|
+
if (!field) return rows || [];
|
|
38
|
+
var copy = (rows || []).slice();
|
|
39
|
+
copy.sort(function(a, b) {
|
|
40
|
+
var result = compareValues(a[field], b[field]);
|
|
41
|
+
return direction === 'desc' ? -result : result;
|
|
42
|
+
});
|
|
43
|
+
return copy;
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useGridDisplayerController } from './useGridDisplayerController.js';
|
|
2
|
+
import GridDisplayerSample from './designs/GridDisplayerSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { GRID_DISPLAYER_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function GridDisplayerPage(props) {
|
|
7
|
+
var controller = useGridDisplayerController(props);
|
|
8
|
+
var ref = useDesignValidator('GridDisplayerPage', GRID_DISPLAYER_RULES);
|
|
9
|
+
return <div ref={ref}><GridDisplayerSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
import { filterRows, sortRows } from './gridDisplayer.js';
|
|
3
|
+
|
|
4
|
+
// Module-level (not recreated per render) — used as fallbacks below so a
|
|
5
|
+
// caller that omits rows/searchFields/sortFields doesn't hand displayRows'
|
|
6
|
+
// useMemo a fresh array identity every render, which would defeat the memo.
|
|
7
|
+
var DEFAULT_ROWS = [];
|
|
8
|
+
var DEFAULT_SEARCH_FIELDS = ['name'];
|
|
9
|
+
// Each sortFields entry is a fully-resolved sort choice — field + direction
|
|
10
|
+
// + label combined into one dropdown option (e.g. "Name (A-Z)") rather than
|
|
11
|
+
// a bare field name paired with a separate asc/desc toggle button. One
|
|
12
|
+
// dropdown, no extra icon button to keep clean.
|
|
13
|
+
var DEFAULT_SORT_FIELDS = [{ value: 'name_asc', label: 'Name (A-Z)', field: 'name', direction: 'asc' }];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} props
|
|
17
|
+
* @param {Array<object>} props.rows
|
|
18
|
+
* @param {*} props.tileFormatComponent - component rendering a tile's CONTENT only
|
|
19
|
+
* (the design owns the clickable/hover-zoom wrapper around it) — receives
|
|
20
|
+
* { row } for a normal tile, or { isAddTile: true } for the leading "add new"
|
|
21
|
+
* tile (only rendered when props.onAddNew is supplied).
|
|
22
|
+
* @param {Array<string>} [props.searchFields=['name']] - row fields the search box matches
|
|
23
|
+
* @param {Array<{value:string, label:string, field:string, direction:'asc'|'desc'}>} [props.sortFields]
|
|
24
|
+
* - default: [{value:'name_asc', label:'Name (A-Z)', field:'name', direction:'asc'}]
|
|
25
|
+
* @param {(row:object)=>void} [props.onSelect] - tile click (non-add tile)
|
|
26
|
+
* @param {()=>void} [props.onAddNew] - if provided, a leading "add new" tile renders
|
|
27
|
+
*/
|
|
28
|
+
export function useGridDisplayerController(props) {
|
|
29
|
+
props = props || {};
|
|
30
|
+
var rows = props.rows || DEFAULT_ROWS;
|
|
31
|
+
var searchFields = props.searchFields || DEFAULT_SEARCH_FIELDS;
|
|
32
|
+
var sortFields = props.sortFields || DEFAULT_SORT_FIELDS;
|
|
33
|
+
|
|
34
|
+
var [query, setQuery] = useState('');
|
|
35
|
+
var [sortValue, setSortValue] = useState(sortFields[0] ? sortFields[0].value : null);
|
|
36
|
+
|
|
37
|
+
var activeSort = null;
|
|
38
|
+
for (var i = 0; i < sortFields.length; i++) {
|
|
39
|
+
if (sortFields[i].value === sortValue) { activeSort = sortFields[i]; break; }
|
|
40
|
+
}
|
|
41
|
+
if (!activeSort) activeSort = sortFields[0] || null;
|
|
42
|
+
|
|
43
|
+
var displayRows = useMemo(function() {
|
|
44
|
+
var filtered = filterRows(rows, query, searchFields);
|
|
45
|
+
return sortRows(filtered, activeSort ? activeSort.field : null, activeSort ? activeSort.direction : 'asc');
|
|
46
|
+
}, [rows, query, searchFields, activeSort]);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
query: query,
|
|
50
|
+
setQuery: setQuery,
|
|
51
|
+
sortFields: sortFields,
|
|
52
|
+
sortValue: activeSort ? activeSort.value : null,
|
|
53
|
+
setSortValue: setSortValue,
|
|
54
|
+
displayRows: displayRows,
|
|
55
|
+
tileFormatComponent: props.tileFormatComponent,
|
|
56
|
+
onSelect: props.onSelect,
|
|
57
|
+
onAddNew: props.onAddNew
|
|
58
|
+
};
|
|
59
|
+
}
|