@xeplr/ui-utils 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +25 -6
  2. package/src/confirm/confirm.js +175 -0
  3. package/src/dataImport/dataImport.js +32 -0
  4. package/src/dataImport/designs/DataImportSample.jsx +71 -0
  5. package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
  6. package/src/dataImport/designs/dataImport.css +98 -0
  7. package/src/dataImport/designs/index.js +2 -0
  8. package/src/dataImport/pages.jsx +17 -0
  9. package/src/dataImport/useDataImportController.js +94 -0
  10. package/src/dataImport/useImportHistoryController.js +67 -0
  11. package/src/dataImport/validateDesign.js +68 -0
  12. package/src/dateField/dateField.js +71 -0
  13. package/src/dateField/designs/DateFieldSample.jsx +30 -0
  14. package/src/dateField/designs/dateField.css +60 -0
  15. package/src/dateField/designs/index.js +1 -0
  16. package/src/dateField/pages.jsx +10 -0
  17. package/src/dateField/useDateFieldController.js +77 -0
  18. package/src/dateField/validateDesign.js +3 -0
  19. package/src/dateRange/dateRange.js +273 -0
  20. package/src/dateRange/designs/DateRangeSample.jsx +138 -0
  21. package/src/dateRange/designs/dateRange.css +69 -0
  22. package/src/dateRange/designs/index.js +1 -0
  23. package/src/dateRange/pages.jsx +10 -0
  24. package/src/dateRange/useDateRangeController.js +118 -0
  25. package/src/dateRange/validateDesign.js +20 -0
  26. package/src/dropdown/designs/DropdownSample.jsx +151 -0
  27. package/src/dropdown/designs/dropdown.css +226 -0
  28. package/src/dropdown/designs/index.js +1 -0
  29. package/src/dropdown/dropdown.js +75 -0
  30. package/src/dropdown/pages.jsx +10 -0
  31. package/src/dropdown/useDropdownController.js +254 -0
  32. package/src/dropdown/validateDesign.js +4 -0
  33. package/src/fileUpload/validateDesign.js +27 -8
  34. package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
  35. package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
  36. package/src/gridDisplayer/designs/index.js +1 -0
  37. package/src/gridDisplayer/gridDisplayer.js +44 -0
  38. package/src/gridDisplayer/pages.jsx +10 -0
  39. package/src/gridDisplayer/useGridDisplayerController.js +59 -0
  40. package/src/gridDisplayer/validateDesign.js +4 -0
  41. package/src/index.js +177 -1
  42. package/src/numberField/designs/NumberFieldSample.jsx +52 -0
  43. package/src/numberField/designs/index.js +1 -0
  44. package/src/numberField/designs/numberField.css +66 -0
  45. package/src/numberField/numberField.js +105 -0
  46. package/src/numberField/pages.jsx +10 -0
  47. package/src/numberField/useNumberFieldController.js +116 -0
  48. package/src/numberField/validateDesign.js +3 -0
  49. package/src/progress/ProgressNotifier.jsx +118 -0
  50. package/src/progress/designs/progressNotifier.css +73 -0
  51. package/src/progress/progressSource.js +168 -0
  52. package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
  53. package/src/rangeField/designs/index.js +1 -0
  54. package/src/rangeField/designs/rangeField.css +26 -0
  55. package/src/rangeField/pages.jsx +10 -0
  56. package/src/rangeField/rangeField.js +29 -0
  57. package/src/rangeField/useRangeFieldController.js +52 -0
  58. package/src/rangeField/validateDesign.js +3 -0
  59. package/src/snackbar/snackbar.js +6 -3
  60. package/src/textField/designs/TextFieldSample.jsx +75 -0
  61. package/src/textField/designs/index.js +1 -0
  62. package/src/textField/designs/textField.css +92 -0
  63. package/src/textField/pages.jsx +10 -0
  64. package/src/textField/textField.js +65 -0
  65. package/src/textField/useTextFieldController.js +101 -0
  66. package/src/textField/validateDesign.js +3 -0
  67. package/src/theme/default.theme.json +934 -0
  68. package/src/theme/index.js +155 -0
@@ -0,0 +1,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,3 @@
1
+ export var NUMBER_FIELD_RULES = [
2
+ { selector: 'input[inputmode="decimal"], input.xeplr-numberfield-input', label: 'Numeric input element' }
3
+ ];
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export var RANGE_FIELD_RULES = [
2
+ { selector: '.xeplr-rangefield-field', label: 'Range field wrapper (from/to)' }
3
+ ];
@@ -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;