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/README.md +103 -11
- package/dist/overtype-webcomponent.esm.js +454 -96
- package/dist/overtype-webcomponent.esm.js.map +3 -3
- package/dist/overtype-webcomponent.js +454 -96
- package/dist/overtype-webcomponent.js.map +3 -3
- package/dist/overtype-webcomponent.min.js +57 -56
- package/dist/overtype.cjs +454 -96
- package/dist/overtype.cjs.map +3 -3
- package/dist/overtype.d.ts +12 -0
- package/dist/overtype.esm.js +454 -96
- package/dist/overtype.esm.js.map +3 -3
- package/dist/overtype.js +454 -96
- package/dist/overtype.js.map +3 -3
- package/dist/overtype.min.js +53 -52
- package/package.json +3 -3
- package/src/link-tooltip.js +18 -2
- package/src/overtype.d.ts +12 -0
- package/src/overtype.js +196 -70
- package/src/shortcuts.js +12 -0
- package/src/toolbar-buttons.js +10 -0
- package/src/toolbar.js +308 -49
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.
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|