@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.
- package/package.json +25 -6
- package/src/confirm/confirm.js +175 -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 +68 -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/dateRange/dateRange.js +273 -0
- package/src/dateRange/designs/DateRangeSample.jsx +138 -0
- package/src/dateRange/designs/dateRange.css +69 -0
- package/src/dateRange/designs/index.js +1 -0
- package/src/dateRange/pages.jsx +10 -0
- package/src/dateRange/useDateRangeController.js +118 -0
- package/src/dateRange/validateDesign.js +20 -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/fileUpload/validateDesign.js +27 -8
- 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 +177 -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/progress/ProgressNotifier.jsx +118 -0
- package/src/progress/designs/progressNotifier.css +73 -0
- package/src/progress/progressSource.js +168 -0
- package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
- package/src/rangeField/designs/index.js +1 -0
- package/src/rangeField/designs/rangeField.css +26 -0
- package/src/rangeField/pages.jsx +10 -0
- package/src/rangeField/rangeField.js +29 -0
- package/src/rangeField/useRangeFieldController.js +52 -0
- package/src/rangeField/validateDesign.js +3 -0
- package/src/snackbar/snackbar.js +6 -3
- package/src/textField/designs/TextFieldSample.jsx +75 -0
- package/src/textField/designs/index.js +1 -0
- package/src/textField/designs/textField.css +92 -0
- package/src/textField/pages.jsx +10 -0
- package/src/textField/textField.js +65 -0
- package/src/textField/useTextFieldController.js +101 -0
- package/src/textField/validateDesign.js +3 -0
- package/src/theme/default.theme.json +934 -0
- 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
|
+
}
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|