diff --git a/src/view/frontend/web/js/toolbar.js b/src/view/frontend/web/js/toolbar.js index 62c9ebc4..c4655126 100644 --- a/src/view/frontend/web/js/toolbar.js +++ b/src/view/frontend/web/js/toolbar.js @@ -39,6 +39,9 @@ function _registerMageforgeToolbar() { /** @type {Function|null} Global keydown handler for keyboard shortcuts */ _keyboardShortcutHandler: null, + /** @type {Map} In-memory audit badge status (avoids DOM reads in score calc) */ + _auditStatus: new Map(), + // ==================================================================== // Lifecycle // ==================================================================== diff --git a/src/view/frontend/web/js/toolbar/audits.js b/src/view/frontend/web/js/toolbar/audits.js index 9756aa40..17316b23 100644 --- a/src/view/frontend/web/js/toolbar/audits.js +++ b/src/view/frontend/web/js/toolbar/audits.js @@ -63,7 +63,8 @@ export const auditMethods = { }, /** - * Calculate a 0–100 score for the given audit list based on current DOM state. + * Calculate a 0–100 score for the given audit list. + * Reads from the in-memory _auditStatus map; unrun audits count as perfect (100). * * @param {import('./audits/index.js').AuditDefinition[]} auditList * @returns {number} @@ -72,21 +73,15 @@ export const auditMethods = { let total = 0; let max = 0; auditList.forEach((audit) => { - const item = this.menu?.querySelector(`[data-audit-key="${audit.key}"]`); - if (!item) return; + if (!this.menu?.querySelector(`[data-audit-key="${audit.key}"]`)) return; max += 100; - const status = item.querySelector(".mageforge-toolbar-menu-status"); - if (!status || !status.textContent.trim()) { - total += 100; - } else if ( - status.classList.contains("mageforge-toolbar-menu-status--success") - ) { + const status = this._auditStatus.get(audit.key); + if (!status || status === "success") { total += 100; - } else if ( - status.classList.contains("mageforge-toolbar-menu-status--warning") - ) { + } else if (status === "warning") { total += 50; } + // error → 0 pts, nothing added }); return max > 0 ? Math.round((total / max) * 100) : 100; }, @@ -471,6 +466,12 @@ export const auditMethods = { * @param {'success'|'error'} type */ setAuditCounterBadge(key, message, type = "success") { + // Keep in-memory status in sync; delete key when badge is cleared (unrun state) + if (message.trim()) { + this._auditStatus.set(key, type); + } else { + this._auditStatus.delete(key); + } if (!this.menu) return; const item = this.menu.querySelector(`[data-audit-key="${key}"]`); if (!item) return; diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js index c9e3d687..62d86457 100644 --- a/src/view/frontend/web/js/toolbar/audits/highlight.js +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -21,32 +21,117 @@ const overlayRegistry = new WeakMap(); /** * Shared update machinery – a single ResizeObserver and capturing scroll - * listener serve all active overlays, throttled via requestAnimationFrame. - * Attached on the first overlay, torn down when the last one is removed. + * listener serve all active overlays and any registered extra callbacks, + * throttled via requestAnimationFrame. + * Attached when the first overlay is created or the first extra callback is + * registered; torn down when both collections are empty again. * - * @type {Map} + * activeOverlays maps each overlay to its tracked element and cleanup + * function so the RAF can perform a single batched read phase followed by a + * single write phase, avoiding forced layout reflows between reads and writes. + * + * @type {Map} */ const activeOverlays = new Map(); + +/** + * Additional per-frame callbacks registered by other audit modules (e.g. + * tab-order) that need to piggyback on the shared scroll/resize listeners. + * + * @type {Set} + */ +const extraCallbacks = new Set(); + let rafPending = false; let sharedRo = null; +/** Attach the shared ResizeObserver and scroll listener if not already active. */ +function attachSharedListeners() { + if (sharedRo) return; + sharedRo = new ResizeObserver(scheduleUpdate); + sharedRo.observe(document.documentElement); + window.addEventListener("scroll", scheduleUpdate, { + passive: true, + capture: true, + }); +} + +/** Detach shared listeners once neither overlays nor extra callbacks need them. */ +function detachSharedListeners() { + if (activeOverlays.size > 0 || extraCallbacks.size > 0) return; + sharedRo?.disconnect(); + sharedRo = null; + window.removeEventListener("scroll", scheduleUpdate, { capture: true }); +} + +/** + * Schedule a single RAF-throttled update pass that: + * 1. Reads all overlay bounding rects in one batched read phase + * 2. Writes all overlay positions in one batched write phase + * 3. Runs any registered extra callbacks (e.g. tab-order repositioning) + * + * Separating reads from writes prevents the browser from performing a full + * layout reflow between every read–write pair ("layout thrashing"). + */ function scheduleUpdate() { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; - // Snapshot before iterating: update() calls may delete entries - // (image disconnected → cleanup) while we are looping. - for (const updateFn of [...activeOverlays.values()]) { - updateFn(); + + // --- Batched read phase: snapshot all bounding rects before any write --- + const entries = [...activeOverlays.entries()]; + const rects = entries.map(([, { el }]) => + el.isConnected ? el.getBoundingClientRect() : null, + ); + + // --- Batched write phase: update all overlay positions --- + entries.forEach(([overlay, { cleanup }], i) => { + const rect = rects[i]; + if (!rect) { + // Element removed from the DOM – clean up its overlay. + cleanup(); + return; + } + overlay.style.top = `${rect.top}px`; + overlay.style.left = `${rect.left}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + }); + + // --- Extra per-frame callbacks (e.g. tab-order badge repositioning) --- + for (const cb of extraCallbacks) { + cb(); } }); } /** - * Creates a fixed-position overlay that tracks any element's - * bounding box in the viewport. Shares a single RAF-throttled scroll/resize - * handler across all active overlays instead of creating one per element. + * Register a callback to be invoked every animation frame alongside overlay + * updates. The shared scroll / resize listeners are kept alive while at least + * one callback is registered, even if no overlay highlights are active. + * + * @param {function} fn + */ +export function addSharedCallback(fn) { + extraCallbacks.add(fn); + attachSharedListeners(); +} + +/** + * Remove a previously registered per-frame callback. Tears down shared + * listeners when no overlays or callbacks remain. + * + * @param {function} fn + */ +export function removeSharedCallback(fn) { + extraCallbacks.delete(fn); + detachSharedListeners(); +} + +/** + * Creates a fixed-position overlay that tracks any element's bounding + * box in the viewport. Participates in the shared RAF-throttled update cycle. * Returns a cleanup function that removes the overlay and deregisters it. * * @param {Element} el @@ -60,12 +145,8 @@ function createOverlay(el, severity = "error") { overlay.classList.add("mageforge-audit-overlay--warning"); document.body.appendChild(overlay); - function update() { - if (!el.isConnected) { - cleanup(); - - return; - } + // Set initial position synchronously so the overlay appears immediately. + if (el.isConnected) { const rect = el.getBoundingClientRect(); overlay.style.top = `${rect.top}px`; overlay.style.left = `${rect.left}px`; @@ -73,32 +154,15 @@ function createOverlay(el, severity = "error") { overlay.style.height = `${rect.height}px`; } - update(); - - activeOverlays.set(overlay, update); - - // Attach shared listeners only when the first overlay is created. - if (activeOverlays.size === 1) { - sharedRo = new ResizeObserver(scheduleUpdate); - sharedRo.observe(document.documentElement); - window.addEventListener("scroll", scheduleUpdate, { - passive: true, - capture: true, - }); - } - - // Named so update() can reference it before its var declaration (hoisting). function cleanup() { activeOverlays.delete(overlay); - // Tear down shared listeners once no overlays remain. - if (activeOverlays.size === 0) { - sharedRo?.disconnect(); - sharedRo = null; - window.removeEventListener("scroll", scheduleUpdate, { capture: true }); - } overlay.remove(); + detachSharedListeners(); } + activeOverlays.set(overlay, { el, cleanup }); + attachSharedListeners(); + return cleanup; } diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js index 4399a148..1062da4b 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js @@ -20,7 +20,7 @@ export default createAudit( return ( style.visibility !== "hidden" && style.display !== "none" && - style.opacity !== "0" + parseFloat(style.opacity) !== 0 ); }, ); diff --git a/src/view/frontend/web/js/toolbar/audits/tab-order.js b/src/view/frontend/web/js/toolbar/audits/tab-order.js index 6207192e..9b271749 100644 --- a/src/view/frontend/web/js/toolbar/audits/tab-order.js +++ b/src/view/frontend/web/js/toolbar/audits/tab-order.js @@ -7,6 +7,8 @@ * Clicking the audit again removes the overlay (toggle). */ +import { addSharedCallback, removeSharedCallback } from "./highlight.js"; + const OVERLAY_ID = "mageforge-tab-order-overlay"; const CSS_ID = "mageforge-tab-order-css"; const CSS_URL = new URL("../../../css/audits/tab-order.css", import.meta.url) @@ -41,12 +43,24 @@ function isVisible(el) { return style.visibility !== "hidden" && style.display !== "none"; } +/** + * Cached overlay state: the sorted element list and their corresponding badge + * and SVG line DOM nodes, stored after renderOverlay() to enable cheap + * repositioning without rebuilding the DOM on scroll/resize. + * + * @type {{ sorted: Element[], badges: HTMLSpanElement[], lines: SVGLineElement[] } | null} + */ +let overlayState = null; + /** * Returns true if the element lies completely outside the visible area of an * ancestor with overflow:hidden/clip (e.g. a carousel slide that is off-canvas). + * + * @param {Element} el + * @param {DOMRect} [preComputedRect] - Pre-computed bounding rect to avoid a duplicate call */ -function isClippedByAncestor(el) { - const elRect = el.getBoundingClientRect(); +function isClippedByAncestor(el, preComputedRect) { + const elRect = preComputedRect ?? el.getBoundingClientRect(); let ancestor = el.parentElement; while (ancestor && ancestor !== document.documentElement) { const style = getComputedStyle(ancestor); @@ -101,18 +115,17 @@ function sortByTabOrder(elements) { } /** - * (Re-)renders the overlay: removes any existing overlay and redraws all - * badges and connecting SVG lines at their current viewport positions. + * Builds the full tab-order overlay (badges + SVG lines) from scratch. + * Uses a batched read-then-write pattern to avoid layout thrashing. + * Stores element → badge/line references in overlayState so that + * repositionOverlay() can update positions cheaply on every scroll/resize + * frame without rebuilding the DOM. * * @param {Element[]} sorted - focusable elements in tab order */ function renderOverlay(sorted) { - const existing = document.getElementById(OVERLAY_ID); - if (existing) { - existing.remove(); - } + document.getElementById(OVERLAY_ID)?.remove(); - // Build overlay const overlay = document.createElement("div"); overlay.id = OVERLAY_ID; overlay.className = "mageforge-tab-order-overlay"; @@ -125,43 +138,81 @@ function renderOverlay(sorted) { document.body.appendChild(overlay); - // Place badges and record centre points - const centres = sorted.map((el, index) => { - const rect = el.getBoundingClientRect(); - const cx = Math.round(rect.left + rect.width / 2); - const cy = Math.round(rect.top); + // --- Batched read phase: all getBoundingClientRect calls before any write --- + const rects = sorted.map((el) => el.getBoundingClientRect()); + const clipped = sorted.map((el, i) => isClippedByAncestor(el, rects[i])); - const clipped = isClippedByAncestor(el); + // --- Batched write phase: create and position all badges --- + const badges = sorted.map((el, index) => { + const cx = Math.round(rects[index].left + rects[index].width / 2); + const cy = Math.round(rects[index].top); const badge = document.createElement("span"); badge.className = "mageforge-tab-order-badge" + (getTabIndex(el) > 0 ? " mageforge-tab-order-badge--negative" : "") + - (clipped ? " mageforge-tab-order-badge--clipped" : ""); + (clipped[index] ? " mageforge-tab-order-badge--clipped" : ""); badge.textContent = index + 1; badge.style.left = cx + "px"; badge.style.top = cy + "px"; overlay.appendChild(badge); - - return { cx, cy, negative: getTabIndex(el) > 0, clipped }; + return badge; }); // Draw connecting lines between consecutive badges - for (let i = 0; i < centres.length - 1; i++) { - const from = centres[i]; - const to = centres[i + 1]; + const lines = []; + for (let i = 0; i < sorted.length - 1; i++) { + const from = rects[i]; + const to = rects[i + 1]; const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.classList.add("mageforge-tab-order-line"); - if (from.negative || to.negative) { + if (getTabIndex(sorted[i]) > 0 || getTabIndex(sorted[i + 1]) > 0) { line.classList.add("mageforge-tab-order-line--negative"); - } else if (from.clipped || to.clipped) { + } else if (clipped[i] || clipped[i + 1]) { line.classList.add("mageforge-tab-order-line--clipped"); } - line.setAttribute("x1", from.cx); - line.setAttribute("y1", from.cy); - line.setAttribute("x2", to.cx); - line.setAttribute("y2", to.cy); + line.setAttribute("x1", Math.round(from.left + from.width / 2)); + line.setAttribute("y1", Math.round(from.top)); + line.setAttribute("x2", Math.round(to.left + to.width / 2)); + line.setAttribute("y2", Math.round(to.top)); svg.appendChild(line); + lines.push(line); } + + overlayState = { sorted, badges, lines }; +} + +/** + * Updates the position of all tab-order badges and SVG connecting lines to + * match the current viewport positions of their elements. Uses a batched + * read-then-write pattern to avoid layout thrashing. + * + * Called via the shared RAF scheduler on every scroll/resize frame instead of + * rebuilding the entire overlay DOM from scratch. + */ +function repositionOverlay() { + if (!overlayState || !document.getElementById(OVERLAY_ID)) return; + + const { sorted, badges, lines } = overlayState; + + // --- Batched read phase --- + const rects = sorted.map((el) => el.getBoundingClientRect()); + + // --- Batched write phase: badge positions --- + badges.forEach((badge, i) => { + badge.style.left = Math.round(rects[i].left + rects[i].width / 2) + "px"; + badge.style.top = Math.round(rects[i].top) + "px"; + }); + + // --- Batched write phase: SVG line endpoints --- + lines.forEach((line, i) => { + line.setAttribute("x1", Math.round(rects[i].left + rects[i].width / 2)); + line.setAttribute("y1", Math.round(rects[i].top)); + line.setAttribute( + "x2", + Math.round(rects[i + 1].left + rects[i + 1].width / 2), + ); + line.setAttribute("y2", Math.round(rects[i + 1].top)); + }); } /** @type {import('./index.js').AuditDefinition} */ @@ -179,14 +230,8 @@ export default { injectCss(); if (!active) { - context._tabOrderObserver?.disconnect(); - context._tabOrderObserver = null; - if (context._tabOrderScrollHandler) { - document.removeEventListener("scroll", context._tabOrderScrollHandler, { - capture: true, - }); - context._tabOrderScrollHandler = null; - } + removeSharedCallback(repositionOverlay); + overlayState = null; document.getElementById(OVERLAY_ID)?.remove(); return; } @@ -207,41 +252,9 @@ export default { renderOverlay(sorted); - // Always recompute from the live DOM so detached / newly added elements are handled correctly - const rerender = () => - renderOverlay( - sortByTabOrder( - Array.from(document.querySelectorAll(FOCUSABLE_SELECTOR)) - .filter((el) => !el.closest(".mageforge-toolbar")) - .filter(isVisible), - ), - ); - - // Re-render on resize or scroll (e.g. DevTools panel, page scroll) - context._tabOrderObserver = new ResizeObserver(() => { - if (!document.getElementById(OVERLAY_ID)) { - context._tabOrderObserver?.disconnect(); - context._tabOrderObserver = null; - return; - } - rerender(); - }); - context._tabOrderObserver.observe(document.body); - - let scrollRaf = null; - context._tabOrderScrollHandler = () => { - if (scrollRaf) return; - scrollRaf = requestAnimationFrame(() => { - scrollRaf = null; - if (document.getElementById(OVERLAY_ID)) { - rerender(); - } - }); - }; - document.addEventListener("scroll", context._tabOrderScrollHandler, { - capture: true, - passive: true, - }); + // Register repositionOverlay as a shared per-frame callback so badge + // positions are updated on scroll/resize without rebuilding the DOM. + addSharedCallback(repositionOverlay); const type = hasNegative ? "error" : "success"; context.setAuditCounterBadge("tab-order", `${sorted.length}`, type); diff --git a/src/view/frontend/web/js/toolbar/ui/build.js b/src/view/frontend/web/js/toolbar/ui/build.js index afd612ab..93302021 100644 --- a/src/view/frontend/web/js/toolbar/ui/build.js +++ b/src/view/frontend/web/js/toolbar/ui/build.js @@ -83,6 +83,9 @@ export const buildMethods = { _buildMenu() { const menu = document.createElement("div"); menu.className = "mageforge-toolbar-menu"; + menu.setAttribute("role", "dialog"); + menu.setAttribute("aria-modal", "true"); + menu.setAttribute("aria-label", "MageForge Toolbar"); menu.appendChild(this._buildMenuHeader()); menu.appendChild(this._buildTabLayout()); menu.appendChild(this._buildMenuFooter()); @@ -102,8 +105,8 @@ export const buildMethods = {
${createLogoSvg("#E5622A")}
MageForge - `; header.querySelector(".mageforge-toolbar-menu-close").onclick = (e) => {