@xeplr/ui-utils 1.0.8 → 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 +67 -6
- 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/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
|
|
|
@@ -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)
|
|
@@ -135,6 +142,9 @@ 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
149
|
| `sanitizeTargetName`, `buildInitialMappings` | `(name) → string`, `({ parts?, originalName? }) → [{ part, target, include }]` | Data import mapping |
|
|
140
150
|
| `configureUpload`, `uploadFile` | `({ url, headers, getHeaders })`, `(files, { url, fieldName, extraData, onProgress }) → Promise` | Multipart upload over XHR |
|
|
@@ -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
|
|
|
@@ -325,6 +335,52 @@ function Tile({ row, isAddTile }) {
|
|
|
325
335
|
| `onSelect` | `(row) => void` | — | Tile click |
|
|
326
336
|
| `onAddNew` | `() => void` | — | If given, a leading add tile is shown |
|
|
327
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
|
+
|
|
328
384
|
## ProgressNotifier
|
|
329
385
|
|
|
330
386
|
Attach your app's event stream **once** at startup, then mount notifiers anywhere with a key. From `xeplr-bi`:
|
|
@@ -395,17 +451,21 @@ These create their own DOM nodes with inline styles, so they need no provider, m
|
|
|
395
451
|
| `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
|
|
396
452
|
| `DATA_IMPORT_RULES` | `input[type="file"]` |
|
|
397
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]` |
|
|
398
457
|
| `IMPORT_HISTORY_RULES` | nothing |
|
|
399
458
|
|
|
400
459
|
## Theming
|
|
401
460
|
|
|
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:
|
|
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:
|
|
403
462
|
|
|
404
463
|
| design | reads | without it |
|
|
405
464
|
|---|---|---|
|
|
406
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 |
|
|
407
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 |
|
|
408
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 |
|
|
409
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 |
|
|
410
470
|
| NumberField, DateField (and so RangeField's two inputs), FileUpload | fixed dark colours, no variables | — |
|
|
411
471
|
|
|
@@ -444,7 +504,7 @@ An edge set to `false` becomes `0` instead of being left out, because a missing
|
|
|
444
504
|
src/
|
|
445
505
|
index.js ─ everything below
|
|
446
506
|
<control>/ ─ dropdown, dateRange, rangeField, dateField, numberField, textField,
|
|
447
|
-
fileUpload, dataImport, gridDisplayer — each has:
|
|
507
|
+
fileUpload, dataImport, gridDisplayer, colorPicker, panel — each has:
|
|
448
508
|
<control>.js model (pure; dataImport.js, uploadFile.js)
|
|
449
509
|
use*Controller.js controller
|
|
450
510
|
validateDesign.js *_RULES (fileUpload's also defines useDesignValidator)
|
|
@@ -455,7 +515,8 @@ src/
|
|
|
455
515
|
confirm/confirm.js ─ raiseConfirm, raiseChoice
|
|
456
516
|
theme/ ─ default.theme.json, index.js (resolveTheme, getThemeVariant, getChartOptions, inputStyleVars, deepMerge)
|
|
457
517
|
data/ ─ countries.json, states.json
|
|
458
|
-
test/ ─ dateRange, rangeField, textField (+ inputStyleVars),
|
|
518
|
+
test/ ─ dateRange, rangeField, textField (+ inputStyleVars), colorPicker, panel,
|
|
519
|
+
validateDesign (DATE_RANGE_RULES), designRules (COLOR_PICKER_RULES, EXPANDERS_RULES, SETTINGS_TREE_RULES)
|
|
459
520
|
```
|
|
460
521
|
|
|
461
522
|
## Tests
|
|
@@ -464,7 +525,7 @@ test/ ─ dateRange, rangeField, textField (+ inputStyleVars
|
|
|
464
525
|
npm test
|
|
465
526
|
```
|
|
466
527
|
|
|
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
|
|
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.
|
|
468
529
|
|
|
469
530
|
## License
|
|
470
531
|
|
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
|
+
}
|
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
|
+
];
|