@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,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,75 @@
|
|
|
1
|
+
import './textField.css';
|
|
2
|
+
|
|
3
|
+
// The default LOOK. Presentation only — every value it renders comes in as a
|
|
4
|
+
// prop, so an app that wants a different one supplies its own design and this
|
|
5
|
+
// is never loaded.
|
|
6
|
+
//
|
|
7
|
+
// Nothing here is hard-coded to a colour or an edge: the CSS reads
|
|
8
|
+
// --xeplr-input-* custom properties, so "bottom rule only" is a theme setting
|
|
9
|
+
// rather than a fork of this file.
|
|
10
|
+
export default function TextFieldSample({
|
|
11
|
+
type, raw, error, remaining, placeholder, disabled, required, maxLength, rows,
|
|
12
|
+
label, help, handleChange, handleBlur, handleKeyDown, handleClear
|
|
13
|
+
}) {
|
|
14
|
+
var multiline = type === 'multiline';
|
|
15
|
+
var search = type === 'search';
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className={'xeplr-textfield' + (error ? ' xeplr-textfield-invalid' : '')}>
|
|
19
|
+
{label && (
|
|
20
|
+
<label className="xeplr-textfield-label">
|
|
21
|
+
{label}{required && <span className="xeplr-textfield-required" aria-hidden="true"> *</span>}
|
|
22
|
+
</label>
|
|
23
|
+
)}
|
|
24
|
+
|
|
25
|
+
<div className="xeplr-textfield-control">
|
|
26
|
+
{multiline ? (
|
|
27
|
+
<textarea
|
|
28
|
+
className="xeplr-textfield-input"
|
|
29
|
+
value={raw}
|
|
30
|
+
rows={rows || 4}
|
|
31
|
+
placeholder={placeholder}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
maxLength={maxLength}
|
|
34
|
+
aria-invalid={error ? 'true' : undefined}
|
|
35
|
+
onChange={handleChange}
|
|
36
|
+
onBlur={handleBlur}
|
|
37
|
+
/>
|
|
38
|
+
) : (
|
|
39
|
+
<input
|
|
40
|
+
className="xeplr-textfield-input"
|
|
41
|
+
type={search ? 'search' : 'text'}
|
|
42
|
+
value={raw}
|
|
43
|
+
placeholder={placeholder}
|
|
44
|
+
disabled={disabled}
|
|
45
|
+
maxLength={maxLength}
|
|
46
|
+
aria-invalid={error ? 'true' : undefined}
|
|
47
|
+
onChange={handleChange}
|
|
48
|
+
onBlur={handleBlur}
|
|
49
|
+
onKeyDown={handleKeyDown}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{/* Only when there is something to clear — a permanently visible ✕ on
|
|
54
|
+
an empty box is a control that does nothing most of the time. */}
|
|
55
|
+
{search && raw && !disabled && (
|
|
56
|
+
<button type="button" className="xeplr-textfield-clear" onClick={handleClear} title="Clear">✕</button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* The error REPLACES the help text rather than stacking under it: two
|
|
61
|
+
lines of guidance where one contradicts the other is worse than one. */}
|
|
62
|
+
{error ? (
|
|
63
|
+
<p className="xeplr-textfield-error">{error}</p>
|
|
64
|
+
) : help ? (
|
|
65
|
+
<p className="xeplr-textfield-help">{help}</p>
|
|
66
|
+
) : null}
|
|
67
|
+
|
|
68
|
+
{/* Shown near the limit, not always. A counter reading 486 of 500 from
|
|
69
|
+
the moment the field appears is noise. */}
|
|
70
|
+
{remaining != null && maxLength != null && remaining <= Math.max(10, maxLength * 0.1) && (
|
|
71
|
+
<p className="xeplr-textfield-count">{remaining} left</p>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as TextFieldSample } from './TextFieldSample.jsx';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* Every value here comes from a --xeplr-input-* custom property, with the
|
|
2
|
+
shipped default as its fallback. That is what makes "border: bottom only"
|
|
3
|
+
a theme setting rather than a fork of this stylesheet — see the `input`
|
|
4
|
+
section of default.theme.json and inputStyleVars(). */
|
|
5
|
+
|
|
6
|
+
/* The gap is the control's OWN spacing when it stands alone in a form. Inside
|
|
7
|
+
a grid the row owns the spacing, so a host can zero it — see .dsh-prop. */
|
|
8
|
+
.xeplr-textfield { display: block; margin-bottom: var(--xeplr-input-text-gap, 12px); }
|
|
9
|
+
|
|
10
|
+
/* The label takes the FULL Font block, so anything the theme accepts for it
|
|
11
|
+
actually renders — underline included. A property the theme takes and the
|
|
12
|
+
stylesheet ignores is a setting that silently does nothing. */
|
|
13
|
+
.xeplr-textfield-label {
|
|
14
|
+
display: block;
|
|
15
|
+
font-family: var(--xeplr-input-text-label-family, inherit);
|
|
16
|
+
font-size: var(--xeplr-input-text-label-size, 11px);
|
|
17
|
+
font-weight: var(--xeplr-input-text-label-weight, 600);
|
|
18
|
+
font-style: var(--xeplr-input-text-label-style, normal);
|
|
19
|
+
font-variant: var(--xeplr-input-text-label-variant, normal);
|
|
20
|
+
color: var(--xeplr-input-text-label-color, #6b6a63);
|
|
21
|
+
line-height: var(--xeplr-input-text-label-line-height, 1.3);
|
|
22
|
+
letter-spacing: var(--xeplr-input-text-label-spacing, .03em);
|
|
23
|
+
text-align: var(--xeplr-input-text-label-align, left);
|
|
24
|
+
text-transform: var(--xeplr-input-text-label-transform, uppercase);
|
|
25
|
+
text-decoration: var(--xeplr-input-text-label-decoration, none);
|
|
26
|
+
opacity: var(--xeplr-input-text-label-opacity, 1);
|
|
27
|
+
margin-bottom: 5px;
|
|
28
|
+
}
|
|
29
|
+
.xeplr-textfield-required { color: var(--xeplr-input-text-invalid-color, #c2372e); }
|
|
30
|
+
|
|
31
|
+
.xeplr-textfield-control { position: relative; display: flex; }
|
|
32
|
+
|
|
33
|
+
.xeplr-textfield-input {
|
|
34
|
+
width: 100%;
|
|
35
|
+
font: inherit;
|
|
36
|
+
font-size: var(--xeplr-input-text-font-size, 12.5px);
|
|
37
|
+
color: var(--xeplr-input-text-color, #1a1a19);
|
|
38
|
+
background: var(--xeplr-input-text-bg, #fff);
|
|
39
|
+
padding: var(--xeplr-input-text-padding, 7px 9px);
|
|
40
|
+
/* Each edge on its own, so a theme can keep one and drop three. */
|
|
41
|
+
border-style: var(--xeplr-input-text-border-style, solid);
|
|
42
|
+
border-color: var(--xeplr-input-text-border-color, #e2e1d9);
|
|
43
|
+
border-top-width: var(--xeplr-input-text-border-top, 1px);
|
|
44
|
+
border-right-width: var(--xeplr-input-text-border-right, 1px);
|
|
45
|
+
border-bottom-width: var(--xeplr-input-text-border-bottom, 1px);
|
|
46
|
+
border-left-width: var(--xeplr-input-text-border-left, 1px);
|
|
47
|
+
border-radius: var(--xeplr-input-text-radius, 6px);
|
|
48
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
49
|
+
}
|
|
50
|
+
.xeplr-textfield-input:hover:not(:disabled) {
|
|
51
|
+
border-color: var(--xeplr-input-text-border-hover, #b6b4a8);
|
|
52
|
+
}
|
|
53
|
+
.xeplr-textfield-input:focus {
|
|
54
|
+
outline: none;
|
|
55
|
+
border-color: var(--xeplr-input-text-border-focus, #2563eb);
|
|
56
|
+
box-shadow: 0 0 0 var(--xeplr-input-text-ring, 3px) var(--xeplr-input-text-ring-color, rgba(37, 99, 235, .15));
|
|
57
|
+
}
|
|
58
|
+
.xeplr-textfield-input:disabled { opacity: .55; cursor: not-allowed; }
|
|
59
|
+
.xeplr-textfield-input::placeholder { color: var(--xeplr-input-text-placeholder, #9d9b92); }
|
|
60
|
+
|
|
61
|
+
textarea.xeplr-textfield-input { resize: vertical; min-height: 64px; }
|
|
62
|
+
|
|
63
|
+
.xeplr-textfield-invalid .xeplr-textfield-input {
|
|
64
|
+
border-color: var(--xeplr-input-text-invalid-color, #c2372e);
|
|
65
|
+
}
|
|
66
|
+
.xeplr-textfield-invalid .xeplr-textfield-input:focus {
|
|
67
|
+
box-shadow: 0 0 0 var(--xeplr-input-text-ring, 3px) var(--xeplr-input-text-invalid-ring, rgba(194, 55, 46, .15));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.xeplr-textfield-clear {
|
|
71
|
+
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
|
72
|
+
border: none; background: none; cursor: pointer;
|
|
73
|
+
color: var(--xeplr-input-text-label-color, #6b6a63);
|
|
74
|
+
font-size: 11px; padding: 2px 4px; border-radius: 4px;
|
|
75
|
+
}
|
|
76
|
+
.xeplr-textfield-clear:hover { background: rgba(0, 0, 0, .06); }
|
|
77
|
+
|
|
78
|
+
.xeplr-textfield-help,
|
|
79
|
+
.xeplr-textfield-error,
|
|
80
|
+
.xeplr-textfield-count {
|
|
81
|
+
margin: 5px 0 0;
|
|
82
|
+
font-size: var(--xeplr-input-text-help-size, 11px);
|
|
83
|
+
line-height: 1.5;
|
|
84
|
+
}
|
|
85
|
+
.xeplr-textfield-help {
|
|
86
|
+
font-family: var(--xeplr-input-text-help-family, inherit);
|
|
87
|
+
font-style: var(--xeplr-input-text-help-style, normal);
|
|
88
|
+
text-decoration: var(--xeplr-input-text-help-decoration, none);
|
|
89
|
+
color: var(--xeplr-input-text-help-color, #6b6a63);
|
|
90
|
+
}
|
|
91
|
+
.xeplr-textfield-error { color: var(--xeplr-input-text-invalid-color, #c2372e); }
|
|
92
|
+
.xeplr-textfield-count { color: var(--xeplr-input-text-label-color, #6b6a63); text-align: right; }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useTextFieldController } from './useTextFieldController.js';
|
|
2
|
+
import TextFieldSample from './designs/TextFieldSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { TEXT_FIELD_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function TextFieldPage(props) {
|
|
7
|
+
var controller = useTextFieldController(props);
|
|
8
|
+
var ref = useDesignValidator('TextFieldPage', TEXT_FIELD_RULES);
|
|
9
|
+
return <div ref={ref}><TextFieldSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextField — pure model. No React.
|
|
3
|
+
*
|
|
4
|
+
* The plain text input, and the two shapes that are the same control with a
|
|
5
|
+
* different edge: a multi-line one and a search one. Kept together because
|
|
6
|
+
* they are one thing to style — an app that wants a bottom-rule-only textbox
|
|
7
|
+
* wants a bottom-rule-only search box too, and splitting them is how those
|
|
8
|
+
* drift apart.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export var TEXT_TYPES = ['text', 'multiline', 'search'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the control accepts as a value. Anything nullish is the empty string:
|
|
15
|
+
* a controlled input handed `undefined` switches to uncontrolled mid-life and
|
|
16
|
+
* React warns about it, which is a bug that only appears once somebody clears
|
|
17
|
+
* a field.
|
|
18
|
+
*/
|
|
19
|
+
export function toValue(raw) {
|
|
20
|
+
return raw === null || raw === undefined ? '' : String(raw);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Trimming is a CHOICE, not a default. " " is a legitimate value in a search
|
|
25
|
+
* box and a mistake in a name field, and only the caller knows which.
|
|
26
|
+
*/
|
|
27
|
+
export function normalise(raw, options) {
|
|
28
|
+
var value = toValue(raw);
|
|
29
|
+
var opts = options || {};
|
|
30
|
+
if (opts.trim) value = value.trim();
|
|
31
|
+
if (opts.maxLength != null && value.length > opts.maxLength) value = value.slice(0, opts.maxLength);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the value satisfies the constraints, and what to say if not.
|
|
37
|
+
*
|
|
38
|
+
* Returns null when it is fine — so a caller can write `error = validate(...)`
|
|
39
|
+
* and render it directly, rather than unpacking a result object to find out
|
|
40
|
+
* there was nothing wrong.
|
|
41
|
+
*/
|
|
42
|
+
export function validate(raw, options) {
|
|
43
|
+
var opts = options || {};
|
|
44
|
+
var value = toValue(raw);
|
|
45
|
+
var trimmed = value.trim();
|
|
46
|
+
|
|
47
|
+
if (opts.required && !trimmed) return opts.requiredMessage || 'This is required';
|
|
48
|
+
if (opts.minLength != null && trimmed.length > 0 && trimmed.length < opts.minLength) {
|
|
49
|
+
return 'At least ' + opts.minLength + ' characters';
|
|
50
|
+
}
|
|
51
|
+
if (opts.maxLength != null && value.length > opts.maxLength) {
|
|
52
|
+
return 'At most ' + opts.maxLength + ' characters';
|
|
53
|
+
}
|
|
54
|
+
if (opts.pattern) {
|
|
55
|
+
var re = opts.pattern instanceof RegExp ? opts.pattern : new RegExp(opts.pattern);
|
|
56
|
+
if (trimmed && !re.test(trimmed)) return opts.patternMessage || 'That is not a valid value';
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How many characters are left, or null when there is no limit to count to. */
|
|
62
|
+
export function remaining(raw, maxLength) {
|
|
63
|
+
if (maxLength == null) return null;
|
|
64
|
+
return maxLength - toValue(raw).length;
|
|
65
|
+
}
|