diff --git a/LICENSE b/LICENSE index f31529a9..db31e73a 100644 --- a/LICENSE +++ b/LICENSE @@ -685,3 +685,12 @@ may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . + + +------------------------------------------------------------------------------- +Third-Party Notices +------------------------------------------------------------------------------- + +This project includes icons from Tabler Icons (https://tabler.io/icons). +Copyright (c) 2020-2024 Paweł Kuna (codecalm) +Licensed under the MIT License (https://github.com/tabler/tabler-icons/blob/main/LICENSE) diff --git a/README.md b/README.md index c8703bf8..a76604ef 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,14 @@ bin/magento mageforge:theme:inspector disable - **License**: [LICENSE](LICENSE) - **Changelog**: [CHANGELOG](CHANGELOG.md) +## Credits + +MageForge uses the following third-party libraries: + +| Library | Author | License | +| ------- | ------ | ------- | +| [Tabler Icons](https://tabler.io/icons) | codecalm | [MIT](https://github.com/tabler/tabler-icons/blob/main/LICENSE) | + --- Thank you for using MageForge! diff --git a/src/Block/Inspector.php b/src/Block/Inspector.php index aa4d20a7..e29b29df 100644 --- a/src/Block/Inspector.php +++ b/src/Block/Inspector.php @@ -18,6 +18,7 @@ class Inspector extends Template { private const XML_PATH_INSPECTOR_ENABLED = 'dev/mageforge_inspector/enabled'; + private const XML_PATH_SHOW_BUTTON_LABELS = 'mageforge/inspector/show_button_labels'; /** * @param Context $context @@ -72,6 +73,16 @@ public function getCssUrl(): string return $this->getViewFileUrl('OpenForgeProject_MageForge::css/inspector.css'); } + /** + * Get Toolbar CSS file URL + * + * @return string + */ + public function getToolbarCssUrl(): string + { + return $this->getViewFileUrl('OpenForgeProject_MageForge::css/toolbar.css'); + } + /** * Get JS file URL * @@ -82,6 +93,28 @@ public function getJsUrl(): string return $this->getViewFileUrl('OpenForgeProject_MageForge::js/inspector.js'); } + /** + * Get Toolbar JS file URL + * + * @return string + */ + public function getToolbarJsUrl(): string + { + return $this->getViewFileUrl('OpenForgeProject_MageForge::js/toolbar.js'); + } + + /** + * Whether button labels should be displayed in the toolbar + * + * @return bool + */ + public function getShowButtonLabels(): bool + { + $value = $this->scopeConfig->getValue(self::XML_PATH_SHOW_BUTTON_LABELS); + // Default to true when not explicitly set to '0' + return $value !== '0'; + } + /** * Get configured theme * diff --git a/src/Console/Command/Dev/InspectorCommand.php b/src/Console/Command/Dev/InspectorCommand.php index 5ba45238..5b1e4f30 100644 --- a/src/Console/Command/Dev/InspectorCommand.php +++ b/src/Console/Command/Dev/InspectorCommand.php @@ -175,7 +175,9 @@ private function showStatus(): int ]); } elseif (!$isEnabled) { $this->io->newLine(); - $this->io->note('Run "bin/magento mageforge:theme:inspector enable" to activate the inspector.'); + $this->io->note( + 'Inspector is disabled. Run "bin/magento mageforge:theme:inspector enable" to activate it.' + ); } else { $this->io->newLine(); $this->io->writeln(' Inspector is active and ready to use!'); diff --git a/src/etc/adminhtml/system.xml b/src/etc/adminhtml/system.xml index 87096a25..4de1c0d1 100644 --- a/src/etc/adminhtml/system.xml +++ b/src/etc/adminhtml/system.xml @@ -5,15 +5,16 @@
- + mageforge OpenForgeProject_MageForge::config_inspector - + - + Magento\Config\Model\Config\Source\Yesno dev/mageforge_inspector/enabled + Enable or disable the MageForge Toolbar. Default: Yes. @@ -21,6 +22,12 @@ mageforge/inspector/theme Choose between Dark, Light, or Auto (System Preference) theme. + + + Magento\Config\Model\Config\Source\Yesno + mageforge/inspector/show_button_labels + Show text labels on the Toolbar and Inspector buttons. Default: Yes. +
diff --git a/src/etc/config.xml b/src/etc/config.xml index bec36f2e..13170d39 100644 --- a/src/etc/config.xml +++ b/src/etc/config.xml @@ -4,12 +4,13 @@ - 0 + 1 dark + 1 diff --git a/src/view/frontend/templates/inspector.phtml b/src/view/frontend/templates/inspector.phtml index 3f61fae2..735a8c22 100644 --- a/src/view/frontend/templates/inspector.phtml +++ b/src/view/frontend/templates/inspector.phtml @@ -12,6 +12,7 @@ */ ?> + @@ -60,8 +61,15 @@ JS; ?> renderTag('script', [], $alpineBootstrap, false) ?> + + +
+
this.handleMouseMove(e); this.clickHandler = (e) => this.handleClick(e); - // Cache for block detection - this.cachedBlocks = null; - this.lastBlocksCacheTime = 0; - this.setupKeyboardShortcuts(); this.createHighlightBox(); this.createInfoBadge(); - this.createFloatingButton(); this.initWebVitalsTracking(); this.cachePageTimings(); + // Listen for inspector-state sync from toolbar + this._inspectorStateHandler = (e) => { + if (this._inspectorFloatButton) { + this._inspectorFloatButton.classList.toggle('mageforge-active', e.detail.active); + } + }; + window.addEventListener('mageforge:toolbar:inspector-state', this._inspectorStateHandler); + + // Append inspector button to toolbar container. + // The toolbar initialises before the inspector, but guard with a + // MutationObserver fallback for edge cases where it hasn't rendered yet. + this._appendInspectorButton(); + // Dispatch init event for Hyvä integration this.$dispatch('mageforge:inspector:init'); }, + _createInspectorFloatButton() { + const btn = document.createElement('button'); + btn.className = 'mageforge-inspector-float-button'; + btn.type = 'button'; + btn.title = 'Activate Inspector (Ctrl+Shift+I)'; + btn.innerHTML = ` + + + + + + + + Inspector + `; + btn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleInspector(); + }; + return btn; + }, + + _appendInspectorButton() { + const _attach = (container) => { + container.querySelector('.mageforge-inspector-float-button')?.remove(); + this._inspectorFloatButton = this._createInspectorFloatButton(); + container.appendChild(this._inspectorFloatButton); + }; + + const toolbarContainer = document.querySelector('.mageforge-toolbar'); + if (toolbarContainer) { + _attach(toolbarContainer); + return; + } + + // Toolbar not in DOM yet – wait for it + const observer = new MutationObserver(() => { + const container = document.querySelector('.mageforge-toolbar'); + if (container) { + observer.disconnect(); + _attach(container); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + this._buttonObserver = observer; + }, + destroy() { + // Remove window event listeners + if (this._inspectorStateHandler) { + window.removeEventListener('mageforge:toolbar:inspector-state', this._inspectorStateHandler); + this._inspectorStateHandler = null; + } + + // Disconnect button injection observer if still running + if (this._buttonObserver) { + this._buttonObserver.disconnect(); + this._buttonObserver = null; + } + + // Clear pending hover timeout + if (this.hoverTimeout) { + clearTimeout(this.hoverTimeout); + this.hoverTimeout = null; + } + // Remove keyboard listener if (this.keydownHandler) { document.removeEventListener('keydown', this.keydownHandler); @@ -125,14 +205,14 @@ function _registerMageforgeInspector() { this.infoBadge.remove(); this.infoBadge = null; } - if (this.floatingButton) { - this.floatingButton.remove(); - this.floatingButton = null; - } if (this.connectorSvg) { this.connectorSvg.remove(); this.connectorSvg = null; } + if (this._inspectorFloatButton) { + this._inspectorFloatButton.remove(); + this._inspectorFloatButton = null; + } }, // ==================================================================== diff --git a/src/view/frontend/web/js/inspector/accessibility.js b/src/view/frontend/web/js/inspector/accessibility.js index ea8a88c9..46a6e778 100644 --- a/src/view/frontend/web/js/inspector/accessibility.js +++ b/src/view/frontend/web/js/inspector/accessibility.js @@ -73,20 +73,16 @@ export const accessibilityMethods = { */ getLazyLoadingStyle(lazyLoading) { let lazyColor = '#94a3b8'; - let lazyIcon = '⚡'; if (lazyLoading.includes('Native')) { lazyColor = '#34d399'; - lazyIcon = '✅'; } else if (lazyLoading.includes('JavaScript')) { lazyColor = '#22d3ee'; - lazyIcon = '🔧'; } else if (lazyLoading === 'Not set') { lazyColor = '#f59e0b'; - lazyIcon = '⚠️'; } - return { lazyIcon, lazyColor }; + return { lazyColor }; }, /** @@ -147,8 +143,11 @@ export const accessibilityMethods = { const ariaLabelledBy = element.getAttribute('aria-labelledby'); if (ariaLabelledBy) { - const labelElement = document.getElementById(ariaLabelledBy); - return labelElement ? labelElement.textContent.trim() : ariaLabelledBy; + const labelText = ariaLabelledBy.trim().split(/\s+/) + .map(id => document.getElementById(id)?.textContent?.trim()) + .filter(Boolean) + .join(' '); + return labelText || ariaLabelledBy; } const altText = tagName === 'img' ? element.getAttribute('alt') : null; @@ -242,6 +241,10 @@ export const accessibilityMethods = { if (element.hasAttribute('disabled')) { return false; } + // hidden inputs are never focusable + if (tagName === 'input' && (element.getAttribute('type') || '').toLowerCase() === 'hidden') { + return false; + } // Links need href if (tagName === 'a' && !element.hasAttribute('href')) { return false; diff --git a/src/view/frontend/web/js/inspector/ui.js b/src/view/frontend/web/js/inspector/ui.js index 96cff6f6..1daa1a67 100644 --- a/src/view/frontend/web/js/inspector/ui.js +++ b/src/view/frontend/web/js/inspector/ui.js @@ -45,51 +45,12 @@ export const uiMethods = { }, /** - * Create floating button for inspector activation - */ - createFloatingButton() { - this.floatingButton = document.createElement('button'); - this.floatingButton.className = 'mageforge-inspector mageforge-inspector-float-button'; - - // Propagate theme from root element to injected body element - if (this.$el && this.$el.hasAttribute('data-theme')) { - this.floatingButton.setAttribute('data-theme', this.$el.getAttribute('data-theme')); - } - - this.floatingButton.type = 'button'; - this.floatingButton.title = 'Activate Inspector (Ctrl+Shift+I)'; - this.floatingButton.innerHTML = ` - - - - - - - - MageForge Inspector - `; - - // Click to toggle inspector - this.floatingButton.onclick = (e) => { - e.preventDefault(); - e.stopPropagation(); - this.toggleInspector(); - }; - - document.body.appendChild(this.floatingButton); - }, - - /** - * Update floating button state + * Notify the toolbar about the current inspector open/closed state */ updateFloatingButton() { - if (!this.floatingButton) return; - - if (this.isOpen) { - this.floatingButton.classList.add('mageforge-active'); - } else { - this.floatingButton.classList.remove('mageforge-active'); - } + window.dispatchEvent(new CustomEvent('mageforge:toolbar:inspector-state', { + detail: { active: this.isOpen } + })); }, /** diff --git a/src/view/frontend/web/js/toolbar.js b/src/view/frontend/web/js/toolbar.js new file mode 100644 index 00000000..518260ce --- /dev/null +++ b/src/view/frontend/web/js/toolbar.js @@ -0,0 +1,79 @@ +/** + * MageForge Toolbar - Standalone audit toolbar. + */ + +import { uiMethods } from './toolbar/ui.js'; +import { auditMethods } from './toolbar/audits.js'; + +function _registerMageforgeToolbar() { + Alpine.data('mageforgeToolbar', () => ({ + // ==================================================================== + // State + // ==================================================================== + menuOpen: false, + + /** @type {Set} Keys of currently active audits */ + activeAudits: new Set(), + + /** @type {Set} Keys of currently collapsed groups */ + collapsedGroups: new Set(), + + /** @type {HTMLDivElement|null} */ + container: null, + + /** @type {HTMLButtonElement|null} */ + burgerButton: null, + + /** @type {HTMLDivElement|null} */ + menu: null, + + /** @type {HTMLButtonElement|null} */ + toggleAllButton: null, + + // ==================================================================== + // Lifecycle + // ==================================================================== + + init() { + try { + const saved = localStorage.getItem('mageforge-toolbar-collapsed-groups'); + if (saved) { + try { JSON.parse(saved).forEach(key => this.collapsedGroups.add(key)); } catch (_) {} + } + } catch (_) {} + this.createToolbar(); + }, + + destroy() { + if (this._outsideClickHandler) { + document.removeEventListener('click', this._outsideClickHandler); + this._outsideClickHandler = null; + } + if (this.container) { + this.container.remove(); + this.container = null; + } + }, + + // ==================================================================== + // Mixins + // ==================================================================== + + ...uiMethods, + ...auditMethods, + })); +} + +// re-initialise any [x-data="mageforgeToolbar"] elements that Alpine skipped +// because the component was not yet registered at that point. +// Otherwise, register on alpine:init which fires before Alpine processes the DOM. +if (typeof Alpine !== 'undefined') { + _registerMageforgeToolbar(); + document.querySelectorAll('[x-data="mageforgeToolbar"]').forEach(function (el) { + if (typeof Alpine.initTree === 'function') { + Alpine.initTree(el); + } + }); +} else { + document.addEventListener('alpine:init', _registerMageforgeToolbar); +} diff --git a/src/view/frontend/web/js/toolbar/audits.js b/src/view/frontend/web/js/toolbar/audits.js new file mode 100644 index 00000000..eb3cd0e1 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits.js @@ -0,0 +1,113 @@ +/** + * MageForge Toolbar - Audit dispatcher + */ + +import { audits, auditGroups } from './audits/index.js'; + +export const auditMethods = { + /** + * Toggles an audit on/off and updates the menu item state. + * + * @param {string} auditKey + */ + runAudit(auditKey) { + const audit = audits.find(a => a.key === auditKey); + if (!audit) return; + + const isActive = this.activeAudits.has(auditKey); + if (isActive) { + this.activeAudits.delete(auditKey); + } else { + this.activeAudits.add(auditKey); + } + audit.run(this, !isActive); + this.setAuditActive(auditKey, !isActive); + }, + + /** + * Activates all inactive audits or deactivates all if all are already active. + */ + toggleAllAudits() { + const allActive = this.activeAudits.size === audits.length; + if (allActive) { + this.deactivateAllAudits(); + } else { + audits.forEach(audit => { + if (!this.activeAudits.has(audit.key)) { + this.runAudit(audit.key); + } + }); + } + }, + + /** + * Deactivates all currently active audits (called when closing the toolbar). + */ + deactivateAllAudits() { + const keys = [...this.activeAudits]; + keys.forEach(key => { + this.activeAudits.delete(key); + const audit = audits.find(a => a.key === key); + if (audit) audit.run(this, false); + this.setAuditActive(key, false); + }); + this.activeAudits.clear(); + this.updateToggleAllButton(); + }, + + /** + * Returns all registered audits (used by UI to build menu items) + * + * @returns {import('./audits/index.js').AuditDefinition[]} + */ + getAudits() { + return audits; + }, + + /** + * Returns all registered audit groups. + * + * @returns {import('./audits/index.js').AuditGroup[]} + */ + getAuditGroups() { + return auditGroups; + }, + + /** + * Toggle collapsed state of a menu group. + * + * @param {string} key + */ + toggleGroup(key) { + if (this.collapsedGroups.has(key)) { + this.collapsedGroups.delete(key); + } else { + this.collapsedGroups.add(key); + } + localStorage.setItem('mageforge-toolbar-collapsed-groups', JSON.stringify([...this.collapsedGroups])); + if (!this.menu) return; + const group = this.menu.querySelector(`[data-group-key="${key}"]`); + if (group) { + group.classList.toggle('mageforge-toolbar-menu-group--collapsed', this.collapsedGroups.has(key)); + } + }, + + /** + * Set the inline counter badge of an audit menu item. + * + * @param {string} key + * @param {string} message + * @param {'success'|'error'} type + */ + setAuditCounterBadge(key, message, type = 'success') { + if (!this.menu) return; + const item = this.menu.querySelector(`[data-audit-key="${key}"]`); + if (!item) return; + const status = item.querySelector('.mageforge-toolbar-menu-status'); + if (!status) return; + status.textContent = message; + status.className = `mageforge-toolbar-menu-status mageforge-toolbar-menu-status--${type}`; + // Reflect error/success on the active item background + item.classList.toggle('mageforge-active--error', type === '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 new file mode 100644 index 00000000..07d10c9d --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js @@ -0,0 +1,40 @@ +/** + * MageForge Toolbar Audit – Images without ALT + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-alt'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'images-without-alt', + icon: '', + label: 'Images without ALT', + description: 'Highlight images without alt attributes', + + /** + * @param {object} context - Alpine toolbar component instance + * @param {boolean} active - true = activate, false = deactivate + */ + run(context, active) { + if (!active) { + document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const images = Array.from(document.querySelectorAll('img')).filter(img => { + if (!img.offsetParent && getComputedStyle(img).position !== 'fixed') return false; + const style = getComputedStyle(img); + if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false; + 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'); + }, +}; 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 new file mode 100644 index 00000000..2898ed2b --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js @@ -0,0 +1,42 @@ +/** + * MageForge Toolbar Audit – Images without width/height + * + * Images missing explicit width and height attributes cause Cumulative Layout Shift (CLS). + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-dimensions'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'images-without-dimensions', + icon: '', + label: 'Images without Dimensions', + description: 'Highlight images missing width/height', + + /** + * @param {object} context - Alpine toolbar component instance + * @param {boolean} active - true = activate, false = deactivate + */ + run(context, active) { + if (!active) { + document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const images = Array.from(document.querySelectorAll('img')).filter(img => { + if (!img.offsetParent && getComputedStyle(img).position !== 'fixed') return false; + const style = getComputedStyle(img); + if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false; + 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'); + }, +}; 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 new file mode 100644 index 00000000..c6e30290 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js @@ -0,0 +1,47 @@ +/** + * MageForge Toolbar Audit – Images without lazy loading + * + * Images below the fold that lack loading="lazy" are loaded eagerly, + * wasting bandwidth and slowing initial page load. + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-lazy-load'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'images-without-lazy-load', + icon: '', + label: 'Images without Lazy Load', + description: 'Highlight off-screen images missing loading="lazy"', + + /** + * @param {object} context - Alpine toolbar component instance + * @param {boolean} active - true = activate, false = deactivate + */ + run(context, active) { + if (!active) { + document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const viewportBottom = window.innerHeight; + + const images = Array.from(document.querySelectorAll('img')).filter(img => { + if (!img.offsetParent && getComputedStyle(img).position !== 'fixed') return false; + const style = getComputedStyle(img); + if (style.visibility === 'hidden' || style.display === 'none' || parseFloat(style.opacity) === 0) return false; + if (img.getAttribute('loading') === 'lazy') return false; + const rect = img.getBoundingClientRect(); + 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'); + }, +}; diff --git a/src/view/frontend/web/js/toolbar/audits/index.js b/src/view/frontend/web/js/toolbar/audits/index.js new file mode 100644 index 00000000..8d6b8f2d --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/index.js @@ -0,0 +1,46 @@ +/** + * MageForge Toolbar – Audit Registry + * + * To add a new audit: + * 1. Create a file in this directory exporting a default AuditDefinition object + * 2. Import it here and add it to the `audits` array (with an optional `group` key) + * + * To add a new group: + * 1. Add an entry to `auditGroups` + * 2. Set `group: ''` on the relevant audits below + * + * @typedef {object} AuditDefinition + * @property {string} key - Unique identifier + * @property {string} icon - Emoji or SVG string shown in menu + * @property {string} label - Short display name + * @property {string} description - Tooltip / subtitle text + * @property {string} [group] - Optional group key (must match an AuditGroup key) + * @property {function(object, boolean): void} run - Audit logic; receives Alpine component as context and active state + * + * @typedef {object} AuditGroup + * @property {string} key - Unique group identifier + * @property {string} label - Display name shown as group header + */ + +import imagesWithoutAlt from './images-without-alt.js'; +import imagesWithoutDimensions from './images-without-dimensions.js'; +import imagesWithoutLazyLoad from './images-without-lazy-load.js'; +import inputsWithoutLabel from './inputs-without-label.js'; +import lowContrastText from './low-contrast-text.js'; +import tabOrder from './tab-order.js'; + +/** @type {AuditGroup[]} */ +export const auditGroups = [ + { key: 'wcag', label: 'WCAG Checks' }, + { key: 'performance', label: 'Performance' }, +]; + +/** @type {AuditDefinition[]} */ +export const audits = [ + { ...imagesWithoutAlt, group: 'wcag' }, + { ...inputsWithoutLabel, group: 'wcag' }, + { ...lowContrastText, group: 'wcag' }, + { ...tabOrder, group: 'wcag' }, + { ...imagesWithoutDimensions, group: 'performance' }, + { ...imagesWithoutLazyLoad, group: 'performance' }, +]; 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 new file mode 100644 index 00000000..8ccd5625 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js @@ -0,0 +1,51 @@ +/** + * MageForge Toolbar Audit – Inputs without label + * + * Form inputs without an associated label or aria-label are inaccessible + * to screen reader users. + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-inputs-without-label'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'inputs-without-label', + icon: '', + label: 'Inputs without Label', + description: 'Highlight form inputs missing a label or aria-label', + + /** + * @param {object} context - Alpine toolbar component instance + * @param {boolean} active - true = activate, false = deactivate + */ + run(context, active) { + if (!active) { + document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const inputs = Array.from(document.querySelectorAll( + 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]), select, textarea' + )).filter(input => { + // aria-label or aria-labelledby present + if (input.hasAttribute('aria-label') && input.getAttribute('aria-label').trim()) return false; + if (input.hasAttribute('aria-labelledby') && input.getAttribute('aria-labelledby').trim().split(/\s+/).some(id => document.getElementById(id))) return false; + // title as fallback label + if (input.hasAttribute('title') && input.getAttribute('title').trim()) return false; + //