Skip to content

Commit 01f82fb

Browse files
fix(table): make table-size picker keyboard navigable (#1421)
1 parent 2526b06 commit 01f82fb

2 files changed

Lines changed: 237 additions & 97 deletions

File tree

src/plugins/table/config.ts

Lines changed: 168 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
*/
1010

1111
import type { IControlType, IDictionary, IJodit } from 'jodit/types';
12+
import {
13+
KEY_DOWN,
14+
KEY_ENTER,
15+
KEY_ESC,
16+
KEY_LEFT,
17+
KEY_RIGHT,
18+
KEY_UP
19+
} from 'jodit/core/constants';
1220
import { Dom } from 'jodit/core/dom';
1321
import { $$, css, scrollIntoViewIfNeeded } from 'jodit/core/helpers';
1422
import { attr } from 'jodit/core/helpers/utils';
@@ -102,27 +110,31 @@ Config.prototype.controls.table = {
102110

103111
const cnt = default_rows_count * default_cols_count;
104112

113+
attr(blocksContainer, {
114+
role: 'grid',
115+
ariaLabel: 'Table size',
116+
ariaRowcount: default_rows_count,
117+
ariaColcount: default_cols_count
118+
});
119+
105120
for (let i = 0; i < cnt; i += 1) {
106121
if (!cells[i]) {
122+
const row = Math.floor(i / default_cols_count) + 1,
123+
col = (i % default_cols_count) + 1;
124+
107125
cells.push(
108126
editor.c.element('span', {
109-
dataIndex: i
127+
dataIndex: i,
128+
role: 'gridcell',
129+
tabindex: i === 0 ? 0 : -1,
130+
ariaLabel: `${row} by ${col}`
110131
})
111132
);
112133
}
113134
}
114135

115-
const mouseenter = (e: MouseEvent, index?: number): void => {
116-
const dv = e.target;
117-
118-
if (!Dom.isTag(dv, 'span')) {
119-
return;
120-
}
121-
122-
const k =
123-
index === undefined || isNaN(index)
124-
? parseInt(attr(dv, '-index') || '0', 10)
125-
: index || 0;
136+
const highlightCell = (cell: HTMLElement): void => {
137+
const k = parseInt(attr(cell, '-index') || '0', 10);
126138

127139
const rows_count = Math.ceil((k + 1) / default_cols_count),
128140
cols_count = (k % default_cols_count) + 1;
@@ -142,118 +154,177 @@ Config.prototype.controls.table = {
142154
rows.textContent = rows_count.toString();
143155
};
144156

145-
editor.e
146-
.on(blocksContainer, 'mousemove', mouseenter)
147-
.on(blocksContainer, 'touchstart mousedown', (e: MouseEvent) => {
148-
const dv = e.target;
157+
const insertTable = (cell: HTMLElement): void => {
158+
const k = parseInt(attr(cell, '-index') || '0', 10);
149159

150-
e.preventDefault();
151-
e.stopImmediatePropagation();
160+
const rows_count = Math.ceil((k + 1) / default_cols_count),
161+
cols_count = (k % default_cols_count) + 1;
152162

153-
if (!Dom.isTag(dv, 'span')) {
154-
return;
155-
}
163+
const crt = editor.createInside,
164+
tbody = crt.element('tbody'),
165+
table = crt.element('table');
156166

157-
const k = parseInt(attr(dv, '-index') || '0', 10);
167+
table.appendChild(tbody);
158168

159-
const rows_count = Math.ceil((k + 1) / default_cols_count),
160-
cols_count = (k % default_cols_count) + 1;
169+
let first_td: HTMLTableCellElement | null = null,
170+
tr: HTMLTableRowElement,
171+
td: HTMLTableCellElement;
161172

162-
const crt = editor.createInside,
163-
tbody = crt.element('tbody'),
164-
table = crt.element('table');
173+
for (let i = 1; i <= rows_count; i += 1) {
174+
tr = crt.element('tr');
165175

166-
table.appendChild(tbody);
176+
for (let j = 1; j <= cols_count; j += 1) {
177+
td = crt.element('td');
167178

168-
let first_td: HTMLTableCellElement | null = null,
169-
tr: HTMLTableRowElement,
170-
td: HTMLTableCellElement;
179+
if (!first_td) {
180+
first_td = td;
181+
}
171182

172-
for (let i = 1; i <= rows_count; i += 1) {
173-
tr = crt.element('tr');
183+
css(td, 'width', (100 / cols_count).toFixed(4) + '%');
174184

175-
for (let j = 1; j <= cols_count; j += 1) {
176-
td = crt.element('td');
185+
td.appendChild(crt.element('br'));
186+
tr.appendChild(crt.text('\n'));
187+
tr.appendChild(crt.text('\t'));
188+
tr.appendChild(td);
189+
}
177190

178-
if (!first_td) {
179-
first_td = td;
180-
}
191+
tbody.appendChild(crt.text('\n'));
192+
tbody.appendChild(tr);
193+
}
181194

182-
css(td, 'width', (100 / cols_count).toFixed(4) + '%');
195+
$$('input[type=checkbox]:checked', options).forEach(
196+
(input: HTMLElement) => {
197+
(input as HTMLInputElement).value
198+
.split(/[\s]+/)
199+
.forEach((className: string) => {
200+
table.classList.add(className);
201+
});
202+
}
203+
);
204+
205+
editor.s.restore();
206+
editor.s.removeMarkers();
207+
editor.editor.normalize();
208+
editor.history.snapshot.restore(snapshot);
209+
210+
const block = Dom.furthest(
211+
editor.s.current(),
212+
Dom.isBlock,
213+
editor.editor
214+
);
215+
216+
if (block && Dom.isEmpty(block)) {
217+
Dom.replace(block, table, undefined, false, true);
218+
} else {
219+
if (block) {
220+
const fake = crt.text('\n');
221+
if (!editor.o.table.splitBlockOnInsertTable) {
222+
Dom.after(block, fake);
223+
Dom.after(fake, table);
224+
} else {
225+
const range = editor.s.range;
226+
range.collapse(false);
227+
range.insertNode(fake);
228+
range.collapse(false);
229+
editor.s.selectRange(range);
183230

184-
td.appendChild(crt.element('br'));
185-
tr.appendChild(crt.text('\n'));
186-
tr.appendChild(crt.text('\t'));
187-
tr.appendChild(td);
188-
}
231+
const firstPart = editor.s.splitSelection(block, fake);
189232

190-
tbody.appendChild(crt.text('\n'));
191-
tbody.appendChild(tr);
233+
if (firstPart) {
234+
Dom.after(firstPart, table);
235+
} else {
236+
Dom.after(block, table);
237+
}
238+
}
239+
} else {
240+
editor.s.insertNode(table, false);
192241
}
242+
}
193243

194-
$$('input[type=checkbox]:checked', options).forEach(
195-
(input: HTMLElement) => {
196-
(input as HTMLInputElement).value
197-
.split(/[\s]+/)
198-
.forEach((className: string) => {
199-
table.classList.add(className);
200-
});
201-
}
202-
);
244+
if (first_td) {
245+
editor.s.setCursorIn(first_td);
246+
scrollIntoViewIfNeeded(first_td, editor.editor, editor.ed);
247+
}
203248

204-
editor.s.restore();
205-
editor.s.removeMarkers();
206-
editor.editor.normalize();
207-
editor.history.snapshot.restore(snapshot);
249+
close();
250+
};
208251

209-
const block = Dom.furthest(
210-
editor.s.current(),
211-
Dom.isBlock,
212-
editor.editor
213-
);
252+
editor.e
253+
.on(blocksContainer, 'mousemove', (e: MouseEvent) => {
254+
if (Dom.isTag(e.target, 'span')) {
255+
highlightCell(e.target);
256+
}
257+
})
258+
.on(blocksContainer, 'touchstart mousedown', (e: MouseEvent) => {
259+
if (!Dom.isTag(e.target, 'span')) {
260+
return;
261+
}
214262

215-
if (block && Dom.isEmpty(block)) {
216-
Dom.replace(block, table, undefined, false, true);
217-
} else {
218-
if (block) {
219-
const fake = crt.text('\n');
220-
if (!editor.o.table.splitBlockOnInsertTable) {
221-
Dom.after(block, fake);
222-
Dom.after(fake, table);
223-
} else {
224-
const range = editor.s.range;
225-
range.collapse(false);
226-
range.insertNode(fake);
227-
range.collapse(false);
228-
editor.s.selectRange(range);
229-
230-
const firstPart = editor.s.splitSelection(
231-
block,
232-
fake
233-
);
234-
235-
if (firstPart) {
236-
Dom.after(firstPart, table);
237-
} else {
238-
Dom.after(block, table);
239-
}
240-
}
241-
} else {
242-
editor.s.insertNode(table, false);
243-
}
263+
e.preventDefault();
264+
e.stopImmediatePropagation();
265+
insertTable(e.target);
266+
})
267+
.on(blocksContainer, 'keydown', (e: KeyboardEvent) => {
268+
if (!Dom.isTag(e.target, 'span')) {
269+
return;
244270
}
245271

246-
if (first_td) {
247-
editor.s.setCursorIn(first_td);
248-
scrollIntoViewIfNeeded(first_td, editor.editor, editor.ed);
272+
if (e.key === KEY_ENTER) {
273+
e.preventDefault();
274+
e.stopImmediatePropagation();
275+
insertTable(e.target);
276+
return;
277+
}
278+
279+
if (e.key === KEY_ESC) {
280+
e.preventDefault();
281+
e.stopImmediatePropagation();
282+
close();
283+
return;
249284
}
250285

251-
close();
286+
const index = parseInt(attr(e.target, '-index') || '0', 10),
287+
row = Math.floor(index / default_cols_count),
288+
col = index % default_cols_count;
289+
let nextIndex = index;
290+
291+
switch (e.key) {
292+
case KEY_LEFT:
293+
nextIndex = col > 0 ? index - 1 : index;
294+
break;
295+
case KEY_RIGHT:
296+
nextIndex =
297+
col < default_cols_count - 1 ? index + 1 : index;
298+
break;
299+
case KEY_UP:
300+
nextIndex =
301+
row > 0 ? index - default_cols_count : index;
302+
break;
303+
case KEY_DOWN:
304+
nextIndex =
305+
row < default_rows_count - 1
306+
? index + default_cols_count
307+
: index;
308+
break;
309+
default:
310+
return;
311+
}
312+
313+
e.preventDefault();
314+
e.stopImmediatePropagation();
315+
316+
if (nextIndex !== index) {
317+
cells[index].tabIndex = -1;
318+
cells[nextIndex].tabIndex = 0;
319+
cells[nextIndex].focus();
320+
highlightCell(cells[nextIndex]);
321+
}
252322
});
253323

254324
if (button && button.parentElement) {
255325
for (let i = 0; i < default_rows_count; i += 1) {
256326
const row = editor.c.div();
327+
attr(row, 'role', 'row');
257328

258329
for (let j = 0; j < default_cols_count; j += 1) {
259330
row.appendChild(cells[i * default_cols_count + j]);

src/plugins/table/table.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,75 @@
55
*/
66

77
describe('Test table plugin', () => {
8+
describe('Keyboard navigation in the table-size picker', () => {
9+
it('should move through the grid with arrow keys and insert with Enter', () => {
10+
const editor = getJodit({
11+
disablePlugins: ['wrapNodes']
12+
});
13+
editor.value = '<p>test|</p>';
14+
editor.focus();
15+
setCursorToChar(editor);
16+
17+
clickButton('table', editor);
18+
const popup = getOpenedPopup(editor);
19+
const grid = popup.querySelector('.jodit-form__container');
20+
const firstCell = grid.querySelector('span[data-index="0"]');
21+
22+
expect(grid.getAttribute('role')).eq('grid');
23+
expect(firstCell.getAttribute('role')).eq('gridcell');
24+
expect(firstCell.tabIndex).eq(0);
25+
expect(grid.querySelectorAll('span[tabindex="0"]').length).eq(1);
26+
27+
firstCell.focus();
28+
simulateEvent('keydown', Jodit.KEY_RIGHT, firstCell);
29+
30+
const secondCell = grid.querySelector('span[data-index="1"]');
31+
expect(editor.ownerDocument.activeElement).eq(secondCell);
32+
expect(secondCell.tabIndex).eq(0);
33+
expect(firstCell.tabIndex).eq(-1);
34+
35+
simulateEvent('keydown', Jodit.KEY_DOWN, secondCell);
36+
37+
const selectedCell = grid.querySelector('span[data-index="11"]');
38+
expect(editor.ownerDocument.activeElement).eq(selectedCell);
39+
expect(popup.querySelector('.jodit-form__center').textContent).eq(
40+
'2 × 2'
41+
);
42+
43+
simulateEvent('keydown', Jodit.KEY_LEFT, selectedCell);
44+
const previousCell = grid.querySelector('span[data-index="10"]');
45+
expect(editor.ownerDocument.activeElement).eq(previousCell);
46+
47+
simulateEvent('keydown', Jodit.KEY_UP, previousCell);
48+
expect(editor.ownerDocument.activeElement).eq(firstCell);
49+
50+
simulateEvent('keydown', Jodit.KEY_LEFT, firstCell);
51+
simulateEvent('keydown', Jodit.KEY_UP, firstCell);
52+
expect(editor.ownerDocument.activeElement).eq(firstCell);
53+
54+
simulateEvent('keydown', Jodit.KEY_RIGHT, firstCell);
55+
simulateEvent('keydown', Jodit.KEY_DOWN, secondCell);
56+
57+
simulateEvent('keydown', Jodit.KEY_ENTER, selectedCell);
58+
59+
expect(editor.editor.querySelectorAll('table td').length).eq(4);
60+
expect(getOpenedPopup(editor)).is.null;
61+
});
62+
63+
it('should close the picker with Escape', () => {
64+
const editor = getJodit();
65+
66+
clickButton('table', editor);
67+
const popup = getOpenedPopup(editor);
68+
const firstCell = popup.querySelector('span[data-index="0"]');
69+
70+
firstCell.focus();
71+
simulateEvent('keydown', Jodit.KEY_ESC, firstCell);
72+
73+
expect(getOpenedPopup(editor)).is.null;
74+
});
75+
});
76+
877
describe('Click button and click to some cell', () => {
978
it('should create and insert new table', () => {
1079
const editor = getJodit({

0 commit comments

Comments
 (0)