@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.
Files changed (43) hide show
  1. package/package.json +22 -6
  2. package/src/confirm/confirm.js +106 -0
  3. package/src/dataImport/dataImport.js +32 -0
  4. package/src/dataImport/designs/DataImportSample.jsx +71 -0
  5. package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
  6. package/src/dataImport/designs/dataImport.css +98 -0
  7. package/src/dataImport/designs/index.js +2 -0
  8. package/src/dataImport/pages.jsx +17 -0
  9. package/src/dataImport/useDataImportController.js +94 -0
  10. package/src/dataImport/useImportHistoryController.js +67 -0
  11. package/src/dataImport/validateDesign.js +49 -0
  12. package/src/dateField/dateField.js +71 -0
  13. package/src/dateField/designs/DateFieldSample.jsx +30 -0
  14. package/src/dateField/designs/dateField.css +60 -0
  15. package/src/dateField/designs/index.js +1 -0
  16. package/src/dateField/pages.jsx +10 -0
  17. package/src/dateField/useDateFieldController.js +77 -0
  18. package/src/dateField/validateDesign.js +3 -0
  19. package/src/dropdown/designs/DropdownSample.jsx +151 -0
  20. package/src/dropdown/designs/dropdown.css +226 -0
  21. package/src/dropdown/designs/index.js +1 -0
  22. package/src/dropdown/dropdown.js +75 -0
  23. package/src/dropdown/pages.jsx +10 -0
  24. package/src/dropdown/useDropdownController.js +254 -0
  25. package/src/dropdown/validateDesign.js +4 -0
  26. package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
  27. package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
  28. package/src/gridDisplayer/designs/index.js +1 -0
  29. package/src/gridDisplayer/gridDisplayer.js +44 -0
  30. package/src/gridDisplayer/pages.jsx +10 -0
  31. package/src/gridDisplayer/useGridDisplayerController.js +59 -0
  32. package/src/gridDisplayer/validateDesign.js +4 -0
  33. package/src/index.js +115 -1
  34. package/src/numberField/designs/NumberFieldSample.jsx +52 -0
  35. package/src/numberField/designs/index.js +1 -0
  36. package/src/numberField/designs/numberField.css +66 -0
  37. package/src/numberField/numberField.js +105 -0
  38. package/src/numberField/pages.jsx +10 -0
  39. package/src/numberField/useNumberFieldController.js +116 -0
  40. package/src/numberField/validateDesign.js +3 -0
  41. package/src/snackbar/snackbar.js +6 -3
  42. package/src/theme/default.theme.json +178 -0
  43. 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,4 @@
1
+ export var DROPDOWN_RULES = [
2
+ { selector: '.xeplr-dropdown-trigger, [role="combobox"]', label: 'Trigger element (combobox)' },
3
+ { selector: '.xeplr-dropdown-panel, [role="listbox"]', label: 'Options panel (listbox)' }
4
+ ];
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export var GRID_DISPLAYER_RULES = [
2
+ { selector: '.xeplr-grid-search', label: 'Search input' },
3
+ { selector: '.xeplr-grid', label: 'Tile grid' }
4
+ ];