Repository: ymrl/a11y-visualizer
Pull request #151 (2024-06-27 14:43:18)

:+1: add small target warning
#88 


Patch:
diff --git a/src/content/dom/isInline.ts b/src/content/dom/isInline.ts
new file mode 100644
index 0000000..f5af056
--- /dev/null
+++ b/src/content/dom/isInline.ts
@@ -0,0 +1,53 @@
+export const isInline = (el: Element): boolean => {
+  if (!el.parentElement) {
+    return false;
+  }
+  const d = el.ownerDocument;
+  const w = d.defaultView;
+  if (!w) return false;
+  const style = w.getComputedStyle(el);
+  const parentTagName = el.parentElement.tagName.toLowerCase();
+
+  // inline で始まらないスタイルの場合はfalse
+  if (!style.display.startsWith("inline")) {
+    return false;
+  }
+
+  // displayがinlneで、親が段落・テーブルセル・箇条書き、キャプション、見出しの場合はtrue
+  if (
+    style.display === "inline" &&
+    [
+      "p",
+      "td",
+      "th",
+      "li",
+      "dt",
+      "dd",
+      "caption",
+      "figcaption",
+      "h1",
+      "h2",
+      "h3",
+      "h4",
+      "h5",
+      "h6",
+    ].includes(parentTagName)
+  ) {
+    return true;
+  }
+
+  const parentStyle = w.getComputedStyle(el.parentElement);
+  // 親スタイルがflexかgridの場合はinlineにならないのでfalse
+  if (
+    parentStyle.display.endsWith("flex") ||
+    parentStyle.display.endsWith("grid")
+  ) {
+    return false;
+  }
+
+  // 兄弟がいる場所まで遡る (spanが入れ子になってたりするので)
+  if (!el.previousSibling && !el.nextSibling) {
+    return isInline(el.parentElement);
+  }
+  return false;
+};
diff --git a/src/content/dom/tips/buttonTips.ts b/src/content/dom/tips/buttonTips.ts
index dc1d893..0edfc32 100644
--- a/src/content/dom/tips/buttonTips.ts
+++ b/src/content/dom/tips/buttonTips.ts
@@ -4,6 +4,8 @@ import { isAriaHidden } from "../isAriaHidden";
 import { isFocusable } from "../isFocusable";
 import { hasInteractiveDescendant } from "../hasInteractiveDescendant";
 import { hasTabIndexDescendant } from "../hasTabIndexDescendant";
+import { isInline } from "../isInline";
+import { isDefaultSize } from "./isDefaultSize";
 
 export const ButtonSelectors = [
   "button",
@@ -58,6 +60,13 @@ export const buttonTips = (
     ) {
       result.push({ type: "error", content: "messages.nestedInteractive" });
     }
+    const rect = el.getBoundingClientRect();
+    if (
+      (rect.width < 24 || rect.height < 24) &&
+      !(isInline(el) || isDefaultSize(el))
+    ) {
+      result.push({ type: "warning", content: "messages.smallTargetSize" });
+    }
   }
   return result;
 };
diff --git a/src/content/dom/tips/formTips.ts b/src/content/dom/tips/formTips.ts
index 5265dd0..bdddaa2 100644
--- a/src/content/dom/tips/formTips.ts
+++ b/src/content/dom/tips/formTips.ts
@@ -1,6 +1,8 @@
 import { computeAccessibleName } from "dom-accessibility-api";
 import { ElementTip } from "../../types";
 import { isAriaHidden, isFocusable, isHidden } from "../index";
+import { isInline } from "../isInline";
+import { isDefaultSize } from "./isDefaultSize";
 
 export const FormSelectors = [
   "input:not([type='hidden']):not([type='button']):not([type='submit']):not([type='reset']):not([type='image'])",
@@ -93,6 +95,24 @@ export const formTips = (
     ) {
       result.push({ type: "error", content: "messages.nestedInteractive" });
     }
+
+    const rect = el.getBoundingClientRect();
+    const checkboxLabel =
+      tagName === "input" &&
+      (typeAttr === "checkbox" || typeAttr === "radio") &&
+      ((el.id && el.ownerDocument.querySelector(`[for="${el.id}"]`)) ||
+        el.closest("label"));
+    const checkboxLabelRect =
+      checkboxLabel && checkboxLabel.getBoundingClientRect();
+    if (
+      (rect.width < 24 || rect.height < 24) &&
+      (!checkboxLabelRect ||
+        checkboxLabelRect.width < 24 ||
+        checkboxLabelRect.height < 24) &&
+      !(isInline(el) || isDefaultSize(el))
+    ) {
+      result.push({ type: "warning", content: "messages.smallTargetSize" });
+    }
   }
   if (tagName === "input" && typeAttr === "radio") {
     const nameAttr = el.getAttribute("name");
diff --git a/src/content/dom/tips/isDefaultSize.ts b/src/content/dom/tips/isDefaultSize.ts
new file mode 100644
index 0000000..f5ef117
--- /dev/null
+++ b/src/content/dom/tips/isDefaultSize.ts
@@ -0,0 +1,154 @@
+const SizePropertiesWithoutWidth = [
+  "height",
+  "min-width",
+  "min-height",
+  "max-width",
+  "max-height",
+  "border-top-width",
+  "border-right-width",
+  "border-bottom-width",
+  "border-left-width",
+  "border-block-end-width",
+  "border-block-start-width",
+  "border-inline-end-width",
+  "border-inline-start-width",
+  "padding-top",
+  "padding-right",
+  "padding-bottom",
+  "padding-left",
+  "padding-block-end",
+  "padding-block-start",
+  "padding-inline-end",
+  "padding-inline-start",
+  "box-sizing",
+  "appearance",
+] as const;
+
+const SizeProperties = ["width", ...SizePropertiesWithoutWidth] as const;
+
+type SizeDeclaration = {
+  [key in (typeof SizeProperties)[number]]: string;
+};
+
+const DefaultStyles: { [key: string]: SizeDeclaration } = {};
+
+const elementType = (el: Element): string | null => {
+  const tagName = el.tagName.toLowerCase();
+  if (tagName === "button") {
+    return "button";
+  }
+  if (tagName === "input") {
+    const type = el.getAttribute("type");
+    if (!type) {
+      return "input-text";
+    }
+    switch (type) {
+      case "text":
+      case "search":
+      case "tel":
+      case "url":
+      case "email":
+      case "password":
+      case "date":
+      case "month":
+      case "week":
+      case "time":
+      case "datetime-local":
+      case "number":
+        return "input-text";
+      case "range":
+        return "input-range";
+      case "color":
+        return "input-color";
+      case "checkbox":
+        return "input-checkbox";
+      case "radio":
+        return "input-radio";
+      case "file":
+        return "input-file";
+      case "submit":
+      case "reset":
+      case "button":
+        return "button";
+      default:
+        return null;
+    }
+  }
+  return null;
+};
+
+const getDefaultStyle = (el: Element): SizeDeclaration | undefined => {
+  const tagName = el.tagName.toLowerCase();
+  const type = elementType(el);
+  if (!type) {
+    return undefined;
+  }
+  if (DefaultStyles[type]) {
+    return DefaultStyles[type];
+  }
+
+  const d = el.ownerDocument;
+  const w = d.defaultView;
+  if (!w) {
+    return undefined;
+  }
+
+  const defaultEl = d.createElement(tagName);
+  if (el.hasAttribute("type")) {
+    defaultEl.setAttribute("type", el.getAttribute("type")!);
+  }
+
+  d.body.appendChild(defaultEl);
+  if ("computedStyleMap" in defaultEl) {
+    // Chrome
+    const styles = defaultEl.computedStyleMap();
+    const decls = Object.fromEntries(
+      SizeProperties.map((prop: (typeof SizeProperties)[number]) => [
+        prop,
+        styles.get(prop)?.toString() || "",
+      ]),
+    ) as SizeDeclaration;
+    DefaultStyles[type] = decls;
+  } else if (w.getDefaultComputedStyle) {
+    // Firefox
+    const styles = w.getDefaultComputedStyle(defaultEl);
+    const decls = Object.fromEntries(
+      (type === "button" ? SizePropertiesWithoutWidth : SizeProperties).map(
+        (prop) => [prop, styles.getPropertyValue(prop)],
+      ),
+    ) as SizeDeclaration;
+    DefaultStyles[type] = decls;
+  } else {
+    const styles = w.getComputedStyle(defaultEl);
+    const decls = Object.fromEntries(
+      (type === "button" ? SizePropertiesWithoutWidth : SizeProperties).map(
+        (prop: (typeof SizeProperties)[number]) => [
+          prop,
+          styles.getPropertyValue(prop) || "",
+        ],
+      ),
+    ) as SizeDeclaration;
+    DefaultStyles[type] = decls;
+  }
+  d.body.removeChild(defaultEl);
+  return DefaultStyles[type];
+};
+
+export const isDefaultSize = (el: Element): boolean => {
+  const defaultStyle = getDefaultStyle(el);
+
+  if (!defaultStyle) {
+    return false;
+  }
+  if ("computedStyleMap" in el) {
+    const elementStyle = el.computedStyleMap();
+    return SizeProperties.every(
+      (prop) => defaultStyle[prop] === elementStyle.get(prop)?.toString(),
+    );
+  }
+  const type = elementType(el);
+  const elementStyle = window.getComputedStyle(el);
+  return (
+    type === "button" ? SizePropertiesWithoutWidth : SizeProperties
+  ).every((prop) => defaultStyle[prop] === elementStyle.getPropertyValue(prop));
+};
diff --git a/src/content/dom/tips/linkTips.ts b/src/content/dom/tips/linkTips.ts
index b9a7792..04845af 100644
--- a/src/content/dom/tips/linkTips.ts
+++ b/src/content/dom/tips/linkTips.ts
@@ -4,6 +4,8 @@ import { isAriaHidden } from "../isAriaHidden";
 import { isFocusable } from "../isFocusable";
 import { hasInteractiveDescendant } from "../hasInteractiveDescendant";
 import { hasTabIndexDescendant } from "../hasTabIndexDescendant";
+import { isInline } from "../isInline";
+import { isDefaultSize } from "./isDefaultSize";
 
 export const LinkSelectors = ["a", "area", '[role="link"]'] as const;
 
@@ -37,6 +39,14 @@ export const linkTips = (
     ) {
       result.push({ type: "error", content: "messages.nestedInteractive" });
     }
+
+    const rect = el.getBoundingClientRect();
+    if (
+      (rect.width < 24 || rect.height < 24) &&
+      !(isInline(el) || isDefaultSize(el))
+    ) {
+      result.push({ type: "warning", content: "messages.smallTargetSize" });
+    }
   }
   if (hasTag) {
     if (!el.hasAttribute("href")) {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index d110474..d59538b 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -112,7 +112,8 @@
     "invalidAriaValue": "Invalid WAI-ARIA Attribute Value",
     "noTitlePage": "No <title> element",
     "noLangPage": "No lang attribute on <html>",
-    "langAndXmlLangMustBeSame": "lang and xml:lang attributes must have the same value"
+    "langAndXmlLangMustBeSame": "lang and xml:lang attributes must have the same value",
+    "smallTargetSize": "Small target"
   },
   "optionsPage": {
     "description": "Settings changed from the popup are saved per host of the web page, and will be applied to other pages on the same host. Changing settings here does not affect those values.",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 87dd2a0..db901d6 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -118,7 +118,8 @@
     "invalidAriaValue": "WAI-ARIA属性値が不正",
     "noTitlePage": "<title>要素なし",
     "noLangPage": "<html>要素にlang属性なし",
-    "langAndXmlLangMustBeSame": "lang属性とxml:lang属性の値が異なる"
+    "langAndXmlLangMustBeSame": "lang属性とxml:lang属性の値が異なる",
+    "smallTargetSize": "ターゲットが小さい"
   },
   "optionsPage": {
     "description": "拡張機能のポップアップから設定を変更すると、閲覧中のWebページのホストごとに設定が保存され、同じホストのWebページに対して適用されます。この画面で設定を変更しても、それらの設定値は変更されません。",
diff --git a/src/types/dom.d.ts b/src/types/dom.d.ts
new file mode 100644
index 0000000..036d687
--- /dev/null
+++ b/src/types/dom.d.ts
@@ -0,0 +1,6 @@
+interface Window {
+  getDefaultComputedStyle?: (
+    elt: Element,
+    pseudoElt?: string | null,
+  ) => CSSStyleDeclaration;
+}


Test patch:
diff --git a/src/content/dom/isInline.test.ts b/src/content/dom/isInline.test.ts
new file mode 100644
index 0000000..4b174dd
--- /dev/null
+++ b/src/content/dom/isInline.test.ts
@@ -0,0 +1,37 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { isInline } from "./isInline";
+
+describe("isInline", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("isolated node", () => {
+    const el = document.createElement("span");
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("element is the only child of block", () => {
+    const p = document.createElement("p");
+    p.setAttribute("style", "display: block");
+    const el = document.createElement("span");
+    el.setAttribute("style", "display: inline");
+    document.body.appendChild(p);
+    p.appendChild(el);
+    expect(isInline(el)).toBe(true);
+  });
+
+  test("element is in inline element", () => {
+    const p = document.createElement("p");
+    p.setAttribute("style", "display: block");
+    const span = document.createElement("span");
+    const el = document.createElement("span");
+    const prev = document.createElement("span");
+    [span, el, prev].forEach((e) => e.setAttribute("style", "display: inline"));
+    document.body.appendChild(p);
+    p.appendChild(prev);
+    p.appendChild(span);
+    span.appendChild(el);
+    expect(isInline(el)).toBe(true);
+  });
+});
diff --git a/src/content/dom/tips/buttonTips.test.ts b/src/content/dom/tips/buttonTips.test.ts
index 0c64e6f..bb11561 100644
--- a/src/content/dom/tips/buttonTips.test.ts
+++ b/src/content/dom/tips/buttonTips.test.ts
@@ -1,6 +1,18 @@
 import { afterEach, describe, expect, test } from "vitest";
 import { buttonTips, isButton } from "./buttonTips";
 
+const getBoundingClientRect = () => ({
+  width: 24,
+  height: 24,
+  x: 0,
+  y: 0,
+  left: 0,
+  top: 0,
+  bottom: 0,
+  right: 0,
+  toJSON: () => "",
+});
+
 describe("isButton()", () => {
   test("div", () => {
     const element = document.createElement("div");
@@ -152,6 +164,7 @@ describe("buttonTips()", () => {
     const element = document.createElement("input");
     element.setAttribute("type", "image");
     element.setAttribute("alt", "Hello");
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(element);
     const result = buttonTips(element);
     expect(result).toHaveLength(0);
@@ -160,6 +173,7 @@ describe("buttonTips()", () => {
   test("input type = image without alt", () => {
     const element = document.createElement("input");
     element.setAttribute("type", "image");
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(element);
     const result = buttonTips(element);
     expect(result).toHaveLength(0);
@@ -170,6 +184,7 @@ describe("buttonTips()", () => {
     element.setAttribute("role", "button");
     element.textContent = "Hello";
     element.tabIndex = 0;
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(element);
     const result = buttonTips(element);
     expect(result).toHaveLength(0);
@@ -180,6 +195,7 @@ describe("buttonTips()", () => {
     element.setAttribute("role", "button");
     element.tabIndex = 0;
     document.body.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     const result = buttonTips(element);
     expect(result).toHaveLength(1);
     expect(
@@ -192,6 +208,7 @@ describe("buttonTips()", () => {
     element.setAttribute("role", "button");
     element.textContent = "Hello";
     document.body.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     const result = buttonTips(element);
     expect(result).toHaveLength(1);
     expect(
@@ -205,6 +222,7 @@ describe("buttonTips()", () => {
     const element = document.createElement("div");
     element.setAttribute("role", "button");
     document.body.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     const result = buttonTips(element);
     expect(result).toHaveLength(2);
     expect(
@@ -223,6 +241,7 @@ describe("buttonTips()", () => {
     element.setAttribute("aria-hidden", "true");
     element.setAttribute("tabindex", "0");
     element.textContent = "Hello";
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(element);
     const result = buttonTips(element);
     expect(result).toHaveLength(0);
@@ -233,6 +252,7 @@ describe("buttonTips()", () => {
     const details = document.createElement("details");
     element.textContent = "Hello";
     details.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(details);
     const result = buttonTips(element);
     expect(result).toHaveLength(0);
@@ -242,6 +262,7 @@ describe("buttonTips()", () => {
     const element = document.createElement("summary");
     const details = document.createElement("details");
     details.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(details);
     const result = buttonTips(element);
     expect(result).toHaveLength(1);
@@ -258,6 +279,7 @@ describe("buttonTips()", () => {
     button.textContent = "world";
     element.appendChild(button);
     document.body.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     const result = buttonTips(element);
     expect(result).toHaveLength(1);
     expect(result[0].type).toBe("error");
@@ -291,4 +313,25 @@ describe("buttonTips()", () => {
     expect(result[0].type).toBe("error");
     expect(result[0].content).toBe("messages.nestedInteractive");
   });
+
+  test("small button", () => {
+    const element = document.createElement("button");
+    element.getBoundingClientRect = () => ({
+      width: 20,
+      height: 20,
+      x: 0,
+      y: 0,
+      left: 0,
+      top: 0,
+      bottom: 0,
+      right: 0,
+      toJSON: () => "",
+    });
+    element.setAttribute("style", "padding: 0");
+    element.textContent = "hello";
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0].type).toBe("warning");
+    expect(result[0].content).toBe("messages.smallTargetSize");
+  });
 });
diff --git a/src/content/dom/tips/formTips.test.ts b/src/content/dom/tips/formTips.test.ts
index cb9e582..4feccff 100644
--- a/src/content/dom/tips/formTips.test.ts
+++ b/src/content/dom/tips/formTips.test.ts
@@ -1,6 +1,18 @@
 import { describe, test, expect, afterEach } from "vitest";
 import { formTips, isFormControl } from "./formTips";
 
+const getBoundingClientRect = () => ({
+  width: 24,
+  height: 24,
+  x: 0,
+  y: 0,
+  left: 0,
+  top: 0,
+  bottom: 0,
+  right: 0,
+  toJSON: () => "",
+});
+
 describe("isFormControl()", () => {
   test("div", () => {
     const element = document.createElement("div");
@@ -40,6 +52,7 @@ describe("formTips()", () => {
 
   test("empty input", () => {
     const element = document.createElement("input");
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(element);
     const result = formTips(element);
     expect(result).toHaveLength(1);
@@ -55,6 +68,7 @@ describe("formTips()", () => {
     const label = document.createElement("label");
     label.textContent = "Name";
     label.htmlFor = "name";
+    element.getBoundingClientRect = getBoundingClientRect;
     document.body.appendChild(label);
     const result = formTips(element);
     expect(result).toHaveLength(0);
@@ -175,6 +189,7 @@ describe("formTips()", () => {
     const parent = document.createElement("a");
     parent.appendChild(element);
     document.body.appendChild(parent);
+    element.getBoundingClientRect = getBoundingClientRect;
     const result = formTips(element);
     expect(result).toHaveLength(1);
     expect(
diff --git a/src/content/dom/tips/isDefaultSize.test.ts b/src/content/dom/tips/isDefaultSize.test.ts
new file mode 100644
index 0000000..c3a80d1
--- /dev/null
+++ b/src/content/dom/tips/isDefaultSize.test.ts
@@ -0,0 +1,30 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { isDefaultSize } from "./isDefaultSize";
+
+describe("isDefaultSize", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("default checkbox", () => {
+    const el = document.createElement("input");
+    el.setAttribute("type", "checkbox");
+    document.body.appendChild(el);
+    expect(isDefaultSize(el)).toBe(true);
+  });
+
+  test("default button", () => {
+    const el = document.createElement("button");
+    el.textContent = "hello";
+    document.body.appendChild(el);
+    expect(isDefaultSize(el)).toBe(true);
+  });
+
+  test("size specified button", () => {
+    const el = document.createElement("button");
+    el.textContent = "hello";
+    el.style.height = "24px";
+    document.body.appendChild(el);
+    expect(isDefaultSize(el)).toBe(false);
+  });
+});
diff --git a/src/content/dom/tips/linkTips.test.ts b/src/content/dom/tips/linkTips.test.ts
index cf624c5..126c68a 100644
--- a/src/content/dom/tips/linkTips.test.ts
+++ b/src/content/dom/tips/linkTips.test.ts
@@ -1,6 +1,18 @@
 import { afterEach, describe, expect, test } from "vitest";
 import { linkTips, isLink } from "./linkTips";
 
+const getBoundingClientRect = () => ({
+  width: 24,
+  height: 24,
+  x: 0,
+  y: 0,
+  left: 0,
+  top: 0,
+  bottom: 0,
+  right: 0,
+  toJSON: () => "",
+});
+
 describe("isLink()", () => {
   test("div", () => {
     const element = document.createElement("div");
@@ -34,6 +46,7 @@ describe("linkTips()", () => {
   test("empty a", () => {
     const element = document.createElement("a");
     document.body.appendChild(element);
+    element.getBoundingClientRect = getBoundingClientRect;
     const result = linkTips(element);
     expect(result).toHaveLength(1);
     expect(result.find((t) => t.type === "warning")).toEqual({
@@ -44,6 +57,7 @@ describe("linkTips()", () => {
 
   test("a with name and href", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.textContent = "Hello";
     element.href = "https://example.com";
     document.body.appendChild(element);
@@ -53,6 +67,7 @@ describe("linkTips()", () => {
 
   test("a with target", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.textContent = "Hello";
     element.href = "https://example.com";
     element.target = "_blank";
@@ -67,6 +82,7 @@ describe("linkTips()", () => {
 
   test("a without href", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.textContent = "Hello";
     document.body.appendChild(element);
     const result = linkTips(element);
@@ -78,6 +94,7 @@ describe("linkTips()", () => {
   });
   test("a without name", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.href = "https://example.com";
     document.body.appendChild(element);
     const result = linkTips(element);
@@ -90,6 +107,7 @@ describe("linkTips()", () => {
 
   test("area", () => {
     const element = document.createElement("area");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.href = "https://example.com";
     element.alt = "Hello";
     const mapElement = document.createElement("map");
@@ -101,6 +119,7 @@ describe("linkTips()", () => {
 
   test("area without href", () => {
     const element = document.createElement("area");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.alt = "Hello";
     const mapElement = document.createElement("map");
     document.body.appendChild(mapElement);
@@ -115,6 +134,7 @@ describe("linkTips()", () => {
 
   test("role = link", () => {
     const element = document.createElement("div");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.setAttribute("role", "link");
     document.body.appendChild(element);
     element.textContent = "Hello";
@@ -125,6 +145,7 @@ describe("linkTips()", () => {
 
   test("invalid role = link", () => {
     const element = document.createElement("div");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.setAttribute("role", "link");
     document.body.appendChild(element);
     const result = linkTips(element);
@@ -141,6 +162,7 @@ describe("linkTips()", () => {
 
   test("nested interactive", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.textContent = "Hello";
     element.href = "https://example.com";
     const child = document.createElement("button");
@@ -158,6 +180,7 @@ describe("linkTips()", () => {
 
   test("nested tabindex", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.textContent = "Hello";
     element.href = "https://example.com";
     const child = document.createElement("div");
@@ -175,6 +198,7 @@ describe("linkTips()", () => {
 
   test("nested a", () => {
     const element = document.createElement("a");
+    element.getBoundingClientRect = getBoundingClientRect;
     element.textContent = "Hello";
     element.href = "https://example.com";
     const child = document.createElement("a");
@@ -199,6 +223,7 @@ describe("linkTips()", () => {
     child.href = "https://example.com";
     parent.appendChild(child);
     document.body.appendChild(parent);
+    child.getBoundingClientRect = getBoundingClientRect;
     const result = linkTips(child);
     expect(result).toHaveLength(1);
     expect(