core/probes/viewport.probe.ts

core/probes/viewport.probe.ts is a file in Project Scripts. 191 lines of code and 49 definitions.

import type {
    ControlFindings,
    LayoutFindings,
    ProbePage,
    ViewportAudit,
    ViewportProbe,
    ViewportRules,
} from "#types/viewport.types";

export const pixelsOf = function pixelsOf(value: string): number {
    const unit = "px";
    return value.endsWith(unit) ? Number(value.slice(0, -unit.length)) : Number.NaN;
};

export const describeElement = function describeElement(element: Element): string {
    const classes = [...element.classList]
        .slice(0, 3)
        .map((name) => `.${name}`)
        .join("");
    const id = element.id.length > 0 ? `#${element.id}` : "";
    return `${element.tagName.toLowerCase()}${id}${classes}`;
};

export const countedEntries = function countedEntries(keys: readonly string[]): readonly string[] {
    const tally = new Map<string, number>();
    for (const key of keys) {
        tally.set(key, (tally.get(key) ?? 0) + 1);
    }
    return [...tally].map(([key, count]) => (count > 1 ? `${key} x${String(count)}` : key));
};

export const isAuditable = function isAuditable(page: ProbePage, rules: ViewportRules, element: Element): boolean {
    const style = page.getComputedStyle(element);
    const shown = style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0;
    return shown && element.closest(rules.hiddenSelector) === null;
};

export const escapesScreen = function escapesScreen(page: ProbePage, rules: ViewportRules, element: Element): boolean {
    const right = page.innerWidth + rules.tolerancePx;
    const box = element.getBoundingClientRect();
    if (box.width === 0 || (box.right <= right && box.left >= -rules.tolerancePx)) {
        return false;
    }
    for (let parent = element.parentElement; parent !== null; parent = parent.parentElement) {
        if (parent.id === rules.scrollRootId || parent === page.document.body) {
            return true;
        }
        const frame = parent.getBoundingClientRect();
        const scrolls = page.getComputedStyle(parent).overflowX !== "visible";
        if (scrolls && frame.right <= right && frame.left >= -rules.tolerancePx) {
            return false;
        }
    }
    return true;
};

export const widestDescendant = function widestDescendant(element: Element): Element | null {
    let widest: Element | null = null;
    let widestRight = element.getBoundingClientRect().right;
    for (const inner of element.querySelectorAll("*")) {
        const { right } = inner.getBoundingClientRect();
        if (right > widestRight) {
            widest = inner;
            widestRight = right;
        }
    }
    return widest;
};

export const clippedEntry = function clippedEntry(
    page: ProbePage,
    rules: ViewportRules,
    probe: ViewportProbe,
    element: Element,
): string | null {
    const style = page.getComputedStyle(element);
    const hidesExtra = rules.cuttingOverflow.includes(style.overflowX) && style.textOverflow !== "ellipsis";
    if (!hidesExtra || element.scrollWidth <= element.clientWidth + rules.tolerancePx) {
        return null;
    }
    const widest = probe.widestDescendant(element);
    const cause = widest === null ? "" : ` by ${probe.describeElement(widest)}`;
    return `${probe.describeElement(element)} ${String(element.clientWidth)}/${String(element.scrollWidth)}${cause}`;
};

export const smallTextEntry = function smallTextEntry(
    page: ProbePage,
    rules: ViewportRules,
    probe: ViewportProbe,
    element: Element,
): string | null {
    const size = probe.pixelsOf(page.getComputedStyle(element).fontSize);
    const ownText = [...element.childNodes].some(
        (node) => node.nodeType === node.TEXT_NODE && (node.textContent ?? "").trim().length > 0,
    );
    const drawn = element.closest(rules.drawingSelector) !== null;
    return ownText && !drawn && size < rules.textMinPx ? `${probe.describeElement(element)} ${String(size)}px` : null;
};

export const layoutFindings = function layoutFindings(
    page: ProbePage,
    rules: ViewportRules,
    probe: ViewportProbe,
): LayoutFindings {
    const escaping: Element[] = [];
    const clipped: string[] = [];
    const text: string[] = [];
    const shown = [...page.document.querySelectorAll("body *")].filter((element) =>
        probe.isAuditable(page, rules, element),
    );
    for (const element of shown) {
        if (!escaping.some((node) => node.contains(element)) && probe.escapesScreen(page, rules, element)) {
            escaping.push(element);
        }
        const cut = probe.clippedEntry(page, rules, probe, element);
        const small = probe.smallTextEntry(page, rules, probe, element);
        clipped.push(...(cut === null ? [] : [cut]));
        text.push(...(small === null ? [] : [small]));
    }
    const overflow = escaping.map((element) => {
        const box = element.getBoundingClientRect();
        return `${probe.describeElement(element)} ${String(Math.round(box.left))}..${String(Math.round(box.right))}`;
    });
    return { clipped, overflow, text: probe.countedEntries(text) };
};

export const controlFindings = function controlFindings(
    page: ProbePage,
    rules: ViewportRules,
    probe: ViewportProbe,
): ControlFindings {
    const inputs = [...page.document.querySelectorAll(rules.fieldSelector)]
        .filter((element) => probe.isAuditable(page, rules, element))
        .map((element) => ({
            name: probe.describeElement(element),
            size: probe.pixelsOf(page.getComputedStyle(element).fontSize),
        }))
        .filter((field) => field.size < rules.inputMinPx)
        .map((field) => `${field.name} ${String(field.size)}px`);
    const targets: string[] = [];
    for (const element of page.document.querySelectorAll(rules.targetSelector)) {
        const inline =
            page.getComputedStyle(element).display === "inline" && element.closest(rules.inlineSelector) !== null;
        const box = element.getBoundingClientRect();
        const small = box.width < rules.targetMinPx || box.height < rules.targetMinPx;
        if (!inline && small && probe.isAuditable(page, rules, element)) {
            targets.push(
                `${probe.describeElement(element)} ${String(Math.round(box.width))}x${String(Math.round(box.height))}`,
            );
        }
    }
    return { inputs, targets: probe.countedEntries(targets) };
};

export const auditPage = function auditPage(
    page: ProbePage,
    rules: ViewportRules,
    probe: ViewportProbe,
): ViewportAudit {
    const root = page.document.getElementById(rules.scrollRootId);
    const rootScrolls = root !== null && root.scrollWidth > root.clientWidth + rules.tolerancePx;
    const layout = probe.layoutFindings(page, rules, probe);
    const controls = probe.controlFindings(page, rules, probe);
    return {
        ...layout,
        ...controls,
        scrollsSideways: rootScrolls || page.document.documentElement.scrollWidth > page.innerWidth,
        viewport: page.innerWidth,
    };
};

export const scrollThrough = async function scrollThrough(page: ProbePage, rules: ViewportRules): Promise<number> {
    const root = page.document.getElementById(rules.scrollRootId) ?? page.document.documentElement;
    const step = Math.max(root.clientHeight, 1);
    const pause = async (): Promise<void> =>
        new Promise((settle) => {
            setTimeout(settle, rules.scrollPauseMs);
        });
    const visit = async (top: number, stops: number): Promise<number> => {
        if (top >= root.scrollHeight) {
            root.scrollTop = 0;
            return stops;
        }
        root.scrollTop = top;
        await pause();
        return visit(top + step, stops + 1);
    };
    const stops = await visit(0, 0);
    await pause();
    return stops;
};

export const VIEWPORT_PROBE: ViewportProbe = {
    clippedEntry,
    controlFindings,
    countedEntries,
    describeElement,
    escapesScreen,
    isAuditable,
    layoutFindings,
    pixelsOf,
    smallTextEntry,
    widestDescendant,
};