@xeplr/ui-utils 1.0.7 → 1.0.8
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/README.md
CHANGED
|
@@ -108,7 +108,7 @@ export default function SelectCompany() {
|
|
|
108
108
|
| hook | takes | returns |
|
|
109
109
|
|---|---|---|
|
|
110
110
|
| `useDropdownController(props)` | DropdownPage props | `mode, source, keys {id,name,extras}, searchable, canCreate, createLabel, creating, createError, createItem(name?), placeholder, disabled, items, filteredItems, selectedItems, selection, loading, error, query, isOpen, rootRef, isSelected(id), open, close, toggle, selectItem(id), removeSelection(id), clearSelection, handleSearch(e), refresh` |
|
|
111
|
-
| `useDateRangeController(props)` | DateRangePage props | `tab, setTab, presets, value, label, range, single, setSingle, singleParsed, suggestions, applySuggestion(text), selectPreset(key), setRangeFrom, setRangeTo, commitSingle(close), clear, allowClear, inheritedLabel` |
|
|
111
|
+
| `useDateRangeController(props)` | DateRangePage props | `tab, setTab, presets, value, label, range, single, setSingle, singleParsed, suggestions, applySuggestion(text), selectPreset(key), setRangeFrom, setRangeTo (edit the draft), applyRange(), rangeProblem, rangeDirty, commitSingle(close), clear, allowClear, inheritedLabel` |
|
|
112
112
|
| `useRangeFieldController(props)` | RangeFieldPage props | `fromLabel, toLabel, from, to`. `from` and `to` are each a `useDateFieldController` result. |
|
|
113
113
|
| `useDateFieldController(props)` | DateFieldPage props | `type, inputType, raw, age, error, placeholder, disabled, min, max, handleChange` |
|
|
114
114
|
| `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
|
|
@@ -129,6 +129,7 @@ export default function SelectCompany() {
|
|
|
129
129
|
| `resolveDateValue` | `(selection, now?, options?) → { from, to } \| null` | Resolves a `quick` / `range` / `single` selection. `null` means no constraint. |
|
|
130
130
|
| `dateValueLabel` | `(selection) → string \| null` | "Last month", "2022-01-01 → 2022-03-31", "From …", "Until …" |
|
|
131
131
|
| `dateValueIsSet` | `(selection, now?) → boolean` | True only if the selection resolves to a range |
|
|
132
|
+
| `rangeProblem` | `({ from, to }) → string \| null` | Why a typed range cannot be applied yet ("Choose a From or a To date", "From is after To"), or `null` |
|
|
132
133
|
| `RANGE_TYPES`, `emptyRange`, `normalizeRange`, `rangeIsSet` | `['date','datetime']`, `() → {from:'',to:''}`, `(r) → {from,to}`, `(r) → boolean` | From/to helpers. A range with one side set counts as set. |
|
|
133
134
|
| `DATE_TYPES`, `isValidISODate`, `calculateAge`, `inputTypeFor`, `toInputValue`, `validateBounds` | `['date','datetime','age']`, `(s)`, `(dob, now?)`, `(type)`, `(value, type)`, `(value, min, max) → { valid, error? }` | Date field helpers |
|
|
134
135
|
| `NUMBER_TYPES`, `sanitizeNumberInput`, `toNumeric`, `formatDisplay`, `validateRange`, `resolveAffixState` | `['float','currency','units']`, `(raw)`, `(raw) → number \| null`, `(value, decimals?)`, `(value, min, max) → { valid, error? }`, `(type, options)` | Number field helpers. `sanitizeNumberInput` keeps digits, the first `.`, and a leading `-`. |
|
|
@@ -189,12 +190,12 @@ export default function SelectCompany() {
|
|
|
189
190
|
|---|---|---|---|
|
|
190
191
|
| `value` | `{ kind: 'quick', preset } \| { kind: 'range', from, to } \| { kind: 'single', value } \| null` | `null` | The selection |
|
|
191
192
|
| `onChange` | `(selection \| null) => void` | no-op | |
|
|
192
|
-
| `onClose` | `() => void` | — | Called after a choice that ends the interaction: a preset, a suggestion, Enter in Single, or Clear |
|
|
193
|
+
| `onClose` | `() => void` | — | Called after a choice that ends the interaction: a preset, a suggestion, Apply (or Enter) in Range or Single, or Clear |
|
|
193
194
|
| `presets` | `[{ key, label }]` | `QUICK_PRESETS` | The Quick list. Only keys that `resolveQuickPreset` knows will resolve. |
|
|
194
195
|
| `allowClear` | `boolean` | `true` | `false` removes the Clear button, and `clear()` refuses too |
|
|
195
196
|
| `inheritedLabel` | `string` | — | Shows "Using …" when `value` is empty |
|
|
196
197
|
|
|
197
|
-
**Every selection resolves to `{ from, to }`, including a single date.** "May 2022" becomes 1–31 May and "2022" becomes the whole year, so a consumer never has to ask which comparison was meant. The Range tab
|
|
198
|
+
**Every selection resolves to `{ from, to }`, including a single date.** "May 2022" becomes 1–31 May and "2022" becomes the whole year, so a consumer never has to ask which comparison was meant. **The Range tab is applied, not taken as you type.** A browser date input holds a complete date while a year is half typed (`2` is `0002-01-01`), so committing each edit ran the host's query for the year 2. The inputs edit a draft, and `onChange` fires once, on **Apply** or Enter. Apply is disabled until the draft is valid (`rangeProblem`: something chosen, From not after To) and differs from what is applied. One open side is allowed. The Single tab commits only once the text parses ("Not a date yet" otherwise), on blur, Enter or Apply. While you type, it suggests months and years, putting the committed year first. The financial-year presets use `fiscalStartMonth` (default April). The page itself does not pass that option, so change it by calling `resolveDateValue(value, now, { fiscalStartMonth })` where you consume the value.
|
|
198
199
|
|
|
199
200
|
## RangeFieldPage
|
|
200
201
|
|
package/package.json
CHANGED
|
@@ -271,3 +271,22 @@ export function dateValueLabel(value) {
|
|
|
271
271
|
export function dateValueIsSet(value, now) {
|
|
272
272
|
return Boolean(resolveDateValue(value, now || new Date()))
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Why a typed range cannot be applied yet, or null when it can.
|
|
277
|
+
*
|
|
278
|
+
* A browser date input holds a COMPLETE date while the year is still being
|
|
279
|
+
* typed ("2" is 0002-…), so a range is never taken as you type — it is
|
|
280
|
+
* applied (the Apply button, or Enter), and this is what Apply asks first.
|
|
281
|
+
* One side empty is fine: "everything since April" is a real range.
|
|
282
|
+
*
|
|
283
|
+
* @param {{from?: string, to?: string}} draft - ISO dates, either may be empty
|
|
284
|
+
* @returns {string|null}
|
|
285
|
+
*/
|
|
286
|
+
export function rangeProblem(draft) {
|
|
287
|
+
var from = (draft && draft.from) || '';
|
|
288
|
+
var to = (draft && draft.to) || '';
|
|
289
|
+
if (!from && !to) return 'Choose a From or a To date';
|
|
290
|
+
if (from && to && from > to) return 'From is after To';
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
@@ -56,17 +56,30 @@ export default function DateRangeSample(props) {
|
|
|
56
56
|
|
|
57
57
|
{props.tab === 'range' && (
|
|
58
58
|
<div className="xeplr-daterange-body">
|
|
59
|
+
{/* Edited as a draft and applied — the inputs hold a full date while
|
|
60
|
+
a year is half typed, so taking each change ran the report for
|
|
61
|
+
the year 2. Enter in either input applies too. */}
|
|
59
62
|
<label className="xeplr-daterange-field">
|
|
60
63
|
<span>From</span>
|
|
61
|
-
<input type="date" value={props.range.from || ''} onChange={function(e) { props.setRangeFrom(e.target.value); }}
|
|
64
|
+
<input type="date" value={props.range.from || ''} onChange={function(e) { props.setRangeFrom(e.target.value); }}
|
|
65
|
+
onKeyDown={function(e) { if (e.key === 'Enter') { e.preventDefault(); props.applyRange(); } }} />
|
|
62
66
|
</label>
|
|
63
67
|
<label className="xeplr-daterange-field">
|
|
64
68
|
<span>To</span>
|
|
65
|
-
<input type="date" value={props.range.to || ''} onChange={function(e) { props.setRangeTo(e.target.value); }}
|
|
69
|
+
<input type="date" value={props.range.to || ''} onChange={function(e) { props.setRangeTo(e.target.value); }}
|
|
70
|
+
onKeyDown={function(e) { if (e.key === 'Enter') { e.preventDefault(); props.applyRange(); } }} />
|
|
66
71
|
</label>
|
|
67
72
|
{/* One-sided is legitimate — "everything since April" is a real
|
|
68
73
|
answer — so it is stated rather than blocked. */}
|
|
69
|
-
<div className="xeplr-daterange-note">
|
|
74
|
+
<div className="xeplr-daterange-note">
|
|
75
|
+
{props.rangeProblem && (props.range.from || props.range.to)
|
|
76
|
+
? props.rangeProblem
|
|
77
|
+
: 'Leave either side empty for an open-ended range.'}
|
|
78
|
+
</div>
|
|
79
|
+
<div className="xeplr-daterange-actions">
|
|
80
|
+
<button type="button" className="xeplr-daterange-apply" data-xeplr-daterange-apply="range"
|
|
81
|
+
disabled={Boolean(props.rangeProblem) || !props.rangeDirty} onClick={props.applyRange}>Apply</button>
|
|
82
|
+
</div>
|
|
70
83
|
</div>
|
|
71
84
|
)}
|
|
72
85
|
|
|
@@ -117,6 +130,12 @@ export default function DateRangeSample(props) {
|
|
|
117
130
|
? (props.singleParsed ? props.singleParsed.from + ' → ' + props.singleParsed.to : 'Not a date yet')
|
|
118
131
|
: 'A day, a whole month, or a whole year.'}
|
|
119
132
|
</div>
|
|
133
|
+
<div className="xeplr-daterange-actions">
|
|
134
|
+
<button type="button" className="xeplr-daterange-apply" data-xeplr-daterange-apply="single"
|
|
135
|
+
disabled={!props.singleParsed}
|
|
136
|
+
// MOUSEDOWN: the input's onBlur would otherwise commit first.
|
|
137
|
+
onMouseDown={function(e) { e.preventDefault(); props.commitSingle(true); }}>Apply</button>
|
|
138
|
+
</div>
|
|
120
139
|
</div>
|
|
121
140
|
)}
|
|
122
141
|
|
|
@@ -67,3 +67,15 @@
|
|
|
67
67
|
}
|
|
68
68
|
.xeplr-daterange-suggest button:hover { background: var(--xeplr-hover, #f4f5f8); }
|
|
69
69
|
.xeplr-daterange-suggest-note { font-size: 11px; color: var(--xeplr-muted, #6b7280); }
|
|
70
|
+
|
|
71
|
+
/* Apply — a typed range (or date) reaches the host only through this, or
|
|
72
|
+
Enter. Disabled while there is nothing new to apply, or it is not valid. */
|
|
73
|
+
.xeplr-daterange-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
|
|
74
|
+
.xeplr-daterange-apply {
|
|
75
|
+
padding: 5px 14px; border-radius: 6px; cursor: pointer;
|
|
76
|
+
font-size: 12px; font-weight: 600;
|
|
77
|
+
border: 1px solid var(--xeplr-accent, #4f5fd6);
|
|
78
|
+
background: var(--xeplr-accent, #4f5fd6);
|
|
79
|
+
color: var(--xeplr-on-accent, #fff);
|
|
80
|
+
}
|
|
81
|
+
.xeplr-daterange-apply:disabled { opacity: 0.45; cursor: default; }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { QUICK_PRESETS, parseSingleDate, dateValueLabel, singleDateSuggestions } from './dateRange.js';
|
|
2
|
+
import { QUICK_PRESETS, parseSingleDate, dateValueLabel, singleDateSuggestions, rangeProblem } from './dateRange.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* DateRange — controller. Owns which tab is open and the half-typed text; the
|
|
@@ -37,7 +37,21 @@ export function useDateRangeController(props) {
|
|
|
37
37
|
if (value && value.kind === 'single') setSingle(value.value || '');
|
|
38
38
|
}, [value]);
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// THE RANGE IS A DRAFT until it is applied. It used to be committed on
|
|
41
|
+
// every change of either input — and a date input holds a complete date
|
|
42
|
+
// while the year is half typed ("2" is 0002-01-01), so typing 2026 ran the
|
|
43
|
+
// report for the year 2. Now the inputs edit this draft, and applyRange
|
|
44
|
+
// (the Apply button, or Enter) commits it.
|
|
45
|
+
var committedRange = value && value.kind === 'range' ? value : null;
|
|
46
|
+
var draftState = useState({ from: committedRange ? committedRange.from || '' : '', to: committedRange ? committedRange.to || '' : '' });
|
|
47
|
+
var range = draftState[0];
|
|
48
|
+
var setRange = draftState[1];
|
|
49
|
+
// A range chosen elsewhere (a reset, another record) replaces the draft.
|
|
50
|
+
useEffect(function() {
|
|
51
|
+
if (value && value.kind === 'range') setRange({ from: value.from || '', to: value.to || '' });
|
|
52
|
+
}, [value]);
|
|
53
|
+
var rangeIssue = rangeProblem(range);
|
|
54
|
+
var rangeDirty = !committedRange || (committedRange.from || '') !== range.from || (committedRange.to || '') !== range.to;
|
|
41
55
|
// Parsed as you type, so "May 202" reads as not-a-date-yet rather than
|
|
42
56
|
// silently saving as nothing.
|
|
43
57
|
var singleParsed = single ? parseSingleDate(single) : null;
|
|
@@ -71,11 +85,19 @@ export function useDateRangeController(props) {
|
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
function setRangeFrom(from) {
|
|
74
|
-
|
|
88
|
+
setRange(function(r) { return { from: from || '', to: r.to }; });
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
function setRangeTo(to) {
|
|
78
|
-
|
|
92
|
+
setRange(function(r) { return { from: r.from, to: to || '' }; });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Commits the draft. Refused while it has a problem (rangeProblem), so a
|
|
96
|
+
// custom design cannot apply a range Apply would have been disabled for.
|
|
97
|
+
function applyRange() {
|
|
98
|
+
if (rangeIssue) return;
|
|
99
|
+
onChange({ kind: 'range', from: range.from, to: range.to });
|
|
100
|
+
if (onClose) onClose();
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
// Only committed once it parses — a partial entry must not overwrite a good
|
|
@@ -110,6 +132,9 @@ export function useDateRangeController(props) {
|
|
|
110
132
|
selectPreset: selectPreset,
|
|
111
133
|
setRangeFrom: setRangeFrom,
|
|
112
134
|
setRangeTo: setRangeTo,
|
|
135
|
+
applyRange: applyRange,
|
|
136
|
+
rangeProblem: rangeIssue,
|
|
137
|
+
rangeDirty: rangeDirty,
|
|
113
138
|
commitSingle: commitSingle,
|
|
114
139
|
clear: clear,
|
|
115
140
|
allowClear: allowClear,
|