Skip to content

Commit 94ea34e

Browse files
committed
Implement roving tabindex for toolbar and picker components
1 parent a836841 commit 94ea34e

File tree

2 files changed

+115
-10
lines changed

2 files changed

+115
-10
lines changed

packages/quill/src/modules/toolbar.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class Toolbar extends Module<ToolbarProps> {
2727
controls: [string, HTMLElement][];
2828
handlers: Record<string, Handler>;
2929

30+
hasRovingTabindex: boolean = false;
31+
3032
constructor(quill: Quill, options: Partial<ToolbarProps>) {
3133
super(quill, options);
3234
if (Array.isArray(this.options.container)) {
@@ -45,6 +47,10 @@ class Toolbar extends Module<ToolbarProps> {
4547
return;
4648
}
4749
this.container.classList.add('ql-toolbar');
50+
51+
// Check if the parent element has the custom "roving-tabindex" class in order to enable or disable roving tabindex
52+
this.hasRovingTabindex = this.container.closest('.roving-tabindex') !== null;
53+
4854
this.controls = [];
4955
this.handlers = {};
5056
if (this.options.handlers) {
@@ -133,10 +139,54 @@ class Toolbar extends Module<ToolbarProps> {
133139
}
134140
this.update(range);
135141
});
136-
input.tabIndex = 123;
142+
143+
if (this.hasRovingTabindex && input.tagName === 'BUTTON') {
144+
input.addEventListener('keydown', (e) => {
145+
this.handleKeyboardEvent(e);
146+
});
147+
}
148+
137149
this.controls.push([format, input]);
138150
}
139151

152+
handleKeyboardEvent(e: KeyboardEvent) {
153+
var target = e.currentTarget;
154+
if (!target) return;
155+
156+
switch (e.key) {
157+
case 'ArrowLeft':
158+
case 'ArrowRight':
159+
this.updateTabIndexes(target, e.key);
160+
break;
161+
}
162+
}
163+
164+
updateTabIndexes(target: EventTarget, key: string) {
165+
const currentIndex = this.controls.findIndex(control => control[1] === target);
166+
const currentItem = this.controls[currentIndex][1];
167+
currentItem.tabIndex = -1;
168+
169+
let nextIndex;
170+
if (key === 'ArrowLeft') {
171+
nextIndex = currentIndex === 0 ? this.controls.length - 1 : currentIndex - 1;
172+
} else if (key === 'ArrowRight') {
173+
nextIndex = currentIndex === this.controls.length - 1 ? 0 : currentIndex + 1;
174+
}
175+
176+
if (nextIndex === undefined) return;
177+
const nextItem = this.controls[nextIndex][1];
178+
if (nextItem.tagName === 'SELECT') {
179+
const qlPickerLabel = nextItem.previousElementSibling?.querySelectorAll('.ql-picker-label')[0];
180+
if (qlPickerLabel && qlPickerLabel.tagName === 'SPAN') {
181+
(qlPickerLabel as HTMLElement).tabIndex = 0;
182+
(qlPickerLabel as HTMLElement).focus();
183+
}
184+
} else {
185+
nextItem.tabIndex = 0;
186+
nextItem.focus();
187+
}
188+
}
189+
140190
update(range: Range | null) {
141191
const formats = range == null ? {} : this.quill.getFormat(range);
142192
this.controls.forEach((pair) => {

packages/quill/src/ui/picker.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class Picker {
1414
container: HTMLElement;
1515
label: HTMLElement;
1616

17+
hasRovingTabindex: boolean = false;
18+
1719
constructor(select: HTMLSelectElement) {
1820
this.select = select;
1921
this.container = document.createElement('span');
@@ -22,6 +24,11 @@ class Picker {
2224
// @ts-expect-error Fix me later
2325
this.select.parentNode.insertBefore(this.container, this.select);
2426

27+
// Set tabIndex for the first item in the toolbar
28+
this.hasRovingTabindex = this.container.closest('.roving-tabindex') !== null;
29+
this.setTabIndexes();
30+
31+
2532
this.label.addEventListener('mousedown', () => {
2633
this.togglePicker();
2734
});
@@ -34,9 +41,6 @@ class Picker {
3441
this.escape();
3542
event.preventDefault();
3643
break;
37-
case 'ArrowLeft':
38-
case 'ArrowRight':
39-
this.toggleTabIndex();
4044
default:
4145
}
4246
});
@@ -51,10 +55,6 @@ class Picker {
5155
toggleAriaAttribute(this.options, 'aria-hidden');
5256
}
5357

54-
toggleTabIndex() {
55-
this.label.tabIndex = this.label.tabIndex === 0 ? -1 : 0;
56-
}
57-
5858
buildItem(option: HTMLOptionElement) {
5959
const item = document.createElement('span');
6060
// @ts-expect-error
@@ -93,16 +93,71 @@ class Picker {
9393
label.classList.add('ql-picker-label');
9494
label.innerHTML = DropdownIcon;
9595

96-
// TODO: @cofi set all tabindex to -1 initially and then per JS set first one to 0. Then per keyboard right/left navigation set the next/prev to 0 and the rest to -1
96+
// Set tabIndex to -1 by default to prevent focus
9797
// @ts-expect-error
9898
label.tabIndex = '-1';
99+
label.addEventListener('keydown', (event) => {
100+
this.handleKeyboardEvent(event);
101+
});
102+
99103

100104
label.setAttribute('role', 'button');
101105
label.setAttribute('aria-expanded', 'false');
102106
this.container.appendChild(label);
103107
return label;
104108
}
105109

110+
setTabIndexes() {
111+
const toolbar = this.select.closest('.ql-toolbar');
112+
if (!toolbar) return;
113+
const items = Array.from(toolbar.querySelectorAll('.ql-picker .ql-picker-label, .ql-toolbar button'));
114+
115+
if (this.hasRovingTabindex) {
116+
if (items[0] === this.label) {
117+
items[0].setAttribute('tabindex', '0')
118+
}
119+
120+
} else {
121+
items.forEach((item) => {
122+
item.setAttribute('tabindex', '0');
123+
});
124+
}
125+
}
126+
127+
handleKeyboardEvent(e: KeyboardEvent) {
128+
if (!this.hasRovingTabindex) return;
129+
var target = e.currentTarget;
130+
if (!target) return;
131+
132+
switch (e.key) {
133+
case 'ArrowLeft':
134+
case 'ArrowRight':
135+
this.updateTabIndexes(target, e.key);
136+
break;
137+
}
138+
}
139+
140+
updateTabIndexes(target: EventTarget, key: string) {
141+
this.label.setAttribute('tabindex', '-1');
142+
143+
const toolbar = this.container.closest('.ql-toolbar');
144+
if (!toolbar) return;
145+
const items = Array.from(toolbar.querySelectorAll('.ql-picker .ql-picker-label, .ql-toolbar button'));
146+
const currentIndex = items.indexOf(target as HTMLElement);
147+
let newIndex;
148+
149+
if (key === 'ArrowLeft') {
150+
newIndex = (currentIndex - 1 + items.length) % items.length;
151+
} else if (key === 'ArrowRight') {
152+
newIndex = (currentIndex + 1) % items.length;
153+
}
154+
155+
if (!newIndex) return;
156+
157+
items[newIndex].setAttribute('tabindex', '0');
158+
(items[newIndex] as HTMLElement).focus();
159+
}
160+
106161
buildOptions() {
107162
const options = document.createElement('span');
108163
options.classList.add('ql-picker-options');
@@ -190,7 +245,7 @@ class Picker {
190245
const item =
191246
// @ts-expect-error Fix me later
192247
this.container.querySelector('.ql-picker-options').children[
193-
this.select.selectedIndex
248+
this.select.selectedIndex
194249
];
195250
option = this.select.options[this.select.selectedIndex];
196251
// @ts-expect-error

0 commit comments

Comments
 (0)