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 / global-escape.test.js

global-escape.test.js in Extendify 3.1.1, at tests/unit/QuickEdit/lib/global-escape.test.js

269 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Global Esc wiring.
2 //
3 // `wireGlobalEscape` attaches one document-level keydown listener that
4 // runs the decideEscapeAction priority order, mutates the right store,
5 // and stops propagation so legacy window-level Esc listeners (the agent
6 // chat's, the old per-feature ones) don't fire stale logic alongside.
7 //
8 // Per-surface Esc handlers — BlockTextEditor's capture-phase listener,
9 // WP's <Modal>, the InlineEditor image-menu handler — fire on their own
10 // surfaces. The global handler is the fallback for "nothing local
11 // consumed Esc."
12
13 const mockQuickEditState = {
14 agentBlock: null,
15 selected: null,
16 committedSelection: null,
17 };
18 const mockSetAgentBlock = jest.fn((agentBlock) => {
19 mockQuickEditState.agentBlock = agentBlock;
20 });
21 const mockClearSelected = jest.fn(() => {
22 mockQuickEditState.selected = null;
23 });
24 const mockSetCommittedSelection = jest.fn((committedSelection) => {
25 mockQuickEditState.committedSelection = committedSelection;
26 });
27
28 jest.mock('@quick-edit/state/store', () => ({
29 useQuickEditStore: {
30 getState: () => ({
31 agentBlock: mockQuickEditState.agentBlock,
32 selected: mockQuickEditState.selected,
33 committedSelection: mockQuickEditState.committedSelection,
34 setAgentBlock: mockSetAgentBlock,
35 clearSelected: mockClearSelected,
36 setCommittedSelection: mockSetCommittedSelection,
37 }),
38 },
39 }));
40
41 const mockEditModeState = { on: false };
42 jest.mock('@quick-edit/state/edit-mode', () => ({
43 useEditModeStore: {
44 getState: () => ({ on: mockEditModeState.on }),
45 },
46 }));
47
48 const dispatchEsc = () => {
49 const event = new KeyboardEvent('keydown', {
50 key: 'Escape',
51 bubbles: true,
52 cancelable: true,
53 });
54 const stopSpy = jest.spyOn(event, 'stopPropagation');
55 const preventSpy = jest.spyOn(event, 'preventDefault');
56 document.dispatchEvent(event);
57 return { event, stopSpy, preventSpy };
58 };
59
60 let unwire = () => {};
61
62 beforeEach(() => {
63 jest.clearAllMocks();
64 mockQuickEditState.agentBlock = null;
65 mockQuickEditState.selected = null;
66 mockQuickEditState.committedSelection = null;
67 mockEditModeState.on = false;
68 });
69
70 afterEach(() => {
71 unwire();
72 unwire = () => {};
73 });
74
75 describe('wireGlobalEscape — edit mode off', () => {
76 it('does nothing and does not stop propagation', async () => {
77 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
78 unwire = wireGlobalEscape();
79
80 const { stopSpy, preventSpy } = dispatchEsc();
81
82 expect(mockSetAgentBlock).not.toHaveBeenCalled();
83 expect(mockClearSelected).not.toHaveBeenCalled();
84 expect(stopSpy).not.toHaveBeenCalled();
85 expect(preventSpy).not.toHaveBeenCalled();
86 });
87
88 it('ignores non-Escape keys even when edit mode is on', async () => {
89 mockEditModeState.on = true;
90 mockQuickEditState.agentBlock = { id: 'b-1' };
91 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
92 unwire = wireGlobalEscape();
93
94 const event = new KeyboardEvent('keydown', {
95 key: 'Enter',
96 bubbles: true,
97 cancelable: true,
98 });
99 const stopSpy = jest.spyOn(event, 'stopPropagation');
100 document.dispatchEvent(event);
101
102 expect(mockSetAgentBlock).not.toHaveBeenCalled();
103 expect(stopSpy).not.toHaveBeenCalled();
104 });
105 });
106
107 describe('wireGlobalEscape — clears the agent block', () => {
108 it('clears the agent block, dispatches cancel-workflow, and stops propagation', async () => {
109 mockEditModeState.on = true;
110 mockQuickEditState.agentBlock = { id: 'b-1' };
111
112 const cancelListener = jest.fn();
113 window.addEventListener('extendify-agent:cancel-workflow', cancelListener);
114
115 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
116 unwire = wireGlobalEscape();
117
118 const { stopSpy, preventSpy } = dispatchEsc();
119
120 expect(mockSetAgentBlock).toHaveBeenCalledWith(null);
121 expect(mockClearSelected).not.toHaveBeenCalled();
122 expect(cancelListener).toHaveBeenCalledTimes(1);
123 expect(stopSpy).toHaveBeenCalled();
124 expect(preventSpy).toHaveBeenCalled();
125
126 window.removeEventListener(
127 'extendify-agent:cancel-workflow',
128 cancelListener,
129 );
130 });
131
132 it('prefers clearing the agent block when both agent block and QE selection are set on DIFFERENT blocks', async () => {
133 mockEditModeState.on = true;
134 mockQuickEditState.agentBlock = { id: 'b-1' };
135 mockQuickEditState.selected = { blockId: 's-1' };
136
137 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
138 unwire = wireGlobalEscape();
139
140 dispatchEsc();
141
142 expect(mockSetAgentBlock).toHaveBeenCalledWith(null);
143 expect(mockClearSelected).not.toHaveBeenCalled();
144 });
145
146 // Two-pill click stages both `selected` and `agentBlock` on the same
147 // block. A single Esc should unwind both — without this collapse the
148 // user has to press Esc twice to fully exit the block.
149 it('clears BOTH the agent block and QE selection in one keypress when they reference the same block', async () => {
150 mockEditModeState.on = true;
151 mockQuickEditState.agentBlock = { id: '5' };
152 mockQuickEditState.selected = { blockId: 5 };
153
154 const cancelListener = jest.fn();
155 window.addEventListener('extendify-agent:cancel-workflow', cancelListener);
156
157 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
158 unwire = wireGlobalEscape();
159
160 const { stopSpy, preventSpy } = dispatchEsc();
161
162 expect(mockSetAgentBlock).toHaveBeenCalledWith(null);
163 expect(mockClearSelected).toHaveBeenCalledTimes(1);
164 expect(cancelListener).toHaveBeenCalledTimes(1);
165 expect(stopSpy).toHaveBeenCalled();
166 expect(preventSpy).toHaveBeenCalled();
167
168 window.removeEventListener(
169 'extendify-agent:cancel-workflow',
170 cancelListener,
171 );
172 });
173 });
174
175 describe('wireGlobalEscape — clears the QE selection', () => {
176 it('clears the QE selection and stops propagation', async () => {
177 mockEditModeState.on = true;
178 mockQuickEditState.selected = { blockId: 's-1' };
179
180 const cancelListener = jest.fn();
181 window.addEventListener('extendify-agent:cancel-workflow', cancelListener);
182
183 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
184 unwire = wireGlobalEscape();
185
186 const { stopSpy, preventSpy } = dispatchEsc();
187
188 expect(mockClearSelected).toHaveBeenCalledTimes(1);
189 expect(mockSetAgentBlock).not.toHaveBeenCalled();
190 expect(cancelListener).not.toHaveBeenCalled();
191 expect(stopSpy).toHaveBeenCalled();
192 expect(preventSpy).toHaveBeenCalled();
193
194 window.removeEventListener(
195 'extendify-agent:cancel-workflow',
196 cancelListener,
197 );
198 });
199 });
200
201 describe('wireGlobalEscape — clears the committed selection', () => {
202 it('clears the committed selection and stops propagation', async () => {
203 mockEditModeState.on = true;
204 mockQuickEditState.committedSelection = {
205 el: 'fake',
206 blockType: 'core/paragraph',
207 };
208
209 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
210 unwire = wireGlobalEscape();
211
212 const { stopSpy, preventSpy } = dispatchEsc();
213
214 expect(mockSetCommittedSelection).toHaveBeenCalledWith(null);
215 expect(mockSetAgentBlock).not.toHaveBeenCalled();
216 expect(mockClearSelected).not.toHaveBeenCalled();
217 expect(stopSpy).toHaveBeenCalled();
218 expect(preventSpy).toHaveBeenCalled();
219 });
220
221 it('prefers clearing QE selection when both QE selection and committedSelection are set', async () => {
222 mockEditModeState.on = true;
223 mockQuickEditState.selected = { blockId: 's-1' };
224 mockQuickEditState.committedSelection = { el: 'fake' };
225
226 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
227 unwire = wireGlobalEscape();
228
229 dispatchEsc();
230
231 expect(mockClearSelected).toHaveBeenCalledTimes(1);
232 expect(mockSetCommittedSelection).not.toHaveBeenCalled();
233 });
234 });
235
236 describe('wireGlobalEscape — noop branch', () => {
237 it('stops propagation but does not toggle edit mode when nothing is selected', async () => {
238 mockEditModeState.on = true;
239
240 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
241 unwire = wireGlobalEscape();
242
243 const { stopSpy, preventSpy } = dispatchEsc();
244
245 expect(mockSetAgentBlock).not.toHaveBeenCalled();
246 expect(mockClearSelected).not.toHaveBeenCalled();
247 expect(stopSpy).toHaveBeenCalled();
248 // preventDefault is reserved for the consuming branches; the noop
249 // branch only stops propagation so legacy listeners stay quiet.
250 expect(preventSpy).not.toHaveBeenCalled();
251 });
252 });
253
254 describe('wireGlobalEscape — teardown', () => {
255 it('returns an unsubscribe fn that detaches the listener', async () => {
256 mockEditModeState.on = true;
257 mockQuickEditState.agentBlock = { id: 'b-1' };
258
259 const { wireGlobalEscape } = await import('@quick-edit/lib/global-escape');
260 const teardown = wireGlobalEscape();
261 teardown();
262 unwire = () => {};
263
264 dispatchEsc();
265
266 expect(mockSetAgentBlock).not.toHaveBeenCalled();
267 });
268 });
269