overtype 2.3.10 → 2.4.0

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/src/toolbar.js CHANGED
@@ -10,6 +10,10 @@ export class Toolbar {
10
10
  this.editor = editor;
11
11
  this.container = null;
12
12
  this.buttons = {};
13
+ this.currentItemIndex = 0;
14
+ this.handleDocumentClick = null;
15
+ this.activeDropdown = null;
16
+ this.activeDropdownButton = null;
13
17
 
14
18
  // Get toolbar buttons array
15
19
  this.toolbarButtons = options.toolbarButtons || [];
@@ -21,8 +25,10 @@ export class Toolbar {
21
25
  create() {
22
26
  this.container = document.createElement('div');
23
27
  this.container.className = 'overtype-toolbar';
28
+ this.container.id = this.getInstanceElementId('toolbar');
24
29
  this.container.setAttribute('role', 'toolbar');
25
30
  this.container.setAttribute('aria-label', 'Formatting toolbar');
31
+ this.container.setAttribute('aria-controls', this.editor.textarea.id);
26
32
 
27
33
  // Create buttons from toolbarButtons array
28
34
  this.toolbarButtons.forEach(buttonConfig => {
@@ -36,10 +42,142 @@ export class Toolbar {
36
42
  }
37
43
  });
38
44
 
45
+ this.setupRovingTabIndex();
46
+ this.updateButtonStates();
47
+
39
48
  // Insert toolbar before the wrapper (as sibling, not child)
40
49
  this.editor.container.insertBefore(this.container, this.editor.wrapper);
41
50
  }
42
51
 
52
+ /**
53
+ * Build a stable id from the owning OverType instance
54
+ */
55
+ getInstanceElementId(name) {
56
+ return `overtype-${this.editor.instanceId}-${name}`;
57
+ }
58
+
59
+ /**
60
+ * Configure toolbar focus management per the ARIA toolbar pattern
61
+ */
62
+ setupRovingTabIndex() {
63
+ const toolbarItems = this.getToolbarItems();
64
+
65
+ if (toolbarItems.length === 0) {
66
+ return;
67
+ }
68
+
69
+ this.currentItemIndex = this.getValidItemIndex(this.currentItemIndex);
70
+ this.updateTabIndexes();
71
+
72
+ this.container.addEventListener('keydown', (e) => {
73
+ this.onToolbarKeydown(e);
74
+ });
75
+
76
+ this.container.addEventListener('focusin', (e) => {
77
+ this.onToolbarFocusin(e);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Get toolbar buttons in DOM order for keyboard navigation
83
+ */
84
+ getToolbarItems() {
85
+ if (!this.container) {
86
+ return [];
87
+ }
88
+
89
+ return Array.from(this.container.querySelectorAll('.overtype-toolbar-button'));
90
+ }
91
+
92
+ /**
93
+ * Handle keyboard navigation within the toolbar
94
+ */
95
+ onToolbarKeydown(e) {
96
+ const toolbarItems = this.getToolbarItems();
97
+
98
+ if (!toolbarItems.includes(e.target)) {
99
+ return;
100
+ }
101
+
102
+ switch (e.key) {
103
+ case 'ArrowRight':
104
+ e.preventDefault();
105
+ this.focusItem(this.currentItemIndex + 1);
106
+ break;
107
+ case 'ArrowLeft':
108
+ e.preventDefault();
109
+ this.focusItem(this.currentItemIndex - 1);
110
+ break;
111
+ case 'Home':
112
+ e.preventDefault();
113
+ this.focusItem(0);
114
+ break;
115
+ case 'End':
116
+ e.preventDefault();
117
+ this.focusItem(toolbarItems.length - 1);
118
+ break;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Remember the focused toolbar item as the toolbar tab stop
124
+ */
125
+ onToolbarFocusin(e) {
126
+ const focusedItemIndex = this.getToolbarItems().indexOf(e.target);
127
+
128
+ if (focusedItemIndex === -1) {
129
+ return;
130
+ }
131
+
132
+ this.currentItemIndex = focusedItemIndex;
133
+ this.updateTabIndexes();
134
+ }
135
+
136
+ /**
137
+ * Move focus to a toolbar item and make it the only tab stop
138
+ */
139
+ focusItem(index) {
140
+ const toolbarItems = this.getToolbarItems();
141
+
142
+ if (toolbarItems.length === 0) {
143
+ return;
144
+ }
145
+
146
+ this.currentItemIndex = this.getValidItemIndex(index, toolbarItems);
147
+ this.updateTabIndexes();
148
+ toolbarItems[this.currentItemIndex].focus();
149
+ }
150
+
151
+ /**
152
+ * Normalize toolbar item indexes with wrapping
153
+ */
154
+ getValidItemIndex(index, toolbarItems = this.getToolbarItems()) {
155
+ const itemCount = toolbarItems.length;
156
+
157
+ if (itemCount === 0) {
158
+ return 0;
159
+ }
160
+
161
+ if (index < 0) {
162
+ return itemCount - 1;
163
+ }
164
+
165
+ if (index >= itemCount) {
166
+ return 0;
167
+ }
168
+
169
+ return index;
170
+ }
171
+
172
+ /**
173
+ * Keep exactly one toolbar item in the page tab sequence
174
+ */
175
+ updateTabIndexes() {
176
+ this.getToolbarItems().forEach((item, index) => {
177
+ item.tabIndex = index === this.currentItemIndex ? 0 : -1;
178
+ });
179
+ }
180
+
43
181
  /**
44
182
  * Create a toolbar separator
45
183
  */
@@ -66,10 +204,27 @@ export class Toolbar {
66
204
  if (buttonConfig.name === 'viewMode') {
67
205
  button.classList.add('has-dropdown');
68
206
  button.dataset.dropdown = 'true';
69
- button.addEventListener('click', (e) => {
207
+ button.setAttribute('aria-haspopup', 'menu');
208
+ button.setAttribute('aria-expanded', 'false');
209
+
210
+ button._clickHandler = (e) => {
70
211
  e.preventDefault();
71
212
  this.toggleViewModeDropdown(button);
72
- });
213
+ };
214
+
215
+ button._keydownHandler = (e) => {
216
+ if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
217
+ return;
218
+ }
219
+
220
+ e.preventDefault();
221
+ const placement = e.key === 'ArrowUp' ? 'last' : 'current';
222
+ this.openViewModeDropdown(button, placement);
223
+ };
224
+
225
+ button.addEventListener('click', button._clickHandler);
226
+ button.addEventListener('keydown', button._keydownHandler);
227
+
73
228
  return button;
74
229
  }
75
230
 
@@ -139,14 +294,20 @@ export class Toolbar {
139
294
  * Not exposed to users - viewMode button behavior is fixed
140
295
  */
141
296
  toggleViewModeDropdown(button) {
142
- // Close any existing dropdown
143
- const existingDropdown = document.querySelector('.overtype-dropdown-menu');
144
- if (existingDropdown) {
145
- existingDropdown.remove();
146
- button.classList.remove('dropdown-active');
297
+ if (this.activeDropdown) {
298
+ this.closeViewModeDropdown(button);
147
299
  return;
148
300
  }
149
301
 
302
+ this.openViewModeDropdown(button);
303
+ }
304
+
305
+ /**
306
+ * Open the view mode dropdown
307
+ */
308
+ openViewModeDropdown(button, focusPlacement = null) {
309
+ this.closeViewModeDropdown(button);
310
+
150
311
  button.classList.add('dropdown-active');
151
312
 
152
313
  const dropdown = this.createViewModeDropdown(button);
@@ -158,19 +319,51 @@ export class Toolbar {
158
319
  dropdown.style.left = `${rect.left}px`;
159
320
 
160
321
  document.body.appendChild(dropdown);
322
+ this.activeDropdown = dropdown;
323
+ this.activeDropdownButton = button;
324
+ button.setAttribute('aria-controls', dropdown.id);
325
+ button.setAttribute('aria-expanded', 'true');
161
326
 
162
327
  // Click outside to close
163
328
  this.handleDocumentClick = (e) => {
164
329
  if (!dropdown.contains(e.target) && !button.contains(e.target)) {
165
- dropdown.remove();
166
- button.classList.remove('dropdown-active');
167
- document.removeEventListener('click', this.handleDocumentClick);
330
+ this.closeViewModeDropdown(button);
168
331
  }
169
332
  };
170
333
 
171
334
  setTimeout(() => {
172
335
  document.addEventListener('click', this.handleDocumentClick);
173
336
  }, 0);
337
+
338
+ if (focusPlacement) {
339
+ this.focusViewModeMenuItem(dropdown, focusPlacement);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Close the view mode dropdown
345
+ */
346
+ closeViewModeDropdown(button = this.activeDropdownButton, returnFocus = false) {
347
+ if (this.activeDropdown) {
348
+ this.activeDropdown.remove();
349
+ this.activeDropdown = null;
350
+ }
351
+
352
+ if (button) {
353
+ button.classList.remove('dropdown-active');
354
+ button.setAttribute('aria-expanded', 'false');
355
+ }
356
+
357
+ if (this.handleDocumentClick) {
358
+ document.removeEventListener('click', this.handleDocumentClick);
359
+ this.handleDocumentClick = null;
360
+ }
361
+
362
+ this.activeDropdownButton = null;
363
+
364
+ if (returnFocus && button) {
365
+ button.focus();
366
+ }
174
367
  }
175
368
 
176
369
  /**
@@ -179,6 +372,13 @@ export class Toolbar {
179
372
  createViewModeDropdown(button) {
180
373
  const dropdown = document.createElement('div');
181
374
  dropdown.className = 'overtype-dropdown-menu';
375
+ dropdown.id = this.getInstanceElementId('toolbar-view-mode-menu');
376
+ dropdown.setAttribute('role', 'menu');
377
+ dropdown.setAttribute('aria-label', 'View mode');
378
+
379
+ dropdown.addEventListener('keydown', (e) => {
380
+ this.onViewModeMenuKeydown(e, button);
381
+ });
182
382
 
183
383
  const items = [
184
384
  { id: 'normal', label: 'Normal Edit', icon: '✓' },
@@ -192,13 +392,16 @@ export class Toolbar {
192
392
  const menuItem = document.createElement('button');
193
393
  menuItem.className = 'overtype-dropdown-item';
194
394
  menuItem.type = 'button';
395
+ menuItem.tabIndex = -1;
396
+ menuItem.setAttribute('role', 'menuitemradio');
397
+ menuItem.setAttribute('aria-checked', String(item.id === currentMode));
195
398
  menuItem.textContent = item.label;
196
399
 
197
400
  if (item.id === currentMode) {
198
401
  menuItem.classList.add('active');
199
- menuItem.setAttribute('aria-current', 'true');
200
402
  const checkmark = document.createElement('span');
201
403
  checkmark.className = 'overtype-dropdown-icon';
404
+ checkmark.setAttribute('aria-hidden', 'true');
202
405
  checkmark.textContent = item.icon;
203
406
  menuItem.prepend(checkmark);
204
407
  }
@@ -220,9 +423,7 @@ export class Toolbar {
220
423
  break;
221
424
  }
222
425
 
223
- dropdown.remove();
224
- button.classList.remove('dropdown-active');
225
- document.removeEventListener('click', this.handleDocumentClick);
426
+ this.closeViewModeDropdown(button, true);
226
427
  });
227
428
 
228
429
  dropdown.appendChild(menuItem);
@@ -231,6 +432,83 @@ export class Toolbar {
231
432
  return dropdown;
232
433
  }
233
434
 
435
+ /**
436
+ * Handle keyboard navigation inside the view mode menu
437
+ */
438
+ onViewModeMenuKeydown(e, button) {
439
+ const menuItems = this.getViewModeMenuItems();
440
+ const currentIndex = menuItems.indexOf(e.target);
441
+
442
+ if (currentIndex === -1) {
443
+ return;
444
+ }
445
+
446
+ switch (e.key) {
447
+ case 'ArrowDown':
448
+ e.preventDefault();
449
+ this.focusViewModeMenuItem(this.activeDropdown, currentIndex + 1);
450
+ break;
451
+ case 'ArrowUp':
452
+ e.preventDefault();
453
+ this.focusViewModeMenuItem(this.activeDropdown, currentIndex - 1);
454
+ break;
455
+ case 'Home':
456
+ e.preventDefault();
457
+ this.focusViewModeMenuItem(this.activeDropdown, 'first');
458
+ break;
459
+ case 'End':
460
+ e.preventDefault();
461
+ this.focusViewModeMenuItem(this.activeDropdown, 'last');
462
+ break;
463
+ case 'Escape':
464
+ e.preventDefault();
465
+ this.closeViewModeDropdown(button, true);
466
+ break;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Focus a view mode menu item by index or placement
472
+ */
473
+ focusViewModeMenuItem(dropdown, placement) {
474
+ const menuItems = this.getViewModeMenuItems(dropdown);
475
+
476
+ if (menuItems.length === 0) {
477
+ return;
478
+ }
479
+
480
+ let index = placement;
481
+
482
+ if (placement === 'first') {
483
+ index = 0;
484
+ } else if (placement === 'last') {
485
+ index = menuItems.length - 1;
486
+ } else if (placement === 'current') {
487
+ index = menuItems.findIndex(item => item.getAttribute('aria-checked') === 'true');
488
+ }
489
+
490
+ if (index < 0) {
491
+ index = menuItems.length - 1;
492
+ }
493
+
494
+ if (index >= menuItems.length) {
495
+ index = 0;
496
+ }
497
+
498
+ menuItems[index].focus();
499
+ }
500
+
501
+ /**
502
+ * Get the current view mode menu items
503
+ */
504
+ getViewModeMenuItems(dropdown = this.activeDropdown) {
505
+ if (!dropdown) {
506
+ return [];
507
+ }
508
+
509
+ return Array.from(dropdown.querySelectorAll('[role="menuitemradio"]'));
510
+ }
511
+
234
512
  /**
235
513
  * Update active states of toolbar buttons
236
514
  */
@@ -243,42 +521,17 @@ export class Toolbar {
243
521
 
244
522
  Object.entries(this.buttons).forEach(([name, button]) => {
245
523
  if (name === 'viewMode') return; // Skip dropdown button
524
+ const buttonConfig = this.toolbarButtons.find(buttonConfig => buttonConfig.name === name);
246
525
 
247
- let isActive = false;
248
-
249
- switch(name) {
250
- case 'bold':
251
- isActive = activeFormats.includes('bold');
252
- break;
253
- case 'italic':
254
- isActive = activeFormats.includes('italic');
255
- break;
256
- case 'code':
257
- isActive = false; // Disabled: unreliable in code blocks
258
- break;
259
- case 'bulletList':
260
- isActive = activeFormats.includes('bullet-list');
261
- break;
262
- case 'orderedList':
263
- isActive = activeFormats.includes('numbered-list');
264
- break;
265
- case 'taskList':
266
- isActive = activeFormats.includes('task-list');
267
- break;
268
- case 'quote':
269
- isActive = activeFormats.includes('quote');
270
- break;
271
- case 'h1':
272
- isActive = activeFormats.includes('header');
273
- break;
274
- case 'h2':
275
- isActive = activeFormats.includes('header-2');
276
- break;
277
- case 'h3':
278
- isActive = activeFormats.includes('header-3');
279
- break;
526
+ if (!buttonConfig?.isActive) {
527
+ return;
280
528
  }
281
529
 
530
+ const isActive = Boolean(buttonConfig.isActive({
531
+ editor: this.editor,
532
+ activeFormats
533
+ }));
534
+
282
535
  button.classList.toggle('active', isActive);
283
536
  button.setAttribute('aria-pressed', isActive.toString());
284
537
  });
@@ -305,16 +558,22 @@ export class Toolbar {
305
558
  destroy() {
306
559
  if (this.container) {
307
560
  // Clean up event listeners
308
- if (this.handleDocumentClick) {
561
+ if (this.activeDropdown) {
562
+ this.closeViewModeDropdown();
563
+ } else if (this.handleDocumentClick) {
309
564
  document.removeEventListener('click', this.handleDocumentClick);
565
+ this.handleDocumentClick = null;
310
566
  }
311
-
312
567
  // Clean up button listeners
313
568
  Object.values(this.buttons).forEach(button => {
314
569
  if (button._clickHandler) {
315
570
  button.removeEventListener('click', button._clickHandler);
316
571
  delete button._clickHandler;
317
572
  }
573
+ if (button._keydownHandler) {
574
+ button.removeEventListener('keydown', button._keydownHandler);
575
+ delete button._keydownHandler;
576
+ }
318
577
  });
319
578
 
320
579
  // Remove container