@openleaf-editor/ui 0.1.0-beta.2 → 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 +18 -0
- package/dist/content-css.d.ts.map +1 -1
- package/dist/content-css.js +231 -16
- package/dist/content-css.js.map +1 -1
- 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 +93 -0
- package/dist/dialog.d.ts.map +1 -1
- package/dist/dialog.js +414 -86
- package/dist/dialog.js.map +1 -1
- package/dist/floating.d.ts +4 -0
- package/dist/floating.d.ts.map +1 -1
- package/dist/floating.js +50 -2
- package/dist/floating.js.map +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +29 -5
- package/dist/help.js.map +1 -1
- package/dist/i18n.d.ts +8 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +32 -7
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +7 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +6 -2
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +0 -1
- package/dist/items.d.ts.map +1 -1
- package/dist/items.js +226 -20
- 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 +31 -2
- package/dist/menu.d.ts.map +1 -1
- package/dist/menu.js +163 -23
- package/dist/menu.js.map +1 -1
- package/dist/openleaf.css +114 -12
- package/dist/overflow.d.ts +31 -4
- package/dist/overflow.d.ts.map +1 -1
- package/dist/overflow.js +267 -82
- package/dist/overflow.js.map +1 -1
- 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 -637
- 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 +71 -12
- package/dist/toolbar.d.ts.map +1 -1
- package/dist/toolbar.js +410 -166
- 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,15 +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';
|
|
33
36
|
import { t, onLocaleChange, withLocale } from './i18n.js';
|
|
37
|
+
import { announce, liveRegion } from './live.js';
|
|
34
38
|
import { ToolbarOverflow } from './overflow.js';
|
|
35
39
|
import { DEFAULT_LAYOUT, getToolbarItem, onRegistryChange, } from './registry.js';
|
|
36
40
|
import { ensureStyles } from './styles.js';
|
|
37
|
-
const BLOCK_TYPE_ID = 'blockType';
|
|
38
41
|
/** Item+callback pairs already reported, so a per-keystroke failure logs once. */
|
|
39
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';
|
|
40
52
|
/**
|
|
41
53
|
* Call a third-party predicate, falling back rather than propagating.
|
|
42
54
|
*
|
|
@@ -50,6 +62,35 @@ const reported = new Set();
|
|
|
50
62
|
* transaction would otherwise emit thousands of identical lines and bury the
|
|
51
63
|
* first one, which is the only one with a useful stack.
|
|
52
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
|
+
}
|
|
53
94
|
function guarded(itemId, kind, run) {
|
|
54
95
|
try {
|
|
55
96
|
return run();
|
|
@@ -65,6 +106,26 @@ function guarded(itemId, kind, run) {
|
|
|
65
106
|
return false;
|
|
66
107
|
}
|
|
67
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
|
+
}
|
|
68
129
|
export class Toolbar {
|
|
69
130
|
el;
|
|
70
131
|
#doc;
|
|
@@ -72,18 +133,31 @@ export class Toolbar {
|
|
|
72
133
|
#view = null;
|
|
73
134
|
#controls = new Map();
|
|
74
135
|
#customs = [];
|
|
75
|
-
|
|
76
|
-
#
|
|
77
|
-
#liveTimer;
|
|
136
|
+
/** Native selects keyed by item id (block type plus any `type: 'select'`). */
|
|
137
|
+
#selects = new Map();
|
|
78
138
|
#layout;
|
|
79
139
|
#label;
|
|
80
140
|
#formats;
|
|
81
141
|
#locale;
|
|
82
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();
|
|
83
157
|
#overflow = null;
|
|
84
158
|
#unsubscribe;
|
|
85
159
|
#unlocale;
|
|
86
|
-
/** Focusable
|
|
160
|
+
/** Focusable controls in DOM order; the roving tabindex walks this. */
|
|
87
161
|
#focusables = [];
|
|
88
162
|
#rovingIndex = 0;
|
|
89
163
|
constructor(host, doc, options = {}) {
|
|
@@ -100,13 +174,12 @@ export class Toolbar {
|
|
|
100
174
|
this.el.className = 'ol-toolbar';
|
|
101
175
|
this.el.setAttribute('role', 'toolbar');
|
|
102
176
|
this.el.setAttribute('aria-label', t(this.#label));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.#live.setAttribute('aria-atomic', 'true');
|
|
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);
|
|
110
183
|
this.el.addEventListener('keydown', this.#onKeydown);
|
|
111
184
|
// Re-render when a plugin registers late. Import-time registration races
|
|
112
185
|
// code-split chunks, and a button that silently never appears is worse than
|
|
@@ -132,24 +205,54 @@ export class Toolbar {
|
|
|
132
205
|
if (this.#view)
|
|
133
206
|
this.#rerenderPreservingState();
|
|
134
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);
|
|
223
|
+
}
|
|
135
224
|
/** Attach to a view and build the controls. */
|
|
136
225
|
mount(view) {
|
|
137
226
|
this.#view = view;
|
|
138
227
|
this.#render();
|
|
139
228
|
if (this.#wantsOverflow && !this.#overflow) {
|
|
140
|
-
|
|
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());
|
|
141
233
|
}
|
|
142
234
|
this.update(view.state);
|
|
143
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
|
+
*/
|
|
144
247
|
destroy() {
|
|
145
248
|
this.#unsubscribe?.();
|
|
146
249
|
this.#unlocale?.();
|
|
147
250
|
this.#overflow?.destroy();
|
|
148
|
-
clearTimeout(this.#liveTimer);
|
|
149
251
|
this.el.removeEventListener('keydown', this.#onKeydown);
|
|
150
252
|
this.#destroyCustoms();
|
|
151
253
|
this.#controls.clear();
|
|
152
254
|
this.#view = null;
|
|
255
|
+
this.el.remove();
|
|
153
256
|
}
|
|
154
257
|
/**
|
|
155
258
|
* Tear down `custom` controls.
|
|
@@ -171,9 +274,15 @@ export class Toolbar {
|
|
|
171
274
|
}
|
|
172
275
|
this.#customs = [];
|
|
173
276
|
}
|
|
174
|
-
/**
|
|
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
|
+
*/
|
|
175
284
|
get liveRegion() {
|
|
176
|
-
return this.#
|
|
285
|
+
return liveRegion(this.#host);
|
|
177
286
|
}
|
|
178
287
|
/* -------------------------------------------------------------- *
|
|
179
288
|
* Rendering
|
|
@@ -189,20 +298,20 @@ export class Toolbar {
|
|
|
189
298
|
this.#destroyCustoms();
|
|
190
299
|
this.el.replaceChildren();
|
|
191
300
|
this.#controls.clear();
|
|
192
|
-
this.#
|
|
301
|
+
this.#selects.clear();
|
|
193
302
|
let group = this.#newGroup();
|
|
194
303
|
for (const token of this.#layout.split(/\s+/).filter(Boolean)) {
|
|
195
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.
|
|
196
310
|
if (group.childElementCount > 0)
|
|
197
311
|
this.el.appendChild(group);
|
|
198
|
-
this.el.appendChild(this.#newSeparator());
|
|
199
312
|
group = this.#newGroup();
|
|
200
313
|
continue;
|
|
201
314
|
}
|
|
202
|
-
if (token === BLOCK_TYPE_ID) {
|
|
203
|
-
group.appendChild(this.#buildBlockTypeSelect());
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
315
|
const spec = getToolbarItem(token);
|
|
207
316
|
// Silently skipping an unknown id would hide a typo in an integrator's
|
|
208
317
|
// `toolbar` attribute forever.
|
|
@@ -216,10 +325,12 @@ export class Toolbar {
|
|
|
216
325
|
group.appendChild(el);
|
|
217
326
|
continue;
|
|
218
327
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
328
|
+
if (spec.type === 'select') {
|
|
329
|
+
const el = this.#buildSelect(spec);
|
|
330
|
+
if (el)
|
|
331
|
+
group.appendChild(el);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
223
334
|
if (spec.type && spec.type !== 'button') {
|
|
224
335
|
console.warn(`@openleaf-editor/ui: toolbar item "${spec.id}" declares type "${spec.type}", ` +
|
|
225
336
|
'which is not implemented yet. It is rendering as a button.');
|
|
@@ -261,43 +372,46 @@ export class Toolbar {
|
|
|
261
372
|
this.update(this.#view.state);
|
|
262
373
|
if (!focusedId)
|
|
263
374
|
return;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
if (!control)
|
|
270
|
-
return;
|
|
271
|
-
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);
|
|
272
380
|
if (index >= 0) {
|
|
273
381
|
this.#rovingIndex = index;
|
|
274
382
|
this.#applyRoving();
|
|
383
|
+
this.#focusables[index]?.focus();
|
|
384
|
+
return;
|
|
275
385
|
}
|
|
276
|
-
control.
|
|
386
|
+
this.#customs.find(({ id }) => id === focusedId)?.control.focusable?.focus();
|
|
277
387
|
}
|
|
278
388
|
#newGroup() {
|
|
279
389
|
const group = this.#doc.createElement('div');
|
|
280
390
|
group.className = 'ol-group';
|
|
281
391
|
return group;
|
|
282
392
|
}
|
|
283
|
-
#newSeparator() {
|
|
284
|
-
const sep = this.#doc.createElement('div');
|
|
285
|
-
sep.className = 'ol-sep';
|
|
286
|
-
// Decorative. A separator announced as "separator" twenty times is noise.
|
|
287
|
-
sep.setAttribute('aria-hidden', 'true');
|
|
288
|
-
return sep;
|
|
289
|
-
}
|
|
290
393
|
#buildButton(spec) {
|
|
291
394
|
const button = this.#doc.createElement('button');
|
|
292
395
|
button.type = 'button';
|
|
293
396
|
button.className = 'ol-btn';
|
|
294
397
|
button.dataset['olId'] = spec.id;
|
|
295
|
-
// The accessible name stays constant across
|
|
296
|
-
// it would double up with what the platform already
|
|
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`.
|
|
297
402
|
button.setAttribute('aria-label', t(spec.label));
|
|
298
|
-
|
|
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.
|
|
299
408
|
const label = t(spec.label);
|
|
300
|
-
button.title =
|
|
409
|
+
button.title = label;
|
|
410
|
+
if (spec.shortcut) {
|
|
411
|
+
const keys = keyShortcutFor(spec.shortcut);
|
|
412
|
+
if (keys)
|
|
413
|
+
button.setAttribute('aria-keyshortcuts', keys);
|
|
414
|
+
}
|
|
301
415
|
if ((spec.kind ?? 'action') === 'toggle') {
|
|
302
416
|
button.setAttribute('aria-pressed', 'false');
|
|
303
417
|
}
|
|
@@ -308,7 +422,7 @@ export class Toolbar {
|
|
|
308
422
|
// nothing.
|
|
309
423
|
button.addEventListener('mousedown', (event) => event.preventDefault());
|
|
310
424
|
button.addEventListener('click', () => this.#invoke(spec));
|
|
311
|
-
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 });
|
|
312
426
|
return button;
|
|
313
427
|
}
|
|
314
428
|
/**
|
|
@@ -332,7 +446,7 @@ export class Toolbar {
|
|
|
332
446
|
if (!view)
|
|
333
447
|
return null;
|
|
334
448
|
try {
|
|
335
|
-
const control = spec.render({ view, host: this.#host });
|
|
449
|
+
const control = spec.render({ view, id: spec.id, host: this.#host, formats: this.#formats });
|
|
336
450
|
this.#customs.push({ id: spec.id, control });
|
|
337
451
|
return control.el;
|
|
338
452
|
}
|
|
@@ -343,39 +457,44 @@ export class Toolbar {
|
|
|
343
457
|
}
|
|
344
458
|
}
|
|
345
459
|
/**
|
|
346
|
-
*
|
|
460
|
+
* Build a `type: 'select'` control from the item's options.
|
|
347
461
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* 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.
|
|
352
465
|
*/
|
|
353
|
-
#
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const options = [
|
|
359
|
-
['p', t('Paragraph')],
|
|
360
|
-
['1', t('Heading 1')],
|
|
361
|
-
['2', t('Heading 2')],
|
|
362
|
-
['3', t('Heading 3')],
|
|
363
|
-
['4', t('Heading 4')],
|
|
364
|
-
['5', t('Heading 5')],
|
|
365
|
-
['6', t('Heading 6')],
|
|
366
|
-
];
|
|
367
|
-
for (const [value, label] of options) {
|
|
368
|
-
const option = this.#doc.createElement('option');
|
|
369
|
-
option.value = value;
|
|
370
|
-
option.textContent = label;
|
|
371
|
-
select.appendChild(option);
|
|
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;
|
|
372
471
|
}
|
|
373
|
-
|
|
472
|
+
const select = this.#doc.createElement('select');
|
|
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) {
|
|
374
478
|
const option = this.#doc.createElement('option');
|
|
375
|
-
option.value =
|
|
376
|
-
option.textContent = t(
|
|
479
|
+
option.value = choice.value;
|
|
480
|
+
option.textContent = t(choice.label);
|
|
377
481
|
select.appendChild(option);
|
|
378
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) {
|
|
379
498
|
select.addEventListener('mousedown', (event) => event.stopPropagation());
|
|
380
499
|
/**
|
|
381
500
|
* Whether the pending change came from a pointer rather than the keyboard.
|
|
@@ -406,27 +525,9 @@ export class Toolbar {
|
|
|
406
525
|
const view = this.#view;
|
|
407
526
|
if (!view)
|
|
408
527
|
return;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// Element first: changing the block type replaces the node, so a class
|
|
413
|
-
// written before that would go with the node it was written on.
|
|
414
|
-
// `setHeading` rather than `toggleHeading` -- picking "Section" twice
|
|
415
|
-
// must not turn the heading back into a paragraph.
|
|
416
|
-
if (element === 'p')
|
|
417
|
-
setParagraph(view.state, view.dispatch, view);
|
|
418
|
-
else if (element !== null && /^h[1-6]$/.test(element)) {
|
|
419
|
-
setHeading(Number(element.slice(1)))(view.state, view.dispatch, view);
|
|
420
|
-
}
|
|
421
|
-
// Null clears, which is what a token naming only an element means: the
|
|
422
|
-
// author picked "Section", not "Section, keeping whatever class was on
|
|
423
|
-
// the paragraph before".
|
|
424
|
-
setBlockClass(className)(view.state, view.dispatch, view);
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
const command = value === 'p' ? setParagraph : toggleHeading(Number(value));
|
|
428
|
-
command(view.state, view.dispatch, view);
|
|
429
|
-
}
|
|
528
|
+
if (this.#host.hasAttribute('readonly'))
|
|
529
|
+
return;
|
|
530
|
+
apply();
|
|
430
531
|
// Return the caret to the content only when the author committed the
|
|
431
532
|
// choice by pointer. Keyboard users keep focus and leave with Tab or
|
|
432
533
|
// Escape.
|
|
@@ -435,8 +536,6 @@ export class Toolbar {
|
|
|
435
536
|
view.focus();
|
|
436
537
|
}
|
|
437
538
|
});
|
|
438
|
-
this.#select = select;
|
|
439
|
-
return select;
|
|
440
539
|
}
|
|
441
540
|
/* -------------------------------------------------------------- *
|
|
442
541
|
* Invocation
|
|
@@ -452,7 +551,7 @@ export class Toolbar {
|
|
|
452
551
|
return;
|
|
453
552
|
try {
|
|
454
553
|
if (spec.run) {
|
|
455
|
-
spec.run({ view, host: this.#host });
|
|
554
|
+
spec.run({ view, host: this.#host, formats: this.#formats });
|
|
456
555
|
return;
|
|
457
556
|
}
|
|
458
557
|
if (spec.command) {
|
|
@@ -471,10 +570,21 @@ export class Toolbar {
|
|
|
471
570
|
/**
|
|
472
571
|
* Reflect the editor state onto the controls.
|
|
473
572
|
*
|
|
474
|
-
* Deliberately synchronous, not batched into an animation frame
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
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.
|
|
478
588
|
*
|
|
479
589
|
* `tr` is passed so announcements can be gated on a real formatting change.
|
|
480
590
|
*/
|
|
@@ -482,6 +592,17 @@ export class Toolbar {
|
|
|
482
592
|
withLocale(this.#locale, () => this.#updateScoped(state, tr));
|
|
483
593
|
}
|
|
484
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;
|
|
485
606
|
// Announce only on a discrete formatting transition, never on cursor
|
|
486
607
|
// movement through already-formatted text. That gate is the whole
|
|
487
608
|
// difference between a useful announcement and a chatty one.
|
|
@@ -490,10 +611,13 @@ export class Toolbar {
|
|
|
490
611
|
for (const control of this.#controls.values()) {
|
|
491
612
|
const { spec } = control;
|
|
492
613
|
const readonly = this.#host.hasAttribute('readonly');
|
|
493
|
-
|
|
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
|
|
494
618
|
? false
|
|
495
619
|
: control.forcedEnabled ??
|
|
496
|
-
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));
|
|
497
621
|
if (enabled !== control.enabled) {
|
|
498
622
|
control.enabled = enabled;
|
|
499
623
|
// aria-disabled, never the disabled attribute: a disabled button is
|
|
@@ -501,15 +625,28 @@ export class Toolbar {
|
|
|
501
625
|
// so an author using a screen reader cannot discover it exists.
|
|
502
626
|
control.el.setAttribute('aria-disabled', enabled ? 'false' : 'true');
|
|
503
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
|
+
}
|
|
504
636
|
if ((spec.kind ?? 'action') === 'toggle') {
|
|
505
637
|
const active = control.forcedActive ??
|
|
506
|
-
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)));
|
|
507
639
|
if (active !== control.active) {
|
|
508
640
|
const previous = control.active;
|
|
509
641
|
control.active = active;
|
|
510
642
|
control.el.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
511
643
|
if (isFormattingChange && previous !== null) {
|
|
512
|
-
|
|
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)));
|
|
513
650
|
}
|
|
514
651
|
}
|
|
515
652
|
}
|
|
@@ -518,41 +655,132 @@ export class Toolbar {
|
|
|
518
655
|
// Readonly is the toolbar's business, not each control's: reflecting it
|
|
519
656
|
// here means a custom control gets the same disabled treatment as a button
|
|
520
657
|
// without every plugin author having to remember the attribute exists.
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (className !== formatClass)
|
|
539
|
-
return false;
|
|
540
|
-
return element === null || element === activeElement;
|
|
541
|
-
});
|
|
542
|
-
if (matching) {
|
|
543
|
-
const next = `format:${matching.token}`;
|
|
544
|
-
if (this.#select.value !== next)
|
|
545
|
-
this.#select.value = next;
|
|
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
|
+
});
|
|
546
675
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
676
|
+
if (unavailable)
|
|
677
|
+
this.#suspend(control.el);
|
|
678
|
+
}
|
|
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);
|
|
551
685
|
}
|
|
552
686
|
}
|
|
553
687
|
if (transitions.length > 0)
|
|
554
688
|
this.#announce(transitions.join(', '));
|
|
555
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
|
+
}
|
|
556
784
|
/**
|
|
557
785
|
* Push a state a predicate cannot derive.
|
|
558
786
|
*
|
|
@@ -577,32 +805,41 @@ export class Toolbar {
|
|
|
577
805
|
this.update(this.#view.state);
|
|
578
806
|
}
|
|
579
807
|
#announce(message) {
|
|
580
|
-
|
|
581
|
-
// and the delay coalesces a held shortcut into one utterance.
|
|
582
|
-
this.#live.textContent = '';
|
|
583
|
-
clearTimeout(this.#liveTimer);
|
|
584
|
-
this.#liveTimer = setTimeout(() => {
|
|
585
|
-
this.#live.textContent = message;
|
|
586
|
-
}, 60);
|
|
808
|
+
announce(this.#host, message);
|
|
587
809
|
}
|
|
588
810
|
/* -------------------------------------------------------------- *
|
|
589
811
|
* Roving tabindex
|
|
590
812
|
* -------------------------------------------------------------- */
|
|
591
813
|
#refreshFocusables() {
|
|
592
|
-
this.#focusables = [...this.el.querySelectorAll(
|
|
814
|
+
this.#focusables = [...this.el.querySelectorAll(FOCUSABLE)];
|
|
593
815
|
this.#rovingIndex = 0;
|
|
594
816
|
this.#applyRoving();
|
|
595
817
|
}
|
|
596
818
|
#applyRoving() {
|
|
597
|
-
this.#focusables.forEach((
|
|
598
|
-
|
|
819
|
+
this.#focusables.forEach((el, index) => {
|
|
820
|
+
el.tabIndex = index === this.#rovingIndex ? 0 : -1;
|
|
599
821
|
});
|
|
600
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
|
+
*/
|
|
601
831
|
#moveRoving(delta) {
|
|
602
|
-
if (this.#focusables.length === 0)
|
|
603
|
-
return;
|
|
604
832
|
const count = this.#focusables.length;
|
|
605
|
-
|
|
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;
|
|
606
843
|
this.#applyRoving();
|
|
607
844
|
this.#focusables[this.#rovingIndex]?.focus();
|
|
608
845
|
}
|
|
@@ -621,13 +858,16 @@ export class Toolbar {
|
|
|
621
858
|
return;
|
|
622
859
|
}
|
|
623
860
|
const target = event.target;
|
|
624
|
-
|
|
625
|
-
// key handling; hijacking Left/Right there would break value changing.
|
|
626
|
-
if (!target || target.tagName !== 'BUTTON')
|
|
861
|
+
if (!target)
|
|
627
862
|
return;
|
|
628
863
|
const index = this.#focusables.indexOf(target);
|
|
629
|
-
if (index
|
|
630
|
-
|
|
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';
|
|
631
871
|
switch (event.key) {
|
|
632
872
|
case 'ArrowRight':
|
|
633
873
|
event.preventDefault();
|
|
@@ -638,10 +878,14 @@ export class Toolbar {
|
|
|
638
878
|
this.#moveRoving(-1);
|
|
639
879
|
break;
|
|
640
880
|
case 'Home':
|
|
881
|
+
if (isSelect)
|
|
882
|
+
break;
|
|
641
883
|
event.preventDefault();
|
|
642
884
|
this.#setRoving(0);
|
|
643
885
|
break;
|
|
644
886
|
case 'End':
|
|
887
|
+
if (isSelect)
|
|
888
|
+
break;
|
|
645
889
|
event.preventDefault();
|
|
646
890
|
this.#setRoving(this.#focusables.length - 1);
|
|
647
891
|
break;
|
|
@@ -656,10 +900,10 @@ export class Toolbar {
|
|
|
656
900
|
this.#focusables[this.#rovingIndex]?.focus();
|
|
657
901
|
return;
|
|
658
902
|
}
|
|
659
|
-
// A
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
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();
|
|
663
907
|
}
|
|
664
908
|
/** Return focus and the prior selection to the editable region. */
|
|
665
909
|
returnFocusToContent() {
|