@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.
Files changed (82) 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 +31 -0
  7. package/dist/content-css.d.ts.map +1 -0
  8. package/dist/content-css.js +272 -0
  9. package/dist/content-css.js.map +1 -0
  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 +158 -18
  15. package/dist/dialog.d.ts.map +1 -1
  16. package/dist/dialog.js +605 -130
  17. package/dist/dialog.js.map +1 -1
  18. package/dist/floating.d.ts +29 -0
  19. package/dist/floating.d.ts.map +1 -0
  20. package/dist/floating.js +130 -0
  21. package/dist/floating.js.map +1 -0
  22. package/dist/help.d.ts +9 -0
  23. package/dist/help.d.ts.map +1 -0
  24. package/dist/help.js +97 -0
  25. package/dist/help.js.map +1 -0
  26. package/dist/i18n.d.ts +39 -0
  27. package/dist/i18n.d.ts.map +1 -0
  28. package/dist/i18n.js +114 -0
  29. package/dist/i18n.js.map +1 -0
  30. package/dist/icons.d.ts +7 -1
  31. package/dist/icons.d.ts.map +1 -1
  32. package/dist/icons.js +10 -2
  33. package/dist/icons.js.map +1 -1
  34. package/dist/index.d.ts +15 -7
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +14 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/items.d.ts +2 -1
  39. package/dist/items.d.ts.map +1 -1
  40. package/dist/items.js +263 -17
  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 +85 -0
  47. package/dist/menu.d.ts.map +1 -0
  48. package/dist/menu.js +500 -0
  49. package/dist/menu.js.map +1 -0
  50. package/dist/openleaf.css +325 -7
  51. package/dist/overflow.d.ts +44 -0
  52. package/dist/overflow.d.ts.map +1 -0
  53. package/dist/overflow.js +323 -0
  54. package/dist/overflow.js.map +1 -0
  55. package/dist/pickers.d.ts +43 -0
  56. package/dist/pickers.d.ts.map +1 -0
  57. package/dist/pickers.js +52 -0
  58. package/dist/pickers.js.map +1 -0
  59. package/dist/registry.d.ts +72 -18
  60. package/dist/registry.d.ts.map +1 -1
  61. package/dist/registry.js +7 -6
  62. package/dist/registry.js.map +1 -1
  63. package/dist/skins.d.ts.map +1 -1
  64. package/dist/skins.js +48 -14
  65. package/dist/skins.js.map +1 -1
  66. package/dist/styles.d.ts +61 -1
  67. package/dist/styles.d.ts.map +1 -1
  68. package/dist/styles.js +77 -421
  69. package/dist/styles.js.map +1 -1
  70. package/dist/testing.d.ts +3 -0
  71. package/dist/testing.d.ts.map +1 -0
  72. package/dist/testing.js +3 -0
  73. package/dist/testing.js.map +1 -0
  74. package/dist/toolbar.d.ts +91 -12
  75. package/dist/toolbar.d.ts.map +1 -1
  76. package/dist/toolbar.js +459 -127
  77. package/dist/toolbar.js.map +1 -1
  78. package/dist/upload.d.ts +11 -0
  79. package/dist/upload.d.ts.map +1 -1
  80. package/dist/upload.js +15 -1
  81. package/dist/upload.js.map +1 -1
  82. 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,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 { activeHeadingLevel, shortcutFor, toggleHeading, setParagraph } from '@openleaf-editor/core';
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
- #select = null;
74
- #live;
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
- /** Focusable buttons in DOM order; the roving tabindex walks this. */
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', options.label ?? 'Formatting');
91
- this.#live = doc.createElement('div');
92
- this.#live.className = 'ol-live';
93
- // Polite and atomic: an assertive region would interrupt the author
94
- // mid-word, and a non-atomic one can read partial updates.
95
- this.#live.setAttribute('role', 'status');
96
- this.#live.setAttribute('aria-live', 'polite');
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
- clearTimeout(this.#liveTimer);
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
- /** 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
+ */
141
284
  get liveRegion() {
142
- return this.#live;
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.#select = null;
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
- // `select` is still unimplemented -- the block-type control is special-cased
179
- // by id, not by type. A declared-and-inert variant is worse than an absent
180
- // one, because the author sees a plausible button and no signal that the
181
- // 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
+ }
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
- if (focusedId === BLOCK_TYPE_ID) {
222
- this.#select?.focus();
223
- return;
224
- }
225
- const control = this.#controls.get(focusedId);
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.el.focus();
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 states. Baking "pressed" into
253
- // it would double up with what the platform already announces.
254
- button.setAttribute('aria-label', spec.label);
255
- const shortcut = spec.shortcut ? shortcutFor(spec.shortcut) : null;
256
- button.title = shortcut ? `${spec.label} (${shortcut})` : spec.label;
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
- * The block-type control.
460
+ * Build a `type: 'select'` control from the item's options.
303
461
  *
304
- * A native `<select>`. A custom listbox would be several hundred lines of
305
- * ARIA that would then owe real screen reader testing to be worth anything,
306
- * and the native control is already tested by the browser vendors. It carries
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
- #buildBlockTypeSelect() {
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', 'Paragraph style');
313
- select.dataset['olId'] = BLOCK_TYPE_ID;
314
- const options = [
315
- ['p', 'Paragraph'],
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
- const value = select.value;
360
- const command = value === 'p' ? setParagraph : toggleHeading(Number(value));
361
- command(view.state, view.dispatch, view);
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. Twenty cheap
407
- * predicates plus a diffed attribute write is sub-millisecond work, and
408
- * batching would trade a perceptible frame of lag between pressing Bold and
409
- * 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.
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
- 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
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
- 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)));
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 trigger = control.el.querySelector('button.ol-btn');
451
- trigger?.setAttribute('aria-disabled', this.#host.hasAttribute('readonly') ? 'true' : 'false');
452
- if (!control.update)
453
- continue;
454
- guarded(id, 'update', () => {
455
- control.update?.(state);
456
- return true;
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.#select) {
460
- const level = activeHeadingLevel(state);
461
- const value = level === null ? 'p' : String(level);
462
- if (this.#select.value !== value)
463
- this.#select.value = value;
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
- // Clear then set on a timer: replacing identical text does not re-announce,
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('button.ol-btn')];
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((button, index) => {
510
- button.tabIndex = index === this.#rovingIndex ? 0 : -1;
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
- 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;
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
- // Arrow roving applies ONLY to buttons. The native <select> owns its own
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 >= 0)
542
- 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';
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 toolbar that is only the block-type select has no roving buttons.
572
- // The shortcut is still documented; swallowing it with nowhere to go
573
- // would make a valid `toolbar` attribute a silent no-op.
574
- 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();
575
907
  }
576
908
  /** Return focus and the prior selection to the editable region. */
577
909
  returnFocusToContent() {