PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
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 / src / QuickEdit / lib / keyboard-entry.js

keyboard-entry.js in Extendify 3.1.0, at src/QuickEdit/lib/keyboard-entry.js

218 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __, sprintf } from '@wordpress/i18n';
2 import { resolveTarget } from './dom';
3 import {
4 askAiTarget,
5 editTarget,
6 hideBar,
7 pillContextFor,
8 showBar,
9 } from './hover-bar';
10
11 const SELECTOR = [
12 '[data-extendify-agent-block-id]',
13 '[data-extendify-part-block-id]',
14 // Product/wpforms taggers write these; the agent's tagger doesn't reach those.
15 '[data-extendify-quick-edit-product-id]',
16 '[data-extendify-quick-edit-wpform-field-id]',
17 ].join(',');
18
19 const KB_READY_ATTR = 'data-extendify-quick-edit-kb-ready';
20 const PREV_TAB_ATTR = 'data-extendify-quick-edit-kb-prev-tab';
21 const PREV_ROLE_ATTR = 'data-extendify-quick-edit-kb-prev-role';
22 const PREV_ARIA_ATTR = 'data-extendify-quick-edit-kb-prev-aria';
23
24 let attached = false;
25 let onFocusIn = null;
26 let onFocusOut = null;
27 let onActivate = null;
28
29 const findTagged = (start) => {
30 let node = start;
31 while (node && node.nodeType === 1 && node !== document.body) {
32 if (node.matches?.(SELECTOR)) return node;
33 node = node.parentElement;
34 }
35 return null;
36 };
37
38 const buildAriaLabel = (el, target, { quickEditable, aiAvailable }) => {
39 const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
40 const snippet = text.length > 60 ? `${text.substring(0, 60)}` : text;
41 // Ask-AI-only blocks (group / columns / media-text) route Enter to the
42 // agent, so announce that action rather than "Edit".
43 if (aiAvailable && !quickEditable) {
44 return snippet
45 ? sprintf(
46 // translators: %s is a snippet of the block's text content.
47 __('Ask AI about "%s"', 'extendify-local'),
48 snippet,
49 )
50 : __('Ask AI', 'extendify-local');
51 }
52 const verb =
53 target?.blockType === 'core/image' || target?.blockType === 'core/cover'
54 ? __('Replace image', 'extendify-local')
55 : __('Edit', 'extendify-local');
56 return snippet
57 ? sprintf(
58 // translators: %1$s is the action verb (Edit / Replace image), %2$s is a snippet of the block's text content.
59 __('%1$s "%2$s"', 'extendify-local'),
60 verb,
61 snippet,
62 )
63 : verb;
64 };
65
66 // Skips role="button" on headings/links/landmarks so SR users
67 // retain heading/link/landmark navigation. Enter/Space still
68 // activates regardless of role.
69 const decorate = () => {
70 for (const el of document.querySelectorAll(SELECTOR)) {
71 if (el.getAttribute(KB_READY_ATTR) === '1') continue;
72 // Empty-string sentinel distinguishes "wasn't set" from "was empty".
73 el.setAttribute(
74 PREV_TAB_ATTR,
75 el.hasAttribute('tabindex') ? el.getAttribute('tabindex') : '',
76 );
77 el.setAttribute(
78 PREV_ROLE_ATTR,
79 el.hasAttribute('role') ? el.getAttribute('role') : '',
80 );
81 el.setAttribute(
82 PREV_ARIA_ATTR,
83 el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : '',
84 );
85 el.setAttribute('tabindex', '0');
86
87 const tag = el.tagName;
88 const isHeading = /^H[1-6]$/.test(tag);
89 const isLink = tag === 'A';
90 const isLandmark = [
91 'NAV',
92 'HEADER',
93 'MAIN',
94 'FOOTER',
95 'ASIDE',
96 'SECTION',
97 ].includes(tag);
98 if (!isHeading && !isLink && !isLandmark) {
99 el.setAttribute('role', 'button');
100 }
101
102 const target = resolveTarget(el);
103 el.setAttribute(
104 'aria-label',
105 buildAriaLabel(el, target, pillContextFor(target)),
106 );
107 el.setAttribute(KB_READY_ATTR, '1');
108 }
109 };
110
111 const undecorate = () => {
112 for (const el of document.querySelectorAll(`[${KB_READY_ATTR}="1"]`)) {
113 const restore = (prevAttr, attr) => {
114 const prev = el.getAttribute(prevAttr);
115 if (prev !== '' && prev !== null) {
116 el.setAttribute(attr, prev);
117 } else {
118 el.removeAttribute(attr);
119 }
120 el.removeAttribute(prevAttr);
121 };
122 restore(PREV_TAB_ATTR, 'tabindex');
123 restore(PREV_ROLE_ATTR, 'role');
124 restore(PREV_ARIA_ATTR, 'aria-label');
125 el.removeAttribute(KB_READY_ATTR);
126 }
127 };
128
129 export const attachKeyboardEntry = ({ getSession }) => {
130 if (attached) return;
131 attached = true;
132
133 decorate();
134
135 onFocusIn = (e) => {
136 if (getSession?.()) return;
137 const el = findTagged(e.target);
138 if (!el) return;
139 hideBar();
140 showBar(el);
141 };
142
143 onFocusOut = (e) => {
144 // Capture the focused element so we can detect "the element was
145 // removed from the DOM" inside the setTimeout below. The Esc-on-
146 // QE-canvas gesture hits this path: the canvas's editable holds
147 // focus, Esc clears `selected`, hover-bar's subscriber re-renders
148 // the bar synchronously, then React commits the unmount in a
149 // later microtask — by setTimeout(0) time the editable is gone
150 // but the bar is fresh. The body-focus branch below would
151 // otherwise tear that fresh bar down.
152 const target = e.target;
153 setTimeout(() => {
154 if (getSession?.()) return;
155 // Image-picker / modal popovers steal focus on mount.
156 if (
157 document.querySelector(
158 '.extendify-quick-edit-image-menu, .extendify-quick-edit-floating-bar',
159 )
160 ) {
161 return;
162 }
163 if (target && !document.body.contains(target)) return;
164 const active = document.activeElement;
165 if (!active || active === document.body) {
166 hideBar();
167 return;
168 }
169 if (findTagged(active)) return;
170 hideBar();
171 }, 0);
172 };
173
174 onActivate = (e) => {
175 if (e.key !== 'Enter' && e.key !== ' ') return;
176 if (getSession?.()) return;
177 const el = findTagged(e.target);
178 if (!el) return;
179 // Skip activation when the keystroke came from a child with
180 // its own action (e.g. a link inside a nav item).
181 if (e.target !== el) return;
182 const target = resolveTarget(el);
183 if (!target?.blockType) return;
184 e.preventDefault();
185 const { quickEditable, aiAvailable } = pillContextFor(target);
186 // Quick-editable blocks open the inline editor (picker types need the
187 // bar mounted first so the dropdown can anchor to it).
188 if (quickEditable) {
189 showBar(el);
190 editTarget(target);
191 return;
192 }
193 // Ask-AI-only blocks (group / columns / media-text) have no editor to
194 // open; route Enter straight to the agent like the Ask AI pill does.
195 if (aiAvailable) {
196 askAiTarget(el);
197 return;
198 }
199 showBar(el);
200 };
201
202 document.addEventListener('focusin', onFocusIn, true);
203 document.addEventListener('focusout', onFocusOut, true);
204 document.addEventListener('keydown', onActivate, true);
205 };
206
207 export const detachKeyboardEntry = () => {
208 if (!attached) return;
209 attached = false;
210 document.removeEventListener('focusin', onFocusIn, true);
211 document.removeEventListener('focusout', onFocusOut, true);
212 document.removeEventListener('keydown', onActivate, true);
213 onFocusIn = null;
214 onFocusOut = null;
215 onActivate = null;
216 undecorate();
217 };
218