@openleaf-editor/ui 0.1.0-beta.1 → 0.1.0-beta.3
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 +85 -0
- package/dist/block-type.d.ts +5 -0
- package/dist/block-type.d.ts.map +1 -0
- package/dist/block-type.js +133 -0
- package/dist/block-type.js.map +1 -0
- package/dist/content-css.d.ts +31 -0
- package/dist/content-css.d.ts.map +1 -0
- package/dist/content-css.js +272 -0
- package/dist/content-css.js.map +1 -0
- package/dist/css.d.ts +23 -0
- package/dist/css.d.ts.map +1 -0
- package/dist/css.js +813 -0
- package/dist/css.js.map +1 -0
- package/dist/dialog.d.ts +158 -18
- package/dist/dialog.d.ts.map +1 -1
- package/dist/dialog.js +605 -130
- package/dist/dialog.js.map +1 -1
- package/dist/floating.d.ts +29 -0
- package/dist/floating.d.ts.map +1 -0
- package/dist/floating.js +130 -0
- package/dist/floating.js.map +1 -0
- package/dist/help.d.ts +9 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +97 -0
- package/dist/help.js.map +1 -0
- package/dist/i18n.d.ts +39 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +114 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +7 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +10 -2
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +2 -1
- package/dist/items.d.ts.map +1 -1
- package/dist/items.js +263 -17
- package/dist/items.js.map +1 -1
- package/dist/live.d.ts +42 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +83 -0
- package/dist/live.js.map +1 -0
- package/dist/menu.d.ts +85 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +500 -0
- package/dist/menu.js.map +1 -0
- package/dist/openleaf.css +325 -7
- package/dist/overflow.d.ts +44 -0
- package/dist/overflow.d.ts.map +1 -0
- package/dist/overflow.js +323 -0
- package/dist/overflow.js.map +1 -0
- package/dist/pickers.d.ts +43 -0
- package/dist/pickers.d.ts.map +1 -0
- package/dist/pickers.js +52 -0
- package/dist/pickers.js.map +1 -0
- package/dist/registry.d.ts +72 -18
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +7 -6
- package/dist/registry.js.map +1 -1
- package/dist/skins.d.ts.map +1 -1
- package/dist/skins.js +48 -14
- package/dist/skins.js.map +1 -1
- package/dist/styles.d.ts +61 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +77 -421
- package/dist/styles.js.map +1 -1
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -0
- package/dist/toolbar.d.ts +91 -12
- package/dist/toolbar.d.ts.map +1 -1
- package/dist/toolbar.js +459 -127
- package/dist/toolbar.js.map +1 -1
- package/dist/upload.d.ts +11 -0
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +15 -1
- package/dist/upload.js.map +1 -1
- package/package.json +17 -3
package/dist/toolbar.js
CHANGED
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Two details that are easy to get wrong and both matter:
|
|
15
15
|
*
|
|
16
|
-
* 1. **Arrow-key roving
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
16
|
+
* 1. **Arrow-key roving covers the `<select>` controls too.** The default bar has
|
|
17
|
+
* four of them -- paragraph style, font family, font size, line height -- and
|
|
18
|
+
* when focus is on one, Left/Right have two competing owners: the roving
|
|
19
|
+
* handler wants to move to the next item, the select natively wants to change
|
|
20
|
+
* its value. Leaving them out was the earlier resolution, and it cost more
|
|
21
|
+
* than it saved: ArrowRight from Redo jumped past four controls to Bold, and
|
|
22
|
+
* each select became its own tab stop, making the default bar five tab stops
|
|
23
|
+
* instead of one. The APG resolution is the one used here -- the toolbar takes
|
|
24
|
+
* Left/Right, and Up/Down, Home/End and typeahead are left to the select, so
|
|
25
|
+
* it is still fully operable and Alt+Down still opens its list.
|
|
23
26
|
*
|
|
24
27
|
* 2. **Escape returns focus and the selection to the content.** Preventing mouse
|
|
25
28
|
* clicks from stealing focus solves the mouse case and leaves the keyboard
|
|
@@ -28,13 +31,24 @@
|
|
|
28
31
|
* is blind-Tabbing through the rest of the host's form. `Alt+F10` enters,
|
|
29
32
|
* Escape leaves, matching TinyMCE and CKEditor 5 so muscle memory transfers.
|
|
30
33
|
*/
|
|
31
|
-
import {
|
|
34
|
+
import { shortcuts } from '@openleaf-editor/core';
|
|
32
35
|
import { ensureSprite, iconElement } from './icons.js';
|
|
36
|
+
import { t, onLocaleChange, withLocale } from './i18n.js';
|
|
37
|
+
import { announce, liveRegion } from './live.js';
|
|
38
|
+
import { ToolbarOverflow } from './overflow.js';
|
|
33
39
|
import { DEFAULT_LAYOUT, getToolbarItem, onRegistryChange, } from './registry.js';
|
|
34
40
|
import { ensureStyles } from './styles.js';
|
|
35
|
-
const BLOCK_TYPE_ID = 'blockType';
|
|
36
41
|
/** Item+callback pairs already reported, so a per-keystroke failure logs once. */
|
|
37
42
|
const reported = new Set();
|
|
43
|
+
/**
|
|
44
|
+
* What the roving tabindex walks.
|
|
45
|
+
*
|
|
46
|
+
* Buttons and native selects alike: every control the bar renders is one of the
|
|
47
|
+
* two, and leaving the selects out is what made the default bar five tab stops.
|
|
48
|
+
* `ol-btn`/`ol-select` rather than the bare tags, so a custom control's own
|
|
49
|
+
* popover -- which lives on the host, not in here -- cannot be walked into.
|
|
50
|
+
*/
|
|
51
|
+
const FOCUSABLE = 'button.ol-btn, select.ol-select';
|
|
38
52
|
/**
|
|
39
53
|
* Call a third-party predicate, falling back rather than propagating.
|
|
40
54
|
*
|
|
@@ -48,6 +62,35 @@ const reported = new Set();
|
|
|
48
62
|
* transaction would otherwise emit thousands of identical lines and bury the
|
|
49
63
|
* first one, which is the only one with a useful stack.
|
|
50
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Predicate answers for one editor state, shared by every bar on the page.
|
|
67
|
+
*
|
|
68
|
+
* A page routinely carries four toolbars over one editor -- the main bar, a
|
|
69
|
+
* second bar, and two floating ones -- and each ran the whole predicate set
|
|
70
|
+
* against the same state, walking the same selection four times over. Dragging
|
|
71
|
+
* a selection across a hundred-page document made that ~480,000 node visits per
|
|
72
|
+
* pointermove, inside `dispatchTransaction`.
|
|
73
|
+
*
|
|
74
|
+
* Safe because the contract on `setItemState` already requires these callbacks
|
|
75
|
+
* to be functions of the document and selection alone: anything the state
|
|
76
|
+
* cannot express -- an upload in flight, a collab lock -- is pushed in through
|
|
77
|
+
* `setItemState` and read from `forcedActive`/`forcedEnabled` before we get
|
|
78
|
+
* here. Two bars asking the same question of the same state cannot legitimately
|
|
79
|
+
* get different answers.
|
|
80
|
+
*/
|
|
81
|
+
const probes = new WeakMap();
|
|
82
|
+
function probe(state, key, compute) {
|
|
83
|
+
let answers = probes.get(state);
|
|
84
|
+
if (!answers) {
|
|
85
|
+
answers = new Map();
|
|
86
|
+
probes.set(state, answers);
|
|
87
|
+
}
|
|
88
|
+
if (answers.has(key))
|
|
89
|
+
return answers.get(key);
|
|
90
|
+
const value = compute();
|
|
91
|
+
answers.set(key, value);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
51
94
|
function guarded(itemId, kind, run) {
|
|
52
95
|
try {
|
|
53
96
|
return run();
|
|
@@ -63,6 +106,26 @@ function guarded(itemId, kind, run) {
|
|
|
63
106
|
return false;
|
|
64
107
|
}
|
|
65
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The `aria-keyshortcuts` spelling of an item's shortcut.
|
|
111
|
+
*
|
|
112
|
+
* Not the same string as the tooltip, and deliberately so: the tooltip is for
|
|
113
|
+
* reading ("Ctrl+B", "⌘B") while this attribute has a defined grammar that
|
|
114
|
+
* assistive technology parses -- named modifiers joined by `+`, with the
|
|
115
|
+
* platform's real modifier rather than a symbol.
|
|
116
|
+
*/
|
|
117
|
+
function keyShortcutFor(label) {
|
|
118
|
+
const found = shortcuts.find((entry) => entry.label === label);
|
|
119
|
+
if (!found)
|
|
120
|
+
return null;
|
|
121
|
+
const mod = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
|
|
122
|
+
? 'Meta'
|
|
123
|
+
: 'Control';
|
|
124
|
+
return found.keys
|
|
125
|
+
.split('-')
|
|
126
|
+
.map((part) => (part === 'Mod' ? mod : part.length === 1 ? part.toUpperCase() : part))
|
|
127
|
+
.join('+');
|
|
128
|
+
}
|
|
66
129
|
export class Toolbar {
|
|
67
130
|
el;
|
|
68
131
|
#doc;
|
|
@@ -70,31 +133,53 @@ export class Toolbar {
|
|
|
70
133
|
#view = null;
|
|
71
134
|
#controls = new Map();
|
|
72
135
|
#customs = [];
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
#liveTimer;
|
|
136
|
+
/** Native selects keyed by item id (block type plus any `type: 'select'`). */
|
|
137
|
+
#selects = new Map();
|
|
76
138
|
#layout;
|
|
139
|
+
#label;
|
|
140
|
+
#formats;
|
|
141
|
+
#locale;
|
|
142
|
+
#wantsOverflow;
|
|
143
|
+
/**
|
|
144
|
+
* True while the host is showing raw HTML instead of the document.
|
|
145
|
+
*
|
|
146
|
+
* Not `setItemState`, which is what the host reaches for elsewhere: that
|
|
147
|
+
* pushes a value per item and has no way to say "and put every other item
|
|
148
|
+
* back afterwards". A mode is one flag, and clearing it is one assignment.
|
|
149
|
+
*/
|
|
150
|
+
#sourceMode = false;
|
|
151
|
+
/**
|
|
152
|
+
* Controls this bar disabled for readonly or source view, so releasing hands
|
|
153
|
+
* back only what it took. Without it, leaving source view would re-enable a
|
|
154
|
+
* control its own `update` had disabled for the current selection.
|
|
155
|
+
*/
|
|
156
|
+
#suspendedByToolbar = new WeakSet();
|
|
157
|
+
#overflow = null;
|
|
77
158
|
#unsubscribe;
|
|
78
|
-
|
|
159
|
+
#unlocale;
|
|
160
|
+
/** Focusable controls in DOM order; the roving tabindex walks this. */
|
|
79
161
|
#focusables = [];
|
|
80
162
|
#rovingIndex = 0;
|
|
81
163
|
constructor(host, doc, options = {}) {
|
|
82
164
|
this.#host = host;
|
|
83
165
|
this.#doc = doc;
|
|
84
166
|
this.#layout = options.layout ?? DEFAULT_LAYOUT;
|
|
167
|
+
this.#label = options.label ?? 'Formatting';
|
|
168
|
+
this.#formats = options.formats ?? [];
|
|
169
|
+
this.#locale = options.locale ?? null;
|
|
170
|
+
this.#wantsOverflow = options.overflow === true;
|
|
85
171
|
ensureStyles(doc);
|
|
86
172
|
ensureSprite(doc);
|
|
87
173
|
this.el = doc.createElement('div');
|
|
88
174
|
this.el.className = 'ol-toolbar';
|
|
89
175
|
this.el.setAttribute('role', 'toolbar');
|
|
90
|
-
this.el.setAttribute('aria-label',
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.#live.setAttribute('aria-atomic', 'true');
|
|
176
|
+
this.el.setAttribute('aria-label', t(this.#label));
|
|
177
|
+
// Mounted on the HOST, and mounted now rather than on the first
|
|
178
|
+
// announcement: a region a screen reader has never seen may not be observed
|
|
179
|
+
// in time to read the text that appears in it. Shared with every other bar
|
|
180
|
+
// on this editor, so a secondary or floating toolbar is never the one that
|
|
181
|
+
// speaks into a detached node.
|
|
182
|
+
liveRegion(host);
|
|
98
183
|
this.el.addEventListener('keydown', this.#onKeydown);
|
|
99
184
|
// Re-render when a plugin registers late. Import-time registration races
|
|
100
185
|
// code-split chunks, and a button that silently never appears is worse than
|
|
@@ -102,20 +187,72 @@ export class Toolbar {
|
|
|
102
187
|
this.#unsubscribe = onRegistryChange(() => {
|
|
103
188
|
this.#rerenderPreservingState();
|
|
104
189
|
});
|
|
190
|
+
this.#unlocale = onLocaleChange(() => {
|
|
191
|
+
this.#rerenderPreservingState();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Change this toolbar's language and rebuild its labels.
|
|
196
|
+
*
|
|
197
|
+
* Per toolbar rather than per document, so one editor switching language does
|
|
198
|
+
* not relabel every other editor on the page.
|
|
199
|
+
*/
|
|
200
|
+
setLocale(next) {
|
|
201
|
+
const value = next ?? null;
|
|
202
|
+
if (value === this.#locale)
|
|
203
|
+
return;
|
|
204
|
+
this.#locale = value;
|
|
205
|
+
if (this.#view)
|
|
206
|
+
this.#rerenderPreservingState();
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Enter or leave source view.
|
|
210
|
+
*
|
|
211
|
+
* Every control except the one that leaves again goes unavailable. In source
|
|
212
|
+
* mode a formatting command ran against the hidden document and the textarea
|
|
213
|
+
* was then reparsed over the top of it, so pressing Bold silently discarded
|
|
214
|
+
* the edit -- a button that does nothing is better than a button that
|
|
215
|
+
* destroys work, and a button that says it is unavailable is better still.
|
|
216
|
+
*/
|
|
217
|
+
setSourceMode(active) {
|
|
218
|
+
if (active === this.#sourceMode)
|
|
219
|
+
return;
|
|
220
|
+
this.#sourceMode = active;
|
|
221
|
+
if (this.#view)
|
|
222
|
+
this.update(this.#view.state);
|
|
105
223
|
}
|
|
106
224
|
/** Attach to a view and build the controls. */
|
|
107
225
|
mount(view) {
|
|
108
226
|
this.#view = view;
|
|
109
227
|
this.#render();
|
|
228
|
+
if (this.#wantsOverflow && !this.#overflow) {
|
|
229
|
+
// Relayout moves whole groups out of the bar and back, so the roving list
|
|
230
|
+
// is rebuilt after it: a control in the More panel is not a stop in the
|
|
231
|
+
// bar, and the More trigger itself is.
|
|
232
|
+
this.#overflow = new ToolbarOverflow(this.el, this.#host, this.#doc, () => this.#refreshFocusables());
|
|
233
|
+
}
|
|
110
234
|
this.update(view.state);
|
|
111
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Detach the toolbar AND take its DOM with it.
|
|
238
|
+
*
|
|
239
|
+
* Removing the nodes is part of the contract, not tidiness. The host appends
|
|
240
|
+
* `el` into the editor element and reads that element's
|
|
241
|
+
* `innerHTML` back as document content when it rebuilds -- so a toolbar left
|
|
242
|
+
* behind by `destroy()` becomes the author's document on the next build, and
|
|
243
|
+
* then gets posted to the server.
|
|
244
|
+
*
|
|
245
|
+
* Idempotent: `.remove()` on a detached node is a no-op.
|
|
246
|
+
*/
|
|
112
247
|
destroy() {
|
|
113
248
|
this.#unsubscribe?.();
|
|
114
|
-
|
|
249
|
+
this.#unlocale?.();
|
|
250
|
+
this.#overflow?.destroy();
|
|
115
251
|
this.el.removeEventListener('keydown', this.#onKeydown);
|
|
116
252
|
this.#destroyCustoms();
|
|
117
253
|
this.#controls.clear();
|
|
118
254
|
this.#view = null;
|
|
255
|
+
this.el.remove();
|
|
119
256
|
}
|
|
120
257
|
/**
|
|
121
258
|
* Tear down `custom` controls.
|
|
@@ -137,31 +274,44 @@ export class Toolbar {
|
|
|
137
274
|
}
|
|
138
275
|
this.#customs = [];
|
|
139
276
|
}
|
|
140
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* The editor's live region.
|
|
279
|
+
*
|
|
280
|
+
* One per host, shared by every bar on it, and already mounted -- so a host
|
|
281
|
+
* that appends this is moving a node it already owns rather than adopting a
|
|
282
|
+
* detached one. Kept on the class because integrations reach for it.
|
|
283
|
+
*/
|
|
141
284
|
get liveRegion() {
|
|
142
|
-
return this.#
|
|
285
|
+
return liveRegion(this.#host);
|
|
143
286
|
}
|
|
144
287
|
/* -------------------------------------------------------------- *
|
|
145
288
|
* Rendering
|
|
146
289
|
* -------------------------------------------------------------- */
|
|
290
|
+
/**
|
|
291
|
+
* Labels are produced inside this toolbar's own locale scope, so two editors
|
|
292
|
+
* with different `lang` values on one page do not overwrite each other.
|
|
293
|
+
*/
|
|
147
294
|
#render() {
|
|
295
|
+
withLocale(this.#locale, () => this.#renderScoped());
|
|
296
|
+
}
|
|
297
|
+
#renderScoped() {
|
|
148
298
|
this.#destroyCustoms();
|
|
149
299
|
this.el.replaceChildren();
|
|
150
300
|
this.#controls.clear();
|
|
151
|
-
this.#
|
|
301
|
+
this.#selects.clear();
|
|
152
302
|
let group = this.#newGroup();
|
|
153
303
|
for (const token of this.#layout.split(/\s+/).filter(Boolean)) {
|
|
154
304
|
if (token === '|') {
|
|
305
|
+
// No separator ELEMENT. The divider is a border on the group -- see
|
|
306
|
+
// css.ts -- and an element between two groups is exactly what stopped
|
|
307
|
+
// `.ol-group + .ol-group` from ever matching, so no divider rendered in
|
|
308
|
+
// any theme. An empty group is also not appended, so `a | | b` gets one
|
|
309
|
+
// divider rather than a doubled rule.
|
|
155
310
|
if (group.childElementCount > 0)
|
|
156
311
|
this.el.appendChild(group);
|
|
157
|
-
this.el.appendChild(this.#newSeparator());
|
|
158
312
|
group = this.#newGroup();
|
|
159
313
|
continue;
|
|
160
314
|
}
|
|
161
|
-
if (token === BLOCK_TYPE_ID) {
|
|
162
|
-
group.appendChild(this.#buildBlockTypeSelect());
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
315
|
const spec = getToolbarItem(token);
|
|
166
316
|
// Silently skipping an unknown id would hide a typo in an integrator's
|
|
167
317
|
// `toolbar` attribute forever.
|
|
@@ -175,10 +325,12 @@ export class Toolbar {
|
|
|
175
325
|
group.appendChild(el);
|
|
176
326
|
continue;
|
|
177
327
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
328
|
+
if (spec.type === 'select') {
|
|
329
|
+
const el = this.#buildSelect(spec);
|
|
330
|
+
if (el)
|
|
331
|
+
group.appendChild(el);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
182
334
|
if (spec.type && spec.type !== 'button') {
|
|
183
335
|
console.warn(`@openleaf-editor/ui: toolbar item "${spec.id}" declares type "${spec.type}", ` +
|
|
184
336
|
'which is not implemented yet. It is rendering as a button.');
|
|
@@ -187,7 +339,9 @@ export class Toolbar {
|
|
|
187
339
|
}
|
|
188
340
|
if (group.childElementCount > 0)
|
|
189
341
|
this.el.appendChild(group);
|
|
342
|
+
this.el.setAttribute('aria-label', t(this.#label));
|
|
190
343
|
this.#refreshFocusables();
|
|
344
|
+
this.#overflow?.reattach();
|
|
191
345
|
}
|
|
192
346
|
/**
|
|
193
347
|
* Rebuild after a registry change without dropping host-pushed state.
|
|
@@ -218,42 +372,46 @@ export class Toolbar {
|
|
|
218
372
|
this.update(this.#view.state);
|
|
219
373
|
if (!focusedId)
|
|
220
374
|
return;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
if (!control)
|
|
227
|
-
return;
|
|
228
|
-
const index = this.#focusables.indexOf(control.el);
|
|
375
|
+
// One lookup for buttons, selects and custom triggers alike: they are all in
|
|
376
|
+
// the roving list, and they are all found by the id the item was registered
|
|
377
|
+
// under -- which is why a control that writes its LABEL into `data-ol-id`
|
|
378
|
+
// silently loses focus here.
|
|
379
|
+
const index = this.#focusables.findIndex((el) => el.dataset['olId'] === focusedId);
|
|
229
380
|
if (index >= 0) {
|
|
230
381
|
this.#rovingIndex = index;
|
|
231
382
|
this.#applyRoving();
|
|
383
|
+
this.#focusables[index]?.focus();
|
|
384
|
+
return;
|
|
232
385
|
}
|
|
233
|
-
control.
|
|
386
|
+
this.#customs.find(({ id }) => id === focusedId)?.control.focusable?.focus();
|
|
234
387
|
}
|
|
235
388
|
#newGroup() {
|
|
236
389
|
const group = this.#doc.createElement('div');
|
|
237
390
|
group.className = 'ol-group';
|
|
238
391
|
return group;
|
|
239
392
|
}
|
|
240
|
-
#newSeparator() {
|
|
241
|
-
const sep = this.#doc.createElement('div');
|
|
242
|
-
sep.className = 'ol-sep';
|
|
243
|
-
// Decorative. A separator announced as "separator" twenty times is noise.
|
|
244
|
-
sep.setAttribute('aria-hidden', 'true');
|
|
245
|
-
return sep;
|
|
246
|
-
}
|
|
247
393
|
#buildButton(spec) {
|
|
248
394
|
const button = this.#doc.createElement('button');
|
|
249
395
|
button.type = 'button';
|
|
250
396
|
button.className = 'ol-btn';
|
|
251
397
|
button.dataset['olId'] = spec.id;
|
|
252
|
-
// The accessible name stays constant across
|
|
253
|
-
// it would double up with what the platform already
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
button.
|
|
398
|
+
// The accessible name stays constant across pressed/unpressed. Baking
|
|
399
|
+
// "pressed" into it would double up with what the platform already
|
|
400
|
+
// announces. A control that does two different things (insert versus
|
|
401
|
+
// edit) may still change its name via `labelFor`.
|
|
402
|
+
button.setAttribute('aria-label', t(spec.label));
|
|
403
|
+
// The title is the label and nothing more. Per accname `title` becomes the
|
|
404
|
+
// DESCRIPTION of an element that already has a name, so "Bold (Ctrl+B)"
|
|
405
|
+
// beside aria-label="Bold" had NVDA say "Bold, button, Bold Ctrl+B". Equal
|
|
406
|
+
// to the name, it is dropped rather than read twice; the shortcut moves to
|
|
407
|
+
// the attribute that exists to carry it.
|
|
408
|
+
const label = t(spec.label);
|
|
409
|
+
button.title = label;
|
|
410
|
+
if (spec.shortcut) {
|
|
411
|
+
const keys = keyShortcutFor(spec.shortcut);
|
|
412
|
+
if (keys)
|
|
413
|
+
button.setAttribute('aria-keyshortcuts', keys);
|
|
414
|
+
}
|
|
257
415
|
if ((spec.kind ?? 'action') === 'toggle') {
|
|
258
416
|
button.setAttribute('aria-pressed', 'false');
|
|
259
417
|
}
|
|
@@ -264,7 +422,7 @@ export class Toolbar {
|
|
|
264
422
|
// nothing.
|
|
265
423
|
button.addEventListener('mousedown', (event) => event.preventDefault());
|
|
266
424
|
button.addEventListener('click', () => this.#invoke(spec));
|
|
267
|
-
this.#controls.set(spec.id, { spec, el: button, active: null, enabled: null });
|
|
425
|
+
this.#controls.set(spec.id, { spec, el: button, active: null, enabled: null, label: null });
|
|
268
426
|
return button;
|
|
269
427
|
}
|
|
270
428
|
/**
|
|
@@ -288,7 +446,7 @@ export class Toolbar {
|
|
|
288
446
|
if (!view)
|
|
289
447
|
return null;
|
|
290
448
|
try {
|
|
291
|
-
const control = spec.render({ view, host: this.#host });
|
|
449
|
+
const control = spec.render({ view, id: spec.id, host: this.#host, formats: this.#formats });
|
|
292
450
|
this.#customs.push({ id: spec.id, control });
|
|
293
451
|
return control.el;
|
|
294
452
|
}
|
|
@@ -299,33 +457,44 @@ export class Toolbar {
|
|
|
299
457
|
}
|
|
300
458
|
}
|
|
301
459
|
/**
|
|
302
|
-
*
|
|
460
|
+
* Build a `type: 'select'` control from the item's options.
|
|
303
461
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* its own accessible name because the toolbar's own label does not describe it.
|
|
462
|
+
* Same keyboard contract as the block-type select: it is a second tab stop,
|
|
463
|
+
* owns its own arrow keys, and only returns focus to the content when the
|
|
464
|
+
* author commits by pointer.
|
|
308
465
|
*/
|
|
309
|
-
#
|
|
466
|
+
#buildSelect(spec) {
|
|
467
|
+
if (!spec.options || !spec.getValue || !spec.applyValue) {
|
|
468
|
+
console.warn(`@openleaf-editor/ui: toolbar item "${spec.id}" declares type "select" but is ` +
|
|
469
|
+
'missing options, getValue, or applyValue, so there is nothing to build.');
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
310
472
|
const select = this.#doc.createElement('select');
|
|
311
|
-
select.className = 'ol-select';
|
|
312
|
-
select.setAttribute('aria-label',
|
|
313
|
-
select.dataset['olId'] =
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
['1', 'Heading 1'],
|
|
317
|
-
['2', 'Heading 2'],
|
|
318
|
-
['3', 'Heading 3'],
|
|
319
|
-
['4', 'Heading 4'],
|
|
320
|
-
['5', 'Heading 5'],
|
|
321
|
-
['6', 'Heading 6'],
|
|
322
|
-
];
|
|
323
|
-
for (const [value, label] of options) {
|
|
473
|
+
select.className = spec.selectMod ? `ol-select ol-select--${spec.selectMod}` : 'ol-select';
|
|
474
|
+
select.setAttribute('aria-label', t(spec.label));
|
|
475
|
+
select.dataset['olId'] = spec.id;
|
|
476
|
+
select.title = t(spec.label);
|
|
477
|
+
for (const choice of spec.options) {
|
|
324
478
|
const option = this.#doc.createElement('option');
|
|
325
|
-
option.value = value;
|
|
326
|
-
option.textContent = label;
|
|
479
|
+
option.value = choice.value;
|
|
480
|
+
option.textContent = t(choice.label);
|
|
327
481
|
select.appendChild(option);
|
|
328
482
|
}
|
|
483
|
+
this.#wireSelectInteraction(select, () => {
|
|
484
|
+
const view = this.#view;
|
|
485
|
+
if (!view || !spec.applyValue)
|
|
486
|
+
return;
|
|
487
|
+
const command = spec.applyValue(select.value);
|
|
488
|
+
command(view.state, view.dispatch, view);
|
|
489
|
+
});
|
|
490
|
+
this.#selects.set(spec.id, select);
|
|
491
|
+
return select;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Shared select wiring: keep pointer vs keyboard focus behaviour identical
|
|
495
|
+
* across every native list in the bar.
|
|
496
|
+
*/
|
|
497
|
+
#wireSelectInteraction(select, apply) {
|
|
329
498
|
select.addEventListener('mousedown', (event) => event.stopPropagation());
|
|
330
499
|
/**
|
|
331
500
|
* Whether the pending change came from a pointer rather than the keyboard.
|
|
@@ -356,9 +525,9 @@ export class Toolbar {
|
|
|
356
525
|
const view = this.#view;
|
|
357
526
|
if (!view)
|
|
358
527
|
return;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
528
|
+
if (this.#host.hasAttribute('readonly'))
|
|
529
|
+
return;
|
|
530
|
+
apply();
|
|
362
531
|
// Return the caret to the content only when the author committed the
|
|
363
532
|
// choice by pointer. Keyboard users keep focus and leave with Tab or
|
|
364
533
|
// Escape.
|
|
@@ -367,8 +536,6 @@ export class Toolbar {
|
|
|
367
536
|
view.focus();
|
|
368
537
|
}
|
|
369
538
|
});
|
|
370
|
-
this.#select = select;
|
|
371
|
-
return select;
|
|
372
539
|
}
|
|
373
540
|
/* -------------------------------------------------------------- *
|
|
374
541
|
* Invocation
|
|
@@ -384,7 +551,7 @@ export class Toolbar {
|
|
|
384
551
|
return;
|
|
385
552
|
try {
|
|
386
553
|
if (spec.run) {
|
|
387
|
-
spec.run({ view, host: this.#host });
|
|
554
|
+
spec.run({ view, host: this.#host, formats: this.#formats });
|
|
388
555
|
return;
|
|
389
556
|
}
|
|
390
557
|
if (spec.command) {
|
|
@@ -403,14 +570,39 @@ export class Toolbar {
|
|
|
403
570
|
/**
|
|
404
571
|
* Reflect the editor state onto the controls.
|
|
405
572
|
*
|
|
406
|
-
* Deliberately synchronous, not batched into an animation frame
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
573
|
+
* Deliberately synchronous, not batched into an animation frame: batching
|
|
574
|
+
* would put a perceptible frame of lag between pressing Bold and the button
|
|
575
|
+
* lighting up.
|
|
576
|
+
*
|
|
577
|
+
* This used to claim the predicates were "sub-millisecond work" and that the
|
|
578
|
+
* performance problem "does not exist". It did. Every control without an
|
|
579
|
+
* `isEnabled` is probed by dry-running its command, each of which walks the
|
|
580
|
+
* selection, and a page routinely carries four bars over one editor -- so a
|
|
581
|
+
* Select-All on a hundred-page document measured 1.4 ms per bar and about
|
|
582
|
+
* 3 ms per pointermove during a selection drag, inside `dispatchTransaction`.
|
|
583
|
+
* What makes synchronous affordable is the three guards below it: skipping
|
|
584
|
+
* transactions that cannot have changed an answer, caching each answer per
|
|
585
|
+
* state so the four bars share one computation, and giving the alignment items
|
|
586
|
+
* an explicit `isEnabled` so they stop dry-running a command that walks the
|
|
587
|
+
* selection twice.
|
|
410
588
|
*
|
|
411
589
|
* `tr` is passed so announcements can be gated on a real formatting change.
|
|
412
590
|
*/
|
|
413
591
|
update(state, tr) {
|
|
592
|
+
withLocale(this.#locale, () => this.#updateScoped(state, tr));
|
|
593
|
+
}
|
|
594
|
+
#updateScoped(state, tr) {
|
|
595
|
+
// Nothing every predicate below reads can have changed: same document, same
|
|
596
|
+
// selection, same stored marks. They each walk the selection, so on a
|
|
597
|
+
// hundred-page Select-All this one line is the difference between 120,000
|
|
598
|
+
// node visits and none -- and metadata-only transactions (plugin pings,
|
|
599
|
+
// collaboration cursors, scroll requests) are most of them.
|
|
600
|
+
//
|
|
601
|
+
// `tr &&` is load-bearing, not defensive: mount(), a readonly change, a
|
|
602
|
+
// plugin reconfigure and setItemState all update with no transaction, and
|
|
603
|
+
// must still refresh.
|
|
604
|
+
if (tr && !tr.docChanged && !tr.selectionSet && !tr.storedMarksSet)
|
|
605
|
+
return;
|
|
414
606
|
// Announce only on a discrete formatting transition, never on cursor
|
|
415
607
|
// movement through already-formatted text. That gate is the whole
|
|
416
608
|
// difference between a useful announcement and a chatty one.
|
|
@@ -419,10 +611,13 @@ export class Toolbar {
|
|
|
419
611
|
for (const control of this.#controls.values()) {
|
|
420
612
|
const { spec } = control;
|
|
421
613
|
const readonly = this.#host.hasAttribute('readonly');
|
|
422
|
-
|
|
614
|
+
// `source` itself stays live in source mode: it is the way back out, and
|
|
615
|
+
// disabling it would strand the author in a textarea.
|
|
616
|
+
const suspended = this.#sourceMode && spec.id !== 'source';
|
|
617
|
+
const enabled = readonly || suspended
|
|
423
618
|
? false
|
|
424
619
|
: control.forcedEnabled ??
|
|
425
|
-
guarded(spec.id, 'isEnabled', () => spec.isEnabled ? spec.isEnabled(state) : spec.command ? spec.command(state) : true);
|
|
620
|
+
probe(state, `${spec.id}e`, () => guarded(spec.id, 'isEnabled', () => spec.isEnabled ? spec.isEnabled(state) : spec.command ? spec.command(state) : true));
|
|
426
621
|
if (enabled !== control.enabled) {
|
|
427
622
|
control.enabled = enabled;
|
|
428
623
|
// aria-disabled, never the disabled attribute: a disabled button is
|
|
@@ -430,15 +625,28 @@ export class Toolbar {
|
|
|
430
625
|
// so an author using a screen reader cannot discover it exists.
|
|
431
626
|
control.el.setAttribute('aria-disabled', enabled ? 'false' : 'true');
|
|
432
627
|
}
|
|
628
|
+
if (spec.labelFor) {
|
|
629
|
+
const label = t(spec.labelFor(state));
|
|
630
|
+
if (label !== control.label) {
|
|
631
|
+
control.label = label;
|
|
632
|
+
control.el.setAttribute('aria-label', label);
|
|
633
|
+
control.el.title = label;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
433
636
|
if ((spec.kind ?? 'action') === 'toggle') {
|
|
434
637
|
const active = control.forcedActive ??
|
|
435
|
-
guarded(spec.id, 'isActive', () => (spec.isActive ? spec.isActive(state) : false));
|
|
638
|
+
probe(state, `${spec.id}a`, () => guarded(spec.id, 'isActive', () => (spec.isActive ? spec.isActive(state) : false)));
|
|
436
639
|
if (active !== control.active) {
|
|
437
640
|
const previous = control.active;
|
|
438
641
|
control.active = active;
|
|
439
642
|
control.el.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
440
643
|
if (isFormattingChange && previous !== null) {
|
|
441
|
-
|
|
644
|
+
// One template key per state rather than a label glued to a bare
|
|
645
|
+
// "on"/"off". The old form pushed the RAW LOOKUP KEY, so a French
|
|
646
|
+
// editor showed "Gras" and announced "Bold on" -- and the two state
|
|
647
|
+
// words had no translation path at all. A template also keeps word
|
|
648
|
+
// order translatable, which a concatenation never can.
|
|
649
|
+
transitions.push(t(active ? '{label} on' : '{label} off').replace('{label}', t(spec.label)));
|
|
442
650
|
}
|
|
443
651
|
}
|
|
444
652
|
}
|
|
@@ -447,24 +655,132 @@ export class Toolbar {
|
|
|
447
655
|
// Readonly is the toolbar's business, not each control's: reflecting it
|
|
448
656
|
// here means a custom control gets the same disabled treatment as a button
|
|
449
657
|
// without every plugin author having to remember the attribute exists.
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
658
|
+
const unavailable = this.#host.hasAttribute('readonly') || this.#sourceMode;
|
|
659
|
+
// Suspension is an OVERRIDE, not an opinion: it can only ever take a
|
|
660
|
+
// control away, and it restores exactly what it took. A control's own
|
|
661
|
+
// `update` is the authority on whether its command applies to this
|
|
662
|
+
// selection -- the table grid disables its trigger when `canInsert` is
|
|
663
|
+
// false -- so writing "available" over that would show an enabled trigger
|
|
664
|
+
// for a command that does nothing.
|
|
665
|
+
//
|
|
666
|
+
// Released BEFORE `update`, forced AFTER it, so whichever of the two is
|
|
667
|
+
// entitled to the answer gets the last word.
|
|
668
|
+
if (!unavailable)
|
|
669
|
+
this.#releaseSuspended(control.el);
|
|
670
|
+
if (control.update) {
|
|
671
|
+
guarded(id, 'update', () => {
|
|
672
|
+
control.update?.(state);
|
|
673
|
+
return true;
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (unavailable)
|
|
677
|
+
this.#suspend(control.el);
|
|
458
678
|
}
|
|
459
|
-
if (this.#
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
679
|
+
if (this.#selects.size > 0) {
|
|
680
|
+
const readonly = this.#host.hasAttribute('readonly') || this.#sourceMode;
|
|
681
|
+
// Only declared `type: 'select'` items live here. Block type is a rendered
|
|
682
|
+
// control and keeps its own state in sync through its ToolbarControl.
|
|
683
|
+
for (const [id, select] of this.#selects) {
|
|
684
|
+
this.#syncRegisteredSelect(id, select, state, readonly);
|
|
685
|
+
}
|
|
464
686
|
}
|
|
465
687
|
if (transitions.length > 0)
|
|
466
688
|
this.#announce(transitions.join(', '));
|
|
467
689
|
}
|
|
690
|
+
/**
|
|
691
|
+
* Every native select a custom control put in the bar, including the control
|
|
692
|
+
* element itself. `querySelectorAll` alone misses the latter, and the latter
|
|
693
|
+
* is the common case: a control whose whole DOM is one select.
|
|
694
|
+
*/
|
|
695
|
+
#nativeSelects(el) {
|
|
696
|
+
const found = [...el.querySelectorAll('select')];
|
|
697
|
+
// localName, not `instanceof`: this bar may be built in a second document,
|
|
698
|
+
// where that document's HTMLSelectElement is a different constructor.
|
|
699
|
+
if (el.localName === 'select')
|
|
700
|
+
found.unshift(el);
|
|
701
|
+
return found;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Take a custom control away while the editor is readonly or in source view.
|
|
705
|
+
*
|
|
706
|
+
* The trigger and every native select inside the control, plus the control
|
|
707
|
+
* element itself when that IS a select -- block type's is
|
|
708
|
+
* (packages/ui/src/block-type.ts), which is how a `<select>` with no
|
|
709
|
+
* `button.ol-btn` inside it stayed fully operable in source mode: the author
|
|
710
|
+
* could pick "Heading 3" against the hidden document, and leaving source view
|
|
711
|
+
* reparsed the textarea over the result and discarded it.
|
|
712
|
+
*
|
|
713
|
+
* Selects get the real `disabled` as well as aria. With aria alone the select
|
|
714
|
+
* still opens, still changes its own displayed value, and still fires
|
|
715
|
+
* `change`, which is the whole failure.
|
|
716
|
+
*
|
|
717
|
+
* Anything already unavailable by the control's own reckoning is left alone
|
|
718
|
+
* and NOT recorded, so releasing cannot hand back something the control
|
|
719
|
+
* itself was refusing.
|
|
720
|
+
*/
|
|
721
|
+
#suspend(el) {
|
|
722
|
+
const trigger = el.querySelector('button.ol-btn');
|
|
723
|
+
if (trigger && trigger.getAttribute('aria-disabled') !== 'true') {
|
|
724
|
+
this.#suspendedByToolbar.add(trigger);
|
|
725
|
+
trigger.setAttribute('aria-disabled', 'true');
|
|
726
|
+
}
|
|
727
|
+
for (const select of this.#nativeSelects(el)) {
|
|
728
|
+
if (select.disabled)
|
|
729
|
+
continue;
|
|
730
|
+
this.#suspendedByToolbar.add(select);
|
|
731
|
+
select.setAttribute('aria-disabled', 'true');
|
|
732
|
+
select.disabled = true;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/** Give back exactly what `#suspend` took, and nothing else. */
|
|
736
|
+
#releaseSuspended(el) {
|
|
737
|
+
const trigger = el.querySelector('button.ol-btn');
|
|
738
|
+
if (trigger && this.#suspendedByToolbar.delete(trigger)) {
|
|
739
|
+
trigger.setAttribute('aria-disabled', 'false');
|
|
740
|
+
}
|
|
741
|
+
for (const select of this.#nativeSelects(el)) {
|
|
742
|
+
if (!this.#suspendedByToolbar.delete(select))
|
|
743
|
+
continue;
|
|
744
|
+
select.setAttribute('aria-disabled', 'false');
|
|
745
|
+
select.disabled = false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
#syncRegisteredSelect(id, select, state, readonly) {
|
|
749
|
+
const spec = getToolbarItem(id);
|
|
750
|
+
if (!spec?.getValue) {
|
|
751
|
+
select.disabled = readonly;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const value = probe(state, `${id} v`, () => {
|
|
755
|
+
try {
|
|
756
|
+
return spec.getValue(state);
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
const key = `${id}:getValue`;
|
|
760
|
+
if (!reported.has(key)) {
|
|
761
|
+
reported.add(key);
|
|
762
|
+
console.error(`@openleaf-editor/ui: the getValue callback for toolbar item "${id}" threw. ` +
|
|
763
|
+
'The control is shown as Default. This is a bug in whatever registered it, ' +
|
|
764
|
+
'not in the editor.', error);
|
|
765
|
+
}
|
|
766
|
+
return '';
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
// Inherited sizes/families that are not in the preset list still need a
|
|
770
|
+
// visible option, or the select snaps to Default and looks cleared.
|
|
771
|
+
if (value !== '' && ![...select.options].some((option) => option.value === value)) {
|
|
772
|
+
const option = this.#doc.createElement('option');
|
|
773
|
+
option.value = value;
|
|
774
|
+
option.textContent = value;
|
|
775
|
+
select.appendChild(option);
|
|
776
|
+
}
|
|
777
|
+
if (select.value !== value)
|
|
778
|
+
select.value = value;
|
|
779
|
+
const enabled = spec.isEnabled
|
|
780
|
+
? probe(state, `${id} e`, () => guarded(id, 'isEnabled', () => spec.isEnabled(state)))
|
|
781
|
+
: true;
|
|
782
|
+
select.disabled = readonly || !enabled;
|
|
783
|
+
}
|
|
468
784
|
/**
|
|
469
785
|
* Push a state a predicate cannot derive.
|
|
470
786
|
*
|
|
@@ -489,32 +805,41 @@ export class Toolbar {
|
|
|
489
805
|
this.update(this.#view.state);
|
|
490
806
|
}
|
|
491
807
|
#announce(message) {
|
|
492
|
-
|
|
493
|
-
// and the delay coalesces a held shortcut into one utterance.
|
|
494
|
-
this.#live.textContent = '';
|
|
495
|
-
clearTimeout(this.#liveTimer);
|
|
496
|
-
this.#liveTimer = setTimeout(() => {
|
|
497
|
-
this.#live.textContent = message;
|
|
498
|
-
}, 60);
|
|
808
|
+
announce(this.#host, message);
|
|
499
809
|
}
|
|
500
810
|
/* -------------------------------------------------------------- *
|
|
501
811
|
* Roving tabindex
|
|
502
812
|
* -------------------------------------------------------------- */
|
|
503
813
|
#refreshFocusables() {
|
|
504
|
-
this.#focusables = [...this.el.querySelectorAll(
|
|
814
|
+
this.#focusables = [...this.el.querySelectorAll(FOCUSABLE)];
|
|
505
815
|
this.#rovingIndex = 0;
|
|
506
816
|
this.#applyRoving();
|
|
507
817
|
}
|
|
508
818
|
#applyRoving() {
|
|
509
|
-
this.#focusables.forEach((
|
|
510
|
-
|
|
819
|
+
this.#focusables.forEach((el, index) => {
|
|
820
|
+
el.tabIndex = index === this.#rovingIndex ? 0 : -1;
|
|
511
821
|
});
|
|
512
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Step to the next control, skipping any that cannot take focus.
|
|
825
|
+
*
|
|
826
|
+
* Buttons carry `aria-disabled` and stay reachable on purpose. A `<select>`
|
|
827
|
+
* has no such option -- a readonly editor really does disable it, and a
|
|
828
|
+
* disabled element silently refuses `focus()`, which would leave the arrow
|
|
829
|
+
* keys dead at that position.
|
|
830
|
+
*/
|
|
513
831
|
#moveRoving(delta) {
|
|
514
|
-
if (this.#focusables.length === 0)
|
|
515
|
-
return;
|
|
516
832
|
const count = this.#focusables.length;
|
|
517
|
-
|
|
833
|
+
if (count === 0)
|
|
834
|
+
return;
|
|
835
|
+
let index = this.#rovingIndex;
|
|
836
|
+
for (let step = 0; step < count; step += 1) {
|
|
837
|
+
index = (index + delta + count) % count;
|
|
838
|
+
const el = this.#focusables[index];
|
|
839
|
+
if (el && !el.disabled)
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
this.#rovingIndex = index;
|
|
518
843
|
this.#applyRoving();
|
|
519
844
|
this.#focusables[this.#rovingIndex]?.focus();
|
|
520
845
|
}
|
|
@@ -533,13 +858,16 @@ export class Toolbar {
|
|
|
533
858
|
return;
|
|
534
859
|
}
|
|
535
860
|
const target = event.target;
|
|
536
|
-
|
|
537
|
-
// key handling; hijacking Left/Right there would break value changing.
|
|
538
|
-
if (!target || target.tagName !== 'BUTTON')
|
|
861
|
+
if (!target)
|
|
539
862
|
return;
|
|
540
863
|
const index = this.#focusables.indexOf(target);
|
|
541
|
-
if (index
|
|
542
|
-
|
|
864
|
+
if (index < 0)
|
|
865
|
+
return;
|
|
866
|
+
this.#rovingIndex = index;
|
|
867
|
+
// Left/Right belong to the toolbar on every control, including a select.
|
|
868
|
+
// Everything else a select uses -- Up/Down, Home/End, typeahead, Alt+Down to
|
|
869
|
+
// open the list -- is left alone, which is what keeps it operable.
|
|
870
|
+
const isSelect = target.tagName === 'SELECT';
|
|
543
871
|
switch (event.key) {
|
|
544
872
|
case 'ArrowRight':
|
|
545
873
|
event.preventDefault();
|
|
@@ -550,10 +878,14 @@ export class Toolbar {
|
|
|
550
878
|
this.#moveRoving(-1);
|
|
551
879
|
break;
|
|
552
880
|
case 'Home':
|
|
881
|
+
if (isSelect)
|
|
882
|
+
break;
|
|
553
883
|
event.preventDefault();
|
|
554
884
|
this.#setRoving(0);
|
|
555
885
|
break;
|
|
556
886
|
case 'End':
|
|
887
|
+
if (isSelect)
|
|
888
|
+
break;
|
|
557
889
|
event.preventDefault();
|
|
558
890
|
this.#setRoving(this.#focusables.length - 1);
|
|
559
891
|
break;
|
|
@@ -568,10 +900,10 @@ export class Toolbar {
|
|
|
568
900
|
this.#focusables[this.#rovingIndex]?.focus();
|
|
569
901
|
return;
|
|
570
902
|
}
|
|
571
|
-
// A
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
this.#
|
|
903
|
+
// A bar of nothing but custom controls has no roving stop of its own. The
|
|
904
|
+
// shortcut is still documented; swallowing it with nowhere to go would make
|
|
905
|
+
// a valid `toolbar` attribute a silent no-op.
|
|
906
|
+
this.#customs.find(({ control }) => control.focusable)?.control.focusable?.focus();
|
|
575
907
|
}
|
|
576
908
|
/** Return focus and the prior selection to the editable region. */
|
|
577
909
|
returnFocusToContent() {
|