@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.
Files changed (78) hide show
  1. package/README.md +85 -0
  2. package/dist/block-type.d.ts +5 -0
  3. package/dist/block-type.d.ts.map +1 -0
  4. package/dist/block-type.js +133 -0
  5. package/dist/block-type.js.map +1 -0
  6. package/dist/content-css.d.ts +18 -0
  7. package/dist/content-css.d.ts.map +1 -1
  8. package/dist/content-css.js +231 -16
  9. package/dist/content-css.js.map +1 -1
  10. package/dist/css.d.ts +23 -0
  11. package/dist/css.d.ts.map +1 -0
  12. package/dist/css.js +813 -0
  13. package/dist/css.js.map +1 -0
  14. package/dist/dialog.d.ts +93 -0
  15. package/dist/dialog.d.ts.map +1 -1
  16. package/dist/dialog.js +414 -86
  17. package/dist/dialog.js.map +1 -1
  18. package/dist/floating.d.ts +4 -0
  19. package/dist/floating.d.ts.map +1 -1
  20. package/dist/floating.js +50 -2
  21. package/dist/floating.js.map +1 -1
  22. package/dist/help.d.ts +1 -1
  23. package/dist/help.d.ts.map +1 -1
  24. package/dist/help.js +29 -5
  25. package/dist/help.js.map +1 -1
  26. package/dist/i18n.d.ts +8 -0
  27. package/dist/i18n.d.ts.map +1 -1
  28. package/dist/i18n.js +32 -7
  29. package/dist/i18n.js.map +1 -1
  30. package/dist/icons.d.ts +7 -1
  31. package/dist/icons.d.ts.map +1 -1
  32. package/dist/icons.js +6 -2
  33. package/dist/icons.js.map +1 -1
  34. package/dist/index.d.ts +10 -8
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +8 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/items.d.ts +0 -1
  39. package/dist/items.d.ts.map +1 -1
  40. package/dist/items.js +226 -20
  41. package/dist/items.js.map +1 -1
  42. package/dist/live.d.ts +42 -0
  43. package/dist/live.d.ts.map +1 -0
  44. package/dist/live.js +83 -0
  45. package/dist/live.js.map +1 -0
  46. package/dist/menu.d.ts +31 -2
  47. package/dist/menu.d.ts.map +1 -1
  48. package/dist/menu.js +163 -23
  49. package/dist/menu.js.map +1 -1
  50. package/dist/openleaf.css +114 -12
  51. package/dist/overflow.d.ts +31 -4
  52. package/dist/overflow.d.ts.map +1 -1
  53. package/dist/overflow.js +267 -82
  54. package/dist/overflow.js.map +1 -1
  55. package/dist/registry.d.ts +72 -18
  56. package/dist/registry.d.ts.map +1 -1
  57. package/dist/registry.js +7 -6
  58. package/dist/registry.js.map +1 -1
  59. package/dist/skins.d.ts.map +1 -1
  60. package/dist/skins.js +48 -14
  61. package/dist/skins.js.map +1 -1
  62. package/dist/styles.d.ts +61 -1
  63. package/dist/styles.d.ts.map +1 -1
  64. package/dist/styles.js +77 -637
  65. package/dist/styles.js.map +1 -1
  66. package/dist/testing.d.ts +3 -0
  67. package/dist/testing.d.ts.map +1 -0
  68. package/dist/testing.js +3 -0
  69. package/dist/testing.js.map +1 -0
  70. package/dist/toolbar.d.ts +71 -12
  71. package/dist/toolbar.d.ts.map +1 -1
  72. package/dist/toolbar.js +410 -166
  73. package/dist/toolbar.js.map +1 -1
  74. package/dist/upload.d.ts +11 -0
  75. package/dist/upload.d.ts.map +1 -1
  76. package/dist/upload.js +15 -1
  77. package/dist/upload.js.map +1 -1
  78. 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 is applied only to `<button>` elements.** The block-type
17
- * control is a native `<select>`, and when focus is on it, Left/Right have two
18
- * competing owners -- the roving handler wants to move to the next item, the
19
- * select natively wants to change its value. Intercepting those keys breaks
20
- * the select; not intercepting them breaks the toolbar contract. The
21
- * resolution is that the select owns all of its own key events and is a
22
- * genuine second tab stop rather than part of the roving scheme.
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 { activeBlockClass, activeHeadingLevel, formatParts, setBlockClass, setHeading, setParagraph, shortcutFor, toggleHeading, } from '@openleaf-editor/core';
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
- #select = null;
76
- #live;
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 buttons in DOM order; the roving tabindex walks this. */
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
- this.#live = doc.createElement('div');
104
- this.#live.className = 'ol-live';
105
- // Polite and atomic: an assertive region would interrupt the author
106
- // mid-word, and a non-atomic one can read partial updates.
107
- this.#live.setAttribute('role', 'status');
108
- this.#live.setAttribute('aria-live', 'polite');
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
- this.#overflow = new ToolbarOverflow(this.el, this.#host, this.#doc);
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
- /** The live region element, which the host mounts once. */
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.#live;
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.#select = null;
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
- // `select` is still unimplemented -- the block-type control is special-cased
220
- // by id, not by type. A declared-and-inert variant is worse than an absent
221
- // one, because the author sees a plausible button and no signal that the
222
- // control they asked for was not built.
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
- if (focusedId === BLOCK_TYPE_ID) {
265
- this.#select?.focus();
266
- return;
267
- }
268
- const control = this.#controls.get(focusedId);
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.el.focus();
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 states. Baking "pressed" into
296
- // it would double up with what the platform already announces.
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
- const shortcut = spec.shortcut ? shortcutFor(spec.shortcut) : null;
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 = shortcut ? `${label} (${shortcut})` : 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
+ }
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
- * The block-type control.
460
+ * Build a `type: 'select'` control from the item's options.
347
461
  *
348
- * A native `<select>`. A custom listbox would be several hundred lines of
349
- * ARIA that would then owe real screen reader testing to be worth anything,
350
- * and the native control is already tested by the browser vendors. It carries
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
- #buildBlockTypeSelect() {
354
- const select = this.#doc.createElement('select');
355
- select.className = 'ol-select';
356
- select.setAttribute('aria-label', t('Paragraph style'));
357
- select.dataset['olId'] = BLOCK_TYPE_ID;
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
- for (const format of this.#formats) {
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 = `format:${format.token}`;
376
- option.textContent = t(format.label);
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
- const value = select.value;
410
- if (value.startsWith('format:')) {
411
- const { element, className } = formatParts(value.slice('format:'.length));
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. Twenty cheap
475
- * predicates plus a diffed attribute write is sub-millisecond work, and
476
- * batching would trade a perceptible frame of lag between pressing Bold and
477
- * the button lighting up for a performance problem that does not exist.
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
- const enabled = readonly
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
- transitions.push(`${spec.label} ${active ? 'on' : 'off'}`);
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 trigger = control.el.querySelector('button.ol-btn');
522
- trigger?.setAttribute('aria-disabled', this.#host.hasAttribute('readonly') ? 'true' : 'false');
523
- if (!control.update)
524
- continue;
525
- guarded(id, 'update', () => {
526
- control.update?.(state);
527
- return true;
528
- });
529
- }
530
- if (this.#select) {
531
- const formatClass = activeBlockClass(state);
532
- const level = activeHeadingLevel(state);
533
- const activeElement = level === null ? 'p' : `h${level}`;
534
- // Both halves have to agree, or `p.lead` would look active on an
535
- // `<h2 class="lead">` and picking it again would appear to do nothing.
536
- const matching = this.#formats.find((format) => {
537
- const { element, className } = formatParts(format.token);
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
- else {
548
- const value = level === null ? 'p' : String(level);
549
- if (this.#select.value !== value)
550
- this.#select.value = value;
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
- // Clear then set on a timer: replacing identical text does not re-announce,
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('button.ol-btn')];
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((button, index) => {
598
- button.tabIndex = index === this.#rovingIndex ? 0 : -1;
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
- this.#rovingIndex = (this.#rovingIndex + delta + count) % count;
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
- // Arrow roving applies ONLY to buttons. The native <select> owns its own
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 >= 0)
630
- this.#rovingIndex = index;
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 toolbar that is only the block-type select has no roving buttons.
660
- // The shortcut is still documented; swallowing it with nowhere to go
661
- // would make a valid `toolbar` attribute a silent no-op.
662
- this.#select?.focus();
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() {