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-undo.test.js

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

307 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // keyboard-undo wires Cmd/Ctrl+Z to the cross-block undo stack and bails
2 // when any host editor / media frame / popover is open or when the
3 // keystroke originates in an editable surface.
4
5 jest.mock('@quick-edit/state/undo', () => ({
6 ANNOUNCE_STORAGE_KEY: 'extendify-quick-edit-undo-announce',
7 getStackDepth: jest.fn(),
8 performUndo: jest.fn(),
9 }));
10
11 const trackedDocListeners = [];
12 const originalDocAddEventListener = document.addEventListener.bind(document);
13 document.addEventListener = (type, handler, opts) => {
14 trackedDocListeners.push([type, handler, opts]);
15 return originalDocAddEventListener(type, handler, opts);
16 };
17
18 beforeEach(() => {
19 jest.resetModules();
20 jest.clearAllMocks();
21 document.body.innerHTML = '';
22 window.sessionStorage.clear();
23 });
24
25 afterEach(async () => {
26 const { detachKeyboardUndo } = await import('@quick-edit/lib/keyboard-undo');
27 detachKeyboardUndo();
28 for (const [type, handler, opts] of trackedDocListeners) {
29 document.removeEventListener(type, handler, opts);
30 }
31 trackedDocListeners.length = 0;
32 });
33
34 const fireUndo = (init = {}) => {
35 const event = new KeyboardEvent('keydown', {
36 key: 'z',
37 bubbles: true,
38 cancelable: true,
39 ...init,
40 });
41 const preventSpy = jest.spyOn(event, 'preventDefault');
42 document.dispatchEvent(event);
43 return { event, preventSpy };
44 };
45
46 describe('attachKeyboardUndo — modifier matrix', () => {
47 it('fires on Cmd+Z', async () => {
48 const { attachKeyboardUndo } = await import(
49 '@quick-edit/lib/keyboard-undo'
50 );
51 const undo = require('@quick-edit/state/undo');
52 undo.getStackDepth.mockReturnValue(1);
53 attachKeyboardUndo();
54
55 const { preventSpy } = fireUndo({ metaKey: true });
56 expect(undo.performUndo).toHaveBeenCalled();
57 expect(preventSpy).toHaveBeenCalled();
58 });
59
60 it('fires on Ctrl+Z', async () => {
61 const { attachKeyboardUndo } = await import(
62 '@quick-edit/lib/keyboard-undo'
63 );
64 const undo = require('@quick-edit/state/undo');
65 undo.getStackDepth.mockReturnValue(1);
66 attachKeyboardUndo();
67
68 fireUndo({ ctrlKey: true });
69 expect(undo.performUndo).toHaveBeenCalled();
70 });
71
72 it('also accepts uppercase Z', async () => {
73 const { attachKeyboardUndo } = await import(
74 '@quick-edit/lib/keyboard-undo'
75 );
76 const undo = require('@quick-edit/state/undo');
77 undo.getStackDepth.mockReturnValue(1);
78 attachKeyboardUndo();
79
80 fireUndo({ metaKey: true, key: 'Z' });
81 expect(undo.performUndo).toHaveBeenCalled();
82 });
83
84 it('ignores Cmd+Z while Shift is held (Cmd+Shift+Z is "redo")', async () => {
85 const { attachKeyboardUndo } = await import(
86 '@quick-edit/lib/keyboard-undo'
87 );
88 const undo = require('@quick-edit/state/undo');
89 undo.getStackDepth.mockReturnValue(1);
90 attachKeyboardUndo();
91
92 fireUndo({ metaKey: true, shiftKey: true });
93 expect(undo.performUndo).not.toHaveBeenCalled();
94 });
95
96 it('ignores Cmd+Z while Alt is held', async () => {
97 const { attachKeyboardUndo } = await import(
98 '@quick-edit/lib/keyboard-undo'
99 );
100 const undo = require('@quick-edit/state/undo');
101 undo.getStackDepth.mockReturnValue(1);
102 attachKeyboardUndo();
103
104 fireUndo({ metaKey: true, altKey: true });
105 expect(undo.performUndo).not.toHaveBeenCalled();
106 });
107
108 it('ignores plain Z', async () => {
109 const { attachKeyboardUndo } = await import(
110 '@quick-edit/lib/keyboard-undo'
111 );
112 const undo = require('@quick-edit/state/undo');
113 undo.getStackDepth.mockReturnValue(1);
114 attachKeyboardUndo();
115
116 fireUndo({});
117 expect(undo.performUndo).not.toHaveBeenCalled();
118 });
119 });
120
121 describe('attachKeyboardUndo — host-element bail-outs', () => {
122 it.each([
123 ['.extendify-quick-edit-host'],
124 ['.media-modal'],
125 ['.extendify-quick-edit-color-popover'],
126 ['.extendify-quick-edit-image-menu'],
127 ])('bails when %s is mounted', async (selector) => {
128 const host = document.createElement('div');
129 host.className = selector.slice(1);
130 document.body.appendChild(host);
131
132 const { attachKeyboardUndo } = await import(
133 '@quick-edit/lib/keyboard-undo'
134 );
135 const undo = require('@quick-edit/state/undo');
136 undo.getStackDepth.mockReturnValue(1);
137 attachKeyboardUndo();
138
139 fireUndo({ metaKey: true });
140 expect(undo.performUndo).not.toHaveBeenCalled();
141 });
142
143 it('bails when the keystroke target is a non-empty INPUT/TEXTAREA or a SELECT', async () => {
144 const input = document.createElement('input');
145 input.value = 'typed';
146 const textarea = document.createElement('textarea');
147 textarea.value = 'typed';
148 const select = document.createElement('select');
149 document.body.append(input, textarea, select);
150
151 const { attachKeyboardUndo } = await import(
152 '@quick-edit/lib/keyboard-undo'
153 );
154 const undo = require('@quick-edit/state/undo');
155 undo.getStackDepth.mockReturnValue(1);
156 attachKeyboardUndo();
157
158 for (const el of [input, textarea, select]) {
159 const event = new KeyboardEvent('keydown', {
160 key: 'z',
161 metaKey: true,
162 bubbles: true,
163 cancelable: true,
164 });
165 Object.defineProperty(event, 'target', { value: el });
166 document.dispatchEvent(event);
167 }
168 expect(undo.performUndo).not.toHaveBeenCalled();
169 });
170
171 // An empty field has no native undo to protect, so it must not swallow the
172 // page-level undo: the agent chat box auto-focuses empty on the frontend
173 // after a Quick Edit save + reload, and Cmd+Z there should still revert.
174 it('fires when the keystroke target is an empty INPUT/TEXTAREA', async () => {
175 const input = document.createElement('input');
176 const textarea = document.createElement('textarea');
177 document.body.append(input, textarea);
178
179 const { attachKeyboardUndo } = await import(
180 '@quick-edit/lib/keyboard-undo'
181 );
182 const undo = require('@quick-edit/state/undo');
183 undo.getStackDepth.mockReturnValue(1);
184 attachKeyboardUndo();
185
186 for (const el of [input, textarea]) {
187 const event = new KeyboardEvent('keydown', {
188 key: 'z',
189 metaKey: true,
190 bubbles: true,
191 cancelable: true,
192 });
193 Object.defineProperty(event, 'target', { value: el });
194 document.dispatchEvent(event);
195 }
196 expect(undo.performUndo).toHaveBeenCalledTimes(2);
197 });
198
199 it('bails when the keystroke target is contentEditable', async () => {
200 const editable = document.createElement('div');
201 // jsdom doesn't auto-derive isContentEditable from the attribute; set explicitly.
202 Object.defineProperty(editable, 'isContentEditable', {
203 value: true,
204 configurable: true,
205 });
206 document.body.appendChild(editable);
207
208 const { attachKeyboardUndo } = await import(
209 '@quick-edit/lib/keyboard-undo'
210 );
211 const undo = require('@quick-edit/state/undo');
212 undo.getStackDepth.mockReturnValue(1);
213 attachKeyboardUndo();
214
215 const event = new KeyboardEvent('keydown', {
216 key: 'z',
217 metaKey: true,
218 bubbles: true,
219 cancelable: true,
220 });
221 Object.defineProperty(event, 'target', { value: editable });
222 document.dispatchEvent(event);
223 expect(undo.performUndo).not.toHaveBeenCalled();
224 });
225
226 it('bails when the stack is empty', async () => {
227 const { attachKeyboardUndo } = await import(
228 '@quick-edit/lib/keyboard-undo'
229 );
230 const undo = require('@quick-edit/state/undo');
231 undo.getStackDepth.mockReturnValue(0);
232 attachKeyboardUndo();
233
234 const { preventSpy } = fireUndo({ metaKey: true });
235 expect(undo.performUndo).not.toHaveBeenCalled();
236 expect(preventSpy).not.toHaveBeenCalled();
237 });
238 });
239
240 describe('detachKeyboardUndo', () => {
241 it('removes the listener so subsequent shortcuts no-op', async () => {
242 const { attachKeyboardUndo, detachKeyboardUndo } = await import(
243 '@quick-edit/lib/keyboard-undo'
244 );
245 const undo = require('@quick-edit/state/undo');
246 undo.getStackDepth.mockReturnValue(1);
247 attachKeyboardUndo();
248 detachKeyboardUndo();
249
250 fireUndo({ metaKey: true });
251 expect(undo.performUndo).not.toHaveBeenCalled();
252 });
253
254 it('repeated attach is idempotent (no double-fire)', async () => {
255 const { attachKeyboardUndo } = await import(
256 '@quick-edit/lib/keyboard-undo'
257 );
258 const undo = require('@quick-edit/state/undo');
259 undo.getStackDepth.mockReturnValue(1);
260 attachKeyboardUndo();
261 attachKeyboardUndo();
262
263 fireUndo({ metaKey: true });
264 expect(undo.performUndo).toHaveBeenCalledTimes(1);
265 });
266 });
267
268 describe('announcePostReload', () => {
269 it('reads + clears sessionStorage and mounts a polite aria-live region with the message', async () => {
270 jest.useFakeTimers();
271 window.sessionStorage.setItem(
272 'extendify-quick-edit-undo-announce',
273 'Reverted last change.',
274 );
275
276 const { announcePostReload } = await import(
277 '@quick-edit/lib/keyboard-undo'
278 );
279 announcePostReload();
280
281 expect(
282 window.sessionStorage.getItem('extendify-quick-edit-undo-announce'),
283 ).toBeNull();
284 const live = document.querySelector('[role="status"]');
285 expect(live).not.toBeNull();
286 expect(live.getAttribute('aria-live')).toBe('polite');
287 expect(live.getAttribute('aria-atomic')).toBe('true');
288 // Empty on mount; text set on next tick.
289 expect(live.textContent).toBe('');
290
291 jest.advanceTimersByTime(100);
292 expect(live.textContent).toBe('Reverted last change.');
293
294 jest.advanceTimersByTime(5000);
295 expect(document.querySelector('[role="status"]')).toBeNull();
296 jest.useRealTimers();
297 });
298
299 it('is a no-op when sessionStorage has no announcement', async () => {
300 const { announcePostReload } = await import(
301 '@quick-edit/lib/keyboard-undo'
302 );
303 announcePostReload();
304 expect(document.querySelector('[role="status"]')).toBeNull();
305 });
306 });
307