308 lines
8.3 KiB
TypeScript
308 lines
8.3 KiB
TypeScript
export function inspectDocument(options: {
|
|
kind: "accessible" | "overflow";
|
|
ignore: string[];
|
|
disclosures: boolean;
|
|
}) {
|
|
const { document, getComputedStyle, innerWidth } = globalThis as any;
|
|
const findings: {
|
|
rule: string;
|
|
selector: string;
|
|
severity: "error" | "warning" | "review";
|
|
message: string;
|
|
evidence: Record<string, unknown>;
|
|
}[] = [];
|
|
const elements = [...document.querySelectorAll("body *")];
|
|
if (options.disclosures) {
|
|
for (const element of elements) {
|
|
if (element.tagName === "DETAILS") element.open = true;
|
|
}
|
|
}
|
|
const selector = (node: any): string => {
|
|
if (node.id) return `[id=${JSON.stringify(node.id)}]`;
|
|
const parts: string[] = [];
|
|
while (node && node.tagName !== "BODY") {
|
|
const index = [...node.parentElement.children].indexOf(node) + 1;
|
|
parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
|
|
node = node.parentElement;
|
|
}
|
|
return `body > ${parts.join(" > ")}`;
|
|
};
|
|
const add = (
|
|
node: any,
|
|
rule: string,
|
|
message: string,
|
|
evidence: Record<string, unknown> = {},
|
|
severity: "error" | "warning" | "review" = "error",
|
|
) =>
|
|
findings.push({
|
|
selector: selector(node),
|
|
rule,
|
|
message,
|
|
evidence,
|
|
severity,
|
|
});
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = canvas.height = 1;
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
const color = (value: string): number[] => {
|
|
ctx.clearRect(0, 0, 1, 1);
|
|
ctx.fillStyle = value;
|
|
ctx.fillRect(0, 0, 1, 1);
|
|
return [...ctx.getImageData(0, 0, 1, 1).data];
|
|
};
|
|
const composite = (front: number[], back: number[]): number[] =>
|
|
front
|
|
.slice(0, 3)
|
|
.map(
|
|
(channel, index) =>
|
|
(channel * front[3]!) / 255 + back[index]! * (1 - front[3]! / 255),
|
|
)
|
|
.concat(255);
|
|
const luminance = (rgb: number[]): number =>
|
|
rgb
|
|
.slice(0, 3)
|
|
.map((value) => {
|
|
const c = value / 255;
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
})
|
|
.reduce(
|
|
(sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index]!,
|
|
0,
|
|
);
|
|
let checked = 0;
|
|
for (const node of elements) {
|
|
if (options.ignore.some((ignore) => node.closest(ignore))) continue;
|
|
const style = getComputedStyle(node);
|
|
const box = node.getBoundingClientRect();
|
|
if (
|
|
!box.width ||
|
|
!box.height ||
|
|
style.display === "none" ||
|
|
style.visibility !== "visible" ||
|
|
node.closest("[hidden], [inert]")
|
|
)
|
|
continue;
|
|
checked++;
|
|
if (options.kind === "overflow") {
|
|
const clippedX =
|
|
["hidden", "clip"].includes(style.overflowX) &&
|
|
node.scrollWidth > node.clientWidth + 1;
|
|
const clippedY =
|
|
["hidden", "clip"].includes(style.overflowY) &&
|
|
node.scrollHeight > node.clientHeight + 1;
|
|
const truncated =
|
|
style.textOverflow === "ellipsis" ||
|
|
(style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0);
|
|
if ((clippedX || clippedY) && !truncated) {
|
|
add(node, "clipping", "Content exceeds a clipping boundary", {
|
|
overflowX: style.overflowX,
|
|
overflowY: style.overflowY,
|
|
clientWidth: node.clientWidth,
|
|
scrollWidth: node.scrollWidth,
|
|
clientHeight: node.clientHeight,
|
|
scrollHeight: node.scrollHeight,
|
|
});
|
|
}
|
|
let managed = false;
|
|
for (
|
|
let parent = node.parentElement;
|
|
parent;
|
|
parent = parent.parentElement
|
|
) {
|
|
if (
|
|
["auto", "scroll", "hidden", "clip"].includes(
|
|
getComputedStyle(parent).overflowX,
|
|
)
|
|
) {
|
|
managed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!managed && (box.right > innerWidth + 1 || box.left < -1)) {
|
|
add(
|
|
node,
|
|
"viewport-overflow",
|
|
"Element extends outside the viewport without a containing scroll region",
|
|
{
|
|
left: box.left,
|
|
right: box.right,
|
|
viewportWidth: innerWidth,
|
|
},
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
const label =
|
|
node.getAttribute("aria-label") ||
|
|
(node.getAttribute("aria-labelledby") || "")
|
|
.split(/\s+/)
|
|
.map((id: string) => document.getElementById(id)?.textContent || "")
|
|
.join("")
|
|
.trim() ||
|
|
[...(node.labels || [])]
|
|
.map((label: any) => label.textContent)
|
|
.join("")
|
|
.trim();
|
|
for (const id of (node.getAttribute("aria-labelledby") || "")
|
|
.split(/\s+/)
|
|
.filter(Boolean)) {
|
|
if (!document.getElementById(id)) {
|
|
add(
|
|
node,
|
|
"aria-reference",
|
|
"aria-labelledby references a missing element",
|
|
{ id },
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) &&
|
|
!node.disabled &&
|
|
(box.width < 24 || box.height < 24)
|
|
) {
|
|
add(
|
|
node,
|
|
"target-size",
|
|
"Interactive target is smaller than 24 CSS pixels; spacing exceptions require review",
|
|
{ width: box.width, height: box.height },
|
|
"review",
|
|
);
|
|
}
|
|
if (node.tagName === "IMG" && !node.hasAttribute("alt")) {
|
|
add(node, "image-alt", "Image lacks an alt attribute");
|
|
}
|
|
if (
|
|
["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) &&
|
|
!["hidden", "submit", "button", "reset"].includes(node.type) &&
|
|
!label
|
|
)
|
|
add(node, "control-name", "Form control lacks a programmatic label");
|
|
if (
|
|
["A", "BUTTON"].includes(node.tagName) &&
|
|
!label &&
|
|
!node.textContent.trim() &&
|
|
!node.querySelector("img[alt]")
|
|
) {
|
|
add(node, "control-name", "Interactive element lacks an accessible name");
|
|
}
|
|
const ownText = [...node.childNodes].some(
|
|
(child: any) => child.nodeType === 3 && child.textContent.trim(),
|
|
);
|
|
if (!ownText) continue;
|
|
const chain: any[] = [];
|
|
let opacity = 1;
|
|
for (let parent = node; parent; parent = parent.parentElement) {
|
|
chain.unshift(parent);
|
|
opacity *= Number(getComputedStyle(parent).opacity);
|
|
}
|
|
let background = [255, 255, 255, 255];
|
|
const effects: Record<string, unknown>[] = [];
|
|
for (const parent of chain) {
|
|
const css = getComputedStyle(parent);
|
|
background = composite(color(css.backgroundColor), background);
|
|
if (
|
|
css.filter !== "none" ||
|
|
css.backdropFilter !== "none" ||
|
|
css.mixBlendMode !== "normal" ||
|
|
css.backgroundImage !== "none" ||
|
|
css.clipPath !== "none" ||
|
|
css.maskImage !== "none"
|
|
) {
|
|
effects.push({
|
|
selector: selector(parent),
|
|
filter: css.filter,
|
|
backdropFilter: css.backdropFilter,
|
|
blend: css.mixBlendMode,
|
|
backgroundImage: css.backgroundImage,
|
|
clipPath: css.clipPath,
|
|
maskImage: css.maskImage,
|
|
});
|
|
}
|
|
}
|
|
const foreground = color(style.color);
|
|
foreground[3] = foreground[3]! * opacity;
|
|
const text = composite(foreground, background);
|
|
const l1 = luminance(text),
|
|
l2 = luminance(background);
|
|
const contrast = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
const threshold =
|
|
parseFloat(style.fontSize) >= 24 ||
|
|
(parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700)
|
|
? 3
|
|
: 4.5;
|
|
if (effects.length) {
|
|
add(
|
|
node,
|
|
"visibility-effects",
|
|
"Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive",
|
|
{ effects, opacity, nominalContrast: contrast },
|
|
"review",
|
|
);
|
|
}
|
|
if (contrast < threshold) {
|
|
add(
|
|
node,
|
|
"text-contrast",
|
|
"Estimated text contrast falls below the configured text threshold",
|
|
{ contrast, threshold, opacity, estimate: effects.length > 0 },
|
|
effects.length ? "review" : "error",
|
|
);
|
|
}
|
|
if (opacity < 0.5) {
|
|
add(
|
|
node,
|
|
"low-opacity",
|
|
"Effective ancestor opacity reduces text visibility",
|
|
{ opacity },
|
|
"warning",
|
|
);
|
|
}
|
|
}
|
|
if (options.kind === "accessible") {
|
|
const ids = new Set<string>();
|
|
for (const node of document.querySelectorAll("[id]")) {
|
|
if (ids.has(node.id)) {
|
|
add(
|
|
node,
|
|
"duplicate-id",
|
|
"Duplicate identifiers make references ambiguous",
|
|
{ id: node.id },
|
|
);
|
|
}
|
|
ids.add(node.id);
|
|
}
|
|
let previous = 0;
|
|
for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) {
|
|
const level = Number(heading.tagName.slice(1));
|
|
if (previous && level > previous + 1) {
|
|
add(
|
|
heading,
|
|
"heading-order",
|
|
"Heading hierarchy skips a level",
|
|
{ previous, level },
|
|
"warning",
|
|
);
|
|
}
|
|
previous = level;
|
|
}
|
|
if (!document.documentElement.lang) {
|
|
findings.push({
|
|
selector: "html",
|
|
rule: "document-language",
|
|
severity: "error",
|
|
message: "Document language is missing",
|
|
evidence: {},
|
|
});
|
|
}
|
|
if (!document.title.trim()) {
|
|
findings.push({
|
|
selector: "head",
|
|
rule: "document-title",
|
|
severity: "error",
|
|
message: "Document title is missing",
|
|
evidence: {},
|
|
});
|
|
}
|
|
}
|
|
return { findings, checked };
|
|
}
|