@xeplr/ui-utils 1.0.1 → 1.0.3

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.
@@ -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
- * Throws a visible error if any required element is missing.
4
+ * Validates that required elements exist in the rendered design, and reports
5
+ * loudly when they don't console.error plus a data-xeplr-design-invalid
6
+ * marker on the container.
7
+ *
8
+ * A rule matches by id, role or selector. `anyOf` takes a list of selectors
9
+ * and passes when ANY one is present — which is what a design with tabs or
10
+ * steps needs, since only the current one is mounted and demanding all of
11
+ * them at once can never pass.
6
12
  *
7
13
  * @param {string} componentName - Name of the page (for error messages)
8
- * @param {Array<{id?: string, role?: string, selector?: string, label: string}>} requiredElements
14
+ * @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
9
15
  */
10
16
  export function useDesignValidator(componentName, requiredElements) {
11
17
  var containerRef = useRef(null);
@@ -22,20 +28,33 @@ export function useDesignValidator(componentName, requiredElements) {
22
28
  found = !!containerRef.current.querySelector('#' + rule.id);
23
29
  } else if (rule.role) {
24
30
  found = !!containerRef.current.querySelector('[role="' + rule.role + '"]');
31
+ } else if (rule.anyOf) {
32
+ for (var j = 0; j < rule.anyOf.length && !found; j++) {
33
+ found = !!containerRef.current.querySelector(rule.anyOf[j]);
34
+ }
25
35
  } else if (rule.selector) {
26
36
  found = !!containerRef.current.querySelector(rule.selector);
27
37
  }
28
38
 
29
39
  if (!found) {
30
- missing.push(rule.label + (rule.id ? ' (id="' + rule.id + '")' : '') + (rule.selector ? ' (' + rule.selector + ')' : ''));
40
+ var where = rule.id ? ' (id="' + rule.id + '")'
41
+ : rule.anyOf ? ' (one of: ' + rule.anyOf.join(', ') + ')'
42
+ : rule.selector ? ' (' + rule.selector + ')' : '';
43
+ missing.push(rule.label + where);
31
44
  }
32
45
  }
33
46
 
34
47
  if (missing.length > 0) {
35
- throw new Error(
36
- '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
37
- missing.map(function(m) { return ' - ' + m; }).join('\n')
38
- );
48
+ // Reported, not thrown. Throwing from an effect gives React nothing to
49
+ // catch, so a design rule a development-time check took the whole
50
+ // application down with it. The container is marked so the failure is
51
+ // visible on the page as well as in the console, which is what "fail
52
+ // loudly" was ever meant to buy.
53
+ var message = '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
54
+ missing.map(function(m) { return ' - ' + m; }).join('\n');
55
+ console.error(message);
56
+ containerRef.current.setAttribute('data-xeplr-design-invalid', componentName);
57
+ containerRef.current.setAttribute('title', message);
39
58
  }
40
59
  }, []);
41
60
 
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
+ }