@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 changes `onChange` on every edit, and one open side is allowed. The Single tab commits only once the text parses ("Not a date yet" otherwise), on blur or Enter. 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
+ **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "UI utility components: file upload with progress, snackbar, static data (countries/states)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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">Leave either side empty for an open-ended range.</div>
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
- var range = value && value.kind === 'range' ? value : { from: '', to: '' };
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
- onChange({ kind: 'range', from: from, to: range.to || '' });
88
+ setRange(function(r) { return { from: from || '', to: r.to }; });
75
89
  }
76
90
 
77
91
  function setRangeTo(to) {
78
- onChange({ kind: 'range', from: range.from || '', to: to });
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,
package/src/index.js CHANGED
@@ -92,7 +92,8 @@ export {
92
92
  resolveQuickPreset,
93
93
  resolveDateValue,
94
94
  dateValueLabel,
95
- dateValueIsSet
95
+ dateValueIsSet,
96
+ rangeProblem
96
97
  } from './dateRange/dateRange.js';
97
98
 
98
99
  // DateRange — controller