@xeplr/ui-utils 1.0.0 → 1.0.2

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 (68) hide show
  1. package/package.json +25 -6
  2. package/src/confirm/confirm.js +175 -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 +68 -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/dateRange/dateRange.js +273 -0
  20. package/src/dateRange/designs/DateRangeSample.jsx +138 -0
  21. package/src/dateRange/designs/dateRange.css +69 -0
  22. package/src/dateRange/designs/index.js +1 -0
  23. package/src/dateRange/pages.jsx +10 -0
  24. package/src/dateRange/useDateRangeController.js +118 -0
  25. package/src/dateRange/validateDesign.js +20 -0
  26. package/src/dropdown/designs/DropdownSample.jsx +151 -0
  27. package/src/dropdown/designs/dropdown.css +226 -0
  28. package/src/dropdown/designs/index.js +1 -0
  29. package/src/dropdown/dropdown.js +75 -0
  30. package/src/dropdown/pages.jsx +10 -0
  31. package/src/dropdown/useDropdownController.js +254 -0
  32. package/src/dropdown/validateDesign.js +4 -0
  33. package/src/fileUpload/validateDesign.js +27 -8
  34. package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
  35. package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
  36. package/src/gridDisplayer/designs/index.js +1 -0
  37. package/src/gridDisplayer/gridDisplayer.js +44 -0
  38. package/src/gridDisplayer/pages.jsx +10 -0
  39. package/src/gridDisplayer/useGridDisplayerController.js +59 -0
  40. package/src/gridDisplayer/validateDesign.js +4 -0
  41. package/src/index.js +177 -1
  42. package/src/numberField/designs/NumberFieldSample.jsx +52 -0
  43. package/src/numberField/designs/index.js +1 -0
  44. package/src/numberField/designs/numberField.css +66 -0
  45. package/src/numberField/numberField.js +105 -0
  46. package/src/numberField/pages.jsx +10 -0
  47. package/src/numberField/useNumberFieldController.js +116 -0
  48. package/src/numberField/validateDesign.js +3 -0
  49. package/src/progress/ProgressNotifier.jsx +118 -0
  50. package/src/progress/designs/progressNotifier.css +73 -0
  51. package/src/progress/progressSource.js +168 -0
  52. package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
  53. package/src/rangeField/designs/index.js +1 -0
  54. package/src/rangeField/designs/rangeField.css +26 -0
  55. package/src/rangeField/pages.jsx +10 -0
  56. package/src/rangeField/rangeField.js +29 -0
  57. package/src/rangeField/useRangeFieldController.js +52 -0
  58. package/src/rangeField/validateDesign.js +3 -0
  59. package/src/snackbar/snackbar.js +6 -3
  60. package/src/textField/designs/TextFieldSample.jsx +75 -0
  61. package/src/textField/designs/index.js +1 -0
  62. package/src/textField/designs/textField.css +92 -0
  63. package/src/textField/pages.jsx +10 -0
  64. package/src/textField/textField.js +65 -0
  65. package/src/textField/useTextFieldController.js +101 -0
  66. package/src/textField/validateDesign.js +3 -0
  67. package/src/theme/default.theme.json +934 -0
  68. package/src/theme/index.js +155 -0
@@ -0,0 +1,226 @@
1
+ /* Uses @xeplr/ui-account's --xeplr-* theme tokens when available (see
2
+ xeplr-ui-account/src/designs/theme.css — apply .xeplr-theme-light/-dark on
3
+ an ancestor to pick a theme); falls back to the original dark hex values
4
+ when that stylesheet isn't loaded, so this still renders sensibly standalone. */
5
+
6
+ .xeplr-dropdown {
7
+ position: relative;
8
+ font-family: system-ui, -apple-system, sans-serif;
9
+ color: var(--xeplr-text-primary, #e0e0e0);
10
+ }
11
+
12
+ .xeplr-dropdown-trigger {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 8px;
16
+ min-height: 40px;
17
+ background: var(--xeplr-input-bg, #1a1a1a);
18
+ border: 1px solid var(--xeplr-input-border, #444);
19
+ border-radius: 6px;
20
+ padding: 6px 10px 6px 12px;
21
+ cursor: pointer;
22
+ font-size: 14px;
23
+ color: var(--xeplr-input-text, #e0e0e0);
24
+ }
25
+
26
+ .xeplr-dropdown-open .xeplr-dropdown-trigger {
27
+ border-color: var(--xeplr-accent, #646cff);
28
+ box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.18);
29
+ }
30
+
31
+ .xeplr-dropdown-disabled .xeplr-dropdown-trigger {
32
+ opacity: 0.55;
33
+ cursor: not-allowed;
34
+ }
35
+
36
+ .xeplr-dropdown-placeholder { color: var(--xeplr-input-placeholder, #888); flex: 1; }
37
+ .xeplr-dropdown-selected-single { flex: 1; }
38
+ .xeplr-dropdown-selected-multi { flex: 1; }
39
+ .xeplr-dropdown-selected-more { color: var(--xeplr-text-secondary, #888bbf); margin-left: 6px; font-size: 12px; }
40
+
41
+ .xeplr-dropdown-caret {
42
+ margin-left: auto;
43
+ color: var(--xeplr-text-muted, #888);
44
+ font-size: 14px;
45
+ }
46
+
47
+ .xeplr-dropdown-chips {
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ gap: 4px;
51
+ flex: 1;
52
+ }
53
+
54
+ .xeplr-dropdown-chip {
55
+ display: inline-flex;
56
+ align-items: center;
57
+ gap: 6px;
58
+ background: var(--xeplr-tag-bg, #232540);
59
+ border-radius: 999px;
60
+ padding: 3px 4px 3px 10px;
61
+ font-size: 12px;
62
+ color: var(--xeplr-tag-text, #c8cbe8);
63
+ }
64
+
65
+ .xeplr-dropdown-chip-x {
66
+ width: 18px;
67
+ height: 18px;
68
+ border-radius: 50%;
69
+ background: var(--xeplr-bg-primary, #1a1a1a);
70
+ display: grid;
71
+ place-items: center;
72
+ cursor: pointer;
73
+ font-size: 12px;
74
+ line-height: 1;
75
+ }
76
+
77
+ .xeplr-dropdown-chip-x:hover { background: var(--xeplr-danger, #e25555); color: var(--xeplr-accent-text, #fff); }
78
+
79
+ .xeplr-dropdown-panel {
80
+ display: none;
81
+ position: absolute;
82
+ top: calc(100% + 6px);
83
+ left: 0;
84
+ right: 0;
85
+ background: var(--xeplr-bg-tertiary, #141625);
86
+ border: 1px solid var(--xeplr-border-primary, #444);
87
+ border-radius: 6px;
88
+ box-shadow: var(--xeplr-shadow-lg, 0 10px 30px rgba(0, 0, 0, 0.55));
89
+ z-index: 50;
90
+ overflow: hidden;
91
+ max-height: 360px;
92
+ overflow-y: auto;
93
+ }
94
+
95
+ .xeplr-dropdown-panel-open { display: block; }
96
+
97
+ .xeplr-dropdown-search {
98
+ padding: 10px 12px;
99
+ border-bottom: 1px solid var(--xeplr-border-secondary, #2a2d44);
100
+ background: var(--xeplr-bg-secondary, #1a1c30);
101
+ position: sticky;
102
+ top: 0;
103
+ z-index: 1;
104
+ }
105
+
106
+ .xeplr-dropdown-search-input {
107
+ width: 100%;
108
+ background: var(--xeplr-input-bg, #0e0f1c);
109
+ border: 1px solid var(--xeplr-input-border, #2a2d44);
110
+ border-radius: 4px;
111
+ color: var(--xeplr-input-text, #e0e0e0);
112
+ padding: 7px 10px;
113
+ font-size: 13px;
114
+ outline: none;
115
+ }
116
+
117
+ .xeplr-dropdown-search-input:focus {
118
+ border-color: var(--xeplr-accent, #646cff);
119
+ }
120
+
121
+ .xeplr-dropdown-state {
122
+ padding: 14px 16px;
123
+ color: var(--xeplr-text-secondary, #888bbf);
124
+ font-size: 13px;
125
+ text-align: center;
126
+ }
127
+
128
+ .xeplr-dropdown-state-error { color: var(--xeplr-danger, #e25555); }
129
+
130
+ .xeplr-dropdown-row {
131
+ display: grid;
132
+ grid-template-columns: auto 32px 1fr;
133
+ gap: 10px;
134
+ align-items: center;
135
+ padding: 9px 12px;
136
+ cursor: pointer;
137
+ border-bottom: 1px solid var(--xeplr-border-secondary, #1f2236);
138
+ }
139
+
140
+ .xeplr-dropdown-single .xeplr-dropdown-row {
141
+ grid-template-columns: 32px 1fr;
142
+ }
143
+
144
+ .xeplr-dropdown-row:hover { background: var(--xeplr-bg-hover, #1c1f33); }
145
+ .xeplr-dropdown-row-selected { background: var(--xeplr-bg-active, rgba(100, 108, 255, 0.13)); }
146
+
147
+ .xeplr-dropdown-check {
148
+ width: 16px;
149
+ height: 16px;
150
+ border-radius: 4px;
151
+ border: 1.5px solid var(--xeplr-input-border, #444);
152
+ background: var(--xeplr-input-bg, #0e0f1c);
153
+ display: grid;
154
+ place-items: center;
155
+ font-size: 11px;
156
+ color: transparent;
157
+ }
158
+
159
+ .xeplr-dropdown-check-on {
160
+ background: var(--xeplr-accent, #646cff);
161
+ border-color: var(--xeplr-accent, #646cff);
162
+ color: var(--xeplr-accent-text, #fff);
163
+ }
164
+
165
+ .xeplr-dropdown-avatar {
166
+ width: 30px;
167
+ height: 30px;
168
+ border-radius: 50%;
169
+ background: var(--xeplr-tag-bg, #232540);
170
+ color: var(--xeplr-tag-text, #b8bbe0);
171
+ display: grid;
172
+ place-items: center;
173
+ font-size: 11px;
174
+ font-weight: 700;
175
+ }
176
+
177
+ .xeplr-dropdown-info {
178
+ display: flex;
179
+ flex-direction: column;
180
+ min-width: 0;
181
+ }
182
+
183
+ .xeplr-dropdown-name {
184
+ font-size: 13px;
185
+ color: var(--xeplr-text-primary, #e6e8f5);
186
+ font-weight: 600;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
+ white-space: nowrap;
190
+ }
191
+
192
+ .xeplr-dropdown-meta {
193
+ font-size: 11px;
194
+ color: var(--xeplr-text-secondary, #888bbf);
195
+ margin-top: 2px;
196
+ overflow: hidden;
197
+ text-overflow: ellipsis;
198
+ white-space: nowrap;
199
+ }
200
+
201
+ .xeplr-dropdown-footer {
202
+ padding: 8px 12px;
203
+ border-top: 1px solid var(--xeplr-border-secondary, #2a2d44);
204
+ background: var(--xeplr-bg-secondary, #1a1c30);
205
+ text-align: right;
206
+ }
207
+
208
+ .xeplr-dropdown-clear {
209
+ font-size: 12px;
210
+ color: var(--xeplr-text-secondary, #888bbf);
211
+ cursor: pointer;
212
+ }
213
+
214
+ .xeplr-dropdown-clear:hover { color: var(--xeplr-danger, #e25555); }
215
+
216
+ .xeplr-dropdown-create {
217
+ border-top: 1px solid var(--xeplr-border-secondary, #2a2d44);
218
+ color: var(--xeplr-accent, #9fb2ff);
219
+ }
220
+ .xeplr-dropdown-create:hover { background: var(--xeplr-bg-hover, #232744); }
221
+ .xeplr-dropdown-create .xeplr-dropdown-name { color: var(--xeplr-accent, #9fb2ff); font-weight: 600; }
222
+ .xeplr-dropdown-create-plus {
223
+ background: var(--xeplr-tag-bg, #2a2f52);
224
+ color: var(--xeplr-accent, #9fb2ff);
225
+ font-weight: 700;
226
+ }
@@ -0,0 +1 @@
1
+ export { default as DropdownSample } from './DropdownSample.jsx';
@@ -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
+ ];
@@ -1,11 +1,17 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
 
3
3
  /**
4
- * Validates that required elements exist in the rendered design.
5
- * Throws a visible error if any required element is missing.
4
+ * Validates that required elements exist in the rendered design, and reports
5
+ * loudly when they don't console.error plus a data-xeplr-design-invalid
6
+ * marker on the container.
7
+ *
8
+ * A rule matches by id, role or selector. `anyOf` takes a list of selectors
9
+ * and passes when ANY one is present — which is what a design with tabs or
10
+ * steps needs, since only the current one is mounted and demanding all of
11
+ * them at once can never pass.
6
12
  *
7
13
  * @param {string} componentName - Name of the page (for error messages)
8
- * @param {Array<{id?: string, role?: string, selector?: string, label: string}>} requiredElements
14
+ * @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
9
15
  */
10
16
  export function useDesignValidator(componentName, requiredElements) {
11
17
  var containerRef = useRef(null);
@@ -22,20 +28,33 @@ export function useDesignValidator(componentName, requiredElements) {
22
28
  found = !!containerRef.current.querySelector('#' + rule.id);
23
29
  } else if (rule.role) {
24
30
  found = !!containerRef.current.querySelector('[role="' + rule.role + '"]');
31
+ } else if (rule.anyOf) {
32
+ for (var j = 0; j < rule.anyOf.length && !found; j++) {
33
+ found = !!containerRef.current.querySelector(rule.anyOf[j]);
34
+ }
25
35
  } else if (rule.selector) {
26
36
  found = !!containerRef.current.querySelector(rule.selector);
27
37
  }
28
38
 
29
39
  if (!found) {
30
- missing.push(rule.label + (rule.id ? ' (id="' + rule.id + '")' : '') + (rule.selector ? ' (' + rule.selector + ')' : ''));
40
+ var where = rule.id ? ' (id="' + rule.id + '")'
41
+ : rule.anyOf ? ' (one of: ' + rule.anyOf.join(', ') + ')'
42
+ : rule.selector ? ' (' + rule.selector + ')' : '';
43
+ missing.push(rule.label + where);
31
44
  }
32
45
  }
33
46
 
34
47
  if (missing.length > 0) {
35
- throw new Error(
36
- '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
37
- missing.map(function(m) { return ' - ' + m; }).join('\n')
38
- );
48
+ // Reported, not thrown. Throwing from an effect gives React nothing to
49
+ // catch, so a design rule a development-time check took the whole
50
+ // application down with it. The container is marked so the failure is
51
+ // visible on the page as well as in the console, which is what "fail
52
+ // loudly" was ever meant to buy.
53
+ var message = '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
54
+ missing.map(function(m) { return ' - ' + m; }).join('\n');
55
+ console.error(message);
56
+ containerRef.current.setAttribute('data-xeplr-design-invalid', componentName);
57
+ containerRef.current.setAttribute('title', message);
39
58
  }
40
59
  }, []);
41
60
 
@@ -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
+ }