@xeplr/ui-utils 1.0.1 → 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 +4 -1
- package/src/confirm/confirm.js +116 -47
- package/src/dataImport/validateDesign.js +27 -8
- 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/fileUpload/validateDesign.js +27 -8
- package/src/index.js +64 -2
- 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/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 +839 -83
- package/src/theme/index.js +80 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import './dateRange.css';
|
|
2
|
+
|
|
3
|
+
var TABS = [
|
|
4
|
+
['quick', 'Quick date'],
|
|
5
|
+
['range', 'Range'],
|
|
6
|
+
['single', 'Single']
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DateRange — design. Presentation only: everything arrives as props from
|
|
11
|
+
* useDateRangeController, so an app can swap this out without touching the
|
|
12
|
+
* behaviour.
|
|
13
|
+
*
|
|
14
|
+
* Three ways in, because they are genuinely different questions:
|
|
15
|
+
* Quick — "the current month", which stays current as time passes
|
|
16
|
+
* Range — two explicit dates
|
|
17
|
+
* Single — one date, month or year, typed
|
|
18
|
+
*/
|
|
19
|
+
export default function DateRangeSample(props) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="xeplr-daterange">
|
|
22
|
+
<div className="xeplr-daterange-tabs">
|
|
23
|
+
{TABS.map(function(t) {
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
key={t[0]}
|
|
27
|
+
type="button"
|
|
28
|
+
data-xeplr-daterange-tab={t[0]}
|
|
29
|
+
className={props.tab === t[0] ? 'active' : ''}
|
|
30
|
+
onClick={function() { props.setTab(t[0]); }}
|
|
31
|
+
>
|
|
32
|
+
{t[1]}
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
})}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{props.tab === 'quick' && (
|
|
39
|
+
<div className="xeplr-daterange-list">
|
|
40
|
+
{props.presets.map(function(p) {
|
|
41
|
+
var on = props.value && props.value.kind === 'quick' && props.value.preset === p.key;
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={p.key}
|
|
45
|
+
type="button"
|
|
46
|
+
data-xeplr-daterange-preset={p.key}
|
|
47
|
+
className={'xeplr-daterange-item' + (on ? ' on' : '')}
|
|
48
|
+
onClick={function() { props.selectPreset(p.key); }}
|
|
49
|
+
>
|
|
50
|
+
{p.label}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{props.tab === 'range' && (
|
|
58
|
+
<div className="xeplr-daterange-body">
|
|
59
|
+
<label className="xeplr-daterange-field">
|
|
60
|
+
<span>From</span>
|
|
61
|
+
<input type="date" value={props.range.from || ''} onChange={function(e) { props.setRangeFrom(e.target.value); }} />
|
|
62
|
+
</label>
|
|
63
|
+
<label className="xeplr-daterange-field">
|
|
64
|
+
<span>To</span>
|
|
65
|
+
<input type="date" value={props.range.to || ''} onChange={function(e) { props.setRangeTo(e.target.value); }} />
|
|
66
|
+
</label>
|
|
67
|
+
{/* One-sided is legitimate — "everything since April" is a real
|
|
68
|
+
answer — so it is stated rather than blocked. */}
|
|
69
|
+
<div className="xeplr-daterange-note">Leave either side empty for an open-ended range.</div>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{props.tab === 'single' && (
|
|
74
|
+
<div className="xeplr-daterange-body">
|
|
75
|
+
<input
|
|
76
|
+
className="xeplr-daterange-input"
|
|
77
|
+
value={props.single}
|
|
78
|
+
placeholder="1/1/2022, May 2022, or 2022"
|
|
79
|
+
onChange={function(e) { props.setSingle(e.target.value); }}
|
|
80
|
+
onBlur={function() { props.commitSingle(false); }}
|
|
81
|
+
onKeyDown={function(e) {
|
|
82
|
+
if (e.key !== 'Enter') return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
props.commitSingle(true);
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
{/* WHAT YOU PROBABLY MEAN, one click away. "j" is three months and
|
|
88
|
+
every month is several years — the list is shorter than typing
|
|
89
|
+
the rest, and it is ordered so the year already on screen comes
|
|
90
|
+
first. Months and years only; a day is quicker to finish typing
|
|
91
|
+
than to find among thirty. */}
|
|
92
|
+
{props.suggestions && props.suggestions.length > 0 && (
|
|
93
|
+
<ul className="xeplr-daterange-suggest">
|
|
94
|
+
{props.suggestions.map(function(s) {
|
|
95
|
+
return (
|
|
96
|
+
<li key={s.value}>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
// MOUSEDOWN, not click: the input's onBlur commits what
|
|
100
|
+
// is typed and would close this list before a click ever
|
|
101
|
+
// landed on it.
|
|
102
|
+
onMouseDown={function(e) { e.preventDefault(); props.applySuggestion(s.value); }}
|
|
103
|
+
>
|
|
104
|
+
<span>{s.label}</span>
|
|
105
|
+
{s.note && <span className="xeplr-daterange-suggest-note">{s.note}</span>}
|
|
106
|
+
</button>
|
|
107
|
+
</li>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</ul>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{/* A month or a year IS a range, and showing the resolved span is the
|
|
114
|
+
quickest way to confirm "May 2022" was read as intended. */}
|
|
115
|
+
<div className="xeplr-daterange-note">
|
|
116
|
+
{props.single
|
|
117
|
+
? (props.singleParsed ? props.singleParsed.from + ' → ' + props.singleParsed.to : 'Not a date yet')
|
|
118
|
+
: 'A day, a whole month, or a whole year.'}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<div className="xeplr-daterange-foot">
|
|
124
|
+
{/* Absent, not disabled, where the host cannot accept an empty
|
|
125
|
+
selection — a control you can press that does nothing is worse
|
|
126
|
+
than one that was never offered. */}
|
|
127
|
+
{props.allowClear && (
|
|
128
|
+
<button type="button" className="xeplr-daterange-clear" onClick={props.clear}>Clear</button>
|
|
129
|
+
)}
|
|
130
|
+
{props.label && <span className="xeplr-daterange-current">{props.label}</span>}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{props.inheritedLabel && !props.value && (
|
|
134
|
+
<div className="xeplr-daterange-inherited">Using {props.inheritedLabel}</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Namespaced xeplr-daterange-* so a host's own styles can't collide with it. */
|
|
2
|
+
.xeplr-daterange {
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
width: 260px;
|
|
6
|
+
background: var(--xeplr-surface, #fff);
|
|
7
|
+
border: 1px solid var(--xeplr-border-strong, #c9cdd6);
|
|
8
|
+
border-radius: 9px;
|
|
9
|
+
overflow: hidden;
|
|
10
|
+
color: inherit;
|
|
11
|
+
}
|
|
12
|
+
.xeplr-daterange-tabs { display: flex; border-bottom: 1px solid var(--xeplr-border, #e2e5eb); }
|
|
13
|
+
.xeplr-daterange-tabs button {
|
|
14
|
+
flex: 1; padding: 7px 4px; border: none; background: none; cursor: pointer;
|
|
15
|
+
font-size: 11.5px; font-weight: 600; color: var(--xeplr-muted, #6b7280);
|
|
16
|
+
border-bottom: 2px solid transparent;
|
|
17
|
+
}
|
|
18
|
+
.xeplr-daterange-tabs button.active {
|
|
19
|
+
color: var(--xeplr-accent, #4f5fd6);
|
|
20
|
+
border-bottom-color: var(--xeplr-accent, #4f5fd6);
|
|
21
|
+
}
|
|
22
|
+
.xeplr-daterange-list { max-height: 240px; overflow-y: auto; padding: 4px; }
|
|
23
|
+
.xeplr-daterange-item {
|
|
24
|
+
display: block; width: 100%; text-align: left;
|
|
25
|
+
padding: 5px 7px; border: none; border-radius: 5px; background: none;
|
|
26
|
+
font-size: 12.5px; color: inherit; cursor: pointer;
|
|
27
|
+
}
|
|
28
|
+
.xeplr-daterange-item:hover { background: var(--xeplr-hover, #f4f5f8); }
|
|
29
|
+
.xeplr-daterange-item.on { color: var(--xeplr-accent, #4f5fd6); font-weight: 600; }
|
|
30
|
+
.xeplr-daterange-body { padding: 10px; }
|
|
31
|
+
.xeplr-daterange-field { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
32
|
+
.xeplr-daterange-field span { width: 42px; flex-shrink: 0; font-size: 11.5px; color: var(--xeplr-muted, #6b7280); }
|
|
33
|
+
.xeplr-daterange-field input,
|
|
34
|
+
.xeplr-daterange-input {
|
|
35
|
+
flex: 1; min-width: 0; width: 100%; padding: 5px 8px;
|
|
36
|
+
border: 1px solid var(--xeplr-border, #e2e5eb); border-radius: 6px;
|
|
37
|
+
background: var(--xeplr-surface-2, #fafbfc); color: inherit; font-size: 12.5px;
|
|
38
|
+
}
|
|
39
|
+
.xeplr-daterange-note { font-size: 11px; color: var(--xeplr-muted-2, #9aa1ad); margin-top: 6px; line-height: 1.4; }
|
|
40
|
+
.xeplr-daterange-foot {
|
|
41
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
42
|
+
padding: 6px 10px; border-top: 1px solid var(--xeplr-border, #e2e5eb);
|
|
43
|
+
}
|
|
44
|
+
.xeplr-daterange-clear {
|
|
45
|
+
border: none; background: none; cursor: pointer;
|
|
46
|
+
font-size: 11.5px; font-weight: 600; color: var(--xeplr-accent, #4f5fd6);
|
|
47
|
+
}
|
|
48
|
+
.xeplr-daterange-current { font-size: 11px; color: var(--xeplr-muted-2, #9aa1ad); }
|
|
49
|
+
.xeplr-daterange-inherited {
|
|
50
|
+
padding: 6px 10px; font-size: 10.5px; line-height: 1.4;
|
|
51
|
+
color: var(--xeplr-muted-2, #9aa1ad);
|
|
52
|
+
border-top: 1px solid var(--xeplr-border, #e2e5eb);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ── Suggestions, under the Single box ──────────────────────────────────── */
|
|
56
|
+
.xeplr-daterange-suggest {
|
|
57
|
+
list-style: none; margin: 6px 0 0; padding: 0;
|
|
58
|
+
max-height: 190px; overflow: auto;
|
|
59
|
+
border: 1px solid var(--xeplr-border, #e2e5eb); border-radius: 8px;
|
|
60
|
+
}
|
|
61
|
+
.xeplr-daterange-suggest li + li { border-top: 1px solid var(--xeplr-border-secondary, #eef0f4); }
|
|
62
|
+
.xeplr-daterange-suggest button {
|
|
63
|
+
display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
|
|
64
|
+
width: 100%; padding: 7px 10px; border: 0; background: none;
|
|
65
|
+
font: inherit; font-size: 13px; color: var(--xeplr-text, #1c2029);
|
|
66
|
+
text-align: left; cursor: pointer;
|
|
67
|
+
}
|
|
68
|
+
.xeplr-daterange-suggest button:hover { background: var(--xeplr-hover, #f4f5f8); }
|
|
69
|
+
.xeplr-daterange-suggest-note { font-size: 11px; color: var(--xeplr-muted, #6b7280); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DateRangeSample } from './DateRangeSample.jsx';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useDateRangeController } from './useDateRangeController.js';
|
|
2
|
+
import DateRangeSample from './designs/DateRangeSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { DATE_RANGE_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function DateRangePage(props) {
|
|
7
|
+
var controller = useDateRangeController(props);
|
|
8
|
+
var ref = useDesignValidator('DateRangePage', DATE_RANGE_RULES);
|
|
9
|
+
return <div ref={ref}><DateRangeSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { QUICK_PRESETS, parseSingleDate, dateValueLabel, singleDateSuggestions } from './dateRange.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DateRange — controller. Owns which tab is open and the half-typed text; the
|
|
6
|
+
* design draws it. No JSX here.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} props
|
|
9
|
+
* @param {object|null} props.value - the current selection, or null
|
|
10
|
+
* @param {Function} props.onChange - (selection|null) => void
|
|
11
|
+
* @param {Function} [props.onClose] - called after a choice that ends the interaction
|
|
12
|
+
* @param {Array} [props.presets] - override the quick list
|
|
13
|
+
* @param {boolean} [props.allowClear] - default true. Set false where an
|
|
14
|
+
* empty selection is not a state the host can accept — a report bounded by
|
|
15
|
+
* a date, say, for which "no date" means scanning all of history.
|
|
16
|
+
*/
|
|
17
|
+
export function useDateRangeController(props) {
|
|
18
|
+
var value = props.value || null;
|
|
19
|
+
var onChange = props.onChange || function() {};
|
|
20
|
+
var onClose = props.onClose;
|
|
21
|
+
var presets = props.presets || QUICK_PRESETS;
|
|
22
|
+
// Opt-OUT, so every existing caller keeps the button it already had.
|
|
23
|
+
var allowClear = props.allowClear !== false;
|
|
24
|
+
|
|
25
|
+
var initial = value && value.kind ? value.kind : 'quick';
|
|
26
|
+
var tabState = useState(initial);
|
|
27
|
+
var tab = tabState[0];
|
|
28
|
+
var setTab = tabState[1];
|
|
29
|
+
|
|
30
|
+
var singleState = useState(value && value.kind === 'single' ? value.value : '');
|
|
31
|
+
var single = singleState[0];
|
|
32
|
+
var setSingle = singleState[1];
|
|
33
|
+
|
|
34
|
+
// A selection made elsewhere (a reset, a different record) has to show up
|
|
35
|
+
// here, or the box keeps displaying what the user last typed into it.
|
|
36
|
+
useEffect(function() {
|
|
37
|
+
if (value && value.kind === 'single') setSingle(value.value || '');
|
|
38
|
+
}, [value]);
|
|
39
|
+
|
|
40
|
+
var range = value && value.kind === 'range' ? value : { from: '', to: '' };
|
|
41
|
+
// Parsed as you type, so "May 202" reads as not-a-date-yet rather than
|
|
42
|
+
// silently saving as nothing.
|
|
43
|
+
var singleParsed = single ? parseSingleDate(single) : null;
|
|
44
|
+
|
|
45
|
+
// WHAT YEAR TO OFFER FIRST — the one already on screen.
|
|
46
|
+
//
|
|
47
|
+
// Somebody typing "j" after looking at Feb 2025 is almost certainly after a
|
|
48
|
+
// month of 2025. Read from what is COMMITTED rather than from what is being
|
|
49
|
+
// typed, or the anchor would move as soon as a suggestion resolved to a
|
|
50
|
+
// different year and the list would reorder under the cursor.
|
|
51
|
+
var committed = value && value.kind === 'single' ? parseSingleDate(value.value) : null;
|
|
52
|
+
var anchorYear = committed && committed.from
|
|
53
|
+
? Number(String(committed.from).slice(0, 4))
|
|
54
|
+
: new Date().getFullYear();
|
|
55
|
+
|
|
56
|
+
var suggestions = singleDateSuggestions(single, anchorYear);
|
|
57
|
+
|
|
58
|
+
function applySuggestion(text) {
|
|
59
|
+
setSingle(text);
|
|
60
|
+
// COMMITTED IMMEDIATELY. Picking from a list is a decision — leaving it in
|
|
61
|
+
// the box for the user to then press Enter on would be asking twice.
|
|
62
|
+
if (parseSingleDate(text)) {
|
|
63
|
+
onChange({ kind: 'single', value: text });
|
|
64
|
+
if (onClose) onClose();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function selectPreset(key) {
|
|
69
|
+
onChange({ kind: 'quick', preset: key });
|
|
70
|
+
if (onClose) onClose();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setRangeFrom(from) {
|
|
74
|
+
onChange({ kind: 'range', from: from, to: range.to || '' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setRangeTo(to) {
|
|
78
|
+
onChange({ kind: 'range', from: range.from || '', to: to });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only committed once it parses — a partial entry must not overwrite a good
|
|
82
|
+
// selection with nothing.
|
|
83
|
+
function commitSingle(close) {
|
|
84
|
+
if (!singleParsed) return;
|
|
85
|
+
onChange({ kind: 'single', value: single.trim() });
|
|
86
|
+
if (close && onClose) onClose();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clear() {
|
|
90
|
+
// Enforced here, not only by hiding the button: a custom design that
|
|
91
|
+
// renders its own clear must not be able to produce a state the host
|
|
92
|
+
// said it cannot accept.
|
|
93
|
+
if (!allowClear) return;
|
|
94
|
+
onChange(null);
|
|
95
|
+
if (onClose) onClose();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
tab: tab,
|
|
100
|
+
setTab: setTab,
|
|
101
|
+
presets: presets,
|
|
102
|
+
value: value,
|
|
103
|
+
label: dateValueLabel(value),
|
|
104
|
+
range: range,
|
|
105
|
+
single: single,
|
|
106
|
+
setSingle: setSingle,
|
|
107
|
+
singleParsed: singleParsed,
|
|
108
|
+
suggestions: suggestions,
|
|
109
|
+
applySuggestion: applySuggestion,
|
|
110
|
+
selectPreset: selectPreset,
|
|
111
|
+
setRangeFrom: setRangeFrom,
|
|
112
|
+
setRangeTo: setRangeTo,
|
|
113
|
+
commitSingle: commitSingle,
|
|
114
|
+
clear: clear,
|
|
115
|
+
allowClear: allowClear,
|
|
116
|
+
inheritedLabel: props.inheritedLabel || null
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A design that omits any of these can't collect a date, so the page reports
|
|
2
|
+
// loudly at mount rather than rendering a control nobody can use.
|
|
3
|
+
//
|
|
4
|
+
// The tabs are the one thing always on screen. Everything else belongs to a
|
|
5
|
+
// tab — the quick list to "Quick", the date inputs to "Range"/"Single" — so
|
|
6
|
+
// requiring a preset list AND a date input at the same time is a rule no
|
|
7
|
+
// tabbed design can satisfy on any tab. It failed every time it was mounted.
|
|
8
|
+
// `anyOf` asks what is actually meant: is there some way to enter a date?
|
|
9
|
+
export var DATE_RANGE_RULES = [
|
|
10
|
+
{ selector: '.xeplr-daterange-tabs button, [data-xeplr-daterange-tab]', label: 'Tab buttons (quick / range / single)' },
|
|
11
|
+
{
|
|
12
|
+
anyOf: [
|
|
13
|
+
'.xeplr-daterange-list button',
|
|
14
|
+
'[data-xeplr-daterange-preset]',
|
|
15
|
+
'input[type="date"]',
|
|
16
|
+
'.xeplr-daterange-input'
|
|
17
|
+
],
|
|
18
|
+
label: 'A way to pick a date — a quick preset list, or a date input'
|
|
19
|
+
}
|
|
20
|
+
];
|
|
@@ -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
|
|
package/src/index.js
CHANGED
|
@@ -84,6 +84,47 @@ export { DateFieldSample } from './dateField/designs/index.js';
|
|
|
84
84
|
// DateField — ready-made page
|
|
85
85
|
export { DateFieldPage } from './dateField/pages.jsx';
|
|
86
86
|
|
|
87
|
+
// DateRange — model. One selection covering a quick preset, an explicit
|
|
88
|
+
// range, or a single day/month/year; everything resolves to {from, to}.
|
|
89
|
+
export {
|
|
90
|
+
QUICK_PRESETS,
|
|
91
|
+
parseSingleDate,
|
|
92
|
+
resolveQuickPreset,
|
|
93
|
+
resolveDateValue,
|
|
94
|
+
dateValueLabel,
|
|
95
|
+
dateValueIsSet
|
|
96
|
+
} from './dateRange/dateRange.js';
|
|
97
|
+
|
|
98
|
+
// DateRange — controller
|
|
99
|
+
export { useDateRangeController } from './dateRange/useDateRangeController.js';
|
|
100
|
+
|
|
101
|
+
// DateRange — design validation
|
|
102
|
+
export { DATE_RANGE_RULES } from './dateRange/validateDesign.js';
|
|
103
|
+
|
|
104
|
+
// DateRange — sample design
|
|
105
|
+
export { DateRangeSample } from './dateRange/designs/index.js';
|
|
106
|
+
|
|
107
|
+
// DateRange — ready-made page
|
|
108
|
+
export { DateRangePage } from './dateRange/pages.jsx';
|
|
109
|
+
|
|
110
|
+
// RangeField — model. Not DateRange (below) — that's a full quick/range/
|
|
111
|
+
// single POPOVER picker; this is just a From+To pair rendered as one field,
|
|
112
|
+
// for a caller that already knows it wants an explicit range and has
|
|
113
|
+
// nowhere to put a popover.
|
|
114
|
+
export { RANGE_TYPES, emptyRange, normalizeRange, rangeIsSet } from './rangeField/rangeField.js';
|
|
115
|
+
|
|
116
|
+
// RangeField — controller
|
|
117
|
+
export { useRangeFieldController } from './rangeField/useRangeFieldController.js';
|
|
118
|
+
|
|
119
|
+
// RangeField — design validation
|
|
120
|
+
export { RANGE_FIELD_RULES } from './rangeField/validateDesign.js';
|
|
121
|
+
|
|
122
|
+
// RangeField — sample design
|
|
123
|
+
export { RangeFieldSample } from './rangeField/designs/index.js';
|
|
124
|
+
|
|
125
|
+
// RangeField — ready-made page
|
|
126
|
+
export { RangeFieldPage } from './rangeField/pages.jsx';
|
|
127
|
+
|
|
87
128
|
// Dropdown — model
|
|
88
129
|
export {
|
|
89
130
|
DROPDOWN_MODES,
|
|
@@ -124,14 +165,35 @@ export { GridDisplayerPage } from './gridDisplayer/pages.jsx';
|
|
|
124
165
|
export { raiseSnackbar } from './snackbar/snackbar.js';
|
|
125
166
|
|
|
126
167
|
// Confirm
|
|
127
|
-
export { raiseConfirm } from './confirm/confirm.js';
|
|
168
|
+
export { raiseConfirm, raiseChoice } from './confirm/confirm.js';
|
|
128
169
|
|
|
129
170
|
// Theme — brand-neutral DEFAULT design tokens (chart + table), override-friendly.
|
|
130
171
|
// Consumers pass overrides to resolveTheme(); overrides win, rest falls through.
|
|
131
|
-
export { default as DEFAULT_THEME, resolveTheme, getThemeVariant, getChartOptions, deepMerge } from './theme/index.js';
|
|
172
|
+
export { default as DEFAULT_THEME, resolveTheme, getThemeVariant, getChartOptions, inputStyleVars, deepMerge } from './theme/index.js';
|
|
132
173
|
|
|
133
174
|
// Static data
|
|
134
175
|
export { default as COUNTRIES } from './data/countries.json';
|
|
135
176
|
import _statesData from './data/states.json';
|
|
136
177
|
export var STATES = _statesData.STATES;
|
|
137
178
|
export var STATES_IN = _statesData.STATES_IN;
|
|
179
|
+
|
|
180
|
+
// TextField — the plain text input, its multiline and search shapes.
|
|
181
|
+
export { TEXT_TYPES, toValue, normalise, validate, remaining } from './textField/textField.js';
|
|
182
|
+
export { useTextFieldController } from './textField/useTextFieldController.js';
|
|
183
|
+
export { TEXT_FIELD_RULES } from './textField/validateDesign.js';
|
|
184
|
+
export { TextFieldSample } from './textField/designs/index.js';
|
|
185
|
+
export { TextFieldPage } from './textField/pages.jsx';
|
|
186
|
+
|
|
187
|
+
// ── Progress notifier ────────────────────────────────────────────────────
|
|
188
|
+
//
|
|
189
|
+
// A small moving bar and a line of text, driven by an event KEY the back end
|
|
190
|
+
// chooses. Hidden until an event for that key arrives, so it is safe to mount
|
|
191
|
+
// on anything; visible while the work says it is continuing.
|
|
192
|
+
//
|
|
193
|
+
// An app attaches its own stream ONCE with attachProgressSource, adapting
|
|
194
|
+
// whatever it publishes into { key, continue, status?, data }. This package
|
|
195
|
+
// knows nothing about SSE — see progressSource.js for why the sentence is
|
|
196
|
+
// built on the client rather than sent as prose.
|
|
197
|
+
export { attachProgressSource, progressSourceAttached, subscribeToProgress,
|
|
198
|
+
describeProgress, describeComplete } from './progress/progressSource.js';
|
|
199
|
+
export { default as ProgressNotifier } from './progress/ProgressNotifier.jsx';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { subscribeToProgress, describeProgress, describeComplete } from './progressSource.js';
|
|
3
|
+
import './designs/progressNotifier.css';
|
|
4
|
+
|
|
5
|
+
// PROGRESS NOTIFIER — a small moving bar and a line of text, driven by an
|
|
6
|
+
// event key. Mount it next to anything that starts long-running work.
|
|
7
|
+
//
|
|
8
|
+
// ── A NOTIFIER, NOT A PROGRESS BAR ───────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// The bar is INDETERMINATE and always will be. Almost nothing this reports on
|
|
11
|
+
// can honestly say how far through it is: a GROUP BY over twenty million rows
|
|
12
|
+
// knows it is running and nothing else, and a bar creeping to 60% would be a
|
|
13
|
+
// number invented to look reassuring. What moves says "this is alive"; what is
|
|
14
|
+
// TRUE is in the text beside it, which is the count the server actually has.
|
|
15
|
+
//
|
|
16
|
+
// So it is not measuring the work. It is telling you the work exists, and
|
|
17
|
+
// repeating what it last said about itself.
|
|
18
|
+
//
|
|
19
|
+
// ── IT LISTENS, IT DOES NOT ASK ──────────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// No polling and no fetching. The app attaches its stream once with
|
|
22
|
+
// attachProgressSource, and this subscribes to one key on it. A screen shows a
|
|
23
|
+
// notifier for a job it did not start — one already running when the page
|
|
24
|
+
// loaded, or started by somebody else — with no extra wiring, because the key
|
|
25
|
+
// is the thing it is about rather than a handle handed back to whoever
|
|
26
|
+
// launched it.
|
|
27
|
+
|
|
28
|
+
export default function ProgressNotifier({
|
|
29
|
+
eventKey,
|
|
30
|
+
noun,
|
|
31
|
+
// Shown before anything has been heard. Usually nothing — a notifier for
|
|
32
|
+
// work that is not running should take up no room at all.
|
|
33
|
+
idle = null,
|
|
34
|
+
// Keep the finished line on screen instead of clearing it. Worth it where
|
|
35
|
+
// there is nothing else to show the result (a card with no numbers on it);
|
|
36
|
+
// not worth it where the result itself is about to appear underneath.
|
|
37
|
+
keepFinal = false,
|
|
38
|
+
// How long the finished line stays before it clears, when it does.
|
|
39
|
+
finalMs = 4000,
|
|
40
|
+
onComplete,
|
|
41
|
+
onError,
|
|
42
|
+
className = ''
|
|
43
|
+
}) {
|
|
44
|
+
const [state, setState] = useState(null); // { status, message }
|
|
45
|
+
|
|
46
|
+
// THE CALLBACKS LIVE IN A REF, AND THE SUBSCRIPTION DEPENDS ONLY ON THE KEY.
|
|
47
|
+
//
|
|
48
|
+
// They arrive as inline arrows — `onComplete={() => refresh()}` — which are
|
|
49
|
+
// a new function identity on every render of the host. With them in the
|
|
50
|
+
// effect's dependency list, every render tore the subscription down and
|
|
51
|
+
// rebuilt it; and because a stream with no listeners left is CLOSED, and its
|
|
52
|
+
// ticket is single-use, each rebuild opened a new connection and spent a new
|
|
53
|
+
// ticket. Pressing Build produced three of each: one for the busy state, one
|
|
54
|
+
// for the completion, one for the refresh that followed it.
|
|
55
|
+
//
|
|
56
|
+
// A ref keeps the latest callbacks reachable without making the subscription
|
|
57
|
+
// depend on their identity — so the stream is opened once and stays open
|
|
58
|
+
// while the key is unchanged, which is the entire point of sharing it.
|
|
59
|
+
const handlers = useRef({ onComplete: onComplete, onError: onError });
|
|
60
|
+
handlers.current = { onComplete: onComplete, onError: onError };
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!eventKey) return undefined;
|
|
64
|
+
let alive = true;
|
|
65
|
+
let timer = null;
|
|
66
|
+
|
|
67
|
+
const stop = subscribeToProgress(eventKey, (evt) => {
|
|
68
|
+
if (!alive) return;
|
|
69
|
+
|
|
70
|
+
if (evt.state === 'complete') {
|
|
71
|
+
setState({ status: 'complete', message: describeComplete(evt.data, noun) });
|
|
72
|
+
handlers.current.onComplete?.(evt.data);
|
|
73
|
+
// Cleared after a moment unless asked to stay: a finished line that
|
|
74
|
+
// never goes reads, an hour later, as if it were still happening.
|
|
75
|
+
if (!keepFinal) timer = setTimeout(() => { if (alive) setState(null); }, finalMs);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (evt.state === 'error') {
|
|
80
|
+
// The REASON, kept on screen. An error that clears itself is one the
|
|
81
|
+
// user is told about only if they happened to be looking.
|
|
82
|
+
setState({
|
|
83
|
+
status: 'error',
|
|
84
|
+
message: (evt.data && (evt.data.error || evt.data.message)) ||
|
|
85
|
+
('The ' + (noun || 'task') + ' could not be finished')
|
|
86
|
+
});
|
|
87
|
+
handlers.current.onError?.(evt.data);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setState({ status: 'running', message: describeProgress(evt.data, noun) });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
alive = false;
|
|
96
|
+
if (timer) clearTimeout(timer);
|
|
97
|
+
stop();
|
|
98
|
+
};
|
|
99
|
+
// ONLY the key and the presentation options. Not the callbacks — see above.
|
|
100
|
+
}, [eventKey, noun, keepFinal, finalMs]);
|
|
101
|
+
|
|
102
|
+
if (!state) return idle;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
className={`xpn xpn--${state.status} ${className}`.trim()}
|
|
107
|
+
// Announced to a screen reader as it changes, but politely — this is
|
|
108
|
+
// status, not an alert, and it must not interrupt what is being read.
|
|
109
|
+
role="status"
|
|
110
|
+
aria-live="polite"
|
|
111
|
+
>
|
|
112
|
+
{state.status === 'running' && (
|
|
113
|
+
<div className="xpn-bar" aria-hidden="true"><span /></div>
|
|
114
|
+
)}
|
|
115
|
+
<p className="xpn-text">{state.message}</p>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|