@xeplr/ui-utils 1.0.8 → 1.0.10
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 +73 -10
- package/package.json +1 -1
- package/src/colorPicker/colorPicker.js +39 -0
- package/src/colorPicker/designs/ColorPickerSample.jsx +56 -0
- package/src/colorPicker/designs/colorPicker.css +36 -0
- package/src/colorPicker/designs/index.js +1 -0
- package/src/colorPicker/pages.jsx +10 -0
- package/src/colorPicker/useColorPickerController.js +41 -0
- package/src/colorPicker/validateDesign.js +4 -0
- package/src/dataImport/dataImport.js +22 -4
- package/src/dataImport/designs/DataImportSample.jsx +27 -1
- package/src/dataImport/designs/dataImport.css +19 -0
- package/src/dataImport/useDataImportController.js +17 -5
- package/src/index.js +19 -0
- package/src/panel/designs/ExpandersSample.jsx +29 -0
- package/src/panel/designs/PanelSection.jsx +43 -0
- package/src/panel/designs/SettingsTreeSample.jsx +51 -0
- package/src/panel/designs/index.js +3 -0
- package/src/panel/designs/panel.css +72 -0
- package/src/panel/designs/settingsTree.css +69 -0
- package/src/panel/pages.jsx +18 -0
- package/src/panel/panel.js +48 -0
- package/src/panel/useExpandersController.js +23 -0
- package/src/panel/useSettingsTreeController.js +18 -0
- package/src/panel/validateDesign.js +9 -0
package/README.md
CHANGED
|
@@ -100,8 +100,12 @@ export default function SelectCompany() {
|
|
|
100
100
|
| `DataImportPage` | [see below](#dataimportpage) | Upload a file, map its parts to targets, commit |
|
|
101
101
|
| `ImportHistoryPage` | [see below](#importhistorypage) | Past imports in a `XeplrTable`, with Rollback and Delete |
|
|
102
102
|
| `GridDisplayerPage` | [see below](#griddisplayerpage) | Searchable, sortable grid of tiles you render |
|
|
103
|
+
| `ColorPickerPage` | [see below](#colorpickerpage) | Swatches, no colour, a custom colour, opacity and a live preview, as one CSS colour |
|
|
104
|
+
| `ExpandersPage` | [see below](#panel-panelsection-panelfield-expanderspage) | Titled parts of a panel, one open at a time |
|
|
105
|
+
| `SettingsTreePage` | [see below](#panel-panelsection-panelfield-expanderspage) | Nested groups of settings: cards, branches on a rail, one open per level |
|
|
106
|
+
| `PanelSection`, `PanelField` | [see below](#panel-panelsection-panelfield-expanderspage) | A section card with an icon; a row with the label left and help under the control |
|
|
103
107
|
| `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 |
|
|
108
|
+
| `FileUploadSample`, `DataImportSample`, `ImportHistorySample`, `NumberFieldSample`, `DateFieldSample`, `DateRangeSample`, `RangeFieldSample`, `DropdownSample`, `GridDisplayerSample`, `TextFieldSample`, `ColorPickerSample`, `ExpandersSample`, `SettingsTreeSample` | the matching controller's return value | The default designs, usable on their own |
|
|
105
109
|
|
|
106
110
|
### Controllers (hooks)
|
|
107
111
|
|
|
@@ -114,9 +118,12 @@ export default function SelectCompany() {
|
|
|
114
118
|
| `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
|
|
115
119
|
| `useTextFieldController(props)` | TextFieldPage props | `type, raw, error, remaining, placeholder, disabled, required, maxLength, rows, label, help, handleChange, handleBlur, handleKeyDown, handleClear` |
|
|
116
120
|
| `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` |
|
|
121
|
+
| `useDataImportController(options)` | DataImportPage props | `file, inspecting, inspected, mappings, committing, results, error, handleFileChange, updateMapping(i, patch), setFix(i, key, on), checksOf(part), handleCommit, reset, accept, targetLabel` |
|
|
118
122
|
| `useImportHistoryController(options)` | ImportHistoryPage props | `jobs, loading, error, rollingBackId, deletingId, reload, handleRollback(job), handleDelete(job)` |
|
|
119
123
|
| `useGridDisplayerController(props)` | GridDisplayerPage props | `query, setQuery, sortFields, sortValue, setSortValue, displayRows, tileFormatComponent, onSelect, onAddNew` |
|
|
124
|
+
| `useColorPickerController(props)` | ColorPickerPage props | `label, value, hex, opacity, swatches [{color, selected}], custom, allowNone, allowOpacity, previewText, pick(hex), setOpacity(0–100), clear` |
|
|
125
|
+
| `useExpandersController(props)` | ExpandersPage props | `items` (each with `open`), `toggle(key)` |
|
|
126
|
+
| `useSettingsTreeController(props)` | SettingsTreePage props | `nodes` (each with `open`, `depth`, `parentKey`, `children`), `toggle(parentKey, key)` |
|
|
120
127
|
| `useDesignValidator(name, rules)` | page name, rule list | a ref for the design's container |
|
|
121
128
|
|
|
122
129
|
### Model functions (no React)
|
|
@@ -135,8 +142,11 @@ export default function SelectCompany() {
|
|
|
135
142
|
| `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
143
|
| `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
144
|
| `DROPDOWN_MODES`, `normalizeKeys`, `sortByKey`, `filterByQuery`, `pickSelected` | `['single','multi']`, `(keys) → { id, name, extras }`, `(items, key)`, `(items, query, searchKeys)`, `(items, ids, idKey)` | Dropdown helpers |
|
|
145
|
+
| `DEFAULT_SWATCHES`, `parseColor`, `formatColor`, `sameHue` | `[hex]`, `(css) → { hex, opacity } \| null`, `(hex, opacity) → css \| null`, `(a, b) → boolean` | Colour helpers. Opacity 100 writes `#RRGGBB`; anything less writes `#RRGGBBAA`. |
|
|
146
|
+
| `toggleOpen`, `initialOpen` | `(open, key, multiple?) → keys`, `(items, initial?, multiple?) → keys` | Which expanders are open |
|
|
147
|
+
| `toggleBranch`, `decorateTree` | `(open, parentKey, key) → open`, `(nodes, open) → nodes` | Which branch of a settings tree is open at each level; `open` is `{ [parentKey]: key }`, the root's parent `''` |
|
|
138
148
|
| `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 |
|
|
149
|
+
| `sanitizeTargetName`, `buildInitialMappings`, `checksFor` | `(name) → string`, `({ parts?, originalName?, checks? }) → [{ part, target, include, fixes }]`, `(inspectResult, part) → checks[]` | Data import mapping |
|
|
140
150
|
| `configureUpload`, `uploadFile` | `({ url, headers, getHeaders })`, `(files, { url, fieldName, extraData, onProgress }) → Promise` | Multipart upload over XHR |
|
|
141
151
|
| `attachProgressSource`, `progressSourceAttached`, `subscribeToProgress`, `describeProgress`, `describeComplete` | `(subscribe)`, `() → boolean`, `(key, handler) → unsubscribe`, `(data, noun?)`, `(data, noun?)` | The progress event source and its sentences |
|
|
142
152
|
|
|
@@ -155,7 +165,7 @@ export default function SelectCompany() {
|
|
|
155
165
|
| `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
|
|
156
166
|
| `COUNTRIES` | `[{ code, name }]` | 196 countries |
|
|
157
167
|
| `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 |
|
|
168
|
+
| `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`, `COLOR_PICKER_RULES`, `EXPANDERS_RULES`, `SETTINGS_TREE_RULES` | rule arrays | What each page requires of a design |
|
|
159
169
|
|
|
160
170
|
## DropdownPage
|
|
161
171
|
|
|
@@ -284,13 +294,15 @@ configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer '
|
|
|
284
294
|
|
|
285
295
|
| prop | type | meaning |
|
|
286
296
|
|---|---|---|
|
|
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. |
|
|
297
|
+
| `onInspect` | `(filePath, file) => Promise<{ format, parts?, checks? }>` | **Required.** Say what an uploaded file contains. `parts` holds, for example, an Excel workbook's sheet names. `checks` says what is wrong with a part before it is loaded — see below. |
|
|
298
|
+
| `onCommit` | `(filePath, [{ part, target, fixes }], { originalName }) => Promise<{ results } \| results[]>` | **Required.** Load the included parts into their targets. `fixes` is the person's choice for each fix the checks offered. |
|
|
289
299
|
| `accept` | `string` | The file input's `accept` attribute, for example `'.csv,.json,.xlsx,.xls'` |
|
|
290
300
|
| `targetLabel` | `string` | Placeholder for the target name (default `'Target'`) |
|
|
291
301
|
|
|
292
302
|
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
303
|
|
|
304
|
+
**Checks, before anything is loaded.** `onInspect` may return `checks: { [part]: [{ key, message, fixLabel?, fixDefault?, examples? }] }` (the key `''` for a one-unit file). Each shows under its part as a warning with its message and examples. One with a `fixLabel` offers the fix as a checkbox, on unless `fixDefault: false`; the choice reaches `onCommit` as `fixes[key]`. A result row's `note` is shown under it — what a fix did. The component knows no particular check: the app decides what to look for and what its fix means (BI: rows pasted into one cell, recovered).
|
|
305
|
+
|
|
294
306
|
`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
307
|
|
|
296
308
|
## ImportHistoryPage
|
|
@@ -325,6 +337,52 @@ function Tile({ row, isAddTile }) {
|
|
|
325
337
|
| `onSelect` | `(row) => void` | — | Tile click |
|
|
326
338
|
| `onAddNew` | `() => void` | — | If given, a leading add tile is shown |
|
|
327
339
|
|
|
340
|
+
## ColorPickerPage
|
|
341
|
+
|
|
342
|
+
```jsx
|
|
343
|
+
<ColorPickerPage label="Background" value={fill} onChange={setFill} />
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
| prop | default | meaning |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| `value` | — | `'#RRGGBB'`, `'#RRGGBBAA'`, or `null` for no colour |
|
|
349
|
+
| `onChange` | — | `(value \| null) => void`, on every pick, slide or clear |
|
|
350
|
+
| `swatches` | `DEFAULT_SWATCHES` | 14 soft and strong colours |
|
|
351
|
+
| `allowNone` | `true` | Offer "no colour" |
|
|
352
|
+
| `allowOpacity` | `true` | The opacity slider and % box |
|
|
353
|
+
| `previewText` | `'Aa 123'` | Text in the preview box |
|
|
354
|
+
| `label` | — | Line above the swatches |
|
|
355
|
+
|
|
356
|
+
**The value is one CSS colour, never a colour plus an opacity.** `#RRGGBBAA` is understood by every browser and style sheet, so a saved style needs no second field and nothing else has to learn about opacity. A colour not among the swatches still shows as picked, in the custom swatch.
|
|
357
|
+
|
|
358
|
+
## Panel: PanelSection, PanelField, ExpandersPage
|
|
359
|
+
|
|
360
|
+
The parts of a settings panel. `PanelSection` and `PanelField` are presentation only and take children; the expanders have a controller.
|
|
361
|
+
|
|
362
|
+
```jsx
|
|
363
|
+
<PanelSection icon={<TypeIcon />} title="Field" subtitle="What this column is">
|
|
364
|
+
<PanelField label="Label" help="Shown as the column header">
|
|
365
|
+
<input value={label} onChange={…} />
|
|
366
|
+
</PanelField>
|
|
367
|
+
</PanelSection>
|
|
368
|
+
|
|
369
|
+
<ExpandersPage items={[
|
|
370
|
+
{ key: 'text', title: 'Text', summary: 'Inter · 13px', icon: <T />, content: <TextRows /> },
|
|
371
|
+
{ key: 'fill', title: 'Fill', content: <ColorPickerPage … /> }
|
|
372
|
+
]} />
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
| component | props |
|
|
376
|
+
|---|---|
|
|
377
|
+
| `PanelSection` | `icon`, `title`, `subtitle`, `actions` (right of the title), `className`, children |
|
|
378
|
+
| `PanelField` | `label`, `badge`, `help` (under the control), `htmlFor`, `stacked` (control under the label, for wide controls), children |
|
|
379
|
+
| `SettingsTreePage` | `nodes: [{ key, title, icon?, count?, content?, children? }]`, `initial` (`{ [parentKey]: key }`, root parent `''`) |
|
|
380
|
+
| `ExpandersPage` | `items: [{ key, title, summary?, icon?, content }]`, `initial` (key or keys open at first; default the first item; `null` for none), `multiple` (let several be open) |
|
|
381
|
+
|
|
382
|
+
Opening an expander closes the others unless `multiple`: a long panel with every part open is the scroll the expanders exist to avoid.
|
|
383
|
+
|
|
384
|
+
`SettingsTreePage` is for a panel with more settings than expanders can hold — a chart has over a hundred, four levels deep. Top-level nodes are cards (an icon tile, the title, `count` as a badge, a chevron); an open one is tinted with an accent bar and lists its children as rows on a dotted rail. A node shows its own `content` first, then its children. One branch is open per level, for the same reason as the expanders. Give every top-level node an icon or none of them: a panel where some groups have one reads as unfinished.
|
|
385
|
+
|
|
328
386
|
## ProgressNotifier
|
|
329
387
|
|
|
330
388
|
Attach your app's event stream **once** at startup, then mount notifiers anywhere with a key. From `xeplr-bi`:
|
|
@@ -395,17 +453,21 @@ These create their own DOM nodes with inline styles, so they need no provider, m
|
|
|
395
453
|
| `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
|
|
396
454
|
| `DATA_IMPORT_RULES` | `input[type="file"]` |
|
|
397
455
|
| `GRID_DISPLAYER_RULES` | `.xeplr-grid-search`; `.xeplr-grid` |
|
|
456
|
+
| `COLOR_PICKER_RULES` | `.xeplr-colorpicker-swatch` or `[role="option"]` |
|
|
457
|
+
| `EXPANDERS_RULES` | `.xeplr-expander-head` or `[aria-expanded]` |
|
|
458
|
+
| `SETTINGS_TREE_RULES` | `.xeplr-tree-head` or `[aria-expanded]` |
|
|
398
459
|
| `IMPORT_HISTORY_RULES` | nothing |
|
|
399
460
|
|
|
400
461
|
## Theming
|
|
401
462
|
|
|
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:
|
|
463
|
+
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-*`, `xeplr-colorpicker-*`, `xeplr-panel-*`, `xeplr-expander-*`, `xeplr-tree-*`). The progress notifier uses `xpn`, `xpn--running` / `--complete` / `--error`, `xpn-bar` and `xpn-text`. What each design reads:
|
|
403
464
|
|
|
404
465
|
| design | reads | without it |
|
|
405
466
|
|---|---|---|
|
|
406
467
|
| 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
468
|
| 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
469
|
| 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. |
|
|
470
|
+
| ColorPicker, Panel | `--xeplr-bg-primary`, `--xeplr-bg-secondary`, `--xeplr-border-primary`, `--xeplr-border-focus`, `--xeplr-text-primary/-secondary/-muted`, `--xeplr-accent`, `--xeplr-accent-soft` | light fallbacks |
|
|
409
471
|
| 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
472
|
| NumberField, DateField (and so RangeField's two inputs), FileUpload | fixed dark colours, no variables | — |
|
|
411
473
|
|
|
@@ -444,7 +506,7 @@ An edge set to `false` becomes `0` instead of being left out, because a missing
|
|
|
444
506
|
src/
|
|
445
507
|
index.js ─ everything below
|
|
446
508
|
<control>/ ─ dropdown, dateRange, rangeField, dateField, numberField, textField,
|
|
447
|
-
fileUpload, dataImport, gridDisplayer — each has:
|
|
509
|
+
fileUpload, dataImport, gridDisplayer, colorPicker, panel — each has:
|
|
448
510
|
<control>.js model (pure; dataImport.js, uploadFile.js)
|
|
449
511
|
use*Controller.js controller
|
|
450
512
|
validateDesign.js *_RULES (fileUpload's also defines useDesignValidator)
|
|
@@ -455,7 +517,8 @@ src/
|
|
|
455
517
|
confirm/confirm.js ─ raiseConfirm, raiseChoice
|
|
456
518
|
theme/ ─ default.theme.json, index.js (resolveTheme, getThemeVariant, getChartOptions, inputStyleVars, deepMerge)
|
|
457
519
|
data/ ─ countries.json, states.json
|
|
458
|
-
test/ ─ dateRange, rangeField, textField (+ inputStyleVars),
|
|
520
|
+
test/ ─ dateRange, rangeField, textField (+ inputStyleVars), colorPicker, panel,
|
|
521
|
+
validateDesign (DATE_RANGE_RULES), designRules (COLOR_PICKER_RULES, EXPANDERS_RULES, SETTINGS_TREE_RULES)
|
|
459
522
|
```
|
|
460
523
|
|
|
461
524
|
## Tests
|
|
@@ -464,7 +527,7 @@ test/ ─ dateRange, rangeField, textField (+ inputStyleVars
|
|
|
464
527
|
npm test
|
|
465
528
|
```
|
|
466
529
|
|
|
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
|
|
530
|
+
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`, the colour, expander and settings-tree models, and the design rules of the date range, colour picker, expanders and settings tree. CI runs the same files with `node --test` and coverage.
|
|
468
531
|
|
|
469
532
|
## License
|
|
470
533
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ColorPicker — model. A colour and how see-through it is, kept as ONE CSS
|
|
2
|
+
// value: '#RRGGBB' when solid, '#RRGGBBAA' when not — a string every browser,
|
|
3
|
+
// every style sheet and every saved style already understands. No React here.
|
|
4
|
+
|
|
5
|
+
/** Soft swatches that read well behind text, light and dark. */
|
|
6
|
+
export var DEFAULT_SWATCHES = [
|
|
7
|
+
'#f3f4f6', '#dbe8ff', '#d9f2e3', '#fdecc8', '#fde0d4', '#f8dcf0', '#e6defd',
|
|
8
|
+
'#9ca3af', '#4f7cff', '#22a06b', '#e0a106', '#e5673d', '#d14fa8', '#7c5cff'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
var HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* '#abc', '#aabbcc' or '#aabbccdd' → { hex: '#aabbcc', opacity: 0–100 }, or
|
|
15
|
+
* null for anything else (no colour).
|
|
16
|
+
*/
|
|
17
|
+
export function parseColor(value) {
|
|
18
|
+
var m = HEX.exec(String(value || '').trim());
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
var h = m[1].toLowerCase();
|
|
21
|
+
if (h.length === 3) h = h.split('').map(function(c) { return c + c; }).join('');
|
|
22
|
+
var alpha = h.length === 8 ? parseInt(h.slice(6), 16) : 255;
|
|
23
|
+
return { hex: '#' + h.slice(0, 6), opacity: Math.round(alpha / 255 * 100) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** { hex, opacity } → the one CSS value: '#RRGGBB' when opacity is 100, '#RRGGBBAA' otherwise. */
|
|
27
|
+
export function formatColor(hex, opacity) {
|
|
28
|
+
var parsed = parseColor(hex);
|
|
29
|
+
if (!parsed) return null;
|
|
30
|
+
var o = Math.max(0, Math.min(100, Math.round(Number(opacity))));
|
|
31
|
+
if (isNaN(o) || o === 100) return parsed.hex;
|
|
32
|
+
return parsed.hex + Math.round(o / 100 * 255).toString(16).padStart(2, '0');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether two colours are the same swatch, whatever their opacity. */
|
|
36
|
+
export function sameHue(a, b) {
|
|
37
|
+
var pa = parseColor(a), pb = parseColor(b);
|
|
38
|
+
return Boolean(pa && pb && pa.hex === pb.hex);
|
|
39
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import './colorPicker.css';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ColorPicker — design. Presentation only; everything from
|
|
5
|
+
* useColorPickerController. Swatches, "no colour", a custom colour, opacity
|
|
6
|
+
* as a slider and a number, and a live preview of the result.
|
|
7
|
+
*/
|
|
8
|
+
export default function ColorPickerSample(props) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="xeplr-colorpicker-wrap">
|
|
11
|
+
<div className="xeplr-colorpicker">
|
|
12
|
+
<div className="xeplr-colorpicker-main">
|
|
13
|
+
{props.label && <div className="xeplr-colorpicker-label">{props.label}</div>}
|
|
14
|
+
<div className="xeplr-colorpicker-swatches" role="listbox" aria-label={props.label || 'Colour'}>
|
|
15
|
+
{props.allowNone && (
|
|
16
|
+
<button type="button" role="option" aria-selected={!props.value} title="No colour"
|
|
17
|
+
className={'xeplr-colorpicker-swatch xeplr-colorpicker-none' + (!props.value ? ' selected' : '')}
|
|
18
|
+
onClick={props.clear}>
|
|
19
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M4 12 12 4" stroke="currentColor" strokeWidth="1.5" /></svg>
|
|
20
|
+
</button>
|
|
21
|
+
)}
|
|
22
|
+
{props.swatches.map(function(s) {
|
|
23
|
+
return (
|
|
24
|
+
<button key={s.color} type="button" role="option" aria-selected={s.selected} title={s.color}
|
|
25
|
+
className={'xeplr-colorpicker-swatch' + (s.selected ? ' selected' : '')}
|
|
26
|
+
style={{ background: s.color }} onClick={function() { props.pick(s.color); }} />
|
|
27
|
+
);
|
|
28
|
+
})}
|
|
29
|
+
<label className={'xeplr-colorpicker-swatch xeplr-colorpicker-custom' + (props.custom ? ' selected' : '')}
|
|
30
|
+
title="Another colour" style={props.custom ? { background: props.hex } : undefined}>
|
|
31
|
+
{!props.custom && <span aria-hidden="true">+</span>}
|
|
32
|
+
<input type="color" value={props.hex || '#4f7cff'} onChange={function(e) { props.pick(e.target.value); }} aria-label="Another colour" />
|
|
33
|
+
</label>
|
|
34
|
+
</div>
|
|
35
|
+
{props.allowOpacity && (
|
|
36
|
+
<div className="xeplr-colorpicker-opacity">
|
|
37
|
+
<span className="xeplr-colorpicker-label">Opacity</span>
|
|
38
|
+
<div className="xeplr-colorpicker-opacity-row">
|
|
39
|
+
<input type="range" min="0" max="100" value={props.opacity} disabled={!props.value}
|
|
40
|
+
onChange={function(e) { props.setOpacity(Number(e.target.value)); }} aria-label="Opacity" />
|
|
41
|
+
<span className="xeplr-colorpicker-opacity-value">
|
|
42
|
+
<input type="number" min="0" max="100" value={props.opacity} disabled={!props.value}
|
|
43
|
+
onChange={function(e) { props.setOpacity(Number(e.target.value)); }} aria-label="Opacity percent" />%
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="xeplr-colorpicker-preview">
|
|
50
|
+
<span className="xeplr-colorpicker-label">Preview</span>
|
|
51
|
+
<div className="xeplr-colorpicker-preview-box" style={props.value ? { background: props.value } : undefined}>{props.previewText}</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* Measured against its own width, not the window's: a picker in a narrow
|
|
2
|
+
side panel puts its preview under the swatches instead of beside them. */
|
|
3
|
+
.xeplr-colorpicker-wrap { container-type: inline-size; }
|
|
4
|
+
.xeplr-colorpicker { display: flex; gap: 16px; align-items: stretch; }
|
|
5
|
+
.xeplr-colorpicker-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
6
|
+
.xeplr-colorpicker-label { font-size: 11.5px; color: var(--xeplr-text-muted, #6b7280); }
|
|
7
|
+
.xeplr-colorpicker-swatches { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
8
|
+
.xeplr-colorpicker-swatch {
|
|
9
|
+
position: relative; width: 28px; height: 28px; border-radius: 8px; padding: 0; cursor: pointer;
|
|
10
|
+
border: 1px solid var(--xeplr-border-primary, #e2e5eb); background: var(--xeplr-bg-primary, #fff);
|
|
11
|
+
display: inline-flex; align-items: center; justify-content: center; color: var(--xeplr-text-muted, #6b7280);
|
|
12
|
+
transition: transform .1s, box-shadow .12s;
|
|
13
|
+
}
|
|
14
|
+
.xeplr-colorpicker-swatch:hover { transform: translateY(-1px); }
|
|
15
|
+
.xeplr-colorpicker-swatch.selected { box-shadow: 0 0 0 2px var(--xeplr-bg-primary, #fff), 0 0 0 4px var(--xeplr-accent, #4f5fd6); }
|
|
16
|
+
.xeplr-colorpicker-custom input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; border: 0; padding: 0; }
|
|
17
|
+
.xeplr-colorpicker-opacity { display: flex; flex-direction: column; gap: 6px; }
|
|
18
|
+
.xeplr-colorpicker-opacity-row { display: flex; align-items: center; gap: 10px; }
|
|
19
|
+
.xeplr-colorpicker-opacity-row input[type="range"] { flex: 1; accent-color: var(--xeplr-accent, #4f5fd6); }
|
|
20
|
+
.xeplr-colorpicker-opacity-value {
|
|
21
|
+
display: inline-flex; align-items: center; gap: 2px; font-size: 12.5px; padding: 6px 10px;
|
|
22
|
+
border: 1px solid var(--xeplr-border-primary, #e2e5eb); border-radius: 8px; background: var(--xeplr-bg-primary, #fff);
|
|
23
|
+
}
|
|
24
|
+
.xeplr-colorpicker-opacity-value input { width: 32px; -moz-appearance: textfield; appearance: textfield; border: 0; background: transparent; text-align: right; font: inherit; color: inherit; padding: 0; }
|
|
25
|
+
.xeplr-colorpicker-preview { width: 120px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; padding-left: 16px; border-left: 1px solid var(--xeplr-border-primary, #e2e5eb); }
|
|
26
|
+
.xeplr-colorpicker-preview-box {
|
|
27
|
+
flex: 1; min-height: 44px; display: flex; align-items: center; padding: 0 12px; border-radius: 10px;
|
|
28
|
+
border: 1px solid var(--xeplr-border-primary, #e2e5eb); font-size: 14px; font-weight: 600; color: var(--xeplr-text-primary, #1f2937);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@container (max-width: 340px) {
|
|
32
|
+
.xeplr-colorpicker { flex-direction: column; gap: 12px; }
|
|
33
|
+
.xeplr-colorpicker-preview { width: auto; padding-left: 0; border-left: 0; padding-top: 12px; border-top: 1px solid var(--xeplr-border-primary, #e2e5eb); }
|
|
34
|
+
}
|
|
35
|
+
.xeplr-colorpicker-opacity-value input::-webkit-inner-spin-button,
|
|
36
|
+
.xeplr-colorpicker-opacity-value input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ColorPickerSample } from './ColorPickerSample.jsx';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useColorPickerController } from './useColorPickerController.js';
|
|
2
|
+
import ColorPickerSample from './designs/ColorPickerSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { COLOR_PICKER_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function ColorPickerPage(props) {
|
|
7
|
+
var controller = useColorPickerController(props);
|
|
8
|
+
var ref = useDesignValidator('ColorPickerPage', COLOR_PICKER_RULES);
|
|
9
|
+
return <div ref={ref}><ColorPickerSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DEFAULT_SWATCHES, parseColor, formatColor, sameHue } from './colorPicker.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ColorPicker — controller. The value is one CSS colour (see colorPicker.js);
|
|
5
|
+
* every change goes straight to onChange. No JSX here.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} props
|
|
8
|
+
* @param {string|null} props.value - '#RRGGBB' / '#RRGGBBAA', or null for none
|
|
9
|
+
* @param {Function} props.onChange - (value|null) => void
|
|
10
|
+
* @param {string[]} [props.swatches] - default DEFAULT_SWATCHES
|
|
11
|
+
* @param {boolean} [props.allowNone] - default true: offer "no colour"
|
|
12
|
+
* @param {boolean} [props.allowOpacity] - default true
|
|
13
|
+
* @param {string} [props.previewText] - default 'Aa 123'
|
|
14
|
+
* @param {string} [props.label]
|
|
15
|
+
*/
|
|
16
|
+
export function useColorPickerController(props) {
|
|
17
|
+
var onChange = props.onChange || function() {};
|
|
18
|
+
var parsed = parseColor(props.value);
|
|
19
|
+
var swatches = props.swatches || DEFAULT_SWATCHES;
|
|
20
|
+
var opacity = parsed ? parsed.opacity : 100;
|
|
21
|
+
|
|
22
|
+
function pick(hex) { onChange(formatColor(hex, parsed ? opacity : 100)); }
|
|
23
|
+
function setOpacity(value) { if (parsed) onChange(formatColor(parsed.hex, value)); }
|
|
24
|
+
function clear() { onChange(null); }
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
label: props.label || null,
|
|
28
|
+
value: parsed ? formatColor(parsed.hex, opacity) : null,
|
|
29
|
+
hex: parsed ? parsed.hex : null,
|
|
30
|
+
opacity: opacity,
|
|
31
|
+
swatches: swatches.map(function(s) { return { color: s, selected: sameHue(s, props.value) }; }),
|
|
32
|
+
// A colour not among the swatches still shows as picked, in the custom one.
|
|
33
|
+
custom: Boolean(parsed && !swatches.some(function(s) { return sameHue(s, props.value); })),
|
|
34
|
+
allowNone: props.allowNone !== false,
|
|
35
|
+
allowOpacity: props.allowOpacity !== false,
|
|
36
|
+
previewText: props.previewText || 'Aa 123',
|
|
37
|
+
pick: pick,
|
|
38
|
+
setOpacity: setOpacity,
|
|
39
|
+
clear: clear
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -16,8 +16,26 @@ export function sanitizeTargetName(name) {
|
|
|
16
16
|
.replace(/^_+|_+$/g, '') || 'table';
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
19
|
+
// What the inspect step found wrong with one part, if anything:
|
|
20
|
+
// inspectResult.checks = { [part]: [{ key, message, fixLabel?, fixDefault? }] }
|
|
21
|
+
// (the key '' for a file that is one unit). A check with a fixLabel offers a
|
|
22
|
+
// fix the person switches on or off before importing; its choice travels
|
|
23
|
+
// with the mapping as fixes[key].
|
|
24
|
+
export function checksFor(inspectResult, part) {
|
|
25
|
+
var all = (inspectResult && inspectResult.checks) || {};
|
|
26
|
+
return all[part == null ? '' : part] || [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function initialFixes(inspectResult, part) {
|
|
30
|
+
var fixes = {};
|
|
31
|
+
checksFor(inspectResult, part).forEach(function(c) {
|
|
32
|
+
if (c.fixLabel) fixes[c.key] = c.fixDefault !== false;
|
|
33
|
+
});
|
|
34
|
+
return fixes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// inspectResult: { filePath, format, originalName?, parts?: string[], checks? }
|
|
38
|
+
// -> [{ part: string|null, target: string, include: boolean, fixes: { [key]: boolean } }]
|
|
21
39
|
//
|
|
22
40
|
// parts present (e.g. Excel sheets) -> one row per part, all included by
|
|
23
41
|
// default. parts absent (e.g. CSV/JSON — the whole file is one unit) -> a
|
|
@@ -25,8 +43,8 @@ export function sanitizeTargetName(name) {
|
|
|
25
43
|
export function buildInitialMappings(inspectResult) {
|
|
26
44
|
if (inspectResult.parts && inspectResult.parts.length) {
|
|
27
45
|
return inspectResult.parts.map(function(part) {
|
|
28
|
-
return { part: part, target: sanitizeTargetName(part), include: true };
|
|
46
|
+
return { part: part, target: sanitizeTargetName(part), include: true, fixes: initialFixes(inspectResult, part) };
|
|
29
47
|
});
|
|
30
48
|
}
|
|
31
|
-
return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true }];
|
|
49
|
+
return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true, fixes: initialFixes(inspectResult, null) }];
|
|
32
50
|
}
|
|
@@ -2,7 +2,7 @@ import './dataImport.css';
|
|
|
2
2
|
|
|
3
3
|
export default function DataImportSample({
|
|
4
4
|
file, inspecting, inspected, mappings, committing, results, error,
|
|
5
|
-
handleFileChange, updateMapping, handleCommit, reset, accept, targetLabel
|
|
5
|
+
handleFileChange, updateMapping, setFix, checksOf, handleCommit, reset, accept, targetLabel
|
|
6
6
|
}) {
|
|
7
7
|
return (
|
|
8
8
|
<div className="xeplr-data-import">
|
|
@@ -37,6 +37,31 @@ export default function DataImportSample({
|
|
|
37
37
|
onChange={function(e) { updateMapping(i, { target: e.target.value }); }}
|
|
38
38
|
placeholder={targetLabel}
|
|
39
39
|
/>
|
|
40
|
+
{/* What the inspect step found, before anything is loaded —
|
|
41
|
+
and the fix, when one is offered, as a choice. */}
|
|
42
|
+
{(checksOf ? checksOf(m.part) : []).map(function(c) {
|
|
43
|
+
return (
|
|
44
|
+
<div key={c.key} className="xeplr-data-import-check" role="alert">
|
|
45
|
+
<p className="xeplr-data-import-check-message">{c.message}</p>
|
|
46
|
+
{c.fixLabel && (
|
|
47
|
+
<label className="xeplr-data-import-check-fix">
|
|
48
|
+
<input
|
|
49
|
+
type="checkbox"
|
|
50
|
+
checked={Boolean(m.fixes && m.fixes[c.key])}
|
|
51
|
+
disabled={!m.include}
|
|
52
|
+
onChange={function(e) { setFix(i, c.key, e.target.checked); }}
|
|
53
|
+
/>
|
|
54
|
+
{c.fixLabel}
|
|
55
|
+
</label>
|
|
56
|
+
)}
|
|
57
|
+
{(c.examples || []).length > 0 && (
|
|
58
|
+
<ul className="xeplr-data-import-check-examples">
|
|
59
|
+
{c.examples.map(function(x, xi) { return <li key={xi}>{x}</li>; })}
|
|
60
|
+
</ul>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
40
65
|
</div>
|
|
41
66
|
);
|
|
42
67
|
})}
|
|
@@ -61,6 +86,7 @@ export default function DataImportSample({
|
|
|
61
86
|
<span className={r.success ? 'xeplr-data-import-result-ok' : 'xeplr-data-import-result-fail'}>
|
|
62
87
|
{r.success ? (r.rows != null ? r.rows + ' rows' : 'Done') : (r.error || 'Failed')}
|
|
63
88
|
</span>
|
|
89
|
+
{r.note && <span className="xeplr-data-import-result-note">{r.note}</span>}
|
|
64
90
|
</div>
|
|
65
91
|
);
|
|
66
92
|
})}
|
|
@@ -96,3 +96,22 @@
|
|
|
96
96
|
.xeplr-import-history-status-failed { color: var(--xeplr-danger, #ff6b6b); }
|
|
97
97
|
.xeplr-import-history-status-rolled-back { color: var(--xeplr-text-muted, #888); }
|
|
98
98
|
.xeplr-import-history-error { color: var(--xeplr-text-secondary, #999); }
|
|
99
|
+
|
|
100
|
+
/* What the inspect step found in a part — on its own line under the part,
|
|
101
|
+
with the fix (when one is offered) as a checkbox the person decides. */
|
|
102
|
+
.xeplr-data-import-mapping-row { flex-wrap: wrap; }
|
|
103
|
+
.xeplr-data-import-check {
|
|
104
|
+
flex-basis: 100%;
|
|
105
|
+
margin: 2px 0 4px;
|
|
106
|
+
padding: 10px 12px;
|
|
107
|
+
border-radius: 8px;
|
|
108
|
+
border: 1px solid var(--xeplr-warning, #a86a00);
|
|
109
|
+
background: var(--xeplr-warning-bg, rgba(168, 106, 0, 0.08));
|
|
110
|
+
color: var(--xeplr-text-primary, #e0e0e0);
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
}
|
|
113
|
+
.xeplr-data-import-check-message { margin: 0 0 6px; line-height: 1.45; }
|
|
114
|
+
.xeplr-data-import-check-fix { display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer; }
|
|
115
|
+
.xeplr-data-import-check-examples { margin: 6px 0 0; padding-left: 18px; font-size: 12px; color: var(--xeplr-text-muted, #888); }
|
|
116
|
+
.xeplr-data-import-result-note { flex-basis: 100%; font-size: 12px; color: var(--xeplr-text-muted, #888); }
|
|
117
|
+
.xeplr-data-import-result-row { flex-wrap: wrap; }
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { uploadFile } from '../fileUpload/uploadFile.js';
|
|
3
|
-
import { buildInitialMappings } from './dataImport.js';
|
|
3
|
+
import { buildInitialMappings, checksFor } from './dataImport.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @param {object} options
|
|
7
7
|
* @param {string} [options.accept] - file input accept attribute, e.g. '.csv,.json,.xlsx,.xls'
|
|
8
8
|
* @param {string} [options.targetLabel='Target'] - placeholder/label for the target-name field
|
|
9
|
-
* @param {(filePath: string, file: File) => Promise<{format: string, parts?: string[]}>} options.onInspect
|
|
9
|
+
* @param {(filePath: string, file: File) => Promise<{format: string, parts?: string[], checks?: object}>} options.onInspect
|
|
10
10
|
* - required. App-supplied: given a file ALREADY uploaded (see below), what
|
|
11
11
|
* does it contain? (format, and for a multi-part file like an Excel
|
|
12
12
|
* workbook, its part/sheet names).
|
|
13
|
-
* @param {(filePath: string, mappings: {part: string|null, target: string}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
|
|
13
|
+
* @param {(filePath: string, mappings: {part: string|null, target: string, fixes: object}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
|
|
14
|
+
* (each mapping's `fixes` is the person's choice for each fix the inspect
|
|
15
|
+
* step's `checks` offered — see checksFor in dataImport.js)
|
|
14
16
|
* - required. App-supplied: load the included parts into their targets.
|
|
15
17
|
* meta.originalName is the picked file's own name (not the server-side
|
|
16
18
|
* path) — pass it through if the app wants it recorded (e.g. import
|
|
@@ -61,6 +63,15 @@ export function useDataImportController(options = {}) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
// A fix the inspect step offered, switched on or off for one part.
|
|
67
|
+
function setFix(index, key, on) {
|
|
68
|
+
setMappings(function(prev) {
|
|
69
|
+
var next = prev.slice();
|
|
70
|
+
next[index] = Object.assign({}, next[index], { fixes: Object.assign({}, next[index].fixes, { [key]: on }) });
|
|
71
|
+
return next;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
function updateMapping(index, patch) {
|
|
65
76
|
setMappings(function(prev) {
|
|
66
77
|
var next = prev.slice();
|
|
@@ -75,7 +86,7 @@ export function useDataImportController(options = {}) {
|
|
|
75
86
|
setCommitting(true);
|
|
76
87
|
setError('');
|
|
77
88
|
try {
|
|
78
|
-
var payload = included.map(function(m) { return { part: m.part, target: m.target }; });
|
|
89
|
+
var payload = included.map(function(m) { return { part: m.part, target: m.target, fixes: m.fixes || {} }; });
|
|
79
90
|
var res = await options.onCommit(inspected.filePath, payload, { originalName: inspected.originalName });
|
|
80
91
|
setResults((res && res.results) || res || []);
|
|
81
92
|
} catch (err) {
|
|
@@ -87,7 +98,8 @@ export function useDataImportController(options = {}) {
|
|
|
87
98
|
|
|
88
99
|
return {
|
|
89
100
|
file, inspecting, inspected, mappings, committing, results, error,
|
|
90
|
-
handleFileChange, updateMapping, handleCommit, reset,
|
|
101
|
+
handleFileChange, updateMapping, setFix, handleCommit, reset,
|
|
102
|
+
checksOf: function(part) { return checksFor(inspected, part); },
|
|
91
103
|
accept: options.accept,
|
|
92
104
|
targetLabel: options.targetLabel || 'Target'
|
|
93
105
|
};
|
package/src/index.js
CHANGED
|
@@ -198,3 +198,22 @@ export { TextFieldPage } from './textField/pages.jsx';
|
|
|
198
198
|
export { attachProgressSource, progressSourceAttached, subscribeToProgress,
|
|
199
199
|
describeProgress, describeComplete } from './progress/progressSource.js';
|
|
200
200
|
export { default as ProgressNotifier } from './progress/ProgressNotifier.jsx';
|
|
201
|
+
|
|
202
|
+
// ColorPicker — one CSS colour, '#RRGGBB' solid or '#RRGGBBAA' see-through:
|
|
203
|
+
// swatches, "no colour", a custom colour, opacity, and a live preview.
|
|
204
|
+
export { DEFAULT_SWATCHES, parseColor, formatColor, sameHue } from './colorPicker/colorPicker.js';
|
|
205
|
+
export { useColorPickerController } from './colorPicker/useColorPickerController.js';
|
|
206
|
+
export { COLOR_PICKER_RULES } from './colorPicker/validateDesign.js';
|
|
207
|
+
export { ColorPickerSample } from './colorPicker/designs/index.js';
|
|
208
|
+
export { ColorPickerPage } from './colorPicker/pages.jsx';
|
|
209
|
+
|
|
210
|
+
// Panel — the parts of a settings panel: section cards (PanelSection), rows
|
|
211
|
+
// with the label left and help under the control (PanelField), and
|
|
212
|
+
// expanders, opened one at a time unless `multiple`; and a settings tree —
|
|
213
|
+
// groups as cards, branches on a rail, one open per level.
|
|
214
|
+
export { toggleOpen, initialOpen, toggleBranch, decorateTree } from './panel/panel.js';
|
|
215
|
+
export { useExpandersController } from './panel/useExpandersController.js';
|
|
216
|
+
export { useSettingsTreeController } from './panel/useSettingsTreeController.js';
|
|
217
|
+
export { EXPANDERS_RULES, SETTINGS_TREE_RULES } from './panel/validateDesign.js';
|
|
218
|
+
export { PanelSection, PanelField, ExpandersSample, SettingsTreeSample } from './panel/designs/index.js';
|
|
219
|
+
export { ExpandersPage, SettingsTreePage } from './panel/pages.jsx';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import './panel.css';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expanders — design. Each item a header (icon, title, a short summary of
|
|
5
|
+
* what is set) that opens its content. Everything from useExpandersController.
|
|
6
|
+
*/
|
|
7
|
+
export default function ExpandersSample(props) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="xeplr-expanders">
|
|
10
|
+
{props.items.map(function(item) {
|
|
11
|
+
var id = 'xeplr-expander-' + item.key;
|
|
12
|
+
return (
|
|
13
|
+
<div key={item.key} className={'xeplr-expander' + (item.open ? ' open' : '')}>
|
|
14
|
+
<button type="button" className="xeplr-expander-head" aria-expanded={item.open} aria-controls={id}
|
|
15
|
+
onClick={function() { props.toggle(item.key); }}>
|
|
16
|
+
{item.icon && <span className="xeplr-expander-icon" aria-hidden="true">{item.icon}</span>}
|
|
17
|
+
<span className="xeplr-expander-title">{item.title}</span>
|
|
18
|
+
{item.summary && <span className="xeplr-expander-summary">{item.summary}</span>}
|
|
19
|
+
<svg className="xeplr-expander-chevron" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
|
20
|
+
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
21
|
+
</svg>
|
|
22
|
+
</button>
|
|
23
|
+
{item.open && <div className="xeplr-expander-body" id={id}>{item.content}</div>}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
})}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import './panel.css';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A card that groups one part of a settings panel: an icon, a title, an
|
|
5
|
+
* optional line under it, and the rows. Presentation only.
|
|
6
|
+
*/
|
|
7
|
+
export function PanelSection(props) {
|
|
8
|
+
return (
|
|
9
|
+
<section className={'xeplr-panel-section' + (props.className ? ' ' + props.className : '')}>
|
|
10
|
+
{(props.title || props.icon) && (
|
|
11
|
+
<header className="xeplr-panel-section-head">
|
|
12
|
+
{props.icon && <span className="xeplr-panel-section-icon" aria-hidden="true">{props.icon}</span>}
|
|
13
|
+
<div className="xeplr-panel-section-titles">
|
|
14
|
+
<h3 className="xeplr-panel-section-title">{props.title}</h3>
|
|
15
|
+
{props.subtitle && <div className="xeplr-panel-section-subtitle">{props.subtitle}</div>}
|
|
16
|
+
</div>
|
|
17
|
+
{props.actions && <div className="xeplr-panel-section-actions">{props.actions}</div>}
|
|
18
|
+
</header>
|
|
19
|
+
)}
|
|
20
|
+
<div className="xeplr-panel-section-body">{props.children}</div>
|
|
21
|
+
</section>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One row of a panel: the label on the left, the control on the right, and a
|
|
27
|
+
* line of help under the control. `stacked` puts the control under the label,
|
|
28
|
+
* for a control too wide to share the line (a colour picker).
|
|
29
|
+
*/
|
|
30
|
+
export function PanelField(props) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={'xeplr-panel-field' + (props.stacked ? ' xeplr-panel-field-stacked' : '')}>
|
|
33
|
+
<label className="xeplr-panel-field-label" htmlFor={props.htmlFor}>
|
|
34
|
+
{props.label}
|
|
35
|
+
{props.badge && <span className="xeplr-panel-field-badge">{props.badge}</span>}
|
|
36
|
+
</label>
|
|
37
|
+
<div className="xeplr-panel-field-control">
|
|
38
|
+
{props.children}
|
|
39
|
+
{props.help && <div className="xeplr-panel-field-help">{props.help}</div>}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import './settingsTree.css';
|
|
2
|
+
|
|
3
|
+
function Chevron() {
|
|
4
|
+
return (
|
|
5
|
+
<svg className="xeplr-tree-chevron" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
|
6
|
+
<path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
7
|
+
</svg>
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Node(props) {
|
|
12
|
+
var n = props.node;
|
|
13
|
+
var top = n.depth === 0;
|
|
14
|
+
return (
|
|
15
|
+
<div className={'xeplr-tree-node ' + (top ? 'xeplr-tree-group' : 'xeplr-tree-item') + (n.open ? ' open' : '')}
|
|
16
|
+
data-depth={n.depth}>
|
|
17
|
+
<button type="button" className="xeplr-tree-head" aria-expanded={n.open}
|
|
18
|
+
onClick={function() { props.toggle(n.parentKey, n.key); }}>
|
|
19
|
+
{!top && <span className="xeplr-tree-dot" aria-hidden="true" />}
|
|
20
|
+
{n.icon && <span className={top ? 'xeplr-tree-tile' : 'xeplr-tree-icon'} aria-hidden="true">{n.icon}</span>}
|
|
21
|
+
<span className="xeplr-tree-title">{n.title}</span>
|
|
22
|
+
{n.count ? <span className="xeplr-tree-count">{n.count}</span> : null}
|
|
23
|
+
<Chevron />
|
|
24
|
+
</button>
|
|
25
|
+
{n.open && (n.content || n.children) && (
|
|
26
|
+
<div className="xeplr-tree-body">
|
|
27
|
+
{n.content && <div className="xeplr-tree-content">{n.content}</div>}
|
|
28
|
+
{n.children && (
|
|
29
|
+
<div className="xeplr-tree-children">
|
|
30
|
+
{n.children.map(function(c) { return <Node key={c.key} node={c} toggle={props.toggle} />; })}
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* SettingsTree — design. Top-level groups are cards (an icon tile, a title,
|
|
41
|
+
* how many settings are set, a chevron); an open group is tinted with an
|
|
42
|
+
* accent bar, its branches listed on a dotted rail. Everything from
|
|
43
|
+
* useSettingsTreeController.
|
|
44
|
+
*/
|
|
45
|
+
export default function SettingsTreeSample(props) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="xeplr-tree">
|
|
48
|
+
{props.nodes.map(function(n) { return <Node key={n.key} node={n} toggle={props.toggle} />; })}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* A settings panel: section cards, label-left rows, expanders. Uses the
|
|
2
|
+
--xeplr-* theme tokens; falls back to light values standalone. */
|
|
3
|
+
|
|
4
|
+
.xeplr-panel-section {
|
|
5
|
+
border: 1px solid var(--xeplr-border-secondary, #eef0f4);
|
|
6
|
+
border-radius: 14px;
|
|
7
|
+
background: var(--xeplr-bg-primary, #fff);
|
|
8
|
+
box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 2px 8px rgba(16, 24, 40, .04);
|
|
9
|
+
padding: 16px;
|
|
10
|
+
}
|
|
11
|
+
.xeplr-panel-section + .xeplr-panel-section { margin-top: 12px; }
|
|
12
|
+
.xeplr-panel-section-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
13
|
+
.xeplr-panel-section-icon {
|
|
14
|
+
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
|
|
15
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
16
|
+
background: var(--xeplr-accent-soft, #eef1fd); color: var(--xeplr-accent, #4f5fd6);
|
|
17
|
+
}
|
|
18
|
+
.xeplr-panel-section-titles { flex: 1; min-width: 0; }
|
|
19
|
+
.xeplr-panel-section-title {
|
|
20
|
+
margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
|
|
21
|
+
color: var(--xeplr-text-secondary, #4b5563);
|
|
22
|
+
}
|
|
23
|
+
.xeplr-panel-section-subtitle { font-size: 12px; color: var(--xeplr-text-muted, #8a8f98); margin-top: 3px; }
|
|
24
|
+
.xeplr-panel-section-body { display: flex; flex-direction: column; gap: 14px; }
|
|
25
|
+
|
|
26
|
+
.xeplr-panel-field { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 12px; align-items: start; }
|
|
27
|
+
.xeplr-panel-field-stacked { grid-template-columns: minmax(0, 1fr); gap: 6px; }
|
|
28
|
+
.xeplr-panel-field-label {
|
|
29
|
+
font-size: 13px; font-weight: 500; color: var(--xeplr-text-primary, #1f2937); padding-top: 8px;
|
|
30
|
+
display: flex; align-items: center; gap: 6px; min-width: 0;
|
|
31
|
+
}
|
|
32
|
+
.xeplr-panel-field-stacked .xeplr-panel-field-label { padding-top: 0; }
|
|
33
|
+
.xeplr-panel-field-badge {
|
|
34
|
+
font-size: 10.5px; padding: 1px 6px; border-radius: 999px;
|
|
35
|
+
background: var(--xeplr-bg-secondary, #f3f4f6); color: var(--xeplr-text-muted, #6b7280);
|
|
36
|
+
}
|
|
37
|
+
.xeplr-panel-field-control { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
38
|
+
.xeplr-panel-field-control > select,
|
|
39
|
+
.xeplr-panel-field-control > input:not([type="checkbox"]):not([type="radio"]) { width: 100%; box-sizing: border-box; }
|
|
40
|
+
.xeplr-panel-field-help { font-size: 12px; line-height: 1.45; color: var(--xeplr-text-muted, #8a8f98); }
|
|
41
|
+
|
|
42
|
+
.xeplr-expanders { display: flex; flex-direction: column; gap: 8px; }
|
|
43
|
+
.xeplr-expander {
|
|
44
|
+
border: 1px solid var(--xeplr-border-secondary, #eef0f4); border-radius: 12px;
|
|
45
|
+
background: var(--xeplr-bg-primary, #fff); overflow: hidden;
|
|
46
|
+
}
|
|
47
|
+
.xeplr-expander.open {
|
|
48
|
+
border-color: var(--xeplr-accent, #4f5fd6);
|
|
49
|
+
box-shadow: 0 0 0 3px var(--xeplr-accent-soft, #eef1fd);
|
|
50
|
+
}
|
|
51
|
+
.xeplr-expander.open > .xeplr-expander-head { background: var(--xeplr-accent-soft, #eef1fd); }
|
|
52
|
+
.xeplr-expander-head {
|
|
53
|
+
width: 100%; display: flex; align-items: center; gap: 10px; padding: 11px 14px;
|
|
54
|
+
background: transparent; border: 0; cursor: pointer; text-align: left; font: inherit;
|
|
55
|
+
color: var(--xeplr-text-primary, #1f2937);
|
|
56
|
+
}
|
|
57
|
+
.xeplr-expander-head:hover { background: var(--xeplr-bg-secondary, #f7f8fa); }
|
|
58
|
+
.xeplr-expander-icon { display: inline-flex; color: var(--xeplr-text-muted, #6b7280); }
|
|
59
|
+
.xeplr-expander.open .xeplr-expander-icon { color: var(--xeplr-accent, #4f5fd6); }
|
|
60
|
+
.xeplr-expander-title { font-size: 13.5px; font-weight: 600; }
|
|
61
|
+
.xeplr-expander.open .xeplr-expander-title { color: var(--xeplr-accent, #4f5fd6); }
|
|
62
|
+
.xeplr-expander-summary {
|
|
63
|
+
margin-left: auto; font-size: 11.5px; color: var(--xeplr-text-muted, #6b7280);
|
|
64
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
|
65
|
+
}
|
|
66
|
+
.xeplr-expander-chevron { flex-shrink: 0; color: var(--xeplr-text-muted, #6b7280); transition: transform .15s; }
|
|
67
|
+
.xeplr-expander-title + .xeplr-expander-chevron { margin-left: auto; }
|
|
68
|
+
.xeplr-expander.open .xeplr-expander-chevron { transform: rotate(180deg); }
|
|
69
|
+
.xeplr-expander-body {
|
|
70
|
+
padding: 14px; border-top: 1px solid var(--xeplr-border-secondary, #eef0f4);
|
|
71
|
+
display: flex; flex-direction: column; gap: 12px;
|
|
72
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* A settings tree: groups as cards, branches on a dotted rail. Uses the
|
|
2
|
+
--xeplr-* theme tokens; falls back to light values standalone. */
|
|
3
|
+
|
|
4
|
+
.xeplr-tree { display: flex; flex-direction: column; gap: 10px; }
|
|
5
|
+
|
|
6
|
+
.xeplr-tree-group {
|
|
7
|
+
position: relative;
|
|
8
|
+
border: 1px solid var(--xeplr-border-secondary, #eceef3);
|
|
9
|
+
border-radius: 12px;
|
|
10
|
+
background: var(--xeplr-bg-primary, #fff);
|
|
11
|
+
box-shadow: 0 1px 2px rgba(16, 24, 40, .04);
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
.xeplr-tree-group.open { box-shadow: 0 2px 10px rgba(16, 24, 40, .06); }
|
|
15
|
+
/* The accent bar down the left of the open group. */
|
|
16
|
+
.xeplr-tree-group.open::before {
|
|
17
|
+
content: ''; position: absolute; left: 0; top: 0; height: 52px; width: 3px;
|
|
18
|
+
background: var(--xeplr-accent, #3b5bdb); border-radius: 0 3px 3px 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.xeplr-tree-head {
|
|
22
|
+
width: 100%; display: flex; align-items: center; gap: 12px;
|
|
23
|
+
border: 0; background: transparent; cursor: pointer; text-align: left;
|
|
24
|
+
font: inherit; color: var(--xeplr-text-primary, #111827);
|
|
25
|
+
}
|
|
26
|
+
.xeplr-tree-group > .xeplr-tree-head { min-height: 52px; padding: 8px 14px 8px 12px; }
|
|
27
|
+
.xeplr-tree-group > .xeplr-tree-head:hover { background: var(--xeplr-bg-secondary, #f8f9fb); }
|
|
28
|
+
.xeplr-tree-group.open > .xeplr-tree-head { background: var(--xeplr-accent-soft, #edf2ff); }
|
|
29
|
+
.xeplr-tree-group > .xeplr-tree-head .xeplr-tree-title { font-size: 14px; font-weight: 600; }
|
|
30
|
+
|
|
31
|
+
.xeplr-tree-tile {
|
|
32
|
+
width: 32px; height: 32px; flex-shrink: 0; border-radius: 8px;
|
|
33
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
34
|
+
background: var(--xeplr-accent-soft, #edf2ff); color: var(--xeplr-accent, #3b5bdb);
|
|
35
|
+
}
|
|
36
|
+
.xeplr-tree-group.open .xeplr-tree-tile { background: var(--xeplr-bg-primary, #fff); }
|
|
37
|
+
.xeplr-tree-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
38
|
+
.xeplr-tree-count {
|
|
39
|
+
font-size: 11.5px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
|
|
40
|
+
background: var(--xeplr-accent-soft, #edf2ff); color: var(--xeplr-accent, #3b5bdb);
|
|
41
|
+
}
|
|
42
|
+
.xeplr-tree-chevron { margin-left: auto; flex-shrink: 0; color: var(--xeplr-text-muted, #6b7280); transition: transform .15s; }
|
|
43
|
+
.xeplr-tree-node.open > .xeplr-tree-head .xeplr-tree-chevron { transform: rotate(90deg); color: var(--xeplr-accent, #3b5bdb); }
|
|
44
|
+
|
|
45
|
+
.xeplr-tree-body { padding: 4px 8px 8px; }
|
|
46
|
+
.xeplr-tree-content { padding: 8px 6px; display: flex; flex-direction: column; gap: 10px; }
|
|
47
|
+
|
|
48
|
+
/* Branches: a dotted rail on the left, a dot per branch on it. */
|
|
49
|
+
.xeplr-tree-children { position: relative; padding-left: 26px; }
|
|
50
|
+
.xeplr-tree-children::before {
|
|
51
|
+
content: ''; position: absolute; left: 11px; top: 14px; bottom: 14px;
|
|
52
|
+
border-left: 1px dashed var(--xeplr-border-primary, #d9dde5);
|
|
53
|
+
}
|
|
54
|
+
.xeplr-tree-item + .xeplr-tree-item { border-top: 1px solid var(--xeplr-border-secondary, #f0f1f4); }
|
|
55
|
+
.xeplr-tree-item > .xeplr-tree-head { position: relative; min-height: 42px; padding: 6px 10px; border-radius: 8px; }
|
|
56
|
+
.xeplr-tree-item > .xeplr-tree-head:hover { background: var(--xeplr-bg-secondary, #f6f7f9); }
|
|
57
|
+
.xeplr-tree-item.open > .xeplr-tree-head { background: var(--xeplr-accent-soft, #edf2ff); }
|
|
58
|
+
.xeplr-tree-item > .xeplr-tree-head .xeplr-tree-title { font-size: 13.5px; font-weight: 500; }
|
|
59
|
+
.xeplr-tree-dot {
|
|
60
|
+
position: absolute; left: -18px; top: 50%; width: 7px; height: 7px; margin-top: -3.5px;
|
|
61
|
+
border-radius: 50%; background: var(--xeplr-border-primary, #c9ced8);
|
|
62
|
+
}
|
|
63
|
+
.xeplr-tree-item.open > .xeplr-tree-head .xeplr-tree-dot {
|
|
64
|
+
width: 9px; height: 9px; margin-top: -4.5px; left: -19px; background: var(--xeplr-accent, #3b5bdb);
|
|
65
|
+
}
|
|
66
|
+
.xeplr-tree-icon { display: inline-flex; color: var(--xeplr-text-secondary, #4b5563); flex-shrink: 0; }
|
|
67
|
+
.xeplr-tree-item.open > .xeplr-tree-head .xeplr-tree-icon,
|
|
68
|
+
.xeplr-tree-item.open > .xeplr-tree-head .xeplr-tree-title { color: var(--xeplr-accent, #3b5bdb); }
|
|
69
|
+
.xeplr-tree-item > .xeplr-tree-body { padding: 4px 0 8px; }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useExpandersController } from './useExpandersController.js';
|
|
2
|
+
import { useSettingsTreeController } from './useSettingsTreeController.js';
|
|
3
|
+
import ExpandersSample from './designs/ExpandersSample.jsx';
|
|
4
|
+
import SettingsTreeSample from './designs/SettingsTreeSample.jsx';
|
|
5
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
6
|
+
import { EXPANDERS_RULES, SETTINGS_TREE_RULES } from './validateDesign.js';
|
|
7
|
+
|
|
8
|
+
export function ExpandersPage(props) {
|
|
9
|
+
var controller = useExpandersController(props);
|
|
10
|
+
var ref = useDesignValidator('ExpandersPage', EXPANDERS_RULES);
|
|
11
|
+
return <div ref={ref}><ExpandersSample {...controller} /></div>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SettingsTreePage(props) {
|
|
15
|
+
var controller = useSettingsTreeController(props);
|
|
16
|
+
var ref = useDesignValidator('SettingsTreePage', SETTINGS_TREE_RULES);
|
|
17
|
+
return <div ref={ref}><SettingsTreeSample {...controller} /></div>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Panel — model. Which expanders of a settings panel are open. A panel opens
|
|
2
|
+
// one at a time by default (opening one closes the rest), or any number with
|
|
3
|
+
// `multiple`. No React here.
|
|
4
|
+
|
|
5
|
+
/** The open keys after `key` is clicked. */
|
|
6
|
+
export function toggleOpen(open, key, multiple) {
|
|
7
|
+
var isOpen = open.indexOf(key) !== -1;
|
|
8
|
+
if (multiple) return isOpen ? open.filter(function(k) { return k !== key; }) : open.concat([key]);
|
|
9
|
+
return isOpen ? [] : [key];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The keys open at first: `initial` (a key or a list), else the first item, kept to known keys. */
|
|
13
|
+
export function initialOpen(items, initial, multiple) {
|
|
14
|
+
var keys = items.map(function(i) { return i.key; });
|
|
15
|
+
var wanted = initial === undefined ? keys.slice(0, 1) : [].concat(initial || []);
|
|
16
|
+
wanted = wanted.filter(function(k) { return keys.indexOf(k) !== -1; });
|
|
17
|
+
return multiple ? wanted : wanted.slice(0, 1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Settings tree ─────────────────────────────────────────────────────────
|
|
21
|
+
// Which branch is open at each level: { [parentKey]: openChildKey }, the root's
|
|
22
|
+
// parent being ''. One open per level, so opening a sibling closes the other
|
|
23
|
+
// and a deep tree never becomes one long scroll.
|
|
24
|
+
|
|
25
|
+
/** The open map after `key` (a child of `parentKey`) is clicked. */
|
|
26
|
+
export function toggleBranch(open, parentKey, key) {
|
|
27
|
+
var next = Object.assign({}, open);
|
|
28
|
+
if (next[parentKey] === key) delete next[parentKey];
|
|
29
|
+
else next[parentKey] = key;
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The nodes with `open` and `depth` filled in, all the way down. A node is
|
|
35
|
+
* { key, title, icon?, count?, content?, children? }.
|
|
36
|
+
*/
|
|
37
|
+
export function decorateTree(nodes, open, parentKey, depth) {
|
|
38
|
+
parentKey = parentKey || '';
|
|
39
|
+
depth = depth || 0;
|
|
40
|
+
return (nodes || []).map(function(n) {
|
|
41
|
+
return Object.assign({}, n, {
|
|
42
|
+
parentKey: parentKey,
|
|
43
|
+
depth: depth,
|
|
44
|
+
open: open[parentKey] === n.key,
|
|
45
|
+
children: n.children && n.children.length ? decorateTree(n.children, open, n.key, depth + 1) : null
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { toggleOpen, initialOpen } from './panel.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expanders — controller. A list of titled parts of a panel, opened one at a
|
|
6
|
+
* time unless `multiple`. No JSX here.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} props
|
|
9
|
+
* @param {Array<{key, title, summary?, icon?, content}>} props.items
|
|
10
|
+
* @param {string|string[]|null} [props.initial] - open at first (default: the first item)
|
|
11
|
+
* @param {boolean} [props.multiple] - let several be open together
|
|
12
|
+
*/
|
|
13
|
+
export function useExpandersController(props) {
|
|
14
|
+
var items = props.items || [];
|
|
15
|
+
var multiple = Boolean(props.multiple);
|
|
16
|
+
var state = useState(function() { return initialOpen(items, props.initial, multiple); });
|
|
17
|
+
var open = state[0];
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
items: items.map(function(i) { return Object.assign({}, i, { open: open.indexOf(i.key) !== -1 }); }),
|
|
21
|
+
toggle: function(key) { state[1](function(o) { return toggleOpen(o, key, multiple); }); }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { toggleBranch, decorateTree } from './panel.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SettingsTree — controller. Nested groups of settings, one open per level.
|
|
6
|
+
* No JSX here.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} props
|
|
9
|
+
* @param {Array<{key, title, icon?, count?, content?, children?}>} props.nodes
|
|
10
|
+
* @param {object} [props.initial] - { [parentKey]: openChildKey }, root parent ''
|
|
11
|
+
*/
|
|
12
|
+
export function useSettingsTreeController(props) {
|
|
13
|
+
var state = useState(props.initial || {});
|
|
14
|
+
return {
|
|
15
|
+
nodes: decorateTree(props.nodes, state[0]),
|
|
16
|
+
toggle: function(parentKey, key) { state[1](function(o) { return toggleBranch(o, parentKey, key); }); }
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// An expanders design must give each item a header that opens it.
|
|
2
|
+
export var EXPANDERS_RULES = [
|
|
3
|
+
{ selector: '.xeplr-expander-head, [aria-expanded]', label: 'A header per item that opens it' }
|
|
4
|
+
];
|
|
5
|
+
|
|
6
|
+
// A settings tree design must give each node a header that opens it.
|
|
7
|
+
export var SETTINGS_TREE_RULES = [
|
|
8
|
+
{ selector: '.xeplr-tree-head, [aria-expanded]', label: 'A header per node that opens it' }
|
|
9
|
+
];
|