diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index f1aae65..394412a 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -403,6 +403,23 @@ .mageforge-audit-low-contrast { outline: 3px solid var(--mageforge-color-red) !important; outline-offset: 2px; + box-shadow: inset 0 0 0 3px var(--mageforge-color-red) !important; +} + +/* Fixed-position overlay injected over highlighted elements */ +.mageforge-audit-img-overlay { + position: fixed; + pointer-events: none; + background-color: rgba(239, 68, 68, 0.5); + outline: 3px solid var(--mageforge-color-red); + outline-offset: 0; + z-index: 9999997; +} + +/* Non-image elements (inputs, text) get a colour filter for visibility */ +.mageforge-audit-inputs-without-label, +.mageforge-audit-low-contrast { + filter: brightness(0.5) sepia(1) saturate(12) hue-rotate(330deg) !important; } /* ============================================================================ diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js new file mode 100644 index 0000000..9b5d523 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -0,0 +1,154 @@ +/** + * MageForge Toolbar – Shared highlight helpers + * + * Audits that mark elements by adding a CSS class use these two helpers + * instead of duplicating the same logic. The CSS class is derived from the + * audit key: `mageforge-audit-`. + */ + +const OVERLAY_CLASS = 'mageforge-audit-img-overlay'; + +/** + * Module-level registry: tracks one overlay cleanup function per + * element and the set of audit keys currently relying on that overlay. + * Using a WeakMap means entries are automatically eligible for GC once + * the image node itself is collected. + * + * @type {WeakMap }>} + */ +const imgOverlayRegistry = 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. + * + * @type {Map} + */ +const activeOverlays = new Map(); +let rafPending = false; +let sharedRo = null; + +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(); + } + }); +} + +/** + * Creates a fixed-position overlay that tracks an element's + * position in the viewport. Shares a single RAF-throttled scroll/resize + * handler across all active overlays instead of creating one per image. + * Returns a cleanup function that removes the overlay and deregisters it. + * + * @param {HTMLImageElement} img + * @returns {function} cleanup + */ +function createImgOverlay(img) { + const overlay = document.createElement('span'); + overlay.className = OVERLAY_CLASS; + document.body.appendChild(overlay); + + function update() { + if (!img.isConnected) { + cleanup(); + imgOverlayRegistry.delete(img); + return; + } + const rect = img.getBoundingClientRect(); + overlay.style.top = `${rect.top}px`; + overlay.style.left = `${rect.left}px`; + overlay.style.width = `${rect.width}px`; + 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(); + } + + return cleanup; +} + +/** + * Removes the highlight class from all previously marked elements and + * destroys any associated image overlays. + * + * @param {string} key - Audit key (e.g. 'images-without-alt') + */ +export function clearHighlight(key) { + const cls = `mageforge-audit-${key}`; + document.querySelectorAll(`.${cls}`).forEach(el => { + el.classList.remove(cls); + if (el.tagName === 'IMG') { + const entry = imgOverlayRegistry.get(el); + if (entry) { + entry.keys.delete(key); + if (entry.keys.size === 0) { + entry.cleanup(); + imgOverlayRegistry.delete(el); + } + } + } + }); +} + +/** + * Highlights a set of elements by adding the audit CSS class, scrolls to the + * first result, and updates the counter badge on the toolbar menu item. + * + * For elements a fixed-position overlay is injected so the red + * background is visible regardless of parent overflow or border-radius. + * + * @param {Element[]} elements - Elements to mark + * @param {string} key - Audit key (e.g. 'images-without-alt') + * @param {object} context - Alpine toolbar component instance + */ +export function applyHighlight(elements, key, context) { + if (elements.length === 0) { + context.setAuditCounterBadge(key, '0', 'success'); + return; + } + const cls = `mageforge-audit-${key}`; + elements.forEach(el => { + el.classList.add(cls); + if (el.tagName === 'IMG') { + const existing = imgOverlayRegistry.get(el); + if (existing) { + existing.keys.add(key); + } else { + imgOverlayRegistry.set(el, { + cleanup: createImgOverlay(el), + keys: new Set([key]), + }); + } + } + }); + elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + context.setAuditCounterBadge(key, `${elements.length}`, 'error'); +} 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 07d10c9..ba7c1e2 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 @@ -2,7 +2,7 @@ * MageForge Toolbar Audit – Images without ALT */ -const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-alt'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -17,7 +17,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -28,13 +28,6 @@ export default { return !img.hasAttribute('alt') || img.getAttribute('alt').trim() === ''; }); - if (images.length === 0) { - context.setAuditCounterBadge('images-without-alt', '0', 'success'); - return; - } - - images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); - images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('images-without-alt', `${images.length}`, 'error'); + applyHighlight(images, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js index 2898ed2..40e1388 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js @@ -4,7 +4,7 @@ * Images missing explicit width and height attributes cause Cumulative Layout Shift (CLS). */ -const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-dimensions'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -19,7 +19,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -30,13 +30,6 @@ export default { return !img.hasAttribute('width') || !img.hasAttribute('height'); }); - if (images.length === 0) { - context.setAuditCounterBadge('images-without-dimensions', '0', 'success'); - return; - } - - images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); - images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('images-without-dimensions', `${images.length}`, 'error'); + applyHighlight(images, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js index c6e3029..967ed2c 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js @@ -5,7 +5,7 @@ * wasting bandwidth and slowing initial page load. */ -const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-lazy-load'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -20,7 +20,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -35,13 +35,6 @@ export default { return rect.top > viewportBottom; }); - if (images.length === 0) { - context.setAuditCounterBadge('images-without-lazy-load', '0', 'success'); - return; - } - - images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); - images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('images-without-lazy-load', `${images.length}`, 'error'); + applyHighlight(images, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js index 8ccd562..4edab9a 100644 --- a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js +++ b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js @@ -5,7 +5,7 @@ * to screen reader users. */ -const HIGHLIGHT_CLASS = 'mageforge-audit-inputs-without-label'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -20,7 +20,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -39,13 +39,6 @@ export default { return true; }); - if (inputs.length === 0) { - context.setAuditCounterBadge('inputs-without-label', '0', 'success'); - return; - } - - inputs.forEach(el => el.classList.add(HIGHLIGHT_CLASS)); - inputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('inputs-without-label', `${inputs.length}`, 'error'); + applyHighlight(inputs, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js b/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js index ede5db7..7d028a8 100644 --- a/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js +++ b/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js @@ -6,7 +6,7 @@ * - 3:1 for large text (>=18pt or >=14pt bold) */ -const HIGHLIGHT_CLASS = 'mageforge-audit-low-contrast'; +import { applyHighlight, clearHighlight } from './highlight.js'; const _colorCanvas = document.createElement('canvas'); _colorCanvas.width = _colorCanvas.height = 1; @@ -164,7 +164,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -189,13 +189,6 @@ export default { return ratio < threshold; }); - if (failing.length === 0) { - context.setAuditCounterBadge('low-contrast-text', '0', 'success'); - return; - } - - failing.forEach(el => el.classList.add(HIGHLIGHT_CLASS)); - failing[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('low-contrast-text', `${failing.length}`, 'error'); + applyHighlight(failing, this.key, context); }, };