@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,116 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
sanitizeNumberInput,
|
|
4
|
+
toNumeric,
|
|
5
|
+
formatDisplay,
|
|
6
|
+
validateRange,
|
|
7
|
+
resolveAffixState,
|
|
8
|
+
NUMBER_TYPES
|
|
9
|
+
} from './numberField.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} props
|
|
13
|
+
* @param {'float'|'currency'|'units'} [props.type='float']
|
|
14
|
+
* @param {number|null} [props.value] — controlled numeric value
|
|
15
|
+
* @param {number|null} [props.defaultValue] — uncontrolled initial value
|
|
16
|
+
* @param {(value, meta) => void} [props.onChange] — called with (numericValue, { affix })
|
|
17
|
+
* @param {Array} [props.currencies] — for currency type: [{ code, symbol }]
|
|
18
|
+
* @param {string} [props.defaultCurrency]
|
|
19
|
+
* @param {'before'|'after'} [props.currencyPosition='before']
|
|
20
|
+
* @param {Array} [props.units] — for units type: [{ value, label }]
|
|
21
|
+
* @param {string} [props.defaultUnit]
|
|
22
|
+
* @param {'before'|'after'} [props.unitPosition='after']
|
|
23
|
+
* @param {number} [props.min]
|
|
24
|
+
* @param {number} [props.max]
|
|
25
|
+
* @param {number} [props.decimals] — display decimals on blur
|
|
26
|
+
* @param {string} [props.placeholder]
|
|
27
|
+
* @param {boolean} [props.disabled]
|
|
28
|
+
*/
|
|
29
|
+
export function useNumberFieldController(props) {
|
|
30
|
+
props = props || {};
|
|
31
|
+
var type = props.type || 'float';
|
|
32
|
+
if (NUMBER_TYPES.indexOf(type) === -1) {
|
|
33
|
+
throw new Error('[xeplr-ui-utils:NumberField] Unknown type: ' + type + '. Expected one of ' + NUMBER_TYPES.join(', '));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var affixInit = useMemo(function() {
|
|
37
|
+
return resolveAffixState(type, props);
|
|
38
|
+
}, [
|
|
39
|
+
type,
|
|
40
|
+
props.currencies, props.defaultCurrency, props.currencyPosition,
|
|
41
|
+
props.units, props.defaultUnit, props.unitPosition
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
var isControlled = props.value !== undefined;
|
|
45
|
+
var initial = isControlled ? props.value : (props.defaultValue !== undefined ? props.defaultValue : null);
|
|
46
|
+
|
|
47
|
+
var [raw, setRaw] = useState(initial === null || initial === undefined ? '' : String(initial));
|
|
48
|
+
var [affix, setAffix] = useState(affixInit ? affixInit.selected : null);
|
|
49
|
+
var [error, setError] = useState('');
|
|
50
|
+
|
|
51
|
+
useEffect(function() {
|
|
52
|
+
if (isControlled) {
|
|
53
|
+
var next = props.value === null || props.value === undefined ? '' : String(props.value);
|
|
54
|
+
setRaw(next);
|
|
55
|
+
}
|
|
56
|
+
}, [props.value, isControlled]);
|
|
57
|
+
|
|
58
|
+
useEffect(function() {
|
|
59
|
+
if (affixInit) setAffix(affixInit.selected);
|
|
60
|
+
}, [affixInit]);
|
|
61
|
+
|
|
62
|
+
function emit(nextRaw, nextAffix) {
|
|
63
|
+
var numeric = toNumeric(nextRaw);
|
|
64
|
+
var rangeCheck = validateRange(numeric, props.min, props.max);
|
|
65
|
+
setError(rangeCheck.valid ? '' : rangeCheck.error);
|
|
66
|
+
if (props.onChange) {
|
|
67
|
+
props.onChange(numeric, { affix: nextAffix });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleNumberChange(e) {
|
|
72
|
+
var sanitized = sanitizeNumberInput(e.target.value);
|
|
73
|
+
if (!isControlled) setRaw(sanitized);
|
|
74
|
+
emit(sanitized, affix);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleAffixChange(e) {
|
|
78
|
+
var nextValue = e.target.value;
|
|
79
|
+
var next = (affixInit.list).find(function(item) {
|
|
80
|
+
return item[affixInit.valueKey] === nextValue;
|
|
81
|
+
});
|
|
82
|
+
if (!next) return;
|
|
83
|
+
setAffix(next);
|
|
84
|
+
emit(raw, next);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleBlur() {
|
|
88
|
+
var numeric = toNumeric(raw);
|
|
89
|
+
if (numeric === null) return;
|
|
90
|
+
if (typeof props.decimals === 'number') {
|
|
91
|
+
var fixed = numeric.toFixed(props.decimals);
|
|
92
|
+
if (!isControlled) setRaw(fixed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
type,
|
|
98
|
+
raw,
|
|
99
|
+
numeric: toNumeric(raw),
|
|
100
|
+
error,
|
|
101
|
+
placeholder: props.placeholder,
|
|
102
|
+
disabled: !!props.disabled,
|
|
103
|
+
decimals: props.decimals,
|
|
104
|
+
formatDisplay: formatDisplay,
|
|
105
|
+
affix: affixInit ? {
|
|
106
|
+
list: affixInit.list,
|
|
107
|
+
selected: affix,
|
|
108
|
+
position: affixInit.position,
|
|
109
|
+
labelKey: affixInit.labelKey,
|
|
110
|
+
valueKey: affixInit.valueKey
|
|
111
|
+
} : null,
|
|
112
|
+
handleNumberChange,
|
|
113
|
+
handleAffixChange,
|
|
114
|
+
handleBlur
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* Progress notifier. Namespaced xpn-* so it cannot collide with a host app's
|
|
2
|
+
own rules, and self-contained so mounting it never depends on the host
|
|
3
|
+
having loaded some other stylesheet first.
|
|
4
|
+
|
|
5
|
+
var(--xeplr-*, fallback) throughout — picks up @xeplr/ui-account's theme
|
|
6
|
+
tokens when that stylesheet is present, and looks right without it. */
|
|
7
|
+
|
|
8
|
+
.xpn {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
gap: 5px;
|
|
12
|
+
/* Zero height when it renders nothing: the component returns null while
|
|
13
|
+
idle, and this keeps the visible state from adding margin a host did not
|
|
14
|
+
ask for. */
|
|
15
|
+
margin: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.xpn-text {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-size: 12px;
|
|
21
|
+
line-height: 1.35;
|
|
22
|
+
color: var(--xeplr-muted, #6b7280);
|
|
23
|
+
/* A driver's error is one long line with no spaces in it more often than
|
|
24
|
+
not, and must not stretch whatever it is sitting in. */
|
|
25
|
+
overflow-wrap: anywhere;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.xpn--error .xpn-text { color: var(--xeplr-danger, #c2372e); }
|
|
29
|
+
.xpn--complete .xpn-text { color: var(--xeplr-success, #059669); }
|
|
30
|
+
|
|
31
|
+
/* ── the bar ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
INDETERMINATE, always. Nothing this reports on can honestly say how far
|
|
34
|
+
through it is — a GROUP BY over twenty million rows knows only that it is
|
|
35
|
+
running — so the movement says "alive" and the text beside it carries what
|
|
36
|
+
is actually true. A bar creeping toward 60% would be an invented number. */
|
|
37
|
+
|
|
38
|
+
.xpn-bar {
|
|
39
|
+
position: relative;
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: 3px;
|
|
42
|
+
border-radius: 2px;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
background: color-mix(in srgb, var(--xeplr-accent, #2563eb) 18%, transparent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.xpn-bar span {
|
|
48
|
+
position: absolute;
|
|
49
|
+
top: 0;
|
|
50
|
+
left: 0;
|
|
51
|
+
display: block;
|
|
52
|
+
width: 40%;
|
|
53
|
+
height: 100%;
|
|
54
|
+
border-radius: 2px;
|
|
55
|
+
background: var(--xeplr-accent, #2563eb);
|
|
56
|
+
animation: xpn-slide 1.1s ease-in-out infinite;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@keyframes xpn-slide {
|
|
60
|
+
0% { transform: translateX(-100%); }
|
|
61
|
+
100% { transform: translateX(250%); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Motion is the only thing carrying "still going" here, so it is not simply
|
|
65
|
+
removed — it is replaced with a filled, dimmed bar that still reads as a
|
|
66
|
+
state rather than as a finished one. */
|
|
67
|
+
@media (prefers-reduced-motion: reduce) {
|
|
68
|
+
.xpn-bar span {
|
|
69
|
+
animation: none;
|
|
70
|
+
width: 100%;
|
|
71
|
+
opacity: 0.55;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// PROGRESS NOTIFIER — the model.
|
|
2
|
+
//
|
|
3
|
+
// Long-running work on the server must never be silent on the client. Not a
|
|
4
|
+
// spinner that means "something, somewhere" — the actual thing it is doing,
|
|
5
|
+
// updated as it does it, until it says it is finished.
|
|
6
|
+
//
|
|
7
|
+
// ── WHY THIS PACKAGE AND NOT EACH APP ────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// Because it kept being rebuilt. A report had one, a dashboard widget had one,
|
|
10
|
+
// and the next screen that needed one wrote a third with its own bar, its own
|
|
11
|
+
// keyframes and its own idea of what "running" looks like. They drift, and the
|
|
12
|
+
// drift is visible: the same operation reports differently depending on which
|
|
13
|
+
// screen you started it from.
|
|
14
|
+
//
|
|
15
|
+
// ── THE EVENT SHAPE, AND WHY IT IS NOT THE APP'S ─────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// This module knows nothing about SSE, tickets, topics or run stores. An app
|
|
18
|
+
// attaches its own stream once, adapting whatever it publishes into:
|
|
19
|
+
//
|
|
20
|
+
// { key, continue: true|false, status?, data }
|
|
21
|
+
//
|
|
22
|
+
// key — what the work is ABOUT, chosen by the back end: a cube id, a
|
|
23
|
+
// move run id, a report id. A notifier is mounted with the same
|
|
24
|
+
// key, so a screen listens using an id it already has rather than
|
|
25
|
+
// one it has to be handed back.
|
|
26
|
+
// continue — TRUE while the work is still going. This is the whole of the
|
|
27
|
+
// visibility rule: a notifier shows itself when it hears `true`
|
|
28
|
+
// for its key and is otherwise invisible. Nothing on the client
|
|
29
|
+
// decides when to appear.
|
|
30
|
+
// status — optional, and only interesting for how it ENDED: 'error' when
|
|
31
|
+
// it failed. Absent means the ordinary case.
|
|
32
|
+
// data — whatever the work knows about itself. Free-form, deliberately:
|
|
33
|
+
// the server sends what it has, and the SENTENCE is made here. A
|
|
34
|
+
// server that formats prose cannot be re-worded without a
|
|
35
|
+
// deployment, and cannot be translated at all.
|
|
36
|
+
//
|
|
37
|
+
// ── HIDDEN UNTIL TOLD OTHERWISE ──────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// A notifier renders nothing until an event for its key arrives. That is what
|
|
40
|
+
// makes it safe to put on ten components: they all sit at zero height, and the
|
|
41
|
+
// one whose work is running — or all of them, if they share a key — appear
|
|
42
|
+
// together. Nothing needs to know which screen started the job, and a screen
|
|
43
|
+
// opened halfway through a build shows it without asking anybody.
|
|
44
|
+
|
|
45
|
+
var _subscribe = null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Point the notifier at this app's event stream. Called ONCE at startup.
|
|
49
|
+
*
|
|
50
|
+
* `subscribe(handler)` must call `handler({ key, status, data })` for every
|
|
51
|
+
* event and return an unsubscribe function.
|
|
52
|
+
*
|
|
53
|
+
* Nothing here opens a connection: an app already has one stream and should
|
|
54
|
+
* not grow a second for this. The adapter is where its own event shape is
|
|
55
|
+
* translated — see the notes on the contract above.
|
|
56
|
+
*/
|
|
57
|
+
export function attachProgressSource(subscribe) {
|
|
58
|
+
_subscribe = typeof subscribe === 'function' ? subscribe : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether an app has attached a stream. False means every notifier is inert. */
|
|
62
|
+
export function progressSourceAttached() {
|
|
63
|
+
return Boolean(_subscribe);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Listen for events about one key.
|
|
68
|
+
*
|
|
69
|
+
* Returns an unsubscribe function, always — including when no source is
|
|
70
|
+
* attached, so a component's cleanup never has to check. A notifier mounted in
|
|
71
|
+
* an app that never attached one simply shows nothing, rather than throwing in
|
|
72
|
+
* a render.
|
|
73
|
+
*/
|
|
74
|
+
export function subscribeToProgress(key, handler) {
|
|
75
|
+
if (!_subscribe || !key) return function() {};
|
|
76
|
+
return _subscribe(function(evt) {
|
|
77
|
+
if (!evt || evt.key !== key) return;
|
|
78
|
+
handler(normalize(evt));
|
|
79
|
+
}) || function() {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One shape for the component, whichever way the app phrased it.
|
|
84
|
+
*
|
|
85
|
+
* `continue` is the field the back end sends and the one the contract is
|
|
86
|
+
* written around, but it cannot be a variable name in JavaScript — so it is
|
|
87
|
+
* turned into a plain state here, once, rather than being read as
|
|
88
|
+
* `evt['continue']` at four call sites.
|
|
89
|
+
*
|
|
90
|
+
* An event with neither `continue` nor a status is treated as STILL RUNNING.
|
|
91
|
+
* Being wrong that way leaves a bar spinning until the next event; being wrong
|
|
92
|
+
* the other way hides a job that is still going, which is the failure this
|
|
93
|
+
* whole component exists to prevent.
|
|
94
|
+
*/
|
|
95
|
+
function normalize(evt) {
|
|
96
|
+
var state = 'running';
|
|
97
|
+
if (evt.status === 'error') state = 'error';
|
|
98
|
+
else if (evt.continue === false) state = 'complete';
|
|
99
|
+
else if (evt.status === 'complete') state = 'complete';
|
|
100
|
+
|
|
101
|
+
return { key: evt.key, state: state, data: evt.data || {} };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── turning `data` into a sentence ───────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Thousands separators, because 5022241 is not a number anybody reads. */
|
|
107
|
+
function count(n) {
|
|
108
|
+
return Number(n).toLocaleString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What to SAY about a piece of work, from whatever it reported.
|
|
113
|
+
*
|
|
114
|
+
* The order is deliberate. An explicit `message` wins, because work that knows
|
|
115
|
+
* its own phase ("Rolling the rows up…") says it better than anything derived.
|
|
116
|
+
* Failing that, the counters are assembled — and counters are the honest thing
|
|
117
|
+
* to show, since almost nothing here can report a percentage: a GROUP BY does
|
|
118
|
+
* not know how far through it is, and a bar claiming 60% would be invented.
|
|
119
|
+
*
|
|
120
|
+
* The last resort is a plain sentence rather than an empty string. "Working…"
|
|
121
|
+
* next to a moving bar is a true statement; a blank line beside it looks like
|
|
122
|
+
* the thing has stalled.
|
|
123
|
+
*
|
|
124
|
+
* A SAMPLE, in the sense that these are the fields our work happens to send
|
|
125
|
+
* today. Adding one is a line here, not a change to any server.
|
|
126
|
+
*/
|
|
127
|
+
export function describeProgress(data, noun) {
|
|
128
|
+
var d = data || {};
|
|
129
|
+
if (d.message) return d.message;
|
|
130
|
+
|
|
131
|
+
var parts = [];
|
|
132
|
+
// Read vs loaded, kept separate: a move that read 50,000 rows and wrote none
|
|
133
|
+
// is the failure most worth seeing, and one merged number hides it.
|
|
134
|
+
if (d.rowsRead != null) parts.push(count(d.rowsRead) + ' rows read');
|
|
135
|
+
if (d.rowsLoaded != null) parts.push(count(d.rowsLoaded) + ' loaded');
|
|
136
|
+
if (d.rowsScanned != null) parts.push(count(d.rowsScanned) + ' rows scanned');
|
|
137
|
+
if (d.groupCount != null) parts.push(count(d.groupCount) + ' groups');
|
|
138
|
+
if (d.batches != null) parts.push(count(d.batches) + ' batches');
|
|
139
|
+
if (d.cubeRows != null) parts.push(count(d.cubeRows) + ' rows');
|
|
140
|
+
|
|
141
|
+
if (parts.length) return parts.join(' · ') + '…';
|
|
142
|
+
return 'Working on the ' + (noun || 'task') + '…';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What to say once it has finished — the part people actually wait for.
|
|
147
|
+
*
|
|
148
|
+
* Separate from the running text because the tense and the content differ: a
|
|
149
|
+
* finished job reports a RESULT and how long it took, not what it was doing.
|
|
150
|
+
*/
|
|
151
|
+
export function describeComplete(data, noun) {
|
|
152
|
+
var d = data || {};
|
|
153
|
+
if (d.message) return d.message;
|
|
154
|
+
|
|
155
|
+
var what = null;
|
|
156
|
+
if (d.cubeRows != null) what = count(d.cubeRows) + ' rows';
|
|
157
|
+
else if (d.rowsLoaded != null) what = count(d.rowsLoaded) + ' rows';
|
|
158
|
+
else if (d.groupCount != null) what = count(d.groupCount) + ' groups';
|
|
159
|
+
|
|
160
|
+
var took = d.durationMs != null
|
|
161
|
+
? ' in ' + (d.durationMs < 1000
|
|
162
|
+
? d.durationMs + 'ms'
|
|
163
|
+
: (d.durationMs / 1000).toFixed(1) + 's')
|
|
164
|
+
: '';
|
|
165
|
+
|
|
166
|
+
if (what) return 'Finished — ' + what + took;
|
|
167
|
+
return 'Finished' + (took || '') + (noun ? ' — ' + noun : '');
|
|
168
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import DateFieldSample from '../../dateField/designs/DateFieldSample.jsx';
|
|
2
|
+
import './rangeField.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RangeField — design. From and To rendered as ONE field, side by side,
|
|
6
|
+
* sharing a row — not two independent fields that happen to sit near each
|
|
7
|
+
* other. That's the actual point of this component: a caller reaching for
|
|
8
|
+
* "start" and "end" as two separate labeled boxes gets visual distance
|
|
9
|
+
* between two things that are really one idea; this gives them the one idea.
|
|
10
|
+
*/
|
|
11
|
+
export default function RangeFieldSample({ fromLabel, toLabel, from, to }) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="xeplr-rangefield">
|
|
14
|
+
<div className="xeplr-rangefield-field">
|
|
15
|
+
<span className="xeplr-rangefield-label">{fromLabel}</span>
|
|
16
|
+
<DateFieldSample {...from} />
|
|
17
|
+
</div>
|
|
18
|
+
<span className="xeplr-rangefield-sep" aria-hidden="true">→</span>
|
|
19
|
+
<div className="xeplr-rangefield-field">
|
|
20
|
+
<span className="xeplr-rangefield-label">{toLabel}</span>
|
|
21
|
+
<DateFieldSample {...to} />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as RangeFieldSample } from './RangeFieldSample.jsx';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* Namespaced xeplr-rangefield-* so a host's own styles can't collide with it. */
|
|
2
|
+
.xeplr-rangefield {
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: flex-end;
|
|
5
|
+
gap: 10px;
|
|
6
|
+
flex-wrap: wrap;
|
|
7
|
+
}
|
|
8
|
+
.xeplr-rangefield-field {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
gap: 6px;
|
|
12
|
+
flex: 1;
|
|
13
|
+
min-width: 160px;
|
|
14
|
+
}
|
|
15
|
+
.xeplr-rangefield-label {
|
|
16
|
+
font-size: 12px;
|
|
17
|
+
font-weight: 600;
|
|
18
|
+
color: var(--xeplr-muted, #6b7280);
|
|
19
|
+
}
|
|
20
|
+
/* Purely a visual hint that these two boxes are one idea, not a real
|
|
21
|
+
separator — hidden from assistive tech via aria-hidden in the design. */
|
|
22
|
+
.xeplr-rangefield-sep {
|
|
23
|
+
padding-bottom: 10px;
|
|
24
|
+
color: var(--xeplr-muted-2, #9aa1ad);
|
|
25
|
+
font-size: 13px;
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useRangeFieldController } from './useRangeFieldController.js';
|
|
2
|
+
import RangeFieldSample from './designs/RangeFieldSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { RANGE_FIELD_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function RangeFieldPage(props) {
|
|
7
|
+
var controller = useRangeFieldController(props);
|
|
8
|
+
var ref = useDesignValidator('RangeFieldPage', RANGE_FIELD_RULES);
|
|
9
|
+
return <div ref={ref}><RangeFieldSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RangeField — pure model. No React.
|
|
3
|
+
*
|
|
4
|
+
* NOT DateRange (see ../dateRange) — that's a full quick-preset/range/single
|
|
5
|
+
* POPOVER picker, built for a report filter that also needs to remember "the
|
|
6
|
+
* last 30 days" as a living selection. This is just the two-input pair
|
|
7
|
+
* itself: a From and a To, rendered as one field, for a caller that already
|
|
8
|
+
* knows it wants an explicit range and has nowhere to put a popover (a
|
|
9
|
+
* wizard step, a form section) — DateRange's own Range tab is exactly this
|
|
10
|
+
* shape internally and could sit on top of this instead of duplicating it.
|
|
11
|
+
*
|
|
12
|
+
* Two flavours, mirroring dateField's (minus 'age' — a range of ages isn't
|
|
13
|
+
* a thing):
|
|
14
|
+
* - 'date' → ISO date strings (YYYY-MM-DD)
|
|
15
|
+
* - 'datetime' → ISO datetime strings (YYYY-MM-DDTHH:mm)
|
|
16
|
+
*/
|
|
17
|
+
export var RANGE_TYPES = ['date', 'datetime'];
|
|
18
|
+
|
|
19
|
+
export function emptyRange() {
|
|
20
|
+
return { from: '', to: '' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeRange(range) {
|
|
24
|
+
return { from: (range && range.from) || '', to: (range && range.to) || '' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function rangeIsSet(range) {
|
|
28
|
+
return Boolean(range && (range.from || range.to));
|
|
29
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useDateFieldController } from '../dateField/useDateFieldController.js';
|
|
2
|
+
import { normalizeRange } from './rangeField.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Two DateField controllers, cross-bounded — From can't be set past To, To
|
|
6
|
+
* can't be set before From, each side gets that for free rather than the
|
|
7
|
+
* caller re-deriving it. Composition over reimplementation: this is not a
|
|
8
|
+
* second date-input implementation, it's dateField's, called twice.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} props
|
|
11
|
+
* @param {'date'|'datetime'} [props.type='date']
|
|
12
|
+
* @param {{from, to}} [props.value] — controlled; {from:'', to:''} if absent
|
|
13
|
+
* @param {(value: {from, to}) => void} [props.onChange]
|
|
14
|
+
* @param {Date|string} [props.min] — floor for From (and, absent an explicit To, for To too)
|
|
15
|
+
* @param {Date|string} [props.max] — ceiling for To (and, absent an explicit From, for From too)
|
|
16
|
+
* @param {boolean} [props.disabled]
|
|
17
|
+
* @param {string} [props.fromLabel='From']
|
|
18
|
+
* @param {string} [props.toLabel='To']
|
|
19
|
+
* @param {string} [props.toPlaceholder] — e.g. "Now", for an open-ended end
|
|
20
|
+
*/
|
|
21
|
+
export function useRangeFieldController(props) {
|
|
22
|
+
props = props || {};
|
|
23
|
+
var type = props.type || 'date';
|
|
24
|
+
var range = normalizeRange(props.value);
|
|
25
|
+
|
|
26
|
+
var from = useDateFieldController({
|
|
27
|
+
type: type,
|
|
28
|
+
value: range.from || null,
|
|
29
|
+
onChange: function(v) { if (props.onChange) props.onChange({ from: v || '', to: range.to }); },
|
|
30
|
+
min: props.min,
|
|
31
|
+
max: range.to || props.max,
|
|
32
|
+
disabled: props.disabled,
|
|
33
|
+
placeholder: props.fromPlaceholder
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
var to = useDateFieldController({
|
|
37
|
+
type: type,
|
|
38
|
+
value: range.to || null,
|
|
39
|
+
onChange: function(v) { if (props.onChange) props.onChange({ from: range.from, to: v || '' }); },
|
|
40
|
+
min: range.from || props.min,
|
|
41
|
+
max: props.max,
|
|
42
|
+
disabled: props.disabled,
|
|
43
|
+
placeholder: props.toPlaceholder
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
fromLabel: props.fromLabel || 'From',
|
|
48
|
+
toLabel: props.toLabel || 'To',
|
|
49
|
+
from: from,
|
|
50
|
+
to: to
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/snackbar/snackbar.js
CHANGED
|
@@ -2,10 +2,13 @@ var _container = null;
|
|
|
2
2
|
var _queue = [];
|
|
3
3
|
var _timer = null;
|
|
4
4
|
|
|
5
|
+
// var(--xeplr-*, fallback) — picks up @xeplr/ui-account's theme tokens
|
|
6
|
+
// (light/dark) when that stylesheet is loaded, falls back to these original
|
|
7
|
+
// colors otherwise.
|
|
5
8
|
var DESIGNS = {
|
|
6
|
-
success: { bg: '#059669', color: '#fff' },
|
|
7
|
-
error: { bg: '#dc2626', color: '#fff' },
|
|
8
|
-
default: { bg: '#2563eb', color: '#fff' }
|
|
9
|
+
success: { bg: 'var(--xeplr-success, #059669)', color: 'var(--xeplr-accent-text, #fff)' },
|
|
10
|
+
error: { bg: 'var(--xeplr-danger, #dc2626)', color: 'var(--xeplr-accent-text, #fff)' },
|
|
11
|
+
default: { bg: 'var(--xeplr-accent, #2563eb)', color: 'var(--xeplr-accent-text, #fff)' }
|
|
9
12
|
};
|
|
10
13
|
|
|
11
14
|
var DEFAULT_DURATION = 3000;
|