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

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

185 lines 5.6 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 { useQuickEditStore } from '../state/store';
3 import { resolveTarget } from './dom';
4 import { hideBar, isAgentWorking, pinTarget, showBar } from './hover-bar';
5
6 const SELECTOR = [
7 '[data-extendify-agent-block-id]',
8 '[data-extendify-part-block-id]',
9 // Product/wpforms taggers write these; the agent's tagger doesn't reach those.
10 '[data-extendify-quick-edit-product-id]',
11 '[data-extendify-quick-edit-wpform-field-id]',
12 ].join(',');
13
14 const KB_READY_ATTR = 'data-extendify-quick-edit-kb-ready';
15 const PREV_TAB_ATTR = 'data-extendify-quick-edit-kb-prev-tab';
16 const PREV_ROLE_ATTR = 'data-extendify-quick-edit-kb-prev-role';
17 const PREV_ARIA_ATTR = 'data-extendify-quick-edit-kb-prev-aria';
18
19 let attached = false;
20 let onFocusIn = null;
21 let onFocusOut = null;
22 let onActivate = null;
23
24 const findTagged = (start) => {
25 let node = start;
26 while (node && node.nodeType === 1 && node !== document.body) {
27 if (node.matches?.(SELECTOR)) return node;
28 node = node.parentElement;
29 }
30 return null;
31 };
32
33 // Enter only reveals the pills, so announcing "Edit" promises too much.
34 const buildAriaLabel = (el) => {
35 const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
36 const snippet = text.length > 60 ? `${text.substring(0, 60)}` : text;
37 return snippet
38 ? sprintf(
39 // translators: %s is a snippet of the block's text content.
40 __('Editing options for "%s"', 'extendify-local'),
41 snippet,
42 )
43 : __('Editing options', 'extendify-local');
44 };
45
46 // Skips role="button" on headings/links/landmarks so SR users
47 // retain heading/link/landmark navigation. Enter/Space still
48 // activates regardless of role.
49 const decorate = () => {
50 for (const el of document.querySelectorAll(SELECTOR)) {
51 if (el.getAttribute(KB_READY_ATTR) === '1') continue;
52 // Empty-string sentinel distinguishes "wasn't set" from "was empty".
53 el.setAttribute(
54 PREV_TAB_ATTR,
55 el.hasAttribute('tabindex') ? el.getAttribute('tabindex') : '',
56 );
57 el.setAttribute(
58 PREV_ROLE_ATTR,
59 el.hasAttribute('role') ? el.getAttribute('role') : '',
60 );
61 el.setAttribute(
62 PREV_ARIA_ATTR,
63 el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : '',
64 );
65 el.setAttribute('tabindex', '0');
66
67 const tag = el.tagName;
68 const isHeading = /^H[1-6]$/.test(tag);
69 const isLink = tag === 'A';
70 const isLandmark = [
71 'NAV',
72 'HEADER',
73 'MAIN',
74 'FOOTER',
75 'ASIDE',
76 'SECTION',
77 ].includes(tag);
78 if (!isHeading && !isLink && !isLandmark) {
79 el.setAttribute('role', 'button');
80 }
81
82 el.setAttribute('aria-label', buildAriaLabel(el));
83 el.setAttribute(KB_READY_ATTR, '1');
84 }
85 };
86
87 const undecorate = () => {
88 for (const el of document.querySelectorAll(`[${KB_READY_ATTR}="1"]`)) {
89 const restore = (prevAttr, attr) => {
90 const prev = el.getAttribute(prevAttr);
91 if (prev !== '' && prev !== null) {
92 el.setAttribute(attr, prev);
93 } else {
94 el.removeAttribute(attr);
95 }
96 el.removeAttribute(prevAttr);
97 };
98 restore(PREV_TAB_ATTR, 'tabindex');
99 restore(PREV_ROLE_ATTR, 'role');
100 restore(PREV_ARIA_ATTR, 'aria-label');
101 el.removeAttribute(KB_READY_ATTR);
102 }
103 };
104
105 export const attachKeyboardEntry = ({ getSession }) => {
106 if (attached) return;
107 attached = true;
108
109 decorate();
110
111 onFocusIn = (e) => {
112 if (getSession?.()) return;
113 if (isAgentWorking()) return;
114 const el = findTagged(e.target);
115 if (!el) return;
116 hideBar();
117 showBar(el);
118 };
119
120 onFocusOut = (e) => {
121 // Capture the focused element so we can detect "the element was
122 // removed from the DOM" inside the setTimeout below. The Esc-on-
123 // QE-canvas gesture hits this path: the canvas's editable holds
124 // focus, Esc clears `selected`, hover-bar's subscriber re-renders
125 // the bar synchronously, then React commits the unmount in a
126 // later microtask — by setTimeout(0) time the editable is gone
127 // but the bar is fresh. The body-focus branch below would
128 // otherwise tear that fresh bar down.
129 const target = e.target;
130 setTimeout(() => {
131 if (getSession?.()) return;
132 // Image-picker / modal popovers steal focus on mount.
133 if (
134 document.querySelector(
135 '.extendify-quick-edit-image-menu, .extendify-quick-edit-floating-bar',
136 )
137 ) {
138 return;
139 }
140 if (target && !document.body.contains(target)) return;
141 // A pinned bar outlives focus; only an outside click or Esc drops it.
142 if (useQuickEditStore.getState().committedSelection) return;
143 const active = document.activeElement;
144 if (!active || active === document.body) {
145 hideBar();
146 return;
147 }
148 // Pinning moves focus onto a pill, which lives outside the block.
149 if (active.closest?.('.extendify-quick-edit-bar')) return;
150 if (findTagged(active)) return;
151 hideBar();
152 }, 0);
153 };
154
155 onActivate = (e) => {
156 if (e.key !== 'Enter' && e.key !== ' ') return;
157 if (getSession?.()) return;
158 if (isAgentWorking()) return;
159 const el = findTagged(e.target);
160 if (!el) return;
161 // Skip activation when the keystroke came from a child with
162 // its own action (e.g. a link inside a nav item).
163 if (e.target !== el) return;
164 if (!resolveTarget(el)?.blockType) return;
165 e.preventDefault();
166 pinTarget(el);
167 };
168
169 document.addEventListener('focusin', onFocusIn, true);
170 document.addEventListener('focusout', onFocusOut, true);
171 document.addEventListener('keydown', onActivate, true);
172 };
173
174 export const detachKeyboardEntry = () => {
175 if (!attached) return;
176 attached = false;
177 document.removeEventListener('focusin', onFocusIn, true);
178 document.removeEventListener('focusout', onFocusOut, true);
179 document.removeEventListener('keydown', onActivate, true);
180 onFocusIn = null;
181 onFocusOut = null;
182 onActivate = null;
183 undecorate();
184 };
185