flexdesk 0.17.1 → 0.17.2
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/CHANGELOG.md +22 -0
- package/README.md +178 -0
- package/css/base.css +23 -1
- package/css/flexdesk.css +37 -1
- package/dist/flexdesk.css +37 -1
- package/dist/widgets.js +143 -50
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +90 -10
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/tiling/command_palette.js +46 -7
- package/src/tiling/content_registry.js +3 -0
- package/src/tiling/entity_sources.js +10 -0
- package/src/tiling/wm.js +65 -3
- package/src/ui/components/settings_view.js +192 -51
- package/src/ui/notification_center.js +5 -5
package/package.json
CHANGED
|
@@ -72,6 +72,17 @@
|
|
|
72
72
|
* drillLabel the Tab hint ("search rows"); Tab is offered only with it
|
|
73
73
|
* shortcut a key binding drawn on the right
|
|
74
74
|
* verb the Enter hint ('open' by default, 'run' for a command)
|
|
75
|
+
* enabled `() => boolean` (0.17.2). False: the row is still listed and
|
|
76
|
+
* the arrows still reach it, but it is drawn greyed
|
|
77
|
+
* (`twm-cmdpal__item--disabled`, aria-disabled="true") and
|
|
78
|
+
* picking it — click, Enter, Ctrl/Shift+Enter — does nothing and
|
|
79
|
+
* leaves the palette open. Asked on every draw and every pick.
|
|
80
|
+
* disabledReason the disabled row's tooltip (its `title`)
|
|
81
|
+
*
|
|
82
|
+
* A catalog source with `scopedOnly: true` (0.17.2) contributes no rows to a
|
|
83
|
+
* plain typed query, in either layout: its rows are listed only once its
|
|
84
|
+
* prefix has made the type chip (`help: solver`).
|
|
85
|
+
*
|
|
75
86
|
* `hint` keeps its old meaning — drawn AND searched — for a row that sets
|
|
76
87
|
* neither `context` nor `keywords`. A group's header is the taxonomy's
|
|
77
88
|
* `plural` for the kind, else its `label`.
|
|
@@ -281,8 +292,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
281
292
|
const render = () => {
|
|
282
293
|
if (grouped) { renderGrouped(); return; }
|
|
283
294
|
list.innerHTML = filtered.slice(0, MAX_ROWS).map((e, i) => `
|
|
284
|
-
|
|
285
|
-
data-idx="${i}">
|
|
295
|
+
${_itemOpen(e, i === active ? ' twm-cmdpal__item--active' : '', `data-idx="${i}"`)}
|
|
286
296
|
<span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || 'arrow_right'}</span>
|
|
287
297
|
<span class="twm-cmdpal__label">${_esc(e.label || e.id)}</span>
|
|
288
298
|
<span class="twm-cmdpal__kind">${e.kind}</span>
|
|
@@ -343,7 +353,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
343
353
|
const side = r.shortcut
|
|
344
354
|
? `<kbd class="twm-cmdpal__shortcut">${_esc(r.shortcut)}</kbd>`
|
|
345
355
|
: `<span class="twm-cmdpal__kind">${_esc(kindLabel(r))}</span>`;
|
|
346
|
-
return
|
|
356
|
+
return `${_itemOpen(r, on, `data-idx="${i}" role="option"`)}
|
|
347
357
|
<span class="material-symbols-outlined twm-cmdpal__icon">${_esc(r.icon || 'arrow_right')}</span>
|
|
348
358
|
<span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_mark(r.label || r.id, r._hits)}</span>${ctx}</span>
|
|
349
359
|
<span class="twm-cmdpal__side">${side}<span class="twm-cmdpal__acts">${acts.join('')}</span></span>
|
|
@@ -453,6 +463,9 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
453
463
|
const picked = filtered[i];
|
|
454
464
|
if (!picked) return;
|
|
455
465
|
if (picked.more) { showAll(picked.more); return; }
|
|
466
|
+
// A disabled row is listed so it can say why; picking it runs
|
|
467
|
+
// nothing and the palette stays open on it.
|
|
468
|
+
if (_isDisabled(picked)) return;
|
|
456
469
|
// Split and window open a COPY; a single kind refuses both, and the
|
|
457
470
|
// palette stays open on the row whose hints say why.
|
|
458
471
|
if (mode !== 'open' && isSingle(picked)) return;
|
|
@@ -585,6 +598,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
585
598
|
return;
|
|
586
599
|
}
|
|
587
600
|
let pool = entities;
|
|
601
|
+
if (!filterSet) pool = pool.filter((e) => !e._scopedOnly);
|
|
588
602
|
if (commandsOnly) pool = pool.filter((e) => e.kind === 'command');
|
|
589
603
|
else if (filterSet) pool = pool.filter((e) => filterSet.has(_groupOf(e)) || filterSet.has(e.kind));
|
|
590
604
|
else if (q.length < commandMinChars) pool = pool.filter((e) => e.kind !== 'command');
|
|
@@ -649,7 +663,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
649
663
|
const filterSet = typeFilter ? new Set(typeFilter) : null;
|
|
650
664
|
const pool = filterSet
|
|
651
665
|
? entities.filter((e) => filterSet.has(e.kind))
|
|
652
|
-
: entities;
|
|
666
|
+
: entities.filter((e) => !e._scopedOnly);
|
|
653
667
|
if (!q) {
|
|
654
668
|
// Scoped + empty query → list every entity of that
|
|
655
669
|
// kind so the user sees what's available. Unscoped +
|
|
@@ -715,10 +729,18 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
715
729
|
}
|
|
716
730
|
return [...staticCommands, ...extra];
|
|
717
731
|
};
|
|
732
|
+
// A `scopedOnly` source's rows are marked here, from the catalog's
|
|
733
|
+
// own source list, so only the rows the catalog produced are held
|
|
734
|
+
// back — a command or a Recent row of the same kind is not.
|
|
735
|
+
const scopedKinds = new Set((catalog?.sources || [])
|
|
736
|
+
.filter((s) => s?.scopedOnly === true).map((s) => s.navKind));
|
|
737
|
+
const tagScoped = (rows) => (scopedKinds.size
|
|
738
|
+
? rows.map((r) => (scopedKinds.has(r.kind) ? { ...r, _scopedOnly: true } : r))
|
|
739
|
+
: rows);
|
|
718
740
|
// Objects before commands in the grouped layout, so that a tie that
|
|
719
741
|
// survives every rule goes to the object (first seen wins).
|
|
720
742
|
const withCommands = (rows) => (grouped ? [...rows, ...commandRows()] : [...commandRows(), ...rows]);
|
|
721
|
-
entities = _decorateIcons(withCommands(cache && cached ? cached : []), taxonomy);
|
|
743
|
+
entities = _decorateIcons(withCommands(tagScoped(cache && cached ? cached : [])), taxonomy);
|
|
722
744
|
if (grouped) {
|
|
723
745
|
refilter();
|
|
724
746
|
if (typeof empty === 'function') {
|
|
@@ -735,7 +757,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
735
757
|
const loaded = await catalog.loadAll(api);
|
|
736
758
|
if (cache) cached = loaded;
|
|
737
759
|
if (overlay !== mine) return;
|
|
738
|
-
entities = _decorateIcons(withCommands(loaded), taxonomy);
|
|
760
|
+
entities = _decorateIcons(withCommands(tagScoped(loaded)), taxonomy);
|
|
739
761
|
if (grouped ? (!scope && (input.value || typeFilter)) : input.value) refilter();
|
|
740
762
|
} catch (err) {
|
|
741
763
|
console.warn('[cmdpal] entity load failed', err);
|
|
@@ -859,9 +881,26 @@ function _searched(r) {
|
|
|
859
881
|
}
|
|
860
882
|
function _groupOf(r) { return r.group || r.kind; }
|
|
861
883
|
|
|
884
|
+
/** 0.17.2. `enabled` is asked each time; one that throws counts as no. */
|
|
885
|
+
function _isDisabled(r) {
|
|
886
|
+
if (!r || typeof r.enabled !== 'function') return false;
|
|
887
|
+
try { return !r.enabled(); }
|
|
888
|
+
catch (err) { console.warn('[cmdpal] enabled() threw', err); return true; }
|
|
889
|
+
}
|
|
890
|
+
/** A row's opening `<div>` with its classes (`twm-cmdpal__item` plus `extra`)
|
|
891
|
+
* and attributes. A disabled row adds its class, `aria-disabled` and, given a
|
|
892
|
+
* `disabledReason`, the reason as its `title`. */
|
|
893
|
+
function _itemOpen(r, extra, attrs) {
|
|
894
|
+
const off = _isDisabled(r);
|
|
895
|
+
const cls = 'twm-cmdpal__item' + extra + (off ? ' twm-cmdpal__item--disabled' : '');
|
|
896
|
+
const refused = !off ? ''
|
|
897
|
+
: ' aria-disabled="true"' + (r.disabledReason ? ` title="${_esc(r.disabledReason)}"` : '');
|
|
898
|
+
return `<div class="${cls}"${refused} ${attrs}>`;
|
|
899
|
+
}
|
|
900
|
+
|
|
862
901
|
/** Strip the palette's private fields before a row leaves it. */
|
|
863
902
|
function _public(r) {
|
|
864
|
-
const { _hits, _score, ...rest } = r;
|
|
903
|
+
const { _hits, _score, _scopedOnly, ...rest } = r;
|
|
865
904
|
return rest;
|
|
866
905
|
}
|
|
867
906
|
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The tile renderer calls the factory once when the leaf is first
|
|
10
10
|
* mounted with a given (kind, props) pair, and `destroy()` on unmount.
|
|
11
|
+
* A content of a `single` kind may return `navigate(props)`: an open path
|
|
12
|
+
* that reveals the instance instead of mounting it hands it the new props
|
|
13
|
+
* (`wm.revealInstance`, 0.17.2).
|
|
11
14
|
*
|
|
12
15
|
* `ctx` is the shell's ctx plus, per leaf, `leafId` and `setTitle(text)` —
|
|
13
16
|
* rename the pane's chrome title from inside the content, at mount or any
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* row to the record shape every search surface consumes.
|
|
28
28
|
* Return `null` to skip the row.
|
|
29
29
|
*
|
|
30
|
+
* scopedOnly? `true` keeps the source's rows out of a plain typed query:
|
|
31
|
+
* the command palette lists them only behind the source's
|
|
32
|
+
* prefix (`<navKind>:` or an alias that resolves to it). For a
|
|
33
|
+
* source too broad to compete with everything else — help
|
|
34
|
+
* topics, say. Absent or `false`: searched as always (0.17.2).
|
|
35
|
+
*
|
|
30
36
|
* ## Aggregation
|
|
31
37
|
*
|
|
32
38
|
* An embedder whose backend can answer for every source in ONE call
|
|
@@ -48,6 +54,10 @@ export function createEntityCatalog({ sources, aliases = {}, aggregate = null }
|
|
|
48
54
|
throw new TypeError(
|
|
49
55
|
`createEntityCatalog: source '${s?.navKind ?? '?'}' needs navKind + list() + shape()`);
|
|
50
56
|
}
|
|
57
|
+
if (s.scopedOnly != null && typeof s.scopedOnly !== 'boolean') {
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
`createEntityCatalog: source '${s.navKind}' \`scopedOnly\` must be a boolean when present`);
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
if (aggregate != null && typeof aggregate !== 'function') {
|
|
53
63
|
throw new TypeError('createEntityCatalog: `aggregate` must be a function when present');
|
package/src/tiling/wm.js
CHANGED
|
@@ -348,10 +348,57 @@ export class WindowManager {
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
/** Reveal the instance of a single kind that is open: answers whether
|
|
351
|
-
* there was one. Every open path asks this first.
|
|
351
|
+
* there was one. Every open path asks this first.
|
|
352
|
+
*
|
|
353
|
+
* An open path names props too, and revealing alone would drop them: the
|
|
354
|
+
* instance keeps showing what it was opened with. So a revealed content
|
|
355
|
+
* that returned a `navigate(props)` hook from its mount is handed the new
|
|
356
|
+
* props — once per open, the same props twice are two calls — and they
|
|
357
|
+
* are recorded on its tab, so a page restore or a reload mounts it with
|
|
358
|
+
* the latest. A content without the hook is revealed as it is: its tab
|
|
359
|
+
* keeps its props and nothing is called. So is every content when the
|
|
360
|
+
* open names no props at all — a top-nav button, `openInPrimary(kind)` —
|
|
361
|
+
* which asks to SEE the instance, not to empty it. */
|
|
352
362
|
revealInstance(kind, props = {}) {
|
|
353
363
|
const loc = this.findInstance(kind, props);
|
|
354
|
-
|
|
364
|
+
if (!loc || !this._revealAt(loc)) return false;
|
|
365
|
+
if (props && Object.keys(props).length) this._deliverProps(kind, props);
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Hand a revealed instance the props it was opened with, and record them
|
|
370
|
+
* on its tab (and the tile's `content` mirror of it, re-baselining the
|
|
371
|
+
* tile so the write does not rebuild it). Looks
|
|
372
|
+
* the instance up again: a reveal from a page archive moved it into the
|
|
373
|
+
* tile's tabs. A throwing hook is a warning, never a crash. */
|
|
374
|
+
_deliverProps(kind, props) {
|
|
375
|
+
const loc = this.findInstance(kind, props);
|
|
376
|
+
if (!loc || loc.where === 'archive') return;
|
|
377
|
+
const scope = loc.where === 'window' ? { windowId: loc.windowId } : { leafId: loc.leafId };
|
|
378
|
+
const content = this._scopeContent(scope);
|
|
379
|
+
if (typeof content?.navigate !== 'function') return;
|
|
380
|
+
const next = { ...(props || {}) };
|
|
381
|
+
if (loc.where === 'window') {
|
|
382
|
+
const rec = this._windowToLeaf.get(loc.windowId);
|
|
383
|
+
const tab = Array.isArray(rec.tabs) ? rec.tabs[loc.idx] : null;
|
|
384
|
+
if (tab) tab.props = { ...next };
|
|
385
|
+
if (rec.original) rec.original = { ...rec.original, props: { ...next } };
|
|
386
|
+
} else {
|
|
387
|
+
const leaf = this._tree().get(loc.leafId);
|
|
388
|
+
const expected = this.renderer?.leafKey?.(loc.leafId) ?? undefined;
|
|
389
|
+
leaf.tabs[loc.idx].props = { ...next };
|
|
390
|
+
// The tab is the active one now, and `content` mirrors it: the
|
|
391
|
+
// next tab activation re-mirrors it anyway, and a mirror left
|
|
392
|
+
// behind would change the tile's key then and rebuild it.
|
|
393
|
+
if (leaf.content?.kind === kind) leaf.content = { ...leaf.content, props: { ...next } };
|
|
394
|
+
this.renderer?.rebaselineLeaf?.(loc.leafId, expected);
|
|
395
|
+
}
|
|
396
|
+
this._persist();
|
|
397
|
+
try {
|
|
398
|
+
content.navigate({ ...next });
|
|
399
|
+
} catch (err) {
|
|
400
|
+
console.warn('[wm] content navigate threw', err);
|
|
401
|
+
}
|
|
355
402
|
}
|
|
356
403
|
|
|
357
404
|
/** A leaf's archived pages that can come back — every `pageTabs` entry
|
|
@@ -4106,8 +4153,23 @@ export class WindowManager {
|
|
|
4106
4153
|
*
|
|
4107
4154
|
* The lower-level primitives (`openFromContext`,
|
|
4108
4155
|
* `openInTabFromContext`, `openInTabInPrimary`, `openInPrimary`,
|
|
4109
|
-
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`.
|
|
4156
|
+
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`.
|
|
4157
|
+
*
|
|
4158
|
+
* Returns `true` once the navigation is routed. A kind the shell's
|
|
4159
|
+
* content registry has no factory for is refused instead: one
|
|
4160
|
+
* `console.warn` naming it, no tile, tab, window or history touched, and
|
|
4161
|
+
* `false` — never a placeholder tile, never a throw. */
|
|
4110
4162
|
navigate(kind, props = {}, opts = {}) {
|
|
4163
|
+
if (!this.content.has(kind)) {
|
|
4164
|
+
console.warn(`[wm] navigate: no content registered for kind "${String(kind)}"`);
|
|
4165
|
+
return false;
|
|
4166
|
+
}
|
|
4167
|
+
this._route(kind, props, opts);
|
|
4168
|
+
return true;
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
/** The routing behind `navigate`, for a kind known to be registered. */
|
|
4172
|
+
_route(kind, props, opts) {
|
|
4111
4173
|
const { ctx = null, transient = false } = opts;
|
|
4112
4174
|
// Append the tab but stay where you are. Only meaningful with `newTab`.
|
|
4113
4175
|
const background = !!opts.background;
|
|
@@ -50,6 +50,20 @@
|
|
|
50
50
|
* *Reset all* ask `confirm(…)` first (default `openConfirm`) and change
|
|
51
51
|
* nothing unless it resolves true.
|
|
52
52
|
*
|
|
53
|
+
* THE SIDEBAR ELSEWHERE (0.17.2). `sidebar: false` leaves the sidebar out:
|
|
54
|
+
* the view is the content alone. A `SettingsCategories` bound to it, mounted
|
|
55
|
+
* anywhere, draws the filter box, the categories and *Reset all* instead:
|
|
56
|
+
*
|
|
57
|
+
* const view = new SettingsView({ store, eventBus, sidebar: false });
|
|
58
|
+
* view.mount(pageHost);
|
|
59
|
+
* const cats = new SettingsCategories({ view });
|
|
60
|
+
* cats.mount(navigatorHost);
|
|
61
|
+
*
|
|
62
|
+
* Both draw from the view's state, so the filter text and the selected
|
|
63
|
+
* category are the same in both, whichever moved them; `view.subscribe(fn)`
|
|
64
|
+
* tells any other listener. *Reset all* there is `view.resetAll()`, so it asks
|
|
65
|
+
* to confirm as before.
|
|
66
|
+
*
|
|
53
67
|
* Every word on screen that comes from the schema, the store or the user is
|
|
54
68
|
* set as text, so a label or a query holding markup is shown as typed.
|
|
55
69
|
*
|
|
@@ -58,7 +72,9 @@
|
|
|
58
72
|
* + `.twm-settings__content` (`__header` > `__title`, `__description`,
|
|
59
73
|
* `__reset-category`; `section.__group` > `__group-title` + `.__row`
|
|
60
74
|
* [`--modified`] > `__info` (`__label`, `__marker`, `__description`,
|
|
61
|
-
* `__path`) + `__control` + `__reset`; `__empty`).
|
|
75
|
+
* `__path`) + `__control` + `__reset`; `__empty`). A `SettingsCategories` is
|
|
76
|
+
* `.twm-settings-categories` > `.twm-settings__search`, `.twm-settings__nav`
|
|
77
|
+
* and `.twm-settings__reset-all`, the same parts as the sidebar's.
|
|
62
78
|
*/
|
|
63
79
|
|
|
64
80
|
import { ComponentBase } from '../base/component_base.js';
|
|
@@ -121,6 +137,59 @@ function fitNumber(n, { min, max, step }) {
|
|
|
121
137
|
return at(k);
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
// ── the sidebar's parts, drawn alike by SettingsView and SettingsCategories ──
|
|
141
|
+
|
|
142
|
+
/** The filter box: `{ label, input }`; `onInput(text)` on every keystroke. */
|
|
143
|
+
function filterBox(onInput) {
|
|
144
|
+
const label = el('label', 'twm-settings__search');
|
|
145
|
+
const input = el('input', 'twm-settings__search-input');
|
|
146
|
+
input.type = 'search';
|
|
147
|
+
input.placeholder = 'Filter settings…';
|
|
148
|
+
input.autocomplete = 'off';
|
|
149
|
+
input.spellcheck = false;
|
|
150
|
+
input.setAttribute('aria-label', 'Filter settings');
|
|
151
|
+
input.addEventListener('input', () => onInput(input.value));
|
|
152
|
+
label.append(icon('search'), input);
|
|
153
|
+
return { label, input };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The category list; `onSelect(id)` when a category is clicked. */
|
|
157
|
+
function categoryNav(onSelect) {
|
|
158
|
+
const nav = el('nav', 'twm-settings__nav');
|
|
159
|
+
nav.setAttribute('aria-label', 'Settings categories');
|
|
160
|
+
nav.addEventListener('click', (e) => {
|
|
161
|
+
const btn = e.target.closest?.('[data-category]');
|
|
162
|
+
if (btn && nav.contains(btn)) onSelect(btn.dataset.category);
|
|
163
|
+
});
|
|
164
|
+
return nav;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Draw `categories` into `nav`, `active` (an id, or null) marked. */
|
|
168
|
+
function paintCategories(nav, categories, active) {
|
|
169
|
+
const buttons = categories.map((cat) => {
|
|
170
|
+
const b = el('button', 'twm-settings__cat');
|
|
171
|
+
b.type = 'button';
|
|
172
|
+
b.dataset.category = cat.id;
|
|
173
|
+
if (cat.id === active) {
|
|
174
|
+
b.classList.add('twm-settings__cat--active');
|
|
175
|
+
b.setAttribute('aria-current', 'page');
|
|
176
|
+
}
|
|
177
|
+
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
178
|
+
if (cat.description) b.title = String(cat.description);
|
|
179
|
+
return b;
|
|
180
|
+
});
|
|
181
|
+
nav.replaceChildren(...buttons);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The *Reset all* button; `onClick()` when pressed. */
|
|
185
|
+
function resetAllButton(onClick) {
|
|
186
|
+
const b = el('button', 'twm-btn twm-settings__reset-all');
|
|
187
|
+
b.type = 'button';
|
|
188
|
+
b.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
189
|
+
b.addEventListener('click', () => { onClick(); });
|
|
190
|
+
return b;
|
|
191
|
+
}
|
|
192
|
+
|
|
124
193
|
export class SettingsView extends ComponentBase {
|
|
125
194
|
/**
|
|
126
195
|
* @param {object} o
|
|
@@ -130,10 +199,12 @@ export class SettingsView extends ComponentBase {
|
|
|
130
199
|
* @param {string} [o.category] the category shown first (default: the first)
|
|
131
200
|
* @param {number} [o.textDebounceMs]
|
|
132
201
|
* @param {string} [o.title='Settings'] the sidebar heading
|
|
202
|
+
* @param {boolean} [o.sidebar=true] false leaves the sidebar (filter,
|
|
203
|
+
* categories, *Reset all*) out, for a `SettingsCategories` elsewhere
|
|
133
204
|
*/
|
|
134
205
|
constructor({
|
|
135
206
|
store, eventBus, confirm = openConfirm, category = null,
|
|
136
|
-
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', logger,
|
|
207
|
+
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', sidebar = true, logger,
|
|
137
208
|
} = {}) {
|
|
138
209
|
super({ eventBus, logger });
|
|
139
210
|
if (!store || typeof store.getSchema !== 'function') {
|
|
@@ -148,6 +219,9 @@ export class SettingsView extends ComponentBase {
|
|
|
148
219
|
this._query = '';
|
|
149
220
|
this._debounceMs = textDebounceMs;
|
|
150
221
|
this._title = title;
|
|
222
|
+
this._sidebar = sidebar !== false;
|
|
223
|
+
/** `fn({ category, query })` after the category, the filter or the categories change. */
|
|
224
|
+
this._listeners = new Set();
|
|
151
225
|
|
|
152
226
|
this.root = null;
|
|
153
227
|
this._nav = null;
|
|
@@ -166,39 +240,20 @@ export class SettingsView extends ComponentBase {
|
|
|
166
240
|
mount(container) {
|
|
167
241
|
if (this.root) return this.root;
|
|
168
242
|
const root = el('div', 'twm-settings');
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
input.setAttribute('aria-label', 'Filter settings');
|
|
180
|
-
input.addEventListener('input', () => this.setQuery(input.value));
|
|
181
|
-
search.append(icon('search'), input);
|
|
182
|
-
this._search = input;
|
|
183
|
-
|
|
184
|
-
const nav = el('nav', 'twm-settings__nav');
|
|
185
|
-
nav.setAttribute('aria-label', 'Settings categories');
|
|
186
|
-
nav.addEventListener('click', (e) => {
|
|
187
|
-
const btn = e.target.closest?.('[data-category]');
|
|
188
|
-
if (btn && nav.contains(btn)) this.selectCategory(btn.dataset.category);
|
|
189
|
-
});
|
|
190
|
-
this._nav = nav;
|
|
191
|
-
|
|
192
|
-
const resetAll = el('button', 'twm-btn twm-settings__reset-all');
|
|
193
|
-
resetAll.type = 'button';
|
|
194
|
-
resetAll.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
195
|
-
resetAll.addEventListener('click', () => { this.resetAll(); });
|
|
196
|
-
|
|
197
|
-
side.append(head, search, nav, resetAll);
|
|
243
|
+
if (this._sidebar) {
|
|
244
|
+
const side = el('aside', 'twm-settings__sidebar');
|
|
245
|
+
const head = el('div', 'twm-settings__sidebar-title');
|
|
246
|
+
head.append(icon('settings'), el('span', '', this._title));
|
|
247
|
+
const search = filterBox((text) => this.setQuery(text));
|
|
248
|
+
this._search = search.input;
|
|
249
|
+
this._nav = categoryNav((id) => this.selectCategory(id));
|
|
250
|
+
side.append(head, search.label, this._nav, resetAllButton(() => this.resetAll()));
|
|
251
|
+
root.append(side);
|
|
252
|
+
}
|
|
198
253
|
|
|
199
254
|
const content = el('div', 'twm-settings__content');
|
|
200
255
|
this._content = content;
|
|
201
|
-
root.append(
|
|
256
|
+
root.append(content);
|
|
202
257
|
this.root = root;
|
|
203
258
|
container.appendChild(root);
|
|
204
259
|
this._mounted = true;
|
|
@@ -211,6 +266,7 @@ export class SettingsView extends ComponentBase {
|
|
|
211
266
|
|
|
212
267
|
this.#paintNav();
|
|
213
268
|
this.#paintContent();
|
|
269
|
+
this.#notify();
|
|
214
270
|
return root;
|
|
215
271
|
}
|
|
216
272
|
|
|
@@ -220,6 +276,19 @@ export class SettingsView extends ComponentBase {
|
|
|
220
276
|
/** The filter text. */
|
|
221
277
|
getQuery() { return this._query; }
|
|
222
278
|
|
|
279
|
+
/** True while the filter is in effect (its trimmed text is not empty). */
|
|
280
|
+
isFiltering() { return this.#searching(); }
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `fn({ category, query })` after every change of the category, the filter
|
|
284
|
+
* or the registered categories, until the view is disposed. Answers the
|
|
285
|
+
* unsubscribe.
|
|
286
|
+
*/
|
|
287
|
+
subscribe(fn) {
|
|
288
|
+
this._listeners.add(fn);
|
|
289
|
+
return () => this._listeners.delete(fn);
|
|
290
|
+
}
|
|
291
|
+
|
|
223
292
|
/** Show one category; clears the filter. */
|
|
224
293
|
selectCategory(id) {
|
|
225
294
|
if (!this.root) return;
|
|
@@ -229,6 +298,7 @@ export class SettingsView extends ComponentBase {
|
|
|
229
298
|
if (this._search) this._search.value = '';
|
|
230
299
|
this.#paintNav();
|
|
231
300
|
this.#paintContent();
|
|
301
|
+
this.#notify();
|
|
232
302
|
}
|
|
233
303
|
|
|
234
304
|
/** Search every category; an empty query goes back to the category. */
|
|
@@ -239,6 +309,7 @@ export class SettingsView extends ComponentBase {
|
|
|
239
309
|
if (this._search && this._search.value !== q) this._search.value = q;
|
|
240
310
|
this.#paintNav();
|
|
241
311
|
this.#paintContent();
|
|
312
|
+
this.#notify();
|
|
242
313
|
}
|
|
243
314
|
|
|
244
315
|
/** Reset one category after `confirm`; resolves true when it was reset. */
|
|
@@ -287,6 +358,7 @@ export class SettingsView extends ComponentBase {
|
|
|
287
358
|
this._nav = null;
|
|
288
359
|
this._content = null;
|
|
289
360
|
this._search = null;
|
|
361
|
+
this._listeners.clear();
|
|
290
362
|
super.dispose();
|
|
291
363
|
}
|
|
292
364
|
|
|
@@ -310,12 +382,13 @@ export class SettingsView extends ComponentBase {
|
|
|
310
382
|
|| paths.some((p) => schema[p]?.category === active);
|
|
311
383
|
if (touches) {
|
|
312
384
|
this.#paintContent();
|
|
313
|
-
|
|
385
|
+
} else {
|
|
386
|
+
// The slice's defaults may have moved values on screen; an edit still
|
|
387
|
+
// being typed is written first, so the sync shows it rather than drop it.
|
|
388
|
+
this.#flushAll();
|
|
389
|
+
this.#syncAll();
|
|
314
390
|
}
|
|
315
|
-
|
|
316
|
-
// being typed is written first, so the sync shows it rather than drop it.
|
|
317
|
-
this.#flushAll();
|
|
318
|
-
this.#syncAll();
|
|
391
|
+
this.#notify();
|
|
319
392
|
}
|
|
320
393
|
|
|
321
394
|
/** True while a filter is in effect: the trimmed query is not empty. */
|
|
@@ -344,20 +417,14 @@ export class SettingsView extends ComponentBase {
|
|
|
344
417
|
}
|
|
345
418
|
|
|
346
419
|
#paintNav() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
357
|
-
if (cat.description) b.title = String(cat.description);
|
|
358
|
-
return b;
|
|
359
|
-
});
|
|
360
|
-
this._nav.replaceChildren(...buttons);
|
|
420
|
+
if (!this._nav) return;
|
|
421
|
+
paintCategories(this._nav, this.store.getCategories(), this.#searching() ? null : this.getCategory());
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Tell the listeners, after the content is drawn (it settles the category). */
|
|
425
|
+
#notify() {
|
|
426
|
+
const state = { category: this.getCategory(), query: this._query };
|
|
427
|
+
for (const fn of [...this._listeners]) fn(state);
|
|
361
428
|
}
|
|
362
429
|
|
|
363
430
|
#paintContent() {
|
|
@@ -614,6 +681,80 @@ export class SettingsView extends ComponentBase {
|
|
|
614
681
|
}
|
|
615
682
|
}
|
|
616
683
|
|
|
684
|
+
/**
|
|
685
|
+
* SettingsCategories — a SettingsView's filter box, category list and
|
|
686
|
+
* *Reset all*, mounted wherever the app wants them (0.17.2).
|
|
687
|
+
*
|
|
688
|
+
* const view = new SettingsView({ store, eventBus, sidebar: false });
|
|
689
|
+
* view.mount(pageHost);
|
|
690
|
+
* const cats = new SettingsCategories({ view });
|
|
691
|
+
* cats.mount(navigatorHost);
|
|
692
|
+
* cats.dispose(); // before or with the view
|
|
693
|
+
*
|
|
694
|
+
* It holds no state of its own: typing in its filter is `view.setQuery()`,
|
|
695
|
+
* clicking a category `view.selectCategory()`, *Reset all* `view.resetAll()`
|
|
696
|
+
* (which asks the view's `confirm` first), and it redraws from the view on
|
|
697
|
+
* every `view.subscribe()` call. So the filter text and the selected category
|
|
698
|
+
* are always the view's, however either side moved them, and a category
|
|
699
|
+
* registered later joins the list. Its clicks and keystrokes act once the
|
|
700
|
+
* view is mounted.
|
|
701
|
+
*
|
|
702
|
+
* Markup: `.twm-settings-categories` > `.twm-settings__search` (>
|
|
703
|
+
* `__search-input`), `.twm-settings__nav` > `button.twm-settings__cat`
|
|
704
|
+
* [`--active`], `.twm-settings__reset-all`.
|
|
705
|
+
*/
|
|
706
|
+
export class SettingsCategories extends ComponentBase {
|
|
707
|
+
/**
|
|
708
|
+
* @param {object} o
|
|
709
|
+
* @param {SettingsView} o.view the view it lists the categories of
|
|
710
|
+
*/
|
|
711
|
+
constructor({ view, logger } = {}) {
|
|
712
|
+
if (!(view instanceof SettingsView)) {
|
|
713
|
+
throw new TypeError('SettingsCategories: `view` must be a SettingsView');
|
|
714
|
+
}
|
|
715
|
+
super({ eventBus: view.eventBus, logger });
|
|
716
|
+
this.view = view;
|
|
717
|
+
this.root = null;
|
|
718
|
+
this._search = null;
|
|
719
|
+
this._nav = null;
|
|
720
|
+
this._unsubscribe = null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Build the list into `container` (once). */
|
|
724
|
+
mount(container) {
|
|
725
|
+
if (this.root) return this.root;
|
|
726
|
+
const view = this.view;
|
|
727
|
+
const root = el('div', 'twm-settings-categories');
|
|
728
|
+
const search = filterBox((text) => view.setQuery(text));
|
|
729
|
+
this._search = search.input;
|
|
730
|
+
this._nav = categoryNav((id) => view.selectCategory(id));
|
|
731
|
+
root.append(search.label, this._nav, resetAllButton(() => view.resetAll()));
|
|
732
|
+
this.root = root;
|
|
733
|
+
container.appendChild(root);
|
|
734
|
+
this._mounted = true;
|
|
735
|
+
this._unsubscribe = view.subscribe(() => this.#paint());
|
|
736
|
+
this.#paint();
|
|
737
|
+
return root;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
dispose() {
|
|
741
|
+
this._unsubscribe?.();
|
|
742
|
+
this._unsubscribe = null;
|
|
743
|
+
this.root?.remove();
|
|
744
|
+
this.root = null;
|
|
745
|
+
this._search = null;
|
|
746
|
+
this._nav = null;
|
|
747
|
+
super.dispose();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#paint() {
|
|
751
|
+
const view = this.view;
|
|
752
|
+
const q = view.getQuery();
|
|
753
|
+
if (this._search.value !== q) this._search.value = q;
|
|
754
|
+
paintCategories(this._nav, view.store.getCategories(), view.isFiltering() ? null : view.getCategory());
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
617
758
|
/**
|
|
618
759
|
* A content factory for the shell: `(hostEl, props, ctx) -> { title, destroy }`.
|
|
619
760
|
* `props.category` picks the category shown first.
|
|
@@ -105,7 +105,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
105
105
|
this.container = existingContainer;
|
|
106
106
|
this.rootEl = existingContainer.querySelector('.twm-notification-center');
|
|
107
107
|
this.listEl = existingContainer.querySelector('.twm-notification-center__list');
|
|
108
|
-
this.liveRegionEl = existingContainer.querySelector('.notification-center__live-region');
|
|
108
|
+
this.liveRegionEl = existingContainer.querySelector('.twm-notification-center__live-region');
|
|
109
109
|
this._mounted = true;
|
|
110
110
|
this.#subscribeEvents();
|
|
111
111
|
this.#claimToastBus();
|
|
@@ -124,7 +124,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
124
124
|
this.listEl.className = 'twm-notification-center__list';
|
|
125
125
|
|
|
126
126
|
this.liveRegionEl = document.createElement('div');
|
|
127
|
-
this.liveRegionEl.className = 'notification-center__live-region';
|
|
127
|
+
this.liveRegionEl.className = 'twm-notification-center__live-region';
|
|
128
128
|
this.liveRegionEl.setAttribute('aria-live', 'polite');
|
|
129
129
|
this.liveRegionEl.setAttribute('aria-atomic', 'true');
|
|
130
130
|
this.liveRegionEl.style.position = 'absolute';
|
|
@@ -599,7 +599,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
599
599
|
if (!this.#summaryEl) {
|
|
600
600
|
const more = document.createElement('button');
|
|
601
601
|
more.type = 'button';
|
|
602
|
-
more.className = 'notification twm-notification--summary';
|
|
602
|
+
more.className = 'twm-notification twm-notification--summary';
|
|
603
603
|
more.addEventListener('click', () => {
|
|
604
604
|
const held = [...this.held];
|
|
605
605
|
try { this.summaryAction?.(held); } catch (err) { this.logger?.warn?.('summaryAction failed', err); }
|
|
@@ -729,8 +729,8 @@ export class NotificationCenter extends ComponentBase {
|
|
|
729
729
|
#patchItem(entry, notification) {
|
|
730
730
|
const item = entry.el;
|
|
731
731
|
if (entry.severity !== notification.severity) {
|
|
732
|
-
if (entry.severity) item.classList.remove(`notification--${entry.severity}`);
|
|
733
|
-
item.classList.add('notification', `notification--${notification.severity}`);
|
|
732
|
+
if (entry.severity) item.classList.remove(`twm-notification--${entry.severity}`);
|
|
733
|
+
item.classList.add('twm-notification', `twm-notification--${notification.severity}`);
|
|
734
734
|
entry.severity = notification.severity;
|
|
735
735
|
}
|
|
736
736
|
if (entry.titleEl) NotificationCenter.#setText(entry.titleEl, notification.title);
|