@xeplr/ui-utils 1.0.6 → 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
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# @xeplr/ui-utils
|
|
2
|
+
|
|
3
|
+
**The small shared UI pieces the xeplr apps and packages use.** It has form controls (dropdown, text, number, date, date range, from/to range), file upload and data import, import history, a searchable tile grid, and a progress notifier driven by server events. It also has imperative helpers that need no mount point (`raiseSnackbar`, `raiseConfirm`, `raiseChoice`), the default design theme that `@xeplr/ui-charts` and `@xeplr/ui-table` resolve against, and static country and state lists. Every control is split into a pure **model**, a **controller** hook and a replaceable **design**. A ready-made `*Page` component joins the three together.
|
|
4
|
+
|
|
5
|
+
(The package name on npm is `@xeplr/ui-utils`. The GitHub repo and folder are named `xeplr-ui-utils`.)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm i @xeplr/ui-utils @xeplr/ui-table @tanstack/react-table
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies: `react ^18 || ^19` and [`@xeplr/ui-table`](https://www.npmjs.com/package/@xeplr/ui-table), whose own peer is `@tanstack/react-table ^8`. `ImportHistoryPage` draws its table with `XeplrTable`. `src/index.js` imports that design statically, so importing anything from `@xeplr/ui-utils` requires `@xeplr/ui-table` to be installed.
|
|
14
|
+
|
|
15
|
+
The package ships its source (`main: src/index.js`), so your bundler must handle:
|
|
16
|
+
|
|
17
|
+
- **JSX** from `node_modules`;
|
|
18
|
+
- **CSS imports**, because each design imports its own stylesheet (for example `dropdown.css`), so you never import CSS yourself;
|
|
19
|
+
- **JSON imports** (`countries.json`, `states.json`, `default.theme.json`).
|
|
20
|
+
|
|
21
|
+
Vite does all three.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
From `xeplr-bi`'s company picker:
|
|
26
|
+
|
|
27
|
+
```jsx
|
|
28
|
+
import { useState } from 'react'
|
|
29
|
+
import { DropdownPage, raiseSnackbar } from '@xeplr/ui-utils'
|
|
30
|
+
|
|
31
|
+
export default function SelectCompany() {
|
|
32
|
+
const [company, setCompany] = useState(null)
|
|
33
|
+
return (
|
|
34
|
+
<DropdownPage
|
|
35
|
+
fetchData={listCompanies} // () => Promise<[{ id, name }]>
|
|
36
|
+
onCreate={createCompany} // (name) => Promise<created item or its id>
|
|
37
|
+
keys={['id', 'name']}
|
|
38
|
+
value={company ? company.id : null}
|
|
39
|
+
onChange={(id, items) => {
|
|
40
|
+
setCompany(items[0] || null)
|
|
41
|
+
if (items[0]) raiseSnackbar('Switched to ' + items[0].name, { design: 'success' })
|
|
42
|
+
}}
|
|
43
|
+
placeholder="Select company"
|
|
44
|
+
createLabel="Add company"
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Three ways to use it
|
|
51
|
+
|
|
52
|
+
1. **Ready-made.** `DropdownPage`, `DateRangePage`, `RangeFieldPage`, `DateFieldPage`, `NumberFieldPage`, `TextFieldPage`, `FileUploadPage`, `DataImportPage`, `ImportHistoryPage`, `GridDisplayerPage`, and also `ProgressNotifier`. Each page is `controller(props)` → `<Sample {...controller} />`, wrapped in a `<div>` that checks the design against its rules.
|
|
53
|
+
2. **Your own design.** Call `use*Controller(props)` and draw the returned state and handlers however you like. To keep the development-time check, put `useDesignValidator(name, *_RULES)`'s ref on your wrapper (see [Design rules](#design-rules)).
|
|
54
|
+
|
|
55
|
+
```jsx
|
|
56
|
+
import { useDropdownController, useDesignValidator, DROPDOWN_RULES } from '@xeplr/ui-utils'
|
|
57
|
+
|
|
58
|
+
function CompanyPicker(props) {
|
|
59
|
+
const c = useDropdownController(props)
|
|
60
|
+
const ref = useDesignValidator('CompanyPicker', DROPDOWN_RULES)
|
|
61
|
+
return (
|
|
62
|
+
<div ref={ref}>
|
|
63
|
+
<div ref={c.rootRef} className="xeplr-dropdown">
|
|
64
|
+
<button role="combobox" onClick={c.toggle}>{c.selectedItems[0]?.name || c.placeholder}</button>
|
|
65
|
+
{/* always mounted: the rules are checked once, at mount, while the panel is closed */}
|
|
66
|
+
<ul role="listbox" hidden={!c.isOpen}>
|
|
67
|
+
{c.filteredItems.map((it) => (
|
|
68
|
+
<li key={it.id} onClick={() => c.selectItem(it.id)}>{it.name}</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
3. **Model only.** The model files import neither React nor the DOM: `dateRange.js`, `rangeField.js`, `textField.js`, `numberField.js`, `dateField.js`, `dropdown.js`, `gridDisplayer.js`, `dataImport.js`, and `theme/index.js`. They are exported from the root (for a bundler). In plain Node, import the file directly, because the root index pulls in JSX, CSS and JSON. The tests do this:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { resolveDateValue } from '@xeplr/ui-utils/src/dateRange/dateRange.js'
|
|
81
|
+
resolveDateValue({ kind: 'quick', preset: 'last_month' }, new Date(2026, 6, 29))
|
|
82
|
+
// → { from: '2026-06-01', to: '2026-06-30' }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`theme/index.js` imports its JSON with `with { type: 'json' }`, which needs a Node version that supports import attributes. CI uses Node 24.
|
|
86
|
+
|
|
87
|
+
## Exports
|
|
88
|
+
|
|
89
|
+
### Pages and components
|
|
90
|
+
|
|
91
|
+
| export | props | what it is |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `DropdownPage` | [see below](#dropdownpage) | Single or multi select over static or fetched items, with search and optional create |
|
|
94
|
+
| `DateRangePage` | [see below](#daterangepage) | Quick preset / Range / Single tabs, all resolving to `{ from, to }` |
|
|
95
|
+
| `RangeFieldPage` | [see below](#rangefieldpage) | A From and a To date input drawn as one field, each bounded by the other |
|
|
96
|
+
| `DateFieldPage` | [see below](#datefieldpage) | Date, datetime, or date of birth with the age computed |
|
|
97
|
+
| `NumberFieldPage` | [see below](#numberfieldpage) | Float, currency or units input with an affix picker |
|
|
98
|
+
| `TextFieldPage` | [see below](#textfieldpage) | Text, multiline or search input, themed by `--xeplr-input-text-*` |
|
|
99
|
+
| `FileUploadPage` | [see below](#fileuploadpage) | Drag-and-drop upload with type and size checks and a progress bar |
|
|
100
|
+
| `DataImportPage` | [see below](#dataimportpage) | Upload a file, map its parts to targets, commit |
|
|
101
|
+
| `ImportHistoryPage` | [see below](#importhistorypage) | Past imports in a `XeplrTable`, with Rollback and Delete |
|
|
102
|
+
| `GridDisplayerPage` | [see below](#griddisplayerpage) | Searchable, sortable grid of tiles you render |
|
|
103
|
+
| `ProgressNotifier` | [see below](#progressnotifier) | Indeterminate bar and a line of text for one event key |
|
|
104
|
+
| `FileUploadSample`, `DataImportSample`, `ImportHistorySample`, `NumberFieldSample`, `DateFieldSample`, `DateRangeSample`, `RangeFieldSample`, `DropdownSample`, `GridDisplayerSample`, `TextFieldSample` | the matching controller's return value | The default designs, usable on their own |
|
|
105
|
+
|
|
106
|
+
### Controllers (hooks)
|
|
107
|
+
|
|
108
|
+
| hook | takes | returns |
|
|
109
|
+
|---|---|---|
|
|
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 (edit the draft), applyRange(), rangeProblem, rangeDirty, commitSingle(close), clear, allowClear, inheritedLabel` |
|
|
112
|
+
| `useRangeFieldController(props)` | RangeFieldPage props | `fromLabel, toLabel, from, to`. `from` and `to` are each a `useDateFieldController` result. |
|
|
113
|
+
| `useDateFieldController(props)` | DateFieldPage props | `type, inputType, raw, age, error, placeholder, disabled, min, max, handleChange` |
|
|
114
|
+
| `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
|
|
115
|
+
| `useTextFieldController(props)` | TextFieldPage props | `type, raw, error, remaining, placeholder, disabled, required, maxLength, rows, label, help, handleChange, handleBlur, handleKeyDown, handleClear` |
|
|
116
|
+
| `useFileUploadController(options)` | FileUploadPage props | `files, uploading, progress, error, result, dragOver, inputRef, handleChange, handleDragOver, handleDragLeave, handleDrop, handleUpload, openFilePicker, removeFile(i), clear, allowedTypes, maxSize, maxFiles` |
|
|
117
|
+
| `useDataImportController(options)` | DataImportPage props | `file, inspecting, inspected, mappings, committing, results, error, handleFileChange, updateMapping(i, patch), handleCommit, reset, accept, targetLabel` |
|
|
118
|
+
| `useImportHistoryController(options)` | ImportHistoryPage props | `jobs, loading, error, rollingBackId, deletingId, reload, handleRollback(job), handleDelete(job)` |
|
|
119
|
+
| `useGridDisplayerController(props)` | GridDisplayerPage props | `query, setQuery, sortFields, sortValue, setSortValue, displayRows, tileFormatComponent, onSelect, onAddNew` |
|
|
120
|
+
| `useDesignValidator(name, rules)` | page name, rule list | a ref for the design's container |
|
|
121
|
+
|
|
122
|
+
### Model functions (no React)
|
|
123
|
+
|
|
124
|
+
| export | signature | what it does |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `QUICK_PRESETS` | `[{ key, label }]` | 17 presets: `today`, `yesterday`, `dby`, `last_7_days`, `last_30_days`, `current_month`, `current_mtd`, `last_month`, `current_quarter`, `current_qtd`, `last_quarter`, `current_calendar_year`, `current_ytd`, `last_calendar_year`, `current_financial_year`, `current_fytd`, `last_financial_year` |
|
|
127
|
+
| `resolveQuickPreset` | `(preset, now, { fiscalStartMonth = 4 }?) → { from, to } \| null` | Resolves a preset against `now`. Unknown presets give `null`. |
|
|
128
|
+
| `parseSingleDate` | `(text) → { from, to } \| null` | Accepts `2022`, `2022-05`, `2022-05-17`, `May 2022` / `may 2022` / `Feb 2022`, and `17/5/2022` (day first) |
|
|
129
|
+
| `resolveDateValue` | `(selection, now?, options?) → { from, to } \| null` | Resolves a `quick` / `range` / `single` selection. `null` means no constraint. |
|
|
130
|
+
| `dateValueLabel` | `(selection) → string \| null` | "Last month", "2022-01-01 → 2022-03-31", "From …", "Until …" |
|
|
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` |
|
|
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. |
|
|
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 |
|
|
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 `-`. |
|
|
136
|
+
| `TEXT_TYPES`, `toValue`, `normalise`, `validate`, `remaining` | `['text','multiline','search']`, `(raw) → string`, `(raw, { trim, maxLength })`, `(raw, { required, requiredMessage, minLength, maxLength, pattern, patternMessage }) → string \| null`, `(raw, maxLength) → number \| null` | Text field helpers |
|
|
137
|
+
| `DROPDOWN_MODES`, `normalizeKeys`, `sortByKey`, `filterByQuery`, `pickSelected` | `['single','multi']`, `(keys) → { id, name, extras }`, `(items, key)`, `(items, query, searchKeys)`, `(items, ids, idKey)` | Dropdown helpers |
|
|
138
|
+
| `filterGridRows`, `sortGridRows` | `(rows, query, fields)`, `(rows, field, 'asc'\|'desc')` | Grid search and sort. An array field matches if any entry matches. |
|
|
139
|
+
| `sanitizeTargetName`, `buildInitialMappings` | `(name) → string`, `({ parts?, originalName? }) → [{ part, target, include }]` | Data import mapping |
|
|
140
|
+
| `configureUpload`, `uploadFile` | `({ url, headers, getHeaders })`, `(files, { url, fieldName, extraData, onProgress }) → Promise` | Multipart upload over XHR |
|
|
141
|
+
| `attachProgressSource`, `progressSourceAttached`, `subscribeToProgress`, `describeProgress`, `describeComplete` | `(subscribe)`, `() → boolean`, `(key, handler) → unsubscribe`, `(data, noun?)`, `(data, noun?)` | The progress event source and its sentences |
|
|
142
|
+
|
|
143
|
+
### Imperative helpers, theme, data
|
|
144
|
+
|
|
145
|
+
| export | signature | what it does |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `raiseSnackbar` | `(message, { design: 'success'\|'error'\|'default', duration = 3000 }?)` | Toast in the bottom-right corner. Click to dismiss. |
|
|
148
|
+
| `raiseConfirm` | `(message, { title, confirmLabel, cancelLabel, danger }?) → Promise<boolean>` | Yes/no modal dialog |
|
|
149
|
+
| `raiseChoice` | `(message, { title, choices: [{ value, label, style?, isDefault? }], dismissValue = null }) → Promise<value>` | Modal dialog with any number of answers |
|
|
150
|
+
| `DEFAULT_THEME` | object | `default.theme.json`: `{ name, default: 'light', font, themes: { light, dark } }`. Each variant has `chart`, `table` and `input: { text, select, number, date }`. |
|
|
151
|
+
| `resolveTheme` | `(overrides?) → theme` | `deepMerge(DEFAULT_THEME, overrides)` |
|
|
152
|
+
| `getThemeVariant` | `(name, overrides?) → { chart, table, input }` | One variant. An unknown name falls back to `default`. |
|
|
153
|
+
| `getChartOptions` | `(variantName, overrides?) → ChartOptions` | The variant's `chart` section plus `fontFamily` |
|
|
154
|
+
| `inputStyleVars` | `(control, overrides?, variantName?) → { '--xeplr-input-<control>-…': value }` | Theme values as CSS custom properties for one control |
|
|
155
|
+
| `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
|
|
156
|
+
| `COUNTRIES` | `[{ code, name }]` | 196 countries |
|
|
157
|
+
| `STATES`, `STATES_IN` | `{ IN: [{ code, name }] }`, `[{ code, name }]` | 36 Indian states and union territories |
|
|
158
|
+
| `FILE_UPLOAD_RULES`, `DATA_IMPORT_RULES`, `IMPORT_HISTORY_RULES`, `NUMBER_FIELD_RULES`, `DATE_FIELD_RULES`, `DATE_RANGE_RULES`, `RANGE_FIELD_RULES`, `DROPDOWN_RULES`, `GRID_DISPLAYER_RULES`, `TEXT_FIELD_RULES` | rule arrays | What each page requires of a design |
|
|
159
|
+
|
|
160
|
+
## DropdownPage
|
|
161
|
+
|
|
162
|
+
| prop | type | default | meaning |
|
|
163
|
+
|---|---|---|---|
|
|
164
|
+
| `mode` | `'single' \| 'multi'` | `'single'` | Any other value throws |
|
|
165
|
+
| `options` | `Array` | — | Static items. Used when `fetchData` is absent. |
|
|
166
|
+
| `fetchData` | `() => Promise<Array>` | — | Loads items **once, on mount** (see `refresh` on the controller) |
|
|
167
|
+
| `keys` | `string[]` | `['id', 'name']` | `[idKey, nameKey, ...extraKeys]`. Extras are shown under the name and are searchable. |
|
|
168
|
+
| `value` | `id \| id[] \| null` | — | Controlled selection |
|
|
169
|
+
| `defaultValue` | `id \| id[]` | — | Uncontrolled initial selection |
|
|
170
|
+
| `onChange` | `(value, items) => void` | — | Single mode gets `id \| null`, multi mode gets `id[]`. `items` are the selected item objects. |
|
|
171
|
+
| `placeholder` | `string` | `'Select…'` | |
|
|
172
|
+
| `searchable` | `boolean` | `true` | Search box in the panel |
|
|
173
|
+
| `disabled` | `boolean` | `false` | |
|
|
174
|
+
| `onCreate` | `(name) => Promise<item \| id>` | — | Enables an "Add “typed text”" row |
|
|
175
|
+
| `allowCreate` | `boolean` | `true` | Set `false` to hide the create row even when `onCreate` is given |
|
|
176
|
+
| `createLabel` | `string` | `'Add'` | Prefix of the create row, for example `'Add company'` |
|
|
177
|
+
|
|
178
|
+
- Items are sorted by the name key, ignoring case. Search matches the name and extra keys as a substring, also ignoring case.
|
|
179
|
+
- The create row appears only when there is typed text and no item's name matches it exactly. After `onCreate`, fetched items are loaded again so the new row has its real server id. The new item is then selected, found by the returned object's id, a returned scalar, or failing both a name match.
|
|
180
|
+
- Clicking outside closes the panel. Picking in single mode closes it too. Multi mode shows chips with × buttons.
|
|
181
|
+
- In single mode an id of `0` is sent as `null`, because the code uses `selection[0] || null`.
|
|
182
|
+
|
|
183
|
+
## DateRangePage
|
|
184
|
+
|
|
185
|
+
```jsx
|
|
186
|
+
<DateRangePage value={value} onChange={setValue} onClose={close} allowClear={false} inheritedLabel="Last month" />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
| prop | type | default | meaning |
|
|
190
|
+
|---|---|---|---|
|
|
191
|
+
| `value` | `{ kind: 'quick', preset } \| { kind: 'range', from, to } \| { kind: 'single', value } \| null` | `null` | The selection |
|
|
192
|
+
| `onChange` | `(selection \| null) => void` | no-op | |
|
|
193
|
+
| `onClose` | `() => void` | — | Called after a choice that ends the interaction: a preset, a suggestion, Apply (or Enter) in Range or Single, or Clear |
|
|
194
|
+
| `presets` | `[{ key, label }]` | `QUICK_PRESETS` | The Quick list. Only keys that `resolveQuickPreset` knows will resolve. |
|
|
195
|
+
| `allowClear` | `boolean` | `true` | `false` removes the Clear button, and `clear()` refuses too |
|
|
196
|
+
| `inheritedLabel` | `string` | — | Shows "Using …" when `value` is empty |
|
|
197
|
+
|
|
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.
|
|
199
|
+
|
|
200
|
+
## RangeFieldPage
|
|
201
|
+
|
|
202
|
+
| prop | type | default | meaning |
|
|
203
|
+
|---|---|---|---|
|
|
204
|
+
| `type` | `'date' \| 'datetime'` | `'date'` | |
|
|
205
|
+
| `value` | `{ from, to }` | `{ from: '', to: '' }` | **Controlled only.** Each side shows `value`, so update it in `onChange`. |
|
|
206
|
+
| `onChange` | `({ from, to }) => void` | — | A cleared side is `''` |
|
|
207
|
+
| `min`, `max` | `Date \| string` | — | Floor for From, ceiling for To |
|
|
208
|
+
| `disabled` | `boolean` | — | |
|
|
209
|
+
| `fromLabel`, `toLabel` | `string` | `'From'`, `'To'` | |
|
|
210
|
+
| `fromPlaceholder`, `toPlaceholder` | `string` | — | For example `toPlaceholder="Now"` for an open end |
|
|
211
|
+
|
|
212
|
+
From's `max` is `to` (or `max`) and To's `min` is `from` (or `min`), so neither side can cross the other. This is not `DateRangePage`: it has no presets and no popover. Use it for a form section that just needs the two inputs.
|
|
213
|
+
|
|
214
|
+
## DateFieldPage
|
|
215
|
+
|
|
216
|
+
| prop | type | default | meaning |
|
|
217
|
+
|---|---|---|---|
|
|
218
|
+
| `type` | `'date' \| 'datetime' \| 'age'` | `'date'` | Any other value throws. `age` is a date of birth input with "Age N yrs" shown beside it. |
|
|
219
|
+
| `value` / `defaultValue` | `Date \| string \| null` | — | Controlled / uncontrolled |
|
|
220
|
+
| `onChange` | `(value \| null, meta) => void` | — | `value` is the input's string (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:mm` for datetime). For `age`, `meta.age` holds the age. |
|
|
221
|
+
| `min`, `max` | `Date \| string` | — | Passed to the input. Out of range shows "Date is before minimum" / "Date is after maximum". |
|
|
222
|
+
| `placeholder`, `disabled` | | | |
|
|
223
|
+
|
|
224
|
+
## NumberFieldPage
|
|
225
|
+
|
|
226
|
+
| prop | type | default | meaning |
|
|
227
|
+
|---|---|---|---|
|
|
228
|
+
| `type` | `'float' \| 'currency' \| 'units'` | `'float'` | Any other value throws |
|
|
229
|
+
| `value` / `defaultValue` | `number \| null` | — | Controlled / uncontrolled |
|
|
230
|
+
| `onChange` | `(number \| null, { affix }) => void` | — | Empty, or a lone `-`, gives `null` |
|
|
231
|
+
| `currencies` | `[{ code, symbol }]` | — | **Required** for `currency` (throws otherwise) |
|
|
232
|
+
| `defaultCurrency`, `currencyPosition` | `string`, `'before' \| 'after'` | first, `'before'` | |
|
|
233
|
+
| `units` | `[{ value, label }]` | — | **Required** for `units` (throws otherwise) |
|
|
234
|
+
| `defaultUnit`, `unitPosition` | `string`, `'before' \| 'after'` | first, `'after'` | |
|
|
235
|
+
| `min`, `max` | `number` | — | Out of range shows "Value must be at least / at most …". The value is still sent. |
|
|
236
|
+
| `decimals` | `number` | — | On blur, an uncontrolled field is rewritten to this many decimals |
|
|
237
|
+
| `placeholder`, `disabled` | | | |
|
|
238
|
+
|
|
239
|
+
The input is `type="text" inputMode="decimal"`. Typing is sanitised. An affix list with one entry is shown as static text, and a longer one as a select.
|
|
240
|
+
|
|
241
|
+
## TextFieldPage
|
|
242
|
+
|
|
243
|
+
| prop | type | default | meaning |
|
|
244
|
+
|---|---|---|---|
|
|
245
|
+
| `type` | `'text' \| 'multiline' \| 'search'` | `'text'` | Any other value throws |
|
|
246
|
+
| `value` / `defaultValue` | `string` | — | Controlled / uncontrolled. `null` and `undefined` become `''`. |
|
|
247
|
+
| `onChange` | `(value) => void` | — | Every keystroke |
|
|
248
|
+
| `onCommit` | `(value) => void` | — | On blur, or Enter in a single-line field, after `trim` / `maxLength` are applied |
|
|
249
|
+
| `onCancel` | `() => void` | — | Escape in a single-line field |
|
|
250
|
+
| `required`, `requiredMessage` | `boolean`, `string` | —, `'This is required'` | |
|
|
251
|
+
| `minLength`, `maxLength` | `number` | — | `maxLength` caps the text as you type |
|
|
252
|
+
| `pattern`, `patternMessage` | `RegExp \| string`, `string` | —, `'That is not a valid value'` | |
|
|
253
|
+
| `trim` | `boolean` | `false` | Trim when committing |
|
|
254
|
+
| `label`, `help` | `string` | — | An error replaces the help text |
|
|
255
|
+
| `rows` | `number` | `4` | Multiline only |
|
|
256
|
+
| `placeholder`, `disabled` | | | |
|
|
257
|
+
|
|
258
|
+
The search type shows a ✕ only when there is text, and clearing calls `onChange('')` and `onCommit('')`. The "N left" counter appears only near the limit, at `max(10, 10% of maxLength)` characters or fewer. Without `onCommit`, a commit that changes the text (by trimming) calls `onChange` instead.
|
|
259
|
+
|
|
260
|
+
## FileUploadPage
|
|
261
|
+
|
|
262
|
+
```jsx
|
|
263
|
+
import { configureUpload, FileUploadPage } from '@xeplr/ui-utils'
|
|
264
|
+
|
|
265
|
+
configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer ' + getToken() }) })
|
|
266
|
+
|
|
267
|
+
<FileUploadPage allowedTypes={['image/*', 'application/pdf']} maxSize={10 * 1024 * 1024} maxFiles={3}
|
|
268
|
+
onSuccess={(res) => raiseSnackbar('Uploaded', { design: 'success' })} />
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
| prop | type | default | meaning |
|
|
272
|
+
|---|---|---|---|
|
|
273
|
+
| `allowedTypes` | `string[]` | `['*']` | Exact MIME types, or `type/*` |
|
|
274
|
+
| `maxSize` | `number` (bytes) | 5 MB | Per file |
|
|
275
|
+
| `maxFiles` | `number` | `1` | |
|
|
276
|
+
| `url` | `string` | the `configureUpload` URL | Per-page override |
|
|
277
|
+
| `fieldName` | `string` | `'file'` | Form field each file is appended under |
|
|
278
|
+
| `extraData` | `object` | — | Extra form fields |
|
|
279
|
+
| `onSuccess` / `onError` | `(response)` / `(error)` | — | |
|
|
280
|
+
|
|
281
|
+
`uploadFile` POSTs `multipart/form-data` with `XMLHttpRequest`, so it can report upload progress. The default URL is `/internal/upload`. `getHeaders()`, if set, is called on **every** upload, so headers such as an auth token are always current. Otherwise the static `headers` are used. The response must be JSON. A non-2xx response rejects with the response's `error` or "Upload failed". If any picked file breaks a rule, the whole selection is rejected with a message naming that file.
|
|
282
|
+
|
|
283
|
+
## DataImportPage
|
|
284
|
+
|
|
285
|
+
| prop | type | meaning |
|
|
286
|
+
|---|---|---|
|
|
287
|
+
| `onInspect` | `(filePath, file) => Promise<{ format, parts? }>` | **Required.** Say what an uploaded file contains. `parts` holds, for example, an Excel workbook's sheet names. |
|
|
288
|
+
| `onCommit` | `(filePath, [{ part, target }], { originalName }) => Promise<{ results } \| results[]>` | **Required.** Load the included parts into their targets. |
|
|
289
|
+
| `accept` | `string` | The file input's `accept` attribute, for example `'.csv,.json,.xlsx,.xls'` |
|
|
290
|
+
| `targetLabel` | `string` | Placeholder for the target name (default `'Target'`) |
|
|
291
|
+
|
|
292
|
+
The file is sent through this package's own `uploadFile()` to the URL set by `configureUpload`, not through a prop. That keeps the component free of app-specific upload wiring. The apps point it at `@xeplr/base-apis`' generic upload route. The upload response must include `filePath`. With `parts`, each part becomes a row (included by default) whose target is `sanitizeTargetName(part)`. Without `parts`, there is one row named after the file. Result rows show `target (part)` and then `rows` + " rows", "Done", or `error`, depending on `success`.
|
|
293
|
+
|
|
294
|
+
`sanitizeTargetName('Sales 2024.xlsx')` returns `'sales_2024'`. It lowercases, drops the extension, turns runs of non-`[a-z0-9_]` characters into `_`, trims `_` from both ends, and falls back to `'table'`.
|
|
295
|
+
|
|
296
|
+
## ImportHistoryPage
|
|
297
|
+
|
|
298
|
+
| prop | type | meaning |
|
|
299
|
+
|---|---|---|
|
|
300
|
+
| `onList` | `() => Promise<{ jobs } \| jobs[]>` | **Required, and must be a stable function**: module-level, or wrapped in `useCallback`. The list reloads whenever its identity changes. |
|
|
301
|
+
| `onRollback` | `(job) => Promise` | Rollback of a `completed` job. The list reloads afterwards. |
|
|
302
|
+
| `onDelete` | `(job) => Promise` | Removes a history record. The Delete button is on every row, so without this prop it fails with an error. |
|
|
303
|
+
|
|
304
|
+
Job shape: `{ id, originalFileName, part, target, rows, status: 'completed' | 'failed' | 'rolled-back', startedAt, error, destination?, rowsLoaded?, rowsDropped?, createdByName? }`. Missing optional fields show "—". Both actions ask first with `raiseConfirm` (danger style). The Delete prompt warns that deleting a completed job's record does **not** undo its data and makes it impossible to roll back. The table shows 20 rows per page.
|
|
305
|
+
|
|
306
|
+
## GridDisplayerPage
|
|
307
|
+
|
|
308
|
+
```jsx
|
|
309
|
+
function Tile({ row, isAddTile }) {
|
|
310
|
+
return isAddTile ? <span>+ New</span> : <strong>{row.name}</strong>
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
<GridDisplayerPage rows={dashboards} tileFormatComponent={Tile} searchFields={['name', 'tags']}
|
|
314
|
+
sortFields={[{ value: 'name_asc', label: 'Name (A-Z)', field: 'name', direction: 'asc' },
|
|
315
|
+
{ value: 'updated_desc', label: 'Newest', field: 'updatedAt', direction: 'desc' }]}
|
|
316
|
+
onSelect={open} onAddNew={create} />
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
| prop | type | default | meaning |
|
|
320
|
+
|---|---|---|---|
|
|
321
|
+
| `rows` | `object[]` | `[]` | Tiles are keyed by `row.id` |
|
|
322
|
+
| `tileFormatComponent` | component | — | **Required.** Renders only the tile's content: `{ row }`, or `{ isAddTile: true }` for the leading add tile. |
|
|
323
|
+
| `searchFields` | `string[]` | `['name']` | |
|
|
324
|
+
| `sortFields` | `[{ value, label, field, direction }]` | Name (A-Z) | Each entry is one option in a single sort dropdown |
|
|
325
|
+
| `onSelect` | `(row) => void` | — | Tile click |
|
|
326
|
+
| `onAddNew` | `() => void` | — | If given, a leading add tile is shown |
|
|
327
|
+
|
|
328
|
+
## ProgressNotifier
|
|
329
|
+
|
|
330
|
+
Attach your app's event stream **once** at startup, then mount notifiers anywhere with a key. From `xeplr-bi`:
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
import { attachProgressSource } from '@xeplr/ui-utils'
|
|
334
|
+
|
|
335
|
+
attachProgressSource((handler) => subscribeToRunEvents((evt) => {
|
|
336
|
+
if (evt.type === 'progress') handler({ key: evt.subjectId, continue: true, data: evt.progress || {} })
|
|
337
|
+
if (evt.type === 'complete') handler({ key: evt.subjectId, continue: false, data: evt })
|
|
338
|
+
if (evt.type === 'error') handler({ key: evt.subjectId, continue: false, status: 'error', data: { error: evt.error } })
|
|
339
|
+
})) // subscribeToRunEvents returns its unsubscribe function
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
```jsx
|
|
343
|
+
<ProgressNotifier eventKey={cubeId} noun="tables" onComplete={() => measure(cubeId)} />
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
| prop | type | default | meaning |
|
|
347
|
+
|---|---|---|---|
|
|
348
|
+
| `eventKey` | `string` | — | Listens for events with this `key` |
|
|
349
|
+
| `noun` | `string` | — | Used in fallback text: "Working on the tables…" |
|
|
350
|
+
| `idle` | node | `null` | Rendered before any event arrives |
|
|
351
|
+
| `keepFinal` | `boolean` | `false` | Keep the finished line instead of clearing it |
|
|
352
|
+
| `finalMs` | `number` | `4000` | How long the finished line stays |
|
|
353
|
+
| `onComplete` / `onError` | `(data) => void` | — | |
|
|
354
|
+
| `className` | `string` | `''` | |
|
|
355
|
+
|
|
356
|
+
Event contract: `{ key, continue: true | false, status?, data }`. `continue: false` (or `status: 'complete'`) means finished, and `status: 'error'` means it failed. Anything else, including an event with no `continue` at all, counts as still running. Being wrong that way leaves a bar moving until the next event. Being wrong the other way would hide work that is still going. The text comes from `data`:
|
|
357
|
+
|
|
358
|
+
- While running: `data.message` wins; otherwise the counters `rowsRead`, `rowsLoaded`, `rowsScanned`, `groupCount`, `batches` and `cubeRows`; otherwise "Working on the {noun}…".
|
|
359
|
+
- When finished: `data.message`, or "Finished — N rows in 1.2s", from `cubeRows`, `rowsLoaded` or `groupCount` plus `durationMs`.
|
|
360
|
+
- On error: `data.error`, or `data.message`, stays on screen.
|
|
361
|
+
|
|
362
|
+
With no source attached, every notifier renders `idle` and nothing else.
|
|
363
|
+
|
|
364
|
+
## raiseSnackbar, raiseConfirm, raiseChoice
|
|
365
|
+
|
|
366
|
+
```js
|
|
367
|
+
raiseSnackbar('Saved', { design: 'success' })
|
|
368
|
+
|
|
369
|
+
if (await raiseConfirm('This deletes every row loaded by this import.', { title: 'Rollback this import?', confirmLabel: 'Rollback', danger: true })) { … }
|
|
370
|
+
|
|
371
|
+
const answer = await raiseChoice('You have unsaved changes.', {
|
|
372
|
+
title: 'Leave this page?',
|
|
373
|
+
choices: [
|
|
374
|
+
{ value: 'stay', label: 'Stay' },
|
|
375
|
+
{ value: 'discard', label: 'Leave without saving', style: 'danger' },
|
|
376
|
+
{ value: 'save', label: 'Save and leave', style: 'primary', isDefault: true }
|
|
377
|
+
]
|
|
378
|
+
}) // null if dismissed
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
These create their own DOM nodes with inline styles, so they need no provider, mount point or CSS import. The snackbar stacks in the bottom-right corner and dismisses itself after `duration` or on click. A dialog focuses its **last** button. Escape or a click on the overlay resolves to the dismiss value: `false` for `raiseConfirm`, `dismissValue` (default `null`) for `raiseChoice`.
|
|
382
|
+
|
|
383
|
+
## Design rules
|
|
384
|
+
|
|
385
|
+
`useDesignValidator(name, rules)` returns a ref. After mount it checks, **once**, that the container has each required element. A rule is `{ id | role | selector | anyOf: [selectors], label }`. For every rule that fails it logs `[xeplr-ui-utils] <name> design is missing required elements: …` with `console.error`, and it sets `data-xeplr-design-invalid="<name>"` and a `title` on the container.
|
|
386
|
+
|
|
387
|
+
| rules | require |
|
|
388
|
+
|---|---|
|
|
389
|
+
| `DROPDOWN_RULES` | `.xeplr-dropdown-trigger` or `[role="combobox"]`; `.xeplr-dropdown-panel` or `[role="listbox"]` |
|
|
390
|
+
| `DATE_RANGE_RULES` | tab buttons (`.xeplr-daterange-tabs button` or `[data-xeplr-daterange-tab]`); **any one of** a preset button or a date input |
|
|
391
|
+
| `RANGE_FIELD_RULES` | `.xeplr-rangefield-field` |
|
|
392
|
+
| `DATE_FIELD_RULES` | `input[type="date"]`, `input[type="datetime-local"]` or `.xeplr-datefield-input` |
|
|
393
|
+
| `NUMBER_FIELD_RULES` | `input[inputmode="decimal"]` or `input.xeplr-numberfield-input` |
|
|
394
|
+
| `TEXT_FIELD_RULES` | a text or search input, a `textarea`, or `.xeplr-textfield-input` |
|
|
395
|
+
| `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
|
|
396
|
+
| `DATA_IMPORT_RULES` | `input[type="file"]` |
|
|
397
|
+
| `GRID_DISPLAYER_RULES` | `.xeplr-grid-search`; `.xeplr-grid` |
|
|
398
|
+
| `IMPORT_HISTORY_RULES` | nothing |
|
|
399
|
+
|
|
400
|
+
## Theming
|
|
401
|
+
|
|
402
|
+
The designs use `xeplr-*` class prefixes (`xeplr-dropdown-*`, `xeplr-daterange-*`, `xeplr-rangefield-*`, `xeplr-datefield-*`, `xeplr-numberfield-*`, `xeplr-textfield-*`, `xeplr-upload-*`, `xeplr-data-import-*`, `xeplr-import-history-*`, `xeplr-grid-*`). The progress notifier uses `xpn`, `xpn--running` / `--complete` / `--error`, `xpn-bar` and `xpn-text`. What each design reads:
|
|
403
|
+
|
|
404
|
+
| design | reads | without it |
|
|
405
|
+
|---|---|---|
|
|
406
|
+
| Dropdown, DataImport, ImportHistory, GridDisplayer | `@xeplr/ui-account` theme tokens: `--xeplr-bg-*`, `--xeplr-text-*`, `--xeplr-border-*`, `--xeplr-input-bg/-border/-text/-placeholder`, `--xeplr-accent`, `--xeplr-accent-text`, `--xeplr-danger*`, `--xeplr-success`, `--xeplr-tag-*`, `--xeplr-shadow-lg` | dark fallbacks |
|
|
407
|
+
| Snackbar, confirm dialogs | `--xeplr-success`, `--xeplr-danger`, `--xeplr-accent`, `--xeplr-accent-text`, `--xeplr-bg-overlay`, `--xeplr-bg-secondary/-tertiary`, `--xeplr-text-primary/-secondary`, `--xeplr-border-primary`, `--xeplr-shadow-lg` | the snackbar's original colours; a dark dialog |
|
|
408
|
+
| DateRange, RangeField, ProgressNotifier | `--xeplr-surface`, `--xeplr-surface-2`, `--xeplr-border`, `--xeplr-border-strong`, `--xeplr-border-secondary`, `--xeplr-muted`, `--xeplr-muted-2`, `--xeplr-text`, `--xeplr-hover`, `--xeplr-accent`, `--xeplr-danger`, `--xeplr-success` | light fallbacks. Of these, `ui-account`'s `theme.css` defines only accent, danger, success and border-secondary. |
|
|
409
|
+
| TextField | `--xeplr-input-text-*` (gap, padding, bg, colour, font size, placeholder, each border edge, border colour/hover/focus, radius, the full label font, help text, focus ring, invalid) | shipped defaults |
|
|
410
|
+
| NumberField, DateField (and so RangeField's two inputs), FileUpload | fixed dark colours, no variables | — |
|
|
411
|
+
|
|
412
|
+
`@xeplr/ui-account`'s `ThemeProvider` puts the class `xeplr-theme-{dark|light|medium|bright}` on a wrapper `div`, and its `src/designs/theme.css` defines the tokens for those classes. Load that stylesheet and wrap the app to theme the first two rows.
|
|
413
|
+
|
|
414
|
+
To theme TextField, spread `inputStyleVars` on an ancestor:
|
|
415
|
+
|
|
416
|
+
```jsx
|
|
417
|
+
<div style={inputStyleVars('text', { themes: { light: { input: { text: {
|
|
418
|
+
border: { top: false, right: false, left: false, bottom: true, radius: '0' } // bottom rule only
|
|
419
|
+
} } } } })}>
|
|
420
|
+
<TextFieldPage label="Name" required trim />
|
|
421
|
+
</div>
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
An edge set to `false` becomes `0` instead of being left out, because a missing variable would fall back to the stylesheet's default and bring the border back. `inputStyleVars` also produces `select`, `number` and `date` variables, but no stylesheet in this package reads them yet.
|
|
425
|
+
|
|
426
|
+
## Rules the code enforces
|
|
427
|
+
|
|
428
|
+
- **Unresolvable dates widen, never empty.** `resolveDateValue` returns `null` ("no constraint") for anything it cannot resolve, so a bad selection does not quietly turn a report empty. `dateValueIsSet` means *resolves*, not *has text*: unparseable text has a label but no range, and treating it as set would pass a "do we have a default?" check and then filter nothing.
|
|
429
|
+
- **Presets over literal dates.** A preset resolves against `now`, so a saved report stays current. `last_7_days` includes today. The financial year starts in April unless you pass `fiscalStartMonth`, because hard-coding one month makes reports show the wrong year in most of the world.
|
|
430
|
+
- **Slash dates are day first.** `1/2/2022` is 1 February.
|
|
431
|
+
- **A partial Single entry is never committed.** "May 202" must not overwrite a good selection with nothing. Picking a suggestion commits at once, because a pick is already a decision.
|
|
432
|
+
- **`allowClear={false}` is enforced in `clear()`**, not just by hiding the button. A custom design cannot produce an empty selection that the host said it cannot accept.
|
|
433
|
+
- **Design problems are reported, not thrown.** Throwing from an effect gives React nothing to catch, so a development-time check would crash the whole app. `DATE_RANGE_RULES` uses `anyOf` because a tabbed design mounts one tab at a time. Requiring a preset list *and* a date input at the same time could never pass.
|
|
434
|
+
- **Text errors appear on commit, not on each keystroke.** Complaining that a required field is empty while the first letter is being typed is shouting at someone who hasn't finished. `maxLength` caps input as you type, because a limit you can exceed and only hear about afterwards does nothing. Trimming is opt-in, because `" "` is a valid search and a mistake in a name.
|
|
435
|
+
- **Text values are never `undefined`.** `toValue` turns `null`/`undefined` into `''`, because a controlled input handed `undefined` becomes uncontrolled and React warns.
|
|
436
|
+
- **One dialog at a time.** Opening a dialog resolves any open one with its dismiss value. Otherwise a second overlay and a second key listener would stack over the first. Enter picks only a button marked `isDefault`. With three answers there is no obvious one to guess, and a guess could save or discard someone's work on a stray key press.
|
|
437
|
+
- **ProgressNotifier subscribes by key only.** Its callbacks live in a ref. When inline callbacks were dependencies, each host render tore down the subscription and rebuilt it, opening a new stream and using up a single-use ticket each time. The bar is indeterminate on purpose: work like a `GROUP BY` cannot say how far along it is, and a percentage would be made up. Errors stay on screen. A finished line clears after `finalMs`, because a line that never clears still looks current an hour later.
|
|
438
|
+
- **Uploads go through one configured route.** `DataImportPage` calls `uploadFile()` instead of taking an upload prop, so the component stays independent of any app. Only "what is in the file" and "where does it go" are app-specific.
|
|
439
|
+
- **Theme overrides replace arrays.** An override palette replaces the default instead of blending with it index by index.
|
|
440
|
+
|
|
441
|
+
## Files
|
|
442
|
+
|
|
443
|
+
```
|
|
444
|
+
src/
|
|
445
|
+
index.js ─ everything below
|
|
446
|
+
<control>/ ─ dropdown, dateRange, rangeField, dateField, numberField, textField,
|
|
447
|
+
fileUpload, dataImport, gridDisplayer — each has:
|
|
448
|
+
<control>.js model (pure; dataImport.js, uploadFile.js)
|
|
449
|
+
use*Controller.js controller
|
|
450
|
+
validateDesign.js *_RULES (fileUpload's also defines useDesignValidator)
|
|
451
|
+
designs/ *Sample.jsx + its .css
|
|
452
|
+
pages.jsx *Page = controller + design + validator
|
|
453
|
+
progress/ ─ progressSource.js (model), ProgressNotifier.jsx, designs/progressNotifier.css
|
|
454
|
+
snackbar/snackbar.js ─ raiseSnackbar
|
|
455
|
+
confirm/confirm.js ─ raiseConfirm, raiseChoice
|
|
456
|
+
theme/ ─ default.theme.json, index.js (resolveTheme, getThemeVariant, getChartOptions, inputStyleVars, deepMerge)
|
|
457
|
+
data/ ─ countries.json, states.json
|
|
458
|
+
test/ ─ dateRange, rangeField, textField (+ inputStyleVars), validateDesign (DATE_RANGE_RULES)
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## Tests
|
|
462
|
+
|
|
463
|
+
```sh
|
|
464
|
+
npm test
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
This runs each `test/*.test.js` with plain `node` and stops at the first failing file. The tests cover the date range model, the range field model, the text field model with `inputStyleVars`, and the date range design rules. CI runs the same files with `node --test` and coverage.
|
|
468
|
+
|
|
469
|
+
## License
|
|
470
|
+
|
|
471
|
+
MIT
|
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,
|