diff --git a/bookmarklets/follow-focus-console.js b/bookmarklets/follow-focus-console.js new file mode 100644 index 0000000..bd77e00 --- /dev/null +++ b/bookmarklets/follow-focus-console.js @@ -0,0 +1,25 @@ +/** + * @bookmarklet Follow focus (in console) + * @description Displays the currently focused DOM node in the console + * @author Jason Morris + * @authorUrl https://jasonmorris.com + * @tags accessibility, wcag:2.4.3 + * @auditing true + * @pageTest self + */ +(function () { + if (window._focusLogEnabled) { + document.removeEventListener("focusin", window._focusLogHandler); + window._focusLogEnabled = false; + console.log("Focus logging disabled."); + alert("Focus logging disabled."); + } else { + window._focusLogHandler = function (e) { + console.log("Focused:", e.target); + }; + document.addEventListener("focusin", window._focusLogHandler); + window._focusLogEnabled = true; + console.log("Focus logging enabled."); + alert("Focus logging enabled. Open DevTools console (F12) to see results."); + } +})(); diff --git a/bookmarklets/target-spacing.js b/bookmarklets/target-spacing.js new file mode 100644 index 0000000..00d31fe --- /dev/null +++ b/bookmarklets/target-spacing.js @@ -0,0 +1,311 @@ +/** + * @bookmarklet Target spacing check + * @description Identify controls with spacing or size issues + * @author Jason Morris + * @authorUrl https://jasonmorris.com + * @tags accessibility, wcag:2.5.8 + * @auditing true + * @pageTest self + */ +(function () { + "use strict"; + + const existing = document.getElementById("wcag-target-size-overlay"); + if (existing) { + existing.remove(); + return; + } + + const MIN_SIZE = 24; + const CIRCLE_RADIUS = 12; + + const interactiveSelectors = [ + "a[href]", + "button", + 'input:not([type="hidden"])', + "select", + "textarea", + "summary", + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="tab"]', + "[onclick]", + '[tabindex]:not([tabindex="-1"])', + ]; + + const elements = Array.from( + document.querySelectorAll(interactiveSelectors.join(",")) + ); + + const visibleElements = elements.filter((el) => { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 0 && + rect.height > 0 + ); + }); + + function isInlineTarget(el) { + const style = window.getComputedStyle(el); + const display = style.display; + if (!display.includes("inline")) return false; + const parent = el.parentElement; + if (!parent) return false; + const parentText = parent.textContent.trim(); + const elText = el.textContent.trim(); + return parentText.length > elText.length + 10; + } + + function meetsMinimumSize(rect) { + return rect.width >= MIN_SIZE && rect.height >= MIN_SIZE; + } + + function getCenter(rect) { + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + function circlesIntersect(center1, center2) { + const dx = center1.x - center2.x; + const dy = center1.y - center2.y; + const distance = Math.sqrt(dx * dx + dy * dy); + return distance < MIN_SIZE; + } + + function circleIntersectsRect(center, rect) { + const closestX = Math.max(rect.left, Math.min(center.x, rect.right)); + const closestY = Math.max(rect.top, Math.min(center.y, rect.bottom)); + const dx = center.x - closestX; + const dy = center.y - closestY; + const distance = Math.sqrt(dx * dx + dy * dy); + return distance < CIRCLE_RADIUS; + } + + const results = visibleElements.map((el, index) => { + const rect = el.getBoundingClientRect(); + const isInline = isInlineTarget(el); + const meetsSize = meetsMinimumSize(rect); + return { + element: el, + rect: rect, + index: index, + isInline: isInline, + meetsSize: meetsSize, + center: getCenter(rect), + violation: null, + reason: null, + }; + }); + + results.forEach((target) => { + if (target.meetsSize || target.isInline) { + return; + } + const hasViolation = results.some((other) => { + if (other === target) return false; + if (circleIntersectsRect(target.center, other.rect)) { + target.violation = "spacing"; + target.reason = "Undersized target with insufficient spacing"; + return true; + } + if (!other.meetsSize && !other.isInline) { + if (circlesIntersect(target.center, other.center)) { + target.violation = "spacing"; + target.reason = "Undersized target circles intersect"; + return true; + } + } + return false; + }); + if (!hasViolation) { + target.violation = null; + } + }); + + results.forEach((target) => { + if (!target.meetsSize && !target.isInline && !target.violation) { + return; + } + if ( + !target.meetsSize && + !target.isInline && + target.violation === "spacing" + ) { + return; + } + if (!target.meetsSize && !target.isInline && !target.violation) { + target.violation = null; + } + }); + + const violations = results.filter((r) => r.violation); + const warnings = results.filter( + (r) => !r.meetsSize && !r.isInline && !r.violation + ); + const passes = results.filter((r) => r.meetsSize || r.isInline); + + const overlay = document.createElement("div"); + overlay.id = "wcag-target-size-overlay"; + overlay.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: white; + border: 2px solid #333; + border-radius: 8px; + padding: 0; + max-width: 400px; + z-index: 999999; + font-family: system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + `; + + const headerHTML = ` +