PluginProbe
Extendify / 3.1.1
Extendify v3.1.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / unit / QuickEdit / lib / keyboard-entry.test.js

keyboard-entry.test.js in Extendify 3.1.1, at tests/unit/QuickEdit/lib/keyboard-entry.test.js

426 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // keyboard-entry decorates tagged elements with tabindex/role/aria-label
2 // on attach, drives the hover bar via focusin/focusout, and activates the
3 // editor on Enter/Space. Tests mock hover-bar so we can assert exactly
4 // which calls each event drives, and restore document.addEventListener
5 // across resetModules to prevent listener stacking.
6
7 jest.mock('@quick-edit/lib/hover-bar', () => ({
8 hideBar: jest.fn(),
9 showBar: jest.fn(),
10 editTarget: jest.fn(),
11 askAiTarget: jest.fn(),
12 pillContextFor: jest.fn(() => ({ quickEditable: true, aiAvailable: false })),
13 }));
14
15 const trackedDocListeners = [];
16 const originalDocAddEventListener = document.addEventListener.bind(document);
17 document.addEventListener = (type, handler, opts) => {
18 trackedDocListeners.push([type, handler, opts]);
19 return originalDocAddEventListener(type, handler, opts);
20 };
21
22 const loadModule = async () => {
23 const mod = await import('@quick-edit/lib/keyboard-entry');
24 return mod;
25 };
26
27 beforeEach(() => {
28 jest.resetModules();
29 jest.clearAllMocks();
30 document.body.innerHTML = '';
31 delete window.extQuickEditData;
32 });
33
34 afterEach(async () => {
35 const { detachKeyboardEntry } = await import(
36 '@quick-edit/lib/keyboard-entry'
37 );
38 detachKeyboardEntry();
39 for (const [type, handler, opts] of trackedDocListeners) {
40 document.removeEventListener(type, handler, opts);
41 }
42 trackedDocListeners.length = 0;
43 });
44
45 describe('attachKeyboardEntry — decorate', () => {
46 it('adds tabindex=0 and role=button to a tagged <div>', async () => {
47 const el = document.createElement('div');
48 el.setAttribute('data-extendify-agent-block-id', '1');
49 document.body.appendChild(el);
50
51 const { attachKeyboardEntry } = await loadModule();
52 attachKeyboardEntry({ getSession: () => null });
53
54 expect(el.getAttribute('tabindex')).toBe('0');
55 expect(el.getAttribute('role')).toBe('button');
56 expect(el.getAttribute('aria-label')).toBeTruthy();
57 });
58
59 it('omits role=button on heading / link / landmark tags', async () => {
60 for (const tag of ['h1', 'h2', 'a', 'nav', 'header', 'main', 'footer']) {
61 document.body.innerHTML = '';
62 jest.resetModules();
63 const el = document.createElement(tag);
64 el.setAttribute('data-extendify-agent-block-id', '1');
65 document.body.appendChild(el);
66
67 const { attachKeyboardEntry } = await import(
68 '@quick-edit/lib/keyboard-entry'
69 );
70 attachKeyboardEntry({ getSession: () => null });
71 expect(el.getAttribute('role')).toBeNull();
72 }
73 });
74
75 it('truncates aria-label text past 60 chars and adds Replace image for image/cover targets', async () => {
76 const long = 'a'.repeat(120);
77 const text = document.createElement('p');
78 text.setAttribute('data-extendify-agent-block-id', '1');
79 text.textContent = long;
80 document.body.appendChild(text);
81
82 const image = document.createElement('figure');
83 image.setAttribute('data-extendify-agent-block-id', '2');
84 image.classList.add('wp-block-image');
85 image.textContent = 'Photo';
86 document.body.appendChild(image);
87
88 const { attachKeyboardEntry } = await loadModule();
89 attachKeyboardEntry({ getSession: () => null });
90
91 const textLabel = text.getAttribute('aria-label');
92 expect(textLabel.startsWith('Edit "')).toBe(true);
93 expect(textLabel.includes('')).toBe(true);
94 expect(image.getAttribute('aria-label')).toBe('Replace image "Photo"');
95 });
96
97 it('is idempotent — decorating twice does not re-snapshot the previous values', async () => {
98 const el = document.createElement('p');
99 el.setAttribute('data-extendify-agent-block-id', '1');
100 el.setAttribute('tabindex', '5');
101 document.body.appendChild(el);
102
103 const { attachKeyboardEntry } = await loadModule();
104 attachKeyboardEntry({ getSession: () => null });
105 expect(el.getAttribute('data-extendify-quick-edit-kb-prev-tab')).toBe('5');
106
107 // Manually re-call decorate semantics by detach + attach.
108 const { detachKeyboardEntry } = await import(
109 '@quick-edit/lib/keyboard-entry'
110 );
111 detachKeyboardEntry();
112 expect(el.getAttribute('tabindex')).toBe('5');
113
114 attachKeyboardEntry({ getSession: () => null });
115 expect(el.getAttribute('data-extendify-quick-edit-kb-prev-tab')).toBe('5');
116 });
117
118 it('does nothing on a second attach call (attached flag)', async () => {
119 const el = document.createElement('div');
120 el.setAttribute('data-extendify-agent-block-id', '1');
121 document.body.appendChild(el);
122
123 const { attachKeyboardEntry } = await loadModule();
124 attachKeyboardEntry({ getSession: () => null });
125 const firstTab = el.getAttribute('tabindex');
126
127 el.setAttribute('tabindex', '99');
128 attachKeyboardEntry({ getSession: () => null });
129 // Second call returns early — el's manually-changed tabindex stays.
130 expect(el.getAttribute('tabindex')).toBe('99');
131 expect(firstTab).toBe('0');
132 });
133 });
134
135 describe('attachKeyboardEntry — focusin → hover bar', () => {
136 it('hides the prior bar and shows the new one for a focused tagged element', async () => {
137 const el = document.createElement('div');
138 el.setAttribute('data-extendify-agent-block-id', '1');
139 document.body.appendChild(el);
140
141 const { attachKeyboardEntry } = await loadModule();
142 const { hideBar, showBar } = require('@quick-edit/lib/hover-bar');
143 attachKeyboardEntry({ getSession: () => null });
144 hideBar.mockClear();
145 showBar.mockClear();
146
147 el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
148 expect(hideBar).toHaveBeenCalled();
149 expect(showBar).toHaveBeenCalledWith(el);
150 });
151
152 it('skips the bar when getSession returns truthy (active inline editor)', async () => {
153 const el = document.createElement('div');
154 el.setAttribute('data-extendify-agent-block-id', '1');
155 document.body.appendChild(el);
156
157 const { attachKeyboardEntry } = await loadModule();
158 const { showBar } = require('@quick-edit/lib/hover-bar');
159 attachKeyboardEntry({ getSession: () => ({ id: 'session-1' }) });
160
161 el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
162 expect(showBar).not.toHaveBeenCalled();
163 });
164 });
165
166 describe('attachKeyboardEntry — Enter/Space activation', () => {
167 it('fires editTarget when Enter is pressed on a tagged paragraph', async () => {
168 const el = document.createElement('p');
169 el.setAttribute('data-extendify-agent-block-id', '1');
170 document.body.appendChild(el);
171
172 const { attachKeyboardEntry } = await loadModule();
173 const { editTarget, showBar } = require('@quick-edit/lib/hover-bar');
174 attachKeyboardEntry({ getSession: () => null });
175 editTarget.mockClear();
176 showBar.mockClear();
177
178 const event = new KeyboardEvent('keydown', {
179 key: 'Enter',
180 bubbles: true,
181 cancelable: true,
182 });
183 Object.defineProperty(event, 'target', { value: el });
184 const preventSpy = jest.spyOn(event, 'preventDefault');
185 document.dispatchEvent(event);
186
187 expect(preventSpy).toHaveBeenCalled();
188 expect(editTarget).toHaveBeenCalled();
189 expect(showBar).toHaveBeenCalledWith(el);
190 });
191
192 it('fires editTarget on Space too', async () => {
193 const el = document.createElement('p');
194 el.setAttribute('data-extendify-agent-block-id', '1');
195 document.body.appendChild(el);
196
197 const { attachKeyboardEntry } = await loadModule();
198 const { editTarget } = require('@quick-edit/lib/hover-bar');
199 attachKeyboardEntry({ getSession: () => null });
200 editTarget.mockClear();
201
202 const event = new KeyboardEvent('keydown', {
203 key: ' ',
204 bubbles: true,
205 cancelable: true,
206 });
207 Object.defineProperty(event, 'target', { value: el });
208 document.dispatchEvent(event);
209
210 expect(editTarget).toHaveBeenCalled();
211 });
212
213 it('ignores other keys (Tab, ArrowDown, etc.)', async () => {
214 const el = document.createElement('p');
215 el.setAttribute('data-extendify-agent-block-id', '1');
216 document.body.appendChild(el);
217
218 const { attachKeyboardEntry } = await loadModule();
219 const { editTarget } = require('@quick-edit/lib/hover-bar');
220 attachKeyboardEntry({ getSession: () => null });
221 editTarget.mockClear();
222
223 for (const key of ['Tab', 'ArrowDown', 'a']) {
224 const event = new KeyboardEvent('keydown', {
225 key,
226 bubbles: true,
227 cancelable: true,
228 });
229 Object.defineProperty(event, 'target', { value: el });
230 document.dispatchEvent(event);
231 }
232 expect(editTarget).not.toHaveBeenCalled();
233 });
234
235 it('does not activate when the keystroke target is a child of the tagged element', async () => {
236 const el = document.createElement('li');
237 el.setAttribute('data-extendify-agent-block-id', '1');
238 const child = document.createElement('a');
239 child.href = '#';
240 el.appendChild(child);
241 document.body.appendChild(el);
242
243 const { attachKeyboardEntry } = await loadModule();
244 const { editTarget } = require('@quick-edit/lib/hover-bar');
245 attachKeyboardEntry({ getSession: () => null });
246 editTarget.mockClear();
247
248 const event = new KeyboardEvent('keydown', {
249 key: 'Enter',
250 bubbles: true,
251 cancelable: true,
252 });
253 Object.defineProperty(event, 'target', { value: child });
254 document.dispatchEvent(event);
255
256 expect(editTarget).not.toHaveBeenCalled();
257 });
258 });
259
260 describe('attachKeyboardEntry — Ask-AI-only blocks', () => {
261 it('announces an Ask-AI-only block as "Ask AI about …" instead of "Edit"', async () => {
262 const el = document.createElement('div');
263 el.classList.add('wp-block-group');
264 el.setAttribute('data-extendify-agent-block-id', '1');
265 el.textContent = 'A group with no inline editor';
266 document.body.appendChild(el);
267
268 const { attachKeyboardEntry } = await loadModule();
269 const { pillContextFor } = require('@quick-edit/lib/hover-bar');
270 pillContextFor.mockReturnValue({ quickEditable: false, aiAvailable: true });
271 attachKeyboardEntry({ getSession: () => null });
272
273 expect(el.getAttribute('aria-label')).toBe(
274 'Ask AI about "A group with no inline editor"',
275 );
276 });
277
278 it('routes Enter on an Ask-AI-only block to the agent, not the editor or bar', async () => {
279 const el = document.createElement('div');
280 el.classList.add('wp-block-group');
281 el.setAttribute('data-extendify-agent-block-id', '1');
282 document.body.appendChild(el);
283
284 const { attachKeyboardEntry } = await loadModule();
285 const {
286 pillContextFor,
287 askAiTarget,
288 editTarget,
289 showBar,
290 } = require('@quick-edit/lib/hover-bar');
291 pillContextFor.mockReturnValue({ quickEditable: false, aiAvailable: true });
292 attachKeyboardEntry({ getSession: () => null });
293 askAiTarget.mockClear();
294 editTarget.mockClear();
295 showBar.mockClear();
296
297 const event = new KeyboardEvent('keydown', {
298 key: 'Enter',
299 bubbles: true,
300 cancelable: true,
301 });
302 Object.defineProperty(event, 'target', { value: el });
303 document.dispatchEvent(event);
304
305 expect(askAiTarget).toHaveBeenCalledWith(el);
306 expect(editTarget).not.toHaveBeenCalled();
307 expect(showBar).not.toHaveBeenCalled();
308 });
309
310 it('opens the editor (not the agent) on Enter for a block that is both quick-editable and AI-eligible', async () => {
311 const el = document.createElement('p');
312 el.setAttribute('data-extendify-agent-block-id', '1');
313 document.body.appendChild(el);
314
315 const { attachKeyboardEntry } = await loadModule();
316 const {
317 pillContextFor,
318 askAiTarget,
319 editTarget,
320 } = require('@quick-edit/lib/hover-bar');
321 pillContextFor.mockReturnValue({ quickEditable: true, aiAvailable: true });
322 attachKeyboardEntry({ getSession: () => null });
323 askAiTarget.mockClear();
324 editTarget.mockClear();
325
326 const event = new KeyboardEvent('keydown', {
327 key: 'Enter',
328 bubbles: true,
329 cancelable: true,
330 });
331 Object.defineProperty(event, 'target', { value: el });
332 document.dispatchEvent(event);
333
334 expect(editTarget).toHaveBeenCalled();
335 expect(askAiTarget).not.toHaveBeenCalled();
336 });
337 });
338
339 describe('detachKeyboardEntry — undecorate', () => {
340 it('restores prior tabindex / role / aria-label and removes the kb-ready marker', async () => {
341 const el = document.createElement('p');
342 el.setAttribute('data-extendify-agent-block-id', '1');
343 el.setAttribute('tabindex', '5');
344 el.setAttribute('role', 'menuitem');
345 el.setAttribute('aria-label', 'Original');
346 document.body.appendChild(el);
347
348 const { attachKeyboardEntry, detachKeyboardEntry } = await loadModule();
349 attachKeyboardEntry({ getSession: () => null });
350 expect(el.getAttribute('tabindex')).toBe('0');
351
352 detachKeyboardEntry();
353 expect(el.getAttribute('tabindex')).toBe('5');
354 expect(el.getAttribute('role')).toBe('menuitem');
355 expect(el.getAttribute('aria-label')).toBe('Original');
356 expect(el.hasAttribute('data-extendify-quick-edit-kb-ready')).toBe(false);
357 });
358
359 it('removes the attribute entirely when there was no prior value', async () => {
360 const el = document.createElement('div');
361 el.setAttribute('data-extendify-agent-block-id', '1');
362 document.body.appendChild(el);
363
364 const { attachKeyboardEntry, detachKeyboardEntry } = await loadModule();
365 attachKeyboardEntry({ getSession: () => null });
366 expect(el.getAttribute('tabindex')).toBe('0');
367
368 detachKeyboardEntry();
369 expect(el.hasAttribute('tabindex')).toBe(false);
370 expect(el.hasAttribute('role')).toBe(false);
371 });
372 });
373
374 // focusout → hideBar defers via setTimeout(0); if the focused element
375 // detached first, it must skip hideBar or it tears down the just-rendered bar.
376 describe('attachKeyboardEntry — onFocusOut deferred hideBar', () => {
377 beforeEach(() => {
378 jest.useFakeTimers();
379 });
380
381 afterEach(() => {
382 jest.useRealTimers();
383 });
384
385 const fireFocusOut = (target) =>
386 target.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
387
388 it('does NOT hide the bar when the focusout target detaches before the deferred check', async () => {
389 const tagged = document.createElement('div');
390 tagged.setAttribute('data-extendify-agent-block-id', '1');
391 document.body.appendChild(tagged);
392 const editable = document.createElement('textarea');
393 document.body.appendChild(editable);
394
395 const { attachKeyboardEntry } = await loadModule();
396 const { hideBar } = require('@quick-edit/lib/hover-bar');
397 attachKeyboardEntry({ getSession: () => null });
398 hideBar.mockClear();
399
400 fireFocusOut(editable);
401 editable.remove();
402 jest.advanceTimersByTime(0);
403
404 expect(hideBar).not.toHaveBeenCalled();
405 });
406
407 it('still hides the bar when focus leaves a tagged block to an attached non-tagged element', async () => {
408 const tagged = document.createElement('div');
409 tagged.setAttribute('data-extendify-agent-block-id', '1');
410 document.body.appendChild(tagged);
411 const otherButton = document.createElement('button');
412 document.body.appendChild(otherButton);
413
414 const { attachKeyboardEntry } = await loadModule();
415 const { hideBar } = require('@quick-edit/lib/hover-bar');
416 attachKeyboardEntry({ getSession: () => null });
417 hideBar.mockClear();
418
419 fireFocusOut(tagged);
420 otherButton.focus();
421 jest.advanceTimersByTime(0);
422
423 expect(hideBar).toHaveBeenCalled();
424 });
425 });
426