@xeplr/ui-utils 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,15 +100,19 @@ 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
 
108
112
  | hook | takes | returns |
109
113
  |---|---|---|
110
114
  | `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` |
115
+ | `useDateRangeController(props)` | DateRangePage props | `tab, setTab, presets, value, label, range, single, setSingle, singleParsed, suggestions, applySuggestion(text), selectPreset(key), setRangeFrom, setRangeTo (edit the draft), applyRange(), rangeProblem, rangeDirty, commitSingle(close), clear, allowClear, inheritedLabel` |
112
116
  | `useRangeFieldController(props)` | RangeFieldPage props | `fromLabel, toLabel, from, to`. `from` and `to` are each a `useDateFieldController` result. |
113
117
  | `useDateFieldController(props)` | DateFieldPage props | `type, inputType, raw, age, error, placeholder, disabled, min, max, handleChange` |
114
118
  | `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
@@ -117,6 +121,9 @@ export default function SelectCompany() {
117
121
  | `useDataImportController(options)` | DataImportPage props | `file, inspecting, inspected, mappings, committing, results, error, handleFileChange, updateMapping(i, patch), 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)
@@ -129,11 +136,15 @@ export default function SelectCompany() {
129
136
  | `resolveDateValue` | `(selection, now?, options?) → { from, to } \| null` | Resolves a `quick` / `range` / `single` selection. `null` means no constraint. |
130
137
  | `dateValueLabel` | `(selection) → string \| null` | "Last month", "2022-01-01 → 2022-03-31", "From …", "Until …" |
131
138
  | `dateValueIsSet` | `(selection, now?) → boolean` | True only if the selection resolves to a range |
139
+ | `rangeProblem` | `({ from, to }) → string \| null` | Why a typed range cannot be applied yet ("Choose a From or a To date", "From is after To"), or `null` |
132
140
  | `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
141
  | `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
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 `-`. |
135
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 |
136
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 `''` |
137
148
  | `filterGridRows`, `sortGridRows` | `(rows, query, fields)`, `(rows, field, 'asc'\|'desc')` | Grid search and sort. An array field matches if any entry matches. |
138
149
  | `sanitizeTargetName`, `buildInitialMappings` | `(name) → string`, `({ parts?, originalName? }) → [{ part, target, include }]` | Data import mapping |
139
150
  | `configureUpload`, `uploadFile` | `({ url, headers, getHeaders })`, `(files, { url, fieldName, extraData, onProgress }) → Promise` | Multipart upload over XHR |
@@ -154,7 +165,7 @@ export default function SelectCompany() {
154
165
  | `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
155
166
  | `COUNTRIES` | `[{ code, name }]` | 196 countries |
156
167
  | `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 |
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 |
158
169
 
159
170
  ## DropdownPage
160
171
 
@@ -189,12 +200,12 @@ export default function SelectCompany() {
189
200
  |---|---|---|---|
190
201
  | `value` | `{ kind: 'quick', preset } \| { kind: 'range', from, to } \| { kind: 'single', value } \| null` | `null` | The selection |
191
202
  | `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 |
203
+ | `onClose` | `() => void` | — | Called after a choice that ends the interaction: a preset, a suggestion, Apply (or Enter) in Range or Single, or Clear |
193
204
  | `presets` | `[{ key, label }]` | `QUICK_PRESETS` | The Quick list. Only keys that `resolveQuickPreset` knows will resolve. |
194
205
  | `allowClear` | `boolean` | `true` | `false` removes the Clear button, and `clear()` refuses too |
195
206
  | `inheritedLabel` | `string` | — | Shows "Using …" when `value` is empty |
196
207
 
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.
208
+ **Every selection resolves to `{ from, to }`, including a single date.** "May 2022" becomes 1–31 May and "2022" becomes the whole year, so a consumer never has to ask which comparison was meant. **The Range tab is applied, not taken as you type.** A browser date input holds a complete date while a year is half typed (`2` is `0002-01-01`), so committing each edit ran the host's query for the year 2. The inputs edit a draft, and `onChange` fires once, on **Apply** or Enter. Apply is disabled until the draft is valid (`rangeProblem`: something chosen, From not after To) and differs from what is applied. One open side is allowed. The Single tab commits only once the text parses ("Not a date yet" otherwise), on blur, Enter or Apply. While you type, it suggests months and years, putting the committed year first. The financial-year presets use `fiscalStartMonth` (default April). The page itself does not pass that option, so change it by calling `resolveDateValue(value, now, { fiscalStartMonth })` where you consume the value.
198
209
 
199
210
  ## RangeFieldPage
200
211
 
@@ -324,6 +335,52 @@ function Tile({ row, isAddTile }) {
324
335
  | `onSelect` | `(row) => void` | — | Tile click |
325
336
  | `onAddNew` | `() => void` | — | If given, a leading add tile is shown |
326
337
 
338
+ ## ColorPickerPage
339
+
340
+ ```jsx
341
+ <ColorPickerPage label="Background" value={fill} onChange={setFill} />
342
+ ```
343
+
344
+ | prop | default | meaning |
345
+ |---|---|---|
346
+ | `value` | — | `'#RRGGBB'`, `'#RRGGBBAA'`, or `null` for no colour |
347
+ | `onChange` | — | `(value \| null) => void`, on every pick, slide or clear |
348
+ | `swatches` | `DEFAULT_SWATCHES` | 14 soft and strong colours |
349
+ | `allowNone` | `true` | Offer "no colour" |
350
+ | `allowOpacity` | `true` | The opacity slider and % box |
351
+ | `previewText` | `'Aa 123'` | Text in the preview box |
352
+ | `label` | — | Line above the swatches |
353
+
354
+ **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.
355
+
356
+ ## Panel: PanelSection, PanelField, ExpandersPage
357
+
358
+ The parts of a settings panel. `PanelSection` and `PanelField` are presentation only and take children; the expanders have a controller.
359
+
360
+ ```jsx
361
+ <PanelSection icon={<TypeIcon />} title="Field" subtitle="What this column is">
362
+ <PanelField label="Label" help="Shown as the column header">
363
+ <input value={label} onChange={…} />
364
+ </PanelField>
365
+ </PanelSection>
366
+
367
+ <ExpandersPage items={[
368
+ { key: 'text', title: 'Text', summary: 'Inter · 13px', icon: <T />, content: <TextRows /> },
369
+ { key: 'fill', title: 'Fill', content: <ColorPickerPage … /> }
370
+ ]} />
371
+ ```
372
+
373
+ | component | props |
374
+ |---|---|
375
+ | `PanelSection` | `icon`, `title`, `subtitle`, `actions` (right of the title), `className`, children |
376
+ | `PanelField` | `label`, `badge`, `help` (under the control), `htmlFor`, `stacked` (control under the label, for wide controls), children |
377
+ | `SettingsTreePage` | `nodes: [{ key, title, icon?, count?, content?, children? }]`, `initial` (`{ [parentKey]: key }`, root parent `''`) |
378
+ | `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) |
379
+
380
+ Opening an expander closes the others unless `multiple`: a long panel with every part open is the scroll the expanders exist to avoid.
381
+
382
+ `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.
383
+
327
384
  ## ProgressNotifier
328
385
 
329
386
  Attach your app's event stream **once** at startup, then mount notifiers anywhere with a key. From `xeplr-bi`:
@@ -394,17 +451,21 @@ These create their own DOM nodes with inline styles, so they need no provider, m
394
451
  | `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
395
452
  | `DATA_IMPORT_RULES` | `input[type="file"]` |
396
453
  | `GRID_DISPLAYER_RULES` | `.xeplr-grid-search`; `.xeplr-grid` |
454
+ | `COLOR_PICKER_RULES` | `.xeplr-colorpicker-swatch` or `[role="option"]` |
455
+ | `EXPANDERS_RULES` | `.xeplr-expander-head` or `[aria-expanded]` |
456
+ | `SETTINGS_TREE_RULES` | `.xeplr-tree-head` or `[aria-expanded]` |
397
457
  | `IMPORT_HISTORY_RULES` | nothing |
398
458
 
399
459
  ## Theming
400
460
 
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:
461
+ 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:
402
462
 
403
463
  | design | reads | without it |
404
464
  |---|---|---|
405
465
  | 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
466
  | 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
467
  | 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. |
468
+ | 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 |
408
469
  | 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
470
  | NumberField, DateField (and so RangeField's two inputs), FileUpload | fixed dark colours, no variables | — |
410
471
 
@@ -443,7 +504,7 @@ An edge set to `false` becomes `0` instead of being left out, because a missing
443
504
  src/
444
505
  index.js ─ everything below
445
506
  <control>/ ─ dropdown, dateRange, rangeField, dateField, numberField, textField,
446
- fileUpload, dataImport, gridDisplayer — each has:
507
+ fileUpload, dataImport, gridDisplayer, colorPicker, panel — each has:
447
508
  <control>.js model (pure; dataImport.js, uploadFile.js)
448
509
  use*Controller.js controller
449
510
  validateDesign.js *_RULES (fileUpload's also defines useDesignValidator)
@@ -454,7 +515,8 @@ src/
454
515
  confirm/confirm.js ─ raiseConfirm, raiseChoice
455
516
  theme/ ─ default.theme.json, index.js (resolveTheme, getThemeVariant, getChartOptions, inputStyleVars, deepMerge)
456
517
  data/ ─ countries.json, states.json
457
- test/ ─ dateRange, rangeField, textField (+ inputStyleVars), validateDesign (DATE_RANGE_RULES)
518
+ test/ ─ dateRange, rangeField, textField (+ inputStyleVars), colorPicker, panel,
519
+ validateDesign (DATE_RANGE_RULES), designRules (COLOR_PICKER_RULES, EXPANDERS_RULES, SETTINGS_TREE_RULES)
458
520
  ```
459
521
 
460
522
  ## Tests
@@ -463,7 +525,7 @@ test/ ─ dateRange, rangeField, textField (+ inputStyleVars
463
525
  npm test
464
526
  ```
465
527
 
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.
528
+ 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.
467
529
 
468
530
  ## License
469
531
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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",
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ // A colour picker design must offer swatches to pick from.
2
+ export var COLOR_PICKER_RULES = [
3
+ { selector: '.xeplr-colorpicker-swatch, [role="option"]', label: 'Swatches to pick from' }
4
+ ];
@@ -271,3 +271,22 @@ export function dateValueLabel(value) {
271
271
  export function dateValueIsSet(value, now) {
272
272
  return Boolean(resolveDateValue(value, now || new Date()))
273
273
  }
274
+
275
+ /**
276
+ * Why a typed range cannot be applied yet, or null when it can.
277
+ *
278
+ * A browser date input holds a COMPLETE date while the year is still being
279
+ * typed ("2" is 0002-…), so a range is never taken as you type — it is
280
+ * applied (the Apply button, or Enter), and this is what Apply asks first.
281
+ * One side empty is fine: "everything since April" is a real range.
282
+ *
283
+ * @param {{from?: string, to?: string}} draft - ISO dates, either may be empty
284
+ * @returns {string|null}
285
+ */
286
+ export function rangeProblem(draft) {
287
+ var from = (draft && draft.from) || '';
288
+ var to = (draft && draft.to) || '';
289
+ if (!from && !to) return 'Choose a From or a To date';
290
+ if (from && to && from > to) return 'From is after To';
291
+ return null;
292
+ }
@@ -56,17 +56,30 @@ export default function DateRangeSample(props) {
56
56
 
57
57
  {props.tab === 'range' && (
58
58
  <div className="xeplr-daterange-body">
59
+ {/* Edited as a draft and applied — the inputs hold a full date while
60
+ a year is half typed, so taking each change ran the report for
61
+ the year 2. Enter in either input applies too. */}
59
62
  <label className="xeplr-daterange-field">
60
63
  <span>From</span>
61
- <input type="date" value={props.range.from || ''} onChange={function(e) { props.setRangeFrom(e.target.value); }} />
64
+ <input type="date" value={props.range.from || ''} onChange={function(e) { props.setRangeFrom(e.target.value); }}
65
+ onKeyDown={function(e) { if (e.key === 'Enter') { e.preventDefault(); props.applyRange(); } }} />
62
66
  </label>
63
67
  <label className="xeplr-daterange-field">
64
68
  <span>To</span>
65
- <input type="date" value={props.range.to || ''} onChange={function(e) { props.setRangeTo(e.target.value); }} />
69
+ <input type="date" value={props.range.to || ''} onChange={function(e) { props.setRangeTo(e.target.value); }}
70
+ onKeyDown={function(e) { if (e.key === 'Enter') { e.preventDefault(); props.applyRange(); } }} />
66
71
  </label>
67
72
  {/* One-sided is legitimate — "everything since April" is a real
68
73
  answer — so it is stated rather than blocked. */}
69
- <div className="xeplr-daterange-note">Leave either side empty for an open-ended range.</div>
74
+ <div className="xeplr-daterange-note">
75
+ {props.rangeProblem && (props.range.from || props.range.to)
76
+ ? props.rangeProblem
77
+ : 'Leave either side empty for an open-ended range.'}
78
+ </div>
79
+ <div className="xeplr-daterange-actions">
80
+ <button type="button" className="xeplr-daterange-apply" data-xeplr-daterange-apply="range"
81
+ disabled={Boolean(props.rangeProblem) || !props.rangeDirty} onClick={props.applyRange}>Apply</button>
82
+ </div>
70
83
  </div>
71
84
  )}
72
85
 
@@ -117,6 +130,12 @@ export default function DateRangeSample(props) {
117
130
  ? (props.singleParsed ? props.singleParsed.from + ' → ' + props.singleParsed.to : 'Not a date yet')
118
131
  : 'A day, a whole month, or a whole year.'}
119
132
  </div>
133
+ <div className="xeplr-daterange-actions">
134
+ <button type="button" className="xeplr-daterange-apply" data-xeplr-daterange-apply="single"
135
+ disabled={!props.singleParsed}
136
+ // MOUSEDOWN: the input's onBlur would otherwise commit first.
137
+ onMouseDown={function(e) { e.preventDefault(); props.commitSingle(true); }}>Apply</button>
138
+ </div>
120
139
  </div>
121
140
  )}
122
141
 
@@ -67,3 +67,15 @@
67
67
  }
68
68
  .xeplr-daterange-suggest button:hover { background: var(--xeplr-hover, #f4f5f8); }
69
69
  .xeplr-daterange-suggest-note { font-size: 11px; color: var(--xeplr-muted, #6b7280); }
70
+
71
+ /* Apply — a typed range (or date) reaches the host only through this, or
72
+ Enter. Disabled while there is nothing new to apply, or it is not valid. */
73
+ .xeplr-daterange-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
74
+ .xeplr-daterange-apply {
75
+ padding: 5px 14px; border-radius: 6px; cursor: pointer;
76
+ font-size: 12px; font-weight: 600;
77
+ border: 1px solid var(--xeplr-accent, #4f5fd6);
78
+ background: var(--xeplr-accent, #4f5fd6);
79
+ color: var(--xeplr-on-accent, #fff);
80
+ }
81
+ .xeplr-daterange-apply:disabled { opacity: 0.45; cursor: default; }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { QUICK_PRESETS, parseSingleDate, dateValueLabel, singleDateSuggestions } from './dateRange.js';
2
+ import { QUICK_PRESETS, parseSingleDate, dateValueLabel, singleDateSuggestions, rangeProblem } from './dateRange.js';
3
3
 
4
4
  /**
5
5
  * DateRange — controller. Owns which tab is open and the half-typed text; the
@@ -37,7 +37,21 @@ export function useDateRangeController(props) {
37
37
  if (value && value.kind === 'single') setSingle(value.value || '');
38
38
  }, [value]);
39
39
 
40
- var range = value && value.kind === 'range' ? value : { from: '', to: '' };
40
+ // THE RANGE IS A DRAFT until it is applied. It used to be committed on
41
+ // every change of either input — and a date input holds a complete date
42
+ // while the year is half typed ("2" is 0002-01-01), so typing 2026 ran the
43
+ // report for the year 2. Now the inputs edit this draft, and applyRange
44
+ // (the Apply button, or Enter) commits it.
45
+ var committedRange = value && value.kind === 'range' ? value : null;
46
+ var draftState = useState({ from: committedRange ? committedRange.from || '' : '', to: committedRange ? committedRange.to || '' : '' });
47
+ var range = draftState[0];
48
+ var setRange = draftState[1];
49
+ // A range chosen elsewhere (a reset, another record) replaces the draft.
50
+ useEffect(function() {
51
+ if (value && value.kind === 'range') setRange({ from: value.from || '', to: value.to || '' });
52
+ }, [value]);
53
+ var rangeIssue = rangeProblem(range);
54
+ var rangeDirty = !committedRange || (committedRange.from || '') !== range.from || (committedRange.to || '') !== range.to;
41
55
  // Parsed as you type, so "May 202" reads as not-a-date-yet rather than
42
56
  // silently saving as nothing.
43
57
  var singleParsed = single ? parseSingleDate(single) : null;
@@ -71,11 +85,19 @@ export function useDateRangeController(props) {
71
85
  }
72
86
 
73
87
  function setRangeFrom(from) {
74
- onChange({ kind: 'range', from: from, to: range.to || '' });
88
+ setRange(function(r) { return { from: from || '', to: r.to }; });
75
89
  }
76
90
 
77
91
  function setRangeTo(to) {
78
- onChange({ kind: 'range', from: range.from || '', to: to });
92
+ setRange(function(r) { return { from: r.from, to: to || '' }; });
93
+ }
94
+
95
+ // Commits the draft. Refused while it has a problem (rangeProblem), so a
96
+ // custom design cannot apply a range Apply would have been disabled for.
97
+ function applyRange() {
98
+ if (rangeIssue) return;
99
+ onChange({ kind: 'range', from: range.from, to: range.to });
100
+ if (onClose) onClose();
79
101
  }
80
102
 
81
103
  // Only committed once it parses — a partial entry must not overwrite a good
@@ -110,6 +132,9 @@ export function useDateRangeController(props) {
110
132
  selectPreset: selectPreset,
111
133
  setRangeFrom: setRangeFrom,
112
134
  setRangeTo: setRangeTo,
135
+ applyRange: applyRange,
136
+ rangeProblem: rangeIssue,
137
+ rangeDirty: rangeDirty,
113
138
  commitSingle: commitSingle,
114
139
  clear: clear,
115
140
  allowClear: allowClear,
package/src/index.js CHANGED
@@ -92,7 +92,8 @@ export {
92
92
  resolveQuickPreset,
93
93
  resolveDateValue,
94
94
  dateValueLabel,
95
- dateValueIsSet
95
+ dateValueIsSet,
96
+ rangeProblem
96
97
  } from './dateRange/dateRange.js';
97
98
 
98
99
  // DateRange — controller
@@ -197,3 +198,22 @@ export { TextFieldPage } from './textField/pages.jsx';
197
198
  export { attachProgressSource, progressSourceAttached, subscribeToProgress,
198
199
  describeProgress, describeComplete } from './progress/progressSource.js';
199
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,3 @@
1
+ export { PanelSection, PanelField } from './PanelSection.jsx';
2
+ export { default as ExpandersSample } from './ExpandersSample.jsx';
3
+ export { default as SettingsTreeSample } from './SettingsTreeSample.jsx';
@@ -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
+ ];