Skip to content

Commit 2dfea0d

Browse files
authored
Merge pull request #178 from OpenForgeProject/feat/add-additional-checks
feat: add additional audits for accessibility and usability checks
2 parents f4382b5 + cfab08a commit 2dfea0d

12 files changed

Lines changed: 433 additions & 23 deletions

src/view/frontend/web/css/toolbar.css

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
--mageforge-color-orange: #fb923c;
2424
--mageforge-color-pink: #C850C0;
2525
--mageforge-color-amber: #edb04d;
26+
--mageforge-color-amber-alpha-15: rgba(237, 176, 77, 0.15);
27+
--mageforge-color-amber-alpha-35: rgba(237, 176, 77, 0.35);
2628
--mageforge-bg-dark: rgba(15, 23, 42, 0.98);
2729
--mageforge-bg-dark-alt: rgba(30, 41, 59, 0.98);
2830
--mageforge-border-color: rgba(148, 163, 184, 0.15);
@@ -146,12 +148,14 @@
146148
bottom: calc(100% + 8px);
147149
left: 0;
148150
background: linear-gradient(135deg, var(--mageforge-bg-dark) 0%, var(--mageforge-bg-dark-alt) 100%);
149-
backdrop-filter: blur(12px);
150151
border: 1px solid var(--mageforge-border-color);
151152
border-radius: 10px;
152-
box-shadow: 0 -8px 24px var(--mageforge-shadow-lg), 0 4px 8px var(--mageforge-shadow-sm);
153-
padding: 6px;
153+
box-shadow: 0 -8px 24px var(--mageforge-shadow-lg), 0 6px 10px var(--mageforge-shadow-sm);
154+
padding: 0 6px 6px;
154155
min-width: 350px;
156+
max-height: 90vh;
157+
overflow-y: auto;
158+
overflow-x: hidden;
155159
font-family: var(--mageforge-font-family);
156160
display: none;
157161
opacity: 0;
@@ -177,11 +181,16 @@
177181

178182
.mageforge-toolbar-menu-title {
179183
display: flex;
184+
flex-wrap: wrap;
180185
align-items: center;
181186
justify-content: space-between;
182-
padding: 4px 8px 2px;
187+
padding: 10px 8px 2px;
183188
border-bottom: 1px solid var(--mageforge-border-color);
184189
margin-bottom: 4px;
190+
position: sticky;
191+
top: 0;
192+
z-index: 99999;
193+
background: linear-gradient(135deg, var(--mageforge-bg-dark) 0%, var(--mageforge-bg-dark-alt) 100%);
185194
}
186195

187196
.mageforge-toolbar-menu-title-text {
@@ -194,11 +203,12 @@
194203
background-image: var(--gradient-brand);
195204
background-clip: text;
196205
-webkit-background-clip: text;
206+
display: block;
197207
}
198208

199209
.mageforge-toolbar-menu-close {
200210
background: none;
201-
border: none;
211+
border: 1px solid var(--mageforge-border-color);
202212
cursor: pointer;
203213
color: var(--mageforge-color-slate-400);
204214
padding: 6px;
@@ -254,6 +264,15 @@
254264
color: var(--mageforge-color-red);
255265
}
256266

267+
.mageforge-toolbar-menu-item.mageforge-active--warning {
268+
background: var(--mageforge-color-amber-alpha-15);
269+
border-color: var(--mageforge-color-amber-alpha-35);
270+
}
271+
272+
.mageforge-toolbar-menu-item.mageforge-active--warning .mageforge-toolbar-menu-label {
273+
color: var(--mageforge-color-amber);
274+
}
275+
257276
.mageforge-toolbar-menu-icon {
258277
font-size: 16px;
259278
flex-shrink: 0;
@@ -309,9 +328,17 @@
309328
}
310329

311330
.mageforge-toolbar-menu-desc {
312-
font-size: 10px;
313331
color: var(--mageforge-color-slate-400);
332+
font-size: 11px;
314333
line-height: 1.3;
334+
user-select: text;
335+
cursor: text;
336+
}
337+
338+
.mageforge-toolbar-menu-desc.mageforge-active {
339+
color: var(--mageforge-color-orange);
340+
font-size: 12px;
341+
user-select: text;
315342
}
316343

317344
.mageforge-toolbar-menu-label-row {
@@ -351,6 +378,12 @@
351378
border: 1px solid var(--mageforge-color-red-alpha-35);
352379
}
353380

381+
.mageforge-toolbar-menu-status--warning {
382+
color: var(--mageforge-color-amber);
383+
background: var(--mageforge-color-amber-alpha-15);
384+
border: 1px solid var(--mageforge-color-amber-alpha-35);
385+
}
386+
354387
/* ============================================================================
355388
Menu Groups
356389
========================================================================== */
@@ -421,6 +454,12 @@
421454
z-index: 9999997;
422455
}
423456

457+
.mageforge-audit-overlay--warning {
458+
background-color: var(--mageforge-color-amber-alpha-35);
459+
outline-color: var(--mageforge-color-amber);
460+
outline-style: dashed;
461+
}
462+
424463
/* ============================================================================
425464
Feedback Toast
426465
========================================================================== */

src/view/frontend/web/js/toolbar/audits.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ export const auditMethods = {
9494
}
9595
},
9696

97+
/**
98+
* Update the description text of an audit menu item.
99+
* Useful for audits that want to surface detail (e.g. which IDs are duplicated).
100+
*
101+
* @param {string} key
102+
* @param {string} text
103+
*/
104+
setAuditDescription(key, text) {
105+
if (!this.menu) return;
106+
const item = this.menu.querySelector(`[data-audit-key="${key}"]`);
107+
if (!item) return;
108+
const desc = item.querySelector('.mageforge-toolbar-menu-desc');
109+
if (!desc) return;
110+
const originalText = desc.dataset.originalText ?? desc.textContent;
111+
if (!desc.dataset.originalText) desc.dataset.originalText = originalText;
112+
const isChanged = text !== originalText;
113+
desc.textContent = text;
114+
desc.classList.toggle('mageforge-active', isChanged);
115+
},
116+
97117
/**
98118
* Set the inline counter badge of an audit menu item.
99119
*
@@ -109,7 +129,8 @@ export const auditMethods = {
109129
if (!status) return;
110130
status.textContent = message;
111131
status.className = `mageforge-toolbar-menu-status mageforge-toolbar-menu-status--${type}`;
112-
// Reflect error/success on the active item background
132+
// Reflect error/warning/success on the active item background
113133
item.classList.toggle('mageforge-active--error', type === 'error');
134+
item.classList.toggle('mageforge-active--warning', type === 'warning');
114135
},
115136
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* MageForge Toolbar Audit – Buttons without type
3+
*
4+
* A <button> without an explicit type attribute defaults to type="submit",
5+
* which can accidentally submit parent forms. Always set type="button",
6+
* type="submit", or type="reset" explicitly.
7+
*/
8+
9+
import { applyHighlight, clearHighlight } from './highlight.js';
10+
11+
/** @type {import('./index.js').AuditDefinition} */
12+
export default {
13+
key: 'buttons-without-type',
14+
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M8 13v-8.5a1.5 1.5 0 0 1 3 0v7.5"></path><path d="M11 11.5a1.5 1.5 0 0 1 3 0v1.5"></path><path d="M14 12a1.5 1.5 0 0 1 3 0v2"></path><path d="M17 13.5a1.5 1.5 0 0 1 3 0v3.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7l-.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path></svg>',
15+
label: 'Buttons without a type',
16+
description: 'Highlight a button missing an explicit type attribute (defaults to submit)',
17+
18+
/**
19+
* @param {object} context - Alpine toolbar component instance
20+
* @param {boolean} active - true = activate, false = deactivate
21+
*/
22+
run(context, active) {
23+
if (!active) {
24+
clearHighlight(this.key);
25+
return;
26+
}
27+
28+
const buttons = Array.from(document.querySelectorAll('button')).filter(btn => {
29+
const type = btn.getAttribute('type');
30+
if (type !== null && type.trim() !== '') return false;
31+
if (!btn.offsetParent && getComputedStyle(btn).position !== 'fixed') return false;
32+
const style = getComputedStyle(btn);
33+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
34+
return true;
35+
});
36+
37+
applyHighlight(buttons, this.key, context);
38+
},
39+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* MageForge Toolbar Audit – Duplicate IDs
3+
*
4+
* IDs must be unique per document. Duplicate IDs break label associations,
5+
* aria-labelledby / aria-describedby references, fragment links, and cause
6+
* unpredictable behaviour with JavaScript querySelector.
7+
*
8+
* Icon source: Tabler Icons (MIT)
9+
*/
10+
11+
import { applyHighlight, clearHighlight } from './highlight.js';
12+
13+
/** @type {import('./index.js').AuditDefinition} */
14+
export default {
15+
key: 'duplicate-ids',
16+
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 8a4 4 0 0 1 4 4a4 4 0 0 1 4 -4"></path><path d="M7 16a4 4 0 0 0 4 -4a4 4 0 0 0 4 4"></path><path d="M9 12h6"></path><path d="M3 12h2"></path><path d="M19 12h2"></path></svg>',
17+
label: 'Duplicate IDs',
18+
description: 'Highlight elements sharing an ID with at least one other element',
19+
20+
/**
21+
* @param {object} context - Alpine toolbar component instance
22+
* @param {boolean} active - true = activate, false = deactivate
23+
*/
24+
run(context, active) {
25+
if (!active) {
26+
clearHighlight(this.key);
27+
context.setAuditDescription(this.key, this.description);
28+
return;
29+
}
30+
31+
/** @type {Map<string, Element[]>} */
32+
const idMap = new Map();
33+
34+
document.querySelectorAll('[id]').forEach(el => {
35+
const id = el.id;
36+
if (!id) return;
37+
if (el.closest('.mageforge-toolbar')) return;
38+
if (!idMap.has(id)) {
39+
idMap.set(id, []);
40+
}
41+
idMap.get(id).push(el);
42+
});
43+
44+
/** @type {string[]} */
45+
const duplicateIdNames = [];
46+
const duplicates = [];
47+
idMap.forEach((els, id) => {
48+
if (els.length > 1) {
49+
duplicateIdNames.push(`#${id}${els.length})`);
50+
els.forEach(el => duplicates.push(el));
51+
}
52+
});
53+
54+
if (duplicates.length > 0) {
55+
context.setAuditDescription(
56+
this.key,
57+
`Duplicate: ${duplicateIdNames.join(', ')}`
58+
);
59+
} else {
60+
context.setAuditDescription(this.key, this.description);
61+
}
62+
63+
applyHighlight(duplicates, this.key, context);
64+
},
65+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* MageForge Toolbar Audit – Empty Links & Buttons
3+
*
4+
* Links and buttons without an accessible name are unusable for screen
5+
* reader and keyboard users (WCAG 2.1 SC 4.1.2, 2.4.6).
6+
*/
7+
8+
import { applyHighlight, clearHighlight } from './highlight.js';
9+
10+
/** @type {import('./index.js').AuditDefinition} */
11+
export default {
12+
key: 'empty-interactive',
13+
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M17 12h-14"></path><path d="M6 9l-3 3l3 3"></path><path d="M20 6v.01"></path><path d="M20 12v.01"></path><path d="M20 18v.01"></path></svg>',
14+
label: 'Empty Links & Buttons',
15+
description: 'Highlight links and buttons missing an accessible name',
16+
17+
/**
18+
* @param {object} context - Alpine toolbar component instance
19+
* @param {boolean} active - true = activate, false = deactivate
20+
*/
21+
run(context, active) {
22+
if (!active) {
23+
clearHighlight(this.key);
24+
return;
25+
}
26+
27+
const elements = Array.from(document.querySelectorAll('a[href], button')).filter(el => {
28+
// Visibility check
29+
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed') return false;
30+
const style = getComputedStyle(el);
31+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
32+
33+
// Accessible name sources
34+
if (el.getAttribute('aria-label')?.trim()) return false;
35+
if (el.getAttribute('title')?.trim()) return false;
36+
if (el.getAttribute('aria-labelledby')?.trim().split(/\s+/).some(id => document.getElementById(id)?.textContent.trim())) return false;
37+
38+
// Text content (excluding whitespace-only)
39+
if (el.textContent.trim()) return false;
40+
41+
// Child <img> with non-empty alt (trimmed)
42+
if (Array.from(el.querySelectorAll('img[alt]')).some(img => img.getAttribute('alt')?.trim())) return false;
43+
44+
// Child <svg> with a <title> element
45+
if (el.querySelector('svg title')?.textContent.trim()) return false;
46+
47+
return true;
48+
});
49+
50+
applyHighlight(elements, this.key, context);
51+
},
52+
};

src/view/frontend/web/js/toolbar/audits/highlight.js

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ function scheduleUpdate() {
5050
* Returns a cleanup function that removes the overlay and deregisters it.
5151
*
5252
* @param {Element} el
53+
* @param {'error'|'warning'} [severity='error']
5354
* @returns {function} cleanup
5455
*/
55-
function createOverlay(el) {
56+
function createOverlay(el, severity = 'error') {
5657
const overlay = document.createElement('span');
5758
overlay.className = AUDIT_OVERLAY_CLASS;
59+
if (severity === 'warning') overlay.classList.add('mageforge-audit-overlay--warning');
5860
document.body.appendChild(overlay);
5961

6062
function update() {
@@ -122,13 +124,22 @@ export function clearHighlight(key) {
122124
* the first result, and updates the counter badge on the toolbar menu item.
123125
* Works for any element type – no special casing required in audit code.
124126
*
125-
* @param {Element[]} elements - Elements to mark
126-
* @param {string} key - Audit key (e.g. 'images-without-alt')
127-
* @param {object} context - Alpine toolbar component instance
127+
* @param {Element[]} elements - Elements to mark
128+
* @param {string} key - Audit key (e.g. 'images-without-alt')
129+
* @param {object} context - Alpine toolbar component instance
130+
* @param {object} [options={}] - Options
131+
* @param {'error'|'warning'} [options.severity='error'] - Visual severity level
132+
* @param {boolean} [options.skipBadge=false] - Skip badge + scroll update
128133
*/
129-
export function applyHighlight(elements, key, context) {
134+
export function applyHighlight(elements, key, context, options = {}) {
135+
const severity = options.severity ?? 'error';
136+
const skipBadge = options.skipBadge ?? false;
137+
138+
// Never flag elements that are part of the MageForge Toolbar itself
139+
elements = elements.filter(el => !el.closest('.mageforge-toolbar'));
140+
130141
if (elements.length === 0) {
131-
context.setAuditCounterBadge(key, '0', 'success');
142+
if (!skipBadge) context.setAuditCounterBadge(key, '0', 'success');
132143
return;
133144
}
134145
const cls = `mageforge-audit-${key}`;
@@ -139,11 +150,13 @@ export function applyHighlight(elements, key, context) {
139150
existing.keys.add(key);
140151
} else {
141152
overlayRegistry.set(el, {
142-
cleanup: createOverlay(el),
153+
cleanup: createOverlay(el, severity),
143154
keys: new Set([key]),
144155
});
145156
}
146157
});
147-
elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
148-
context.setAuditCounterBadge(key, `${elements.length}`, 'error');
158+
if (!skipBadge) {
159+
elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
160+
context.setAuditCounterBadge(key, `${elements.length}`, severity);
161+
}
149162
}

0 commit comments

Comments
 (0)