@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,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
|
+
];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import './dropdown.css';
|
|
2
|
+
|
|
3
|
+
function initials(name) {
|
|
4
|
+
if (!name) return '·';
|
|
5
|
+
var parts = String(name).trim().split(/\s+/).slice(0, 2);
|
|
6
|
+
return parts.map(function(p) { return p.charAt(0).toUpperCase(); }).join('');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function joinExtras(item, extras) {
|
|
10
|
+
var bits = [];
|
|
11
|
+
for (var i = 0; i < extras.length; i++) {
|
|
12
|
+
var v = item[extras[i]];
|
|
13
|
+
if (v != null && v !== '') bits.push(String(v));
|
|
14
|
+
}
|
|
15
|
+
return bits.join(' · ');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function summary(selectedItems, mode, keys, placeholder) {
|
|
19
|
+
if (selectedItems.length === 0) return <span className="xeplr-dropdown-placeholder">{placeholder}</span>;
|
|
20
|
+
if (mode === 'single') {
|
|
21
|
+
return <span className="xeplr-dropdown-selected-single">{selectedItems[0][keys.name]}</span>;
|
|
22
|
+
}
|
|
23
|
+
var first = selectedItems[0][keys.name];
|
|
24
|
+
var more = selectedItems.length - 1;
|
|
25
|
+
return (
|
|
26
|
+
<span className="xeplr-dropdown-selected-multi">
|
|
27
|
+
{first}{more > 0 ? <span className="xeplr-dropdown-selected-more"> +{more}</span> : null}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function DropdownSample({
|
|
33
|
+
mode, keys, searchable, placeholder, disabled,
|
|
34
|
+
filteredItems, selectedItems, loading, error, query, isOpen, rootRef,
|
|
35
|
+
isSelected, toggle, selectItem, removeSelection, clearSelection, handleSearch,
|
|
36
|
+
canCreate, createLabel, creating, createError, createItem
|
|
37
|
+
}) {
|
|
38
|
+
var hasSelection = selectedItems.length > 0;
|
|
39
|
+
var trimmedQuery = (query || '').trim();
|
|
40
|
+
var exactMatch = filteredItems.some(function(item) {
|
|
41
|
+
return String(item[keys.name]).toLowerCase() === trimmedQuery.toLowerCase();
|
|
42
|
+
});
|
|
43
|
+
var showCreate = canCreate && trimmedQuery && !exactMatch;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
ref={rootRef}
|
|
48
|
+
className={'xeplr-dropdown xeplr-dropdown-' + mode + (isOpen ? ' xeplr-dropdown-open' : '') + (disabled ? ' xeplr-dropdown-disabled' : '')}
|
|
49
|
+
>
|
|
50
|
+
<div
|
|
51
|
+
className="xeplr-dropdown-trigger"
|
|
52
|
+
role="combobox"
|
|
53
|
+
aria-expanded={isOpen}
|
|
54
|
+
onClick={toggle}
|
|
55
|
+
>
|
|
56
|
+
{mode === 'multi' && hasSelection ? (
|
|
57
|
+
<div className="xeplr-dropdown-chips">
|
|
58
|
+
{selectedItems.map(function(item) {
|
|
59
|
+
return (
|
|
60
|
+
<span key={item[keys.id]} className="xeplr-dropdown-chip">
|
|
61
|
+
{item[keys.name]}
|
|
62
|
+
<span
|
|
63
|
+
className="xeplr-dropdown-chip-x"
|
|
64
|
+
onClick={function(e) { e.stopPropagation(); removeSelection(item[keys.id]); }}
|
|
65
|
+
>×</span>
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
) : summary(selectedItems, mode, keys, placeholder)}
|
|
71
|
+
<span className="xeplr-dropdown-caret">⌄</span>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div
|
|
75
|
+
className={'xeplr-dropdown-panel' + (isOpen ? ' xeplr-dropdown-panel-open' : '')}
|
|
76
|
+
role="listbox"
|
|
77
|
+
>
|
|
78
|
+
{searchable && (
|
|
79
|
+
<div className="xeplr-dropdown-search">
|
|
80
|
+
<input
|
|
81
|
+
autoFocus
|
|
82
|
+
className="xeplr-dropdown-search-input"
|
|
83
|
+
placeholder="Search…"
|
|
84
|
+
value={query}
|
|
85
|
+
onChange={handleSearch}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{loading && <div className="xeplr-dropdown-state">Loading…</div>}
|
|
91
|
+
{error && <div className="xeplr-dropdown-state xeplr-dropdown-state-error">{error}</div>}
|
|
92
|
+
|
|
93
|
+
{!loading && !error && filteredItems.length === 0 && !showCreate && (
|
|
94
|
+
<div className="xeplr-dropdown-state">
|
|
95
|
+
{canCreate && !trimmedQuery ? 'Type a name to add' : 'No matches'}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{!loading && !error && filteredItems.map(function(item) {
|
|
100
|
+
var selected = isSelected(item[keys.id]);
|
|
101
|
+
var meta = joinExtras(item, keys.extras);
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
key={item[keys.id]}
|
|
105
|
+
role="option"
|
|
106
|
+
aria-selected={selected}
|
|
107
|
+
className={'xeplr-dropdown-row' + (selected ? ' xeplr-dropdown-row-selected' : '')}
|
|
108
|
+
onClick={function() { selectItem(item[keys.id]); }}
|
|
109
|
+
>
|
|
110
|
+
{mode === 'multi' && (
|
|
111
|
+
<span className={'xeplr-dropdown-check' + (selected ? ' xeplr-dropdown-check-on' : '')}>
|
|
112
|
+
{selected ? '✓' : ''}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
<span className="xeplr-dropdown-avatar">{initials(item[keys.name])}</span>
|
|
116
|
+
<span className="xeplr-dropdown-info">
|
|
117
|
+
<span className="xeplr-dropdown-name">{item[keys.name]}</span>
|
|
118
|
+
{meta && <span className="xeplr-dropdown-meta">{meta}</span>}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
|
|
124
|
+
{showCreate && (
|
|
125
|
+
<div
|
|
126
|
+
role="option"
|
|
127
|
+
className="xeplr-dropdown-row xeplr-dropdown-create"
|
|
128
|
+
onClick={function() { if (!creating) createItem(); }}
|
|
129
|
+
>
|
|
130
|
+
<span className="xeplr-dropdown-avatar xeplr-dropdown-create-plus">+</span>
|
|
131
|
+
<span className="xeplr-dropdown-info">
|
|
132
|
+
<span className="xeplr-dropdown-name">
|
|
133
|
+
{creating ? 'Adding…' : (createLabel + ' “' + trimmedQuery + '”')}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{createError && (
|
|
140
|
+
<div className="xeplr-dropdown-state xeplr-dropdown-state-error">{createError}</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{hasSelection && (
|
|
144
|
+
<div className="xeplr-dropdown-footer">
|
|
145
|
+
<span className="xeplr-dropdown-clear" onClick={clearSelection}>Clear selection</span>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|