Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/view/frontend/web/css/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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;
}

/* ============================================================================
Expand Down
154 changes: 154 additions & 0 deletions src/view/frontend/web/js/toolbar/audits/highlight.js
Original file line number Diff line number Diff line change
@@ -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-<key>`.
*/

const OVERLAY_CLASS = 'mageforge-audit-img-overlay';

/**
* Module-level registry: tracks one overlay cleanup function per <img>
* 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<HTMLImageElement, { cleanup: function, keys: Set<string> }>}
*/
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<HTMLSpanElement, function>}
*/
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 <span> that tracks an <img> 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 <img> 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');
}
13 changes: 3 additions & 10 deletions src/view/frontend/web/js/toolbar/audits/images-without-alt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand All @@ -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);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand All @@ -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);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand All @@ -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);
},
};
13 changes: 3 additions & 10 deletions src/view/frontend/web/js/toolbar/audits/inputs-without-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand All @@ -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);
},
};
13 changes: 3 additions & 10 deletions src/view/frontend/web/js/toolbar/audits/low-contrast-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
},
};
Loading