| 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 |
|