diff --git a/src/Console/Command/Hyva/CompatibilityCheckCommand.php b/src/Console/Command/Hyva/CompatibilityCheckCommand.php index 4cd297e8..fa6436f4 100644 --- a/src/Console/Command/Hyva/CompatibilityCheckCommand.php +++ b/src/Console/Command/Hyva/CompatibilityCheckCommand.php @@ -257,7 +257,7 @@ private function runScan( } // Display summary - $this->displaySummary($results); + $this->displaySummary($results['summary']); // Display recommendations if there are issues if ($results['hasIncompatibilities']) { @@ -336,13 +336,11 @@ private function displayDetailedIssues(array $results): void /** * Display summary statistics * - * @param array $results - * @phpstan-param array{summary: CheckSummary} $results + * @param array $summary + * @phpstan-param CheckSummary $summary */ - private function displaySummary(array $results): void + private function displaySummary(array $summary): void { - $summary = $results['summary']; - $this->io->section('Summary'); $summaryData = [ diff --git a/src/Model/TemplateEngine/Decorator/InspectorHints.php b/src/Model/TemplateEngine/Decorator/InspectorHints.php index 138768ca..935c4155 100644 --- a/src/Model/TemplateEngine/Decorator/InspectorHints.php +++ b/src/Model/TemplateEngine/Decorator/InspectorHints.php @@ -4,6 +4,7 @@ namespace OpenForgeProject\MageForge\Model\TemplateEngine\Decorator; +use Magento\Framework\Escaper; use Magento\Framework\Filesystem\Driver\File; use Magento\Framework\Math\Random; use Magento\Framework\View\Element\AbstractBlock; @@ -29,6 +30,7 @@ class InspectorHints implements TemplateEngineInterface * @param Random $random * @param BlockCacheCollector $cacheCollector * @param File $fileDriver + * @param Escaper $escaper * @param string[] $excludedClassPrefixes Block class prefixes to skip inspector wrapping for * @param string[] $excludedTemplatePaths Template path substrings to skip inspector wrapping for */ @@ -38,6 +40,7 @@ public function __construct( private readonly Random $random, private readonly BlockCacheCollector $cacheCollector, private readonly File $fileDriver, + private readonly Escaper $escaper, private readonly array $excludedClassPrefixes = [], private readonly array $excludedTemplatePaths = [], ) { @@ -97,20 +100,6 @@ private function isExcludedTemplate(string $templateFile): bool return false; } - /** - * Check if rendered HTML contains wire attributes (Magewire/Livewire components) - * - * Wrapping these in HTML comments breaks wire:id injection which relies on - * finding the first root element via regex. - * - * @param string $html - * @return bool - */ - private function containsWireAttributes(string $html): bool - { - return str_contains($html, 'wire:id=') || str_contains($html, 'wire:initial-data='); - } - /** * Insert inspector data attributes into the rendered block contents * @@ -137,27 +126,10 @@ public function render(BlockInterface $block, $templateFile, array $dictionary = } // Skip inspector wrapping for templates in excluded paths (e.g. /magewire/ directories). - // Magewire injects wire:id AFTER the template engine returns via regex on the root element. - // Wrapping the output in HTML comments before that element breaks the injection. if ($this->isExcludedTemplate($templateFile)) { return $result; } - // Skip inspector wrapping for Magewire component blocks. - // Magewire sets a 'magewire' data key on the block before rendering and injects wire:id - // via regex AFTER the template engine returns. Wrapping the output in HTML comments - // shifts the offset used by insertAttributesIntoHtmlRoot(), causing broken components. - // Soft dependency: hasData() is a Magento DataObject method, not a Magewire class. - if (method_exists($block, 'hasData') && $block->hasData('magewire')) { - return $result; - } - - // Skip inspector wrapping if the rendered HTML contains wire attributes (Magewire/Livewire). - // This catches container blocks whose children have already been rendered with wire attributes. - if ($this->containsWireAttributes($result)) { - return $result; - } - // Only inject attributes if there's actual HTML content if (empty(trim($result))) { return $result; @@ -177,7 +149,13 @@ public function render(BlockInterface $block, $templateFile, array $dictionary = } /** - * Inject MageForge inspector comment markers into HTML + * Inject MageForge inspector data attributes into the first root HTML element + * + * Injects data-mageforge-id and data-mageforge-block on the opening tag of the + * first HTML element in the output. If the content does not start with an HTML + * element (e.g. a plain URL or text fragment used inside an href attribute by a + * parent PageBuilder template), injection is skipped entirely to avoid corrupting + * the surrounding markup. * * @param string $html * @param BlockInterface $block @@ -213,6 +191,9 @@ private function injectInspectorAttributes( $cacheMetrics = $this->cacheCollector->getCacheInfo($block); $formattedMetrics = $this->cacheCollector->formatMetricsForJson($renderMetrics, $cacheMetrics); + // Detect CMS block identifier (e.g. for PageBuilder blocks rendered via Magento\Cms\Block\Block) + $cmsBlockId = method_exists($block, 'getBlockId') ? (string) $block->getBlockId() : ''; + // Build metadata as JSON $metadata = [ 'id' => $wrapperId, @@ -223,29 +204,46 @@ private function injectInspectorAttributes( 'parent' => $parentBlock, 'alias' => $blockAlias, 'override' => $isOverride, + 'cmsBlockId' => $cmsBlockId, 'performance' => $formattedMetrics['performance'], 'cache' => $formattedMetrics['cache'], ]; - // JSON encode with proper escaping for HTML comments $jsonMetadata = json_encode($metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($jsonMetadata === false) { return $html; } - // Escape any comment terminators in JSON to prevent breaking out of comment - $jsonMetadata = str_replace('-->', '-->', $jsonMetadata); - - // Wrap content with comment markers - $wrappedHtml = sprintf( - "\n%s\n", - $jsonMetadata, + // Escape all characters that need HTML-encoding so the JSON can be safely + // embedded in an HTML attribute. escapeHtml handles &, <, > and quotes. + // The browser automatically decodes HTML entities when getAttribute() is called, + // so JSON.parse() on the JS side will receive the correct string. + $safeJson = $this->escaper->escapeHtml($jsonMetadata); + + // Inject data-mageforge-* attributes on the first root HTML element. + // This avoids HTML comment nodes which corrupt markup when block output is + // embedded inside HTML attribute values (e.g. PageBuilder URL blocks in href="..."). + $replaced = false; + $result = preg_replace_callback( + '/^(\s*<[a-zA-Z][a-zA-Z0-9-]*)/s', + function (array $matches) use ($wrapperId, $safeJson, &$replaced): string { + $replaced = true; + return $matches[0] + . ' data-mageforge-id="' . $wrapperId . '"' + . ' data-mageforge-block="' . $safeJson . '"'; + }, $html, - $wrapperId, + 1, ); - return $wrappedHtml; + // If content doesn't start with an HTML element (e.g. plain text, URLs), + // skip injection to avoid corrupting attribute values in parent templates. + if (!$replaced || $result === null) { + return $html; + } + + return $result; } /** diff --git a/src/Model/TemplateEngine/Decorator/InspectorHintsFactory.php b/src/Model/TemplateEngine/Decorator/InspectorHintsFactory.php new file mode 100644 index 00000000..2e35f98a --- /dev/null +++ b/src/Model/TemplateEngine/Decorator/InspectorHintsFactory.php @@ -0,0 +1,36 @@ + $data + * @return InspectorHints + */ + // phpcs:enable Magento2.Annotation.MethodArguments.ArgumentMissing + public function create(array $data = []): InspectorHints + { + /** @var InspectorHints $instance */ + $instance = $this->objectManager->create(InspectorHints::class, $data); + return $instance; + } +} diff --git a/src/view/frontend/web/css/inspector.css b/src/view/frontend/web/css/inspector.css index b9d9464f..2587f2f2 100644 --- a/src/view/frontend/web/css/inspector.css +++ b/src/view/frontend/web/css/inspector.css @@ -92,12 +92,17 @@ align-items: center; gap: 8px; font-family: var(--mageforge-font-family); - font-size: 0.75rem; + font-size: 14px; font-weight: 600; + line-height: 1; + white-space: nowrap; + text-transform: none; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(8px); letter-spacing: 0.025em; + vertical-align: middle; + text-decoration: none; } .mageforge-inspector-float-button:hover { @@ -109,6 +114,11 @@ transform: translateY(0); } +.mageforge-inspector-float-button:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.8); + outline-offset: 2px; +} + .mageforge-inspector-float-button.mageforge-active { background: linear-gradient(135deg, var(--mageforge-color-green) 0%, var(--mageforge-color-green-dark) 100%); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2), 0 8px 20px rgba(16, 185, 129, 0.5); diff --git a/src/view/frontend/web/js/inspector.js b/src/view/frontend/web/js/inspector.js index 3932f7a8..3e2aeeab 100644 --- a/src/view/frontend/web/js/inspector.js +++ b/src/view/frontend/web/js/inspector.js @@ -76,10 +76,6 @@ function _registerMageforgeInspector() { pageTimings: null, performanceObservers: [], - // Block detection cache - cachedBlocks: null, - lastBlocksCacheTime: 0, - // Window event handler refs (for cleanup) _inspectorStateHandler: null, @@ -109,6 +105,7 @@ function _registerMageforgeInspector() { this._inspectorStateHandler = (e) => { if (this._inspectorFloatButton) { this._inspectorFloatButton.classList.toggle('mageforge-active', e.detail.active); + this._inspectorFloatButton.setAttribute('aria-pressed', e.detail.active ? 'true' : 'false'); } }; window.addEventListener('mageforge:toolbar:inspector-state', this._inspectorStateHandler); @@ -123,10 +120,13 @@ function _registerMageforgeInspector() { }, _createInspectorFloatButton() { - const btn = document.createElement('button'); + // Use div instead of button to avoid Luma/theme button CSS overrides + const btn = document.createElement('div'); btn.className = 'mageforge-inspector-float-button'; - btn.type = 'button'; btn.title = 'Activate Inspector (Ctrl+Shift+I)'; + btn.setAttribute('role', 'button'); + btn.setAttribute('tabindex', '0'); + btn.setAttribute('aria-pressed', 'false'); btn.innerHTML = ` @@ -142,6 +142,22 @@ function _registerMageforgeInspector() { e.stopPropagation(); this.toggleInspector(); }; + btn.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + this.toggleInspector(); + } + if (e.key === ' ') { + e.preventDefault(); // prevent page scroll on Space + } + }; + btn.onkeyup = (e) => { + if (e.key === ' ') { + e.stopPropagation(); + this.toggleInspector(); + } + }; return btn; }, diff --git a/src/view/frontend/web/js/inspector/dom.js b/src/view/frontend/web/js/inspector/dom.js index 3f55303c..68f1dc8f 100644 --- a/src/view/frontend/web/js/inspector/dom.js +++ b/src/view/frontend/web/js/inspector/dom.js @@ -4,134 +4,109 @@ export const domMethods = { /** - * Parse MageForge comment markers in DOM + * Find all MageForge block elements in DOM. + * + * Blocks are identified by the data-mageforge-id attribute injected by + * InspectorHints on the first root HTML element of each rendered block. + * + * @returns {Array<{ data: Object, elements: Element[] }>} */ - parseCommentMarker(comment) { - const text = comment.textContent.trim(); - - // Check if it's a start marker - if (text.startsWith('MAGEFORGE_START ')) { - const jsonStr = text.substring('MAGEFORGE_START '.length); - try { - // Unescape any escaped comment terminators - const unescapedJson = jsonStr.replace(/-->/g, '-->'); - return { - type: 'start', - data: JSON.parse(unescapedJson) - }; - } catch (e) { - console.error('Failed to parse MageForge start marker:', e); - return null; + findAllMageForgeBlocks() { + const blocks = []; + const elements = document.querySelectorAll('[data-mageforge-id]'); + for (const el of elements) { + const block = this._parseBlockElement(el); + if (block) { + blocks.push(block); } } - // Check if it's an end marker - if (text.startsWith('MAGEFORGE_END ')) { - const id = text.substring('MAGEFORGE_END '.length).trim(); + return blocks; + }, + + /** + * Parse block metadata from an element's data-mageforge-block attribute. + * + * @param {Element} el + * @returns {{ data: Object, elements: Element[] }|null} + */ + _parseBlockElement(el) { + const blockJson = el.getAttribute('data-mageforge-block'); + if (!blockJson) return null; + + try { + const data = JSON.parse(blockJson); + data.id = el.getAttribute('data-mageforge-id'); return { - type: 'end', - id: id + data, + elements: [el], }; + } catch (e) { + console.error('Failed to parse MageForge block data:', e); + return null; } - - return null; }, /** - * Find all MageForge block regions in DOM + * Find the MageForge block that contains a given element. + * + * Primary: walks up via closest() for the nearest [data-mageforge-id] ancestor. + * Fallback: for PageBuilder content with multiple root elements (rows), only the + * first root gets data-mageforge-id injected. Walk up to the root [data-content-type] + * element and search siblings for the nearest [data-mageforge-id]. + * + * @param {Element} element + * @returns {{ data: Object, elements: Element[] }|null} */ - findAllMageForgeBlocks() { - const blocks = []; - const walker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_COMMENT, - null - ); - - const stack = []; - let comment; - - while ((comment = walker.nextNode())) { - const parsed = this.parseCommentMarker(comment); - - if (!parsed) continue; - - if (parsed.type === 'start') { - stack.push({ - startComment: comment, - data: parsed.data, - elements: [] - }); - } else if (parsed.type === 'end' && stack.length > 0) { - const currentBlock = stack[stack.length - 1]; - if (currentBlock.data.id === parsed.id) { - currentBlock.endComment = comment; - - // Collect all elements between start and end comments - currentBlock.elements = this.getElementsBetweenComments( - currentBlock.startComment, - currentBlock.endComment - ); - - blocks.push(currentBlock); - stack.pop(); - } - } + findBlockForElement(element) { + const blockEl = element.closest('[data-mageforge-id]'); + if (blockEl) return this._parseBlockElement(blockEl); + + // PageBuilder fallback: multi-root CMS blocks (e.g. multiple rows) + const rootPb = this._findRootPageBuilderElement(element); + if (rootPb) { + const sibling = this._findNearestMageForgeBlock(rootPb); + if (sibling) return this._parseBlockElement(sibling); } - return blocks; + return null; }, /** - * Get all elements between two comment nodes + * Walk up the DOM to find the topmost [data-content-type] element (PageBuilder root). + * + * @param {Element} element + * @returns {Element|null} */ - getElementsBetweenComments(startComment, endComment) { - const elements = []; - let node = startComment.nextSibling; - - while (node && node !== endComment) { - if (node.nodeType === Node.ELEMENT_NODE) { - elements.push(node); - // Also add all descendants - elements.push(...node.querySelectorAll('*')); + _findRootPageBuilderElement(element) { + let current = element; + let rootPb = null; + while (current && current !== document.body) { + if (current.hasAttribute('data-content-type')) { + rootPb = current; } - node = node.nextSibling; + current = current.parentElement; } - - return elements; + return rootPb; }, /** - * Find MageForge block data for a given element + * Search preceding and following siblings for the nearest [data-mageforge-id] element. + * + * @param {Element} element + * @returns {Element|null} */ - findBlockForElement(element) { - // Cache blocks for performance - if (!this.cachedBlocks || Date.now() - this.lastBlocksCacheTime > 1000) { - this.cachedBlocks = this.findAllMageForgeBlocks(); - this.lastBlocksCacheTime = Date.now(); + _findNearestMageForgeBlock(element) { + let sibling = element.previousElementSibling; + while (sibling) { + if (sibling.hasAttribute('data-mageforge-id')) return sibling; + sibling = sibling.previousElementSibling; } - - let closestBlock = null; - let closestDepth = -1; - - // Find the deepest (most specific) block containing this element - for (const block of this.cachedBlocks) { - if (block.elements.includes(element)) { - // Calculate depth (how many ancestors between element and body) - let depth = 0; - let node = element; - while (node && node !== document.body) { - depth++; - node = node.parentElement; - } - - if (depth > closestDepth) { - closestBlock = block; - closestDepth = depth; - } - } + sibling = element.nextElementSibling; + while (sibling) { + if (sibling.hasAttribute('data-mageforge-id')) return sibling; + sibling = sibling.nextElementSibling; } - - return closestBlock; + return null; }, }; diff --git a/src/view/frontend/web/js/inspector/picker.js b/src/view/frontend/web/js/inspector/picker.js index 302534e3..ea7be3c8 100644 --- a/src/view/frontend/web/js/inspector/picker.js +++ b/src/view/frontend/web/js/inspector/picker.js @@ -232,6 +232,12 @@ export const pickerMethods = { return target; } + // For PageBuilder elements where no block could be resolved (e.g. injection + // was skipped entirely), still open the inspector showing inherited/no-data state. + if (target.closest('[data-content-type]')) { + return target; + } + return null; }, }; diff --git a/src/view/frontend/web/js/inspector/tabs.js b/src/view/frontend/web/js/inspector/tabs.js index 204b7447..39e9dec1 100644 --- a/src/view/frontend/web/js/inspector/tabs.js +++ b/src/view/frontend/web/js/inspector/tabs.js @@ -127,17 +127,18 @@ export const tabsMethods = { const src = parentBlock.data || {}; const parentData = { - template: src.template || '', - block: src.block || '', - module: src.module || '', - viewModel: src.viewModel || '', - parent: src.parent || '', - alias: src.alias || '', - override: src.override || '0', - blockClass: src.block || '', - parentBlock: src.parent || '', - blockAlias: src.alias || '', + template: src.template || '', + block: src.block || '', + module: src.module || '', + viewModel: src.viewModel || '', + parent: src.parent || '', + alias: src.alias || '', + override: src.override || '0', + blockClass: src.block || '', + parentBlock: src.parent || '', + blockAlias: src.alias || '', isOverride: src.override === '1', + cmsBlockId: src.cmsBlockId || '', }; // Inheritance note @@ -191,6 +192,11 @@ export const tabsMethods = { container.appendChild(this.createInfoSection('ViewModel', data.viewModel, '#22d3ee')); } + // CMS Block identifier (only for Magento_Cms blocks with PageBuilder content) + if (data.cmsBlockId) { + container.appendChild(this.createInfoSection('CMS Block', data.cmsBlockId, '#f472b6')); + } + // Module section container.appendChild(this.createInfoSection('Module', data.module, '#fbbf24')); }, diff --git a/src/view/frontend/web/js/inspector/ui.js b/src/view/frontend/web/js/inspector/ui.js index 1daa1a67..8421b74b 100644 --- a/src/view/frontend/web/js/inspector/ui.js +++ b/src/view/frontend/web/js/inspector/ui.js @@ -98,10 +98,17 @@ export const uiMethods = { const rect = this.getElementRect(element); const elementId = element.getAttribute('data-mageforge-id'); - // Only rebuild badge content if it's a different element - if (this.infoBadge.dataset.currentElement !== elementId) { + // Only rebuild badge content if it's a different element. + // For PageBuilder fallback elements that lack data-mageforge-id, use element + // reference comparison to avoid rebuilding on every hover/click. + const isSame = elementId !== null + ? this.infoBadge.dataset.currentElement === elementId + : this.infoBadge._currentElement === element; + + if (!isSame) { this.buildBadgeContent(element); - this.infoBadge.dataset.currentElement = elementId; + this.infoBadge.dataset.currentElement = elementId ?? ''; + this.infoBadge._currentElement = element; } this.positionBadge(rect); diff --git a/src/view/frontend/web/js/toolbar.js b/src/view/frontend/web/js/toolbar.js index 0162dd08..b857a636 100644 --- a/src/view/frontend/web/js/toolbar.js +++ b/src/view/frontend/web/js/toolbar.js @@ -21,16 +21,16 @@ function _registerMageforgeToolbar() { /** @type {HTMLDivElement|null} */ container: null, - /** @type {HTMLButtonElement|null} */ + /** @type {HTMLDivElement|null} */ burgerButton: null, /** @type {HTMLDivElement|null} */ menu: null, - /** @type {HTMLButtonElement|null} */ + /** @type {HTMLDivElement|null} */ runAllButton: null, - /** @type {HTMLButtonElement|null} */ + /** @type {HTMLDivElement|null} */ resetButton: null, // ==================================================================== diff --git a/src/view/frontend/web/js/toolbar/ui.js b/src/view/frontend/web/js/toolbar/ui.js index 157bae9b..f8ea8c09 100644 --- a/src/view/frontend/web/js/toolbar/ui.js +++ b/src/view/frontend/web/js/toolbar/ui.js @@ -117,8 +117,9 @@ export const uiMethods = { const buttonRow = document.createElement('div'); buttonRow.className = 'mageforge-toolbar-menu-button-row'; - this.runAllButton = document.createElement('button'); - this.runAllButton.type = 'button'; + this.runAllButton = document.createElement('div'); + this.runAllButton.setAttribute('role', 'button'); + this.runAllButton.setAttribute('tabindex', '0'); this.runAllButton.className = 'mageforge-toolbar-menu-run-all'; this.runAllButton.innerHTML = ` @@ -128,10 +129,18 @@ export const uiMethods = { e.stopPropagation(); this.runAllAuditsForScore(); }; + this.runAllButton.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.runAllAuditsForScore(); } + if (e.key === ' ') { e.preventDefault(); } + }; + this.runAllButton.onkeyup = (e) => { + if (e.key === ' ') { e.stopPropagation(); this.runAllAuditsForScore(); } + }; buttonRow.appendChild(this.runAllButton); - this.resetButton = document.createElement('button'); - this.resetButton.type = 'button'; + this.resetButton = document.createElement('div'); + this.resetButton.setAttribute('role', 'button'); + this.resetButton.setAttribute('tabindex', '0'); this.resetButton.className = 'mageforge-toolbar-menu-reset'; this.resetButton.title = 'Reset score and deactivate all audits'; this.resetButton.setAttribute('aria-label', 'Reset score and deactivate all audits'); @@ -140,6 +149,13 @@ export const uiMethods = { e.stopPropagation(); this.resetScore(); }; + this.resetButton.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.resetScore(); } + if (e.key === ' ') { e.preventDefault(); } + }; + this.resetButton.onkeyup = (e) => { + if (e.key === ' ') { e.stopPropagation(); this.resetScore(); } + }; buttonRow.appendChild(this.resetButton); menuFooter.appendChild(buttonRow); @@ -148,8 +164,9 @@ export const uiMethods = { const buttonRow = document.createElement('div'); buttonRow.className = 'mageforge-toolbar-menu-button-row'; - this.runAllButton = document.createElement('button'); - this.runAllButton.type = 'button'; + this.runAllButton = document.createElement('div'); + this.runAllButton.setAttribute('role', 'button'); + this.runAllButton.setAttribute('tabindex', '0'); this.runAllButton.className = 'mageforge-toolbar-menu-run-all'; this.runAllButton.innerHTML = ` @@ -159,10 +176,18 @@ export const uiMethods = { e.stopPropagation(); this.runAllAuditsForScore(); }; + this.runAllButton.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.runAllAuditsForScore(); } + if (e.key === ' ') { e.preventDefault(); } + }; + this.runAllButton.onkeyup = (e) => { + if (e.key === ' ') { e.stopPropagation(); this.runAllAuditsForScore(); } + }; buttonRow.appendChild(this.runAllButton); - this.resetButton = document.createElement('button'); - this.resetButton.type = 'button'; + this.resetButton = document.createElement('div'); + this.resetButton.setAttribute('role', 'button'); + this.resetButton.setAttribute('tabindex', '0'); this.resetButton.className = 'mageforge-toolbar-menu-reset'; this.resetButton.title = 'Deactivate all audits'; this.resetButton.setAttribute('aria-label', 'Deactivate all audits'); @@ -171,6 +196,13 @@ export const uiMethods = { e.stopPropagation(); this.resetScore(); }; + this.resetButton.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.resetScore(); } + if (e.key === ' ') { e.preventDefault(); } + }; + this.resetButton.onkeyup = (e) => { + if (e.key === ' ') { e.stopPropagation(); this.resetScore(); } + }; buttonRow.appendChild(this.resetButton); menuFooter.appendChild(buttonRow); @@ -182,11 +214,12 @@ export const uiMethods = { menuFooter.appendChild(credit); this.menu.appendChild(menuFooter); - // Burger button (left) - this.burgerButton = document.createElement('button'); + // Burger button (left) — div avoids Luma/theme button CSS overrides + this.burgerButton = document.createElement('div'); this.burgerButton.className = 'mageforge-toolbar-burger'; - this.burgerButton.type = 'button'; this.burgerButton.title = 'Audit tools'; + this.burgerButton.setAttribute('role', 'button'); + this.burgerButton.setAttribute('tabindex', '0'); this.burgerButton.setAttribute('aria-label', 'MageForge Toolbar'); this.burgerButton.setAttribute('aria-expanded', 'false'); this.burgerButton.innerHTML = ` @@ -202,6 +235,22 @@ export const uiMethods = { e.stopPropagation(); this.toggleMenu(); }; + this.burgerButton.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + this.toggleMenu(); + } + if (e.key === ' ') { + e.preventDefault(); // prevent page scroll + } + }; + this.burgerButton.onkeyup = (e) => { + if (e.key === ' ') { + e.stopPropagation(); + this.toggleMenu(); + } + }; this.container.appendChild(this.menu); this.container.appendChild(this.burgerButton); @@ -231,8 +280,9 @@ export const uiMethods = { group.className = 'mageforge-toolbar-menu-group'; group.dataset.groupKey = key; - const header = document.createElement('button'); - header.type = 'button'; + const header = document.createElement('div'); + header.setAttribute('role', 'button'); + header.setAttribute('tabindex', '0'); header.className = 'mageforge-toolbar-menu-group-header'; header.setAttribute('aria-expanded', String(!this.collapsedGroups.has(key))); header.onclick = (e) => { @@ -240,6 +290,13 @@ export const uiMethods = { e.stopPropagation(); this.toggleGroup(key); }; + header.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.toggleGroup(key); } + if (e.key === ' ') { e.preventDefault(); } + }; + header.onkeyup = (e) => { + if (e.key === ' ') { e.stopPropagation(); this.toggleGroup(key); } + }; const headerLabel = document.createElement('span'); headerLabel.className = 'mageforge-toolbar-menu-group-label'; @@ -282,11 +339,12 @@ export const uiMethods = { * @param {string} description * @param {Function} callback * @param {?string} groupKey - Optional parent group key for the item - * @return {HTMLButtonElement} + * @return {HTMLDivElement} */ createMenuItem(key, icon, label, description, callback, groupKey = null) { - const item = document.createElement('button'); - item.type = 'button'; + const item = document.createElement('div'); + item.setAttribute('role', 'button'); + item.setAttribute('tabindex', '0'); item.className = 'mageforge-toolbar-menu-item'; item.dataset.auditKey = key; if (groupKey) item.dataset.groupKey = groupKey; @@ -334,6 +392,13 @@ export const uiMethods = { e.stopPropagation(); callback(); }; + item.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); callback(); } + if (e.key === ' ') { e.preventDefault(); } + }; + item.onkeyup = (e) => { + if (e.key === ' ') { e.stopPropagation(); callback(); } + }; return item; },