@xeplr/ui-utils 1.0.6 → 1.0.7

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.
Files changed (2) hide show
  1. package/README.md +470 -0
  2. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,470 @@
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, 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
+ | `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
+ | `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
+ | `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 `-`. |
135
+ | `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 |
136
+ | `DROPDOWN_MODES`, `normalizeKeys`, `sortByKey`, `filterByQuery`, `pickSelected` | `['single','multi']`, `(keys) → { id, name, extras }`, `(items, key)`, `(items, query, searchKeys)`, `(items, ids, idKey)` | Dropdown helpers |
137
+ | `filterGridRows`, `sortGridRows` | `(rows, query, fields)`, `(rows, field, 'asc'\|'desc')` | Grid search and sort. An array field matches if any entry matches. |
138
+ | `sanitizeTargetName`, `buildInitialMappings` | `(name) → string`, `({ parts?, originalName? }) → [{ part, target, include }]` | Data import mapping |
139
+ | `configureUpload`, `uploadFile` | `({ url, headers, getHeaders })`, `(files, { url, fieldName, extraData, onProgress }) → Promise` | Multipart upload over XHR |
140
+ | `attachProgressSource`, `progressSourceAttached`, `subscribeToProgress`, `describeProgress`, `describeComplete` | `(subscribe)`, `() → boolean`, `(key, handler) → unsubscribe`, `(data, noun?)`, `(data, noun?)` | The progress event source and its sentences |
141
+
142
+ ### Imperative helpers, theme, data
143
+
144
+ | export | signature | what it does |
145
+ |---|---|---|
146
+ | `raiseSnackbar` | `(message, { design: 'success'\|'error'\|'default', duration = 3000 }?)` | Toast in the bottom-right corner. Click to dismiss. |
147
+ | `raiseConfirm` | `(message, { title, confirmLabel, cancelLabel, danger }?) → Promise<boolean>` | Yes/no modal dialog |
148
+ | `raiseChoice` | `(message, { title, choices: [{ value, label, style?, isDefault? }], dismissValue = null }) → Promise<value>` | Modal dialog with any number of answers |
149
+ | `DEFAULT_THEME` | object | `default.theme.json`: `{ name, default: 'light', font, themes: { light, dark } }`. Each variant has `chart`, `table` and `input: { text, select, number, date }`. |
150
+ | `resolveTheme` | `(overrides?) → theme` | `deepMerge(DEFAULT_THEME, overrides)` |
151
+ | `getThemeVariant` | `(name, overrides?) → { chart, table, input }` | One variant. An unknown name falls back to `default`. |
152
+ | `getChartOptions` | `(variantName, overrides?) → ChartOptions` | The variant's `chart` section plus `fontFamily` |
153
+ | `inputStyleVars` | `(control, overrides?, variantName?) → { '--xeplr-input-<control>-…': value }` | Theme values as CSS custom properties for one control |
154
+ | `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
155
+ | `COUNTRIES` | `[{ code, name }]` | 196 countries |
156
+ | `STATES`, `STATES_IN` | `{ IN: [{ code, name }] }`, `[{ code, name }]` | 36 Indian states and union territories |
157
+ | `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 |
158
+
159
+ ## DropdownPage
160
+
161
+ | prop | type | default | meaning |
162
+ |---|---|---|---|
163
+ | `mode` | `'single' \| 'multi'` | `'single'` | Any other value throws |
164
+ | `options` | `Array` | — | Static items. Used when `fetchData` is absent. |
165
+ | `fetchData` | `() => Promise<Array>` | — | Loads items **once, on mount** (see `refresh` on the controller) |
166
+ | `keys` | `string[]` | `['id', 'name']` | `[idKey, nameKey, ...extraKeys]`. Extras are shown under the name and are searchable. |
167
+ | `value` | `id \| id[] \| null` | — | Controlled selection |
168
+ | `defaultValue` | `id \| id[]` | — | Uncontrolled initial selection |
169
+ | `onChange` | `(value, items) => void` | — | Single mode gets `id \| null`, multi mode gets `id[]`. `items` are the selected item objects. |
170
+ | `placeholder` | `string` | `'Select…'` | |
171
+ | `searchable` | `boolean` | `true` | Search box in the panel |
172
+ | `disabled` | `boolean` | `false` | |
173
+ | `onCreate` | `(name) => Promise<item \| id>` | — | Enables an "Add “typed text”" row |
174
+ | `allowCreate` | `boolean` | `true` | Set `false` to hide the create row even when `onCreate` is given |
175
+ | `createLabel` | `string` | `'Add'` | Prefix of the create row, for example `'Add company'` |
176
+
177
+ - Items are sorted by the name key, ignoring case. Search matches the name and extra keys as a substring, also ignoring case.
178
+ - 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.
179
+ - Clicking outside closes the panel. Picking in single mode closes it too. Multi mode shows chips with × buttons.
180
+ - In single mode an id of `0` is sent as `null`, because the code uses `selection[0] || null`.
181
+
182
+ ## DateRangePage
183
+
184
+ ```jsx
185
+ <DateRangePage value={value} onChange={setValue} onClose={close} allowClear={false} inheritedLabel="Last month" />
186
+ ```
187
+
188
+ | prop | type | default | meaning |
189
+ |---|---|---|---|
190
+ | `value` | `{ kind: 'quick', preset } \| { kind: 'range', from, to } \| { kind: 'single', value } \| null` | `null` | The selection |
191
+ | `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
+ | `presets` | `[{ key, label }]` | `QUICK_PRESETS` | The Quick list. Only keys that `resolveQuickPreset` knows will resolve. |
194
+ | `allowClear` | `boolean` | `true` | `false` removes the Clear button, and `clear()` refuses too |
195
+ | `inheritedLabel` | `string` | — | Shows "Using …" when `value` is empty |
196
+
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
+
199
+ ## RangeFieldPage
200
+
201
+ | prop | type | default | meaning |
202
+ |---|---|---|---|
203
+ | `type` | `'date' \| 'datetime'` | `'date'` | |
204
+ | `value` | `{ from, to }` | `{ from: '', to: '' }` | **Controlled only.** Each side shows `value`, so update it in `onChange`. |
205
+ | `onChange` | `({ from, to }) => void` | — | A cleared side is `''` |
206
+ | `min`, `max` | `Date \| string` | — | Floor for From, ceiling for To |
207
+ | `disabled` | `boolean` | — | |
208
+ | `fromLabel`, `toLabel` | `string` | `'From'`, `'To'` | |
209
+ | `fromPlaceholder`, `toPlaceholder` | `string` | — | For example `toPlaceholder="Now"` for an open end |
210
+
211
+ 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.
212
+
213
+ ## DateFieldPage
214
+
215
+ | prop | type | default | meaning |
216
+ |---|---|---|---|
217
+ | `type` | `'date' \| 'datetime' \| 'age'` | `'date'` | Any other value throws. `age` is a date of birth input with "Age N yrs" shown beside it. |
218
+ | `value` / `defaultValue` | `Date \| string \| null` | — | Controlled / uncontrolled |
219
+ | `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. |
220
+ | `min`, `max` | `Date \| string` | — | Passed to the input. Out of range shows "Date is before minimum" / "Date is after maximum". |
221
+ | `placeholder`, `disabled` | | | |
222
+
223
+ ## NumberFieldPage
224
+
225
+ | prop | type | default | meaning |
226
+ |---|---|---|---|
227
+ | `type` | `'float' \| 'currency' \| 'units'` | `'float'` | Any other value throws |
228
+ | `value` / `defaultValue` | `number \| null` | — | Controlled / uncontrolled |
229
+ | `onChange` | `(number \| null, { affix }) => void` | — | Empty, or a lone `-`, gives `null` |
230
+ | `currencies` | `[{ code, symbol }]` | — | **Required** for `currency` (throws otherwise) |
231
+ | `defaultCurrency`, `currencyPosition` | `string`, `'before' \| 'after'` | first, `'before'` | |
232
+ | `units` | `[{ value, label }]` | — | **Required** for `units` (throws otherwise) |
233
+ | `defaultUnit`, `unitPosition` | `string`, `'before' \| 'after'` | first, `'after'` | |
234
+ | `min`, `max` | `number` | — | Out of range shows "Value must be at least / at most …". The value is still sent. |
235
+ | `decimals` | `number` | — | On blur, an uncontrolled field is rewritten to this many decimals |
236
+ | `placeholder`, `disabled` | | | |
237
+
238
+ 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.
239
+
240
+ ## TextFieldPage
241
+
242
+ | prop | type | default | meaning |
243
+ |---|---|---|---|
244
+ | `type` | `'text' \| 'multiline' \| 'search'` | `'text'` | Any other value throws |
245
+ | `value` / `defaultValue` | `string` | — | Controlled / uncontrolled. `null` and `undefined` become `''`. |
246
+ | `onChange` | `(value) => void` | — | Every keystroke |
247
+ | `onCommit` | `(value) => void` | — | On blur, or Enter in a single-line field, after `trim` / `maxLength` are applied |
248
+ | `onCancel` | `() => void` | — | Escape in a single-line field |
249
+ | `required`, `requiredMessage` | `boolean`, `string` | —, `'This is required'` | |
250
+ | `minLength`, `maxLength` | `number` | — | `maxLength` caps the text as you type |
251
+ | `pattern`, `patternMessage` | `RegExp \| string`, `string` | —, `'That is not a valid value'` | |
252
+ | `trim` | `boolean` | `false` | Trim when committing |
253
+ | `label`, `help` | `string` | — | An error replaces the help text |
254
+ | `rows` | `number` | `4` | Multiline only |
255
+ | `placeholder`, `disabled` | | | |
256
+
257
+ 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.
258
+
259
+ ## FileUploadPage
260
+
261
+ ```jsx
262
+ import { configureUpload, FileUploadPage } from '@xeplr/ui-utils'
263
+
264
+ configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer ' + getToken() }) })
265
+
266
+ <FileUploadPage allowedTypes={['image/*', 'application/pdf']} maxSize={10 * 1024 * 1024} maxFiles={3}
267
+ onSuccess={(res) => raiseSnackbar('Uploaded', { design: 'success' })} />
268
+ ```
269
+
270
+ | prop | type | default | meaning |
271
+ |---|---|---|---|
272
+ | `allowedTypes` | `string[]` | `['*']` | Exact MIME types, or `type/*` |
273
+ | `maxSize` | `number` (bytes) | 5 MB | Per file |
274
+ | `maxFiles` | `number` | `1` | |
275
+ | `url` | `string` | the `configureUpload` URL | Per-page override |
276
+ | `fieldName` | `string` | `'file'` | Form field each file is appended under |
277
+ | `extraData` | `object` | — | Extra form fields |
278
+ | `onSuccess` / `onError` | `(response)` / `(error)` | — | |
279
+
280
+ `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.
281
+
282
+ ## DataImportPage
283
+
284
+ | prop | type | meaning |
285
+ |---|---|---|
286
+ | `onInspect` | `(filePath, file) => Promise<{ format, parts? }>` | **Required.** Say what an uploaded file contains. `parts` holds, for example, an Excel workbook's sheet names. |
287
+ | `onCommit` | `(filePath, [{ part, target }], { originalName }) => Promise<{ results } \| results[]>` | **Required.** Load the included parts into their targets. |
288
+ | `accept` | `string` | The file input's `accept` attribute, for example `'.csv,.json,.xlsx,.xls'` |
289
+ | `targetLabel` | `string` | Placeholder for the target name (default `'Target'`) |
290
+
291
+ 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`.
292
+
293
+ `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'`.
294
+
295
+ ## ImportHistoryPage
296
+
297
+ | prop | type | meaning |
298
+ |---|---|---|
299
+ | `onList` | `() => Promise<{ jobs } \| jobs[]>` | **Required, and must be a stable function**: module-level, or wrapped in `useCallback`. The list reloads whenever its identity changes. |
300
+ | `onRollback` | `(job) => Promise` | Rollback of a `completed` job. The list reloads afterwards. |
301
+ | `onDelete` | `(job) => Promise` | Removes a history record. The Delete button is on every row, so without this prop it fails with an error. |
302
+
303
+ 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.
304
+
305
+ ## GridDisplayerPage
306
+
307
+ ```jsx
308
+ function Tile({ row, isAddTile }) {
309
+ return isAddTile ? <span>+ New</span> : <strong>{row.name}</strong>
310
+ }
311
+
312
+ <GridDisplayerPage rows={dashboards} tileFormatComponent={Tile} searchFields={['name', 'tags']}
313
+ sortFields={[{ value: 'name_asc', label: 'Name (A-Z)', field: 'name', direction: 'asc' },
314
+ { value: 'updated_desc', label: 'Newest', field: 'updatedAt', direction: 'desc' }]}
315
+ onSelect={open} onAddNew={create} />
316
+ ```
317
+
318
+ | prop | type | default | meaning |
319
+ |---|---|---|---|
320
+ | `rows` | `object[]` | `[]` | Tiles are keyed by `row.id` |
321
+ | `tileFormatComponent` | component | — | **Required.** Renders only the tile's content: `{ row }`, or `{ isAddTile: true }` for the leading add tile. |
322
+ | `searchFields` | `string[]` | `['name']` | |
323
+ | `sortFields` | `[{ value, label, field, direction }]` | Name (A-Z) | Each entry is one option in a single sort dropdown |
324
+ | `onSelect` | `(row) => void` | — | Tile click |
325
+ | `onAddNew` | `() => void` | — | If given, a leading add tile is shown |
326
+
327
+ ## ProgressNotifier
328
+
329
+ Attach your app's event stream **once** at startup, then mount notifiers anywhere with a key. From `xeplr-bi`:
330
+
331
+ ```js
332
+ import { attachProgressSource } from '@xeplr/ui-utils'
333
+
334
+ attachProgressSource((handler) => subscribeToRunEvents((evt) => {
335
+ if (evt.type === 'progress') handler({ key: evt.subjectId, continue: true, data: evt.progress || {} })
336
+ if (evt.type === 'complete') handler({ key: evt.subjectId, continue: false, data: evt })
337
+ if (evt.type === 'error') handler({ key: evt.subjectId, continue: false, status: 'error', data: { error: evt.error } })
338
+ })) // subscribeToRunEvents returns its unsubscribe function
339
+ ```
340
+
341
+ ```jsx
342
+ <ProgressNotifier eventKey={cubeId} noun="tables" onComplete={() => measure(cubeId)} />
343
+ ```
344
+
345
+ | prop | type | default | meaning |
346
+ |---|---|---|---|
347
+ | `eventKey` | `string` | — | Listens for events with this `key` |
348
+ | `noun` | `string` | — | Used in fallback text: "Working on the tables…" |
349
+ | `idle` | node | `null` | Rendered before any event arrives |
350
+ | `keepFinal` | `boolean` | `false` | Keep the finished line instead of clearing it |
351
+ | `finalMs` | `number` | `4000` | How long the finished line stays |
352
+ | `onComplete` / `onError` | `(data) => void` | — | |
353
+ | `className` | `string` | `''` | |
354
+
355
+ 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`:
356
+
357
+ - While running: `data.message` wins; otherwise the counters `rowsRead`, `rowsLoaded`, `rowsScanned`, `groupCount`, `batches` and `cubeRows`; otherwise "Working on the {noun}…".
358
+ - When finished: `data.message`, or "Finished — N rows in 1.2s", from `cubeRows`, `rowsLoaded` or `groupCount` plus `durationMs`.
359
+ - On error: `data.error`, or `data.message`, stays on screen.
360
+
361
+ With no source attached, every notifier renders `idle` and nothing else.
362
+
363
+ ## raiseSnackbar, raiseConfirm, raiseChoice
364
+
365
+ ```js
366
+ raiseSnackbar('Saved', { design: 'success' })
367
+
368
+ if (await raiseConfirm('This deletes every row loaded by this import.', { title: 'Rollback this import?', confirmLabel: 'Rollback', danger: true })) { … }
369
+
370
+ const answer = await raiseChoice('You have unsaved changes.', {
371
+ title: 'Leave this page?',
372
+ choices: [
373
+ { value: 'stay', label: 'Stay' },
374
+ { value: 'discard', label: 'Leave without saving', style: 'danger' },
375
+ { value: 'save', label: 'Save and leave', style: 'primary', isDefault: true }
376
+ ]
377
+ }) // null if dismissed
378
+ ```
379
+
380
+ 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`.
381
+
382
+ ## Design rules
383
+
384
+ `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.
385
+
386
+ | rules | require |
387
+ |---|---|
388
+ | `DROPDOWN_RULES` | `.xeplr-dropdown-trigger` or `[role="combobox"]`; `.xeplr-dropdown-panel` or `[role="listbox"]` |
389
+ | `DATE_RANGE_RULES` | tab buttons (`.xeplr-daterange-tabs button` or `[data-xeplr-daterange-tab]`); **any one of** a preset button or a date input |
390
+ | `RANGE_FIELD_RULES` | `.xeplr-rangefield-field` |
391
+ | `DATE_FIELD_RULES` | `input[type="date"]`, `input[type="datetime-local"]` or `.xeplr-datefield-input` |
392
+ | `NUMBER_FIELD_RULES` | `input[inputmode="decimal"]` or `input.xeplr-numberfield-input` |
393
+ | `TEXT_FIELD_RULES` | a text or search input, a `textarea`, or `.xeplr-textfield-input` |
394
+ | `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
395
+ | `DATA_IMPORT_RULES` | `input[type="file"]` |
396
+ | `GRID_DISPLAYER_RULES` | `.xeplr-grid-search`; `.xeplr-grid` |
397
+ | `IMPORT_HISTORY_RULES` | nothing |
398
+
399
+ ## Theming
400
+
401
+ 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:
402
+
403
+ | design | reads | without it |
404
+ |---|---|---|
405
+ | 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 |
406
+ | 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 |
407
+ | 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. |
408
+ | 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 |
409
+ | NumberField, DateField (and so RangeField's two inputs), FileUpload | fixed dark colours, no variables | — |
410
+
411
+ `@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.
412
+
413
+ To theme TextField, spread `inputStyleVars` on an ancestor:
414
+
415
+ ```jsx
416
+ <div style={inputStyleVars('text', { themes: { light: { input: { text: {
417
+ border: { top: false, right: false, left: false, bottom: true, radius: '0' } // bottom rule only
418
+ } } } } })}>
419
+ <TextFieldPage label="Name" required trim />
420
+ </div>
421
+ ```
422
+
423
+ 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.
424
+
425
+ ## Rules the code enforces
426
+
427
+ - **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.
428
+ - **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.
429
+ - **Slash dates are day first.** `1/2/2022` is 1 February.
430
+ - **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.
431
+ - **`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.
432
+ - **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.
433
+ - **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.
434
+ - **Text values are never `undefined`.** `toValue` turns `null`/`undefined` into `''`, because a controlled input handed `undefined` becomes uncontrolled and React warns.
435
+ - **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.
436
+ - **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.
437
+ - **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.
438
+ - **Theme overrides replace arrays.** An override palette replaces the default instead of blending with it index by index.
439
+
440
+ ## Files
441
+
442
+ ```
443
+ src/
444
+ index.js ─ everything below
445
+ <control>/ ─ dropdown, dateRange, rangeField, dateField, numberField, textField,
446
+ fileUpload, dataImport, gridDisplayer — each has:
447
+ <control>.js model (pure; dataImport.js, uploadFile.js)
448
+ use*Controller.js controller
449
+ validateDesign.js *_RULES (fileUpload's also defines useDesignValidator)
450
+ designs/ *Sample.jsx + its .css
451
+ pages.jsx *Page = controller + design + validator
452
+ progress/ ─ progressSource.js (model), ProgressNotifier.jsx, designs/progressNotifier.css
453
+ snackbar/snackbar.js ─ raiseSnackbar
454
+ confirm/confirm.js ─ raiseConfirm, raiseChoice
455
+ theme/ ─ default.theme.json, index.js (resolveTheme, getThemeVariant, getChartOptions, inputStyleVars, deepMerge)
456
+ data/ ─ countries.json, states.json
457
+ test/ ─ dateRange, rangeField, textField (+ inputStyleVars), validateDesign (DATE_RANGE_RULES)
458
+ ```
459
+
460
+ ## Tests
461
+
462
+ ```sh
463
+ npm test
464
+ ```
465
+
466
+ 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.
467
+
468
+ ## License
469
+
470
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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",