Repository: ymrl/a11y-visualizer
Pull request #152 (2024-06-30 00:07:57)

:sparkles: Adjust small target exeption logic
#88 


Patch:
diff --git a/src/content/dom/isInline.ts b/src/content/dom/isInline.ts
index f5af056..76c55af 100644
--- a/src/content/dom/isInline.ts
+++ b/src/content/dom/isInline.ts
@@ -1,3 +1,37 @@
+const hasInlineSibling = (el: Element): boolean => {
+  let prev = el.previousSibling;
+  let next = el.nextSibling;
+  while (prev) {
+    if (prev.nodeType === Node.TEXT_NODE) {
+      if (prev.textContent?.trim()) {
+        return true;
+      }
+    } else if (prev.nodeType === Node.ELEMENT_NODE) {
+      const style = window.getComputedStyle(prev as HTMLElement);
+      if (style.display.startsWith("inline")) {
+        return true;
+      }
+      break;
+    }
+    prev = prev.previousSibling;
+  }
+  while (next) {
+    if (next.nodeType === Node.TEXT_NODE) {
+      if (next.textContent?.trim()) {
+        return true;
+      }
+    } else if (next.nodeType === Node.ELEMENT_NODE) {
+      const style = window.getComputedStyle(next as HTMLElement);
+      if (style.display.startsWith("inline")) {
+        return true;
+      }
+      break;
+    }
+    next = next.nextSibling;
+  }
+  return false;
+};
+
 export const isInline = (el: Element): boolean => {
   if (!el.parentElement) {
     return false;
@@ -6,36 +40,13 @@ export const isInline = (el: Element): boolean => {
   const w = d.defaultView;
   if (!w) return false;
   const style = w.getComputedStyle(el);
-  const parentTagName = el.parentElement.tagName.toLowerCase();
+  const { display } = style;
 
   // inline で始まらないスタイルの場合はfalse
-  if (!style.display.startsWith("inline")) {
+  if (!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 (
@@ -45,6 +56,27 @@ export const isInline = (el: Element): boolean => {
     return false;
   }
 
+  const parentLineHeight =
+    parentStyle.lineHeight === "normal"
+      ? parseFloat(parentStyle.fontSize) * 1.2
+      : parseFloat(parentStyle.lineHeight);
+  const height = el.getBoundingClientRect().height;
+
+  // 親要素の行の高さより大きいならfalse
+  if (display !== "inline" && height > parentLineHeight) {
+    return false;
+  }
+
+  // テキスト、またはインラインの兄弟がいる場合はtrue
+  if (hasInlineSibling(el)) {
+    return true;
+  }
+
+  // display: inlineで、テキストを含んでる場合はtrue
+  if (display === "inline" && el.textContent?.trim()) {
+    return true;
+  }
+
   // 兄弟がいる場所まで遡る (spanが入れ子になってたりするので)
   if (!el.previousSibling && !el.nextSibling) {
     return isInline(el.parentElement);
diff --git a/src/content/dom/tips/buttonTips.ts b/src/content/dom/tips/buttonTips.ts
index 0edfc32..40b5ec8 100644
--- a/src/content/dom/tips/buttonTips.ts
+++ b/src/content/dom/tips/buttonTips.ts
@@ -6,6 +6,7 @@ import { hasInteractiveDescendant } from "../hasInteractiveDescendant";
 import { hasTabIndexDescendant } from "../hasTabIndexDescendant";
 import { isInline } from "../isInline";
 import { isDefaultSize } from "./isDefaultSize";
+import { isSmallTarget } from "./isSmallTarget";
 
 export const ButtonSelectors = [
   "button",
@@ -60,11 +61,7 @@ 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))
-    ) {
+    if (isSmallTarget(el) && !(isInline(el) || isDefaultSize(el))) {
       result.push({ type: "warning", content: "messages.smallTargetSize" });
     }
   }
diff --git a/src/content/dom/tips/isSmallTarget.ts b/src/content/dom/tips/isSmallTarget.ts
new file mode 100644
index 0000000..5abef25
--- /dev/null
+++ b/src/content/dom/tips/isSmallTarget.ts
@@ -0,0 +1,19 @@
+export const isSmallTarget = (el: Element): boolean => {
+  const rect = el.getBoundingClientRect();
+  // boundingClientRectで24px以上ならfalse
+  if (rect.width >= 24 && rect.height >= 24) {
+    return false;
+  }
+  const style = window.getComputedStyle(el);
+  const { display } = style;
+  // inline以外の場合は、24px未満ならtrue
+  if (display !== "inline" && rect.width < 24 && rect.height < 24) {
+    return true;
+  }
+
+  // inlineの場合は、子要素に24px以上のものがあればfalse
+  if ([...el.children].some((child) => !isSmallTarget(child))) {
+    return false;
+  }
+  return true;
+};
diff --git a/src/content/dom/tips/linkTips.ts b/src/content/dom/tips/linkTips.ts
index 04845af..194a5fa 100644
--- a/src/content/dom/tips/linkTips.ts
+++ b/src/content/dom/tips/linkTips.ts
@@ -6,6 +6,7 @@ import { hasInteractiveDescendant } from "../hasInteractiveDescendant";
 import { hasTabIndexDescendant } from "../hasTabIndexDescendant";
 import { isInline } from "../isInline";
 import { isDefaultSize } from "./isDefaultSize";
+import { isSmallTarget } from "./isSmallTarget";
 
 export const LinkSelectors = ["a", "area", '[role="link"]'] as const;
 
@@ -40,14 +41,11 @@ 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))
-    ) {
+    if (isSmallTarget(el) && !(isInline(el) || isDefaultSize(el))) {
       result.push({ type: "warning", content: "messages.smallTargetSize" });
     }
   }
+
   if (hasTag) {
     if (!el.hasAttribute("href")) {
       result.push({ type: "warning", content: "messages.noHref" });


Test patch:
diff --git a/src/content/dom/isInline.test.ts b/src/content/dom/isInline.test.ts
index 4b174dd..255f659 100644
--- a/src/content/dom/isInline.test.ts
+++ b/src/content/dom/isInline.test.ts
@@ -11,27 +11,114 @@ describe("isInline", () => {
     expect(isInline(el)).toBe(false);
   });
 
-  test("element is the only child of block", () => {
-    const p = document.createElement("p");
-    p.setAttribute("style", "display: block");
+  test("display: block", () => {
     const el = document.createElement("span");
-    el.setAttribute("style", "display: inline");
-    document.body.appendChild(p);
-    p.appendChild(el);
+    el.style.display = "block";
+    document.body.appendChild(el);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("display: flex", () => {
+    const el = document.createElement("span");
+    el.style.display = "flex";
+    document.body.appendChild(el);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("display: grid", () => {
+    const el = document.createElement("span");
+    el.style.display = "grid";
+    document.body.appendChild(el);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("parent is flex", () => {
+    const parent = document.createElement("div");
+    parent.style.display = "flex";
+    const el = document.createElement("span");
+    parent.appendChild(el);
+    document.body.appendChild(parent);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("parent is grid", () => {
+    const parent = document.createElement("div");
+    parent.style.display = "grid";
+    const el = document.createElement("span");
+    parent.appendChild(el);
+    document.body.appendChild(parent);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("bigger height than parent line height", () => {
+    const parent = document.createElement("div");
+    parent.style.fontSize = "16px";
+    parent.style.lineHeight = "normal"; // 1.2 * 16 = 19.2
+    const el = document.createElement("span");
+    el.style.display = "inline-block";
+    el.getBoundingClientRect = () => ({ height: 20 }) as DOMRect;
+    parent.appendChild(el);
+    document.body.appendChild(parent);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("has inline sibling", () => {
+    const parent = document.createElement("div");
+    const el = document.createElement("span");
+    el.style.display = "inline-block";
+    el.getBoundingClientRect = () => ({ height: 19.2 }) as DOMRect;
+    const sibling = document.createElement("span");
+    sibling.style.display = "inline-block";
+    parent.appendChild(el);
+    parent.appendChild(sibling);
+    document.body.appendChild(parent);
+    expect(isInline(el)).toBe(true);
+  });
+
+  test("has test sibling", () => {
+    const parent = document.createElement("div");
+    const el = document.createElement("span");
+    el.style.display = "inline-block";
+    el.getBoundingClientRect = () => ({ height: 19.2 }) as DOMRect;
+    const sibling = document.createTextNode("test");
+    parent.appendChild(el);
+    parent.appendChild(sibling);
+    document.body.appendChild(parent);
+    expect(isInline(el)).toBe(true);
+  });
+
+  test("display:inline and has text", () => {
+    const el = document.createElement("span");
+    el.style.display = "inline";
+    el.textContent = "test";
+    el.getBoundingClientRect = () => ({ height: 19.2 }) as DOMRect;
+    document.body.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");
+  test("display:inline and does not have text", () => {
+    const el = document.createElement("span");
+    el.style.display = "inline";
+    el.getBoundingClientRect = () => ({ height: 19.2 }) as DOMRect;
+    document.body.appendChild(el);
+    expect(isInline(el)).toBe(false);
+  });
+
+  test("nested span", () => {
+    const grandParent = document.createElement("div");
+    grandParent.style.display = "block";
     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);
+    const parent = document.createElement("span");
+    const parentSibling = document.createElement("span");
+    parentSibling.textContent = "test";
+    [el, parent, parentSibling].forEach((e) => {
+      e.style.display = "inline-block";
+      e.getBoundingClientRect = () => ({ height: 19.2 }) as DOMRect;
+    });
+    parent.appendChild(el);
+    grandParent.appendChild(parent);
+    grandParent.appendChild(parentSibling);
+    document.body.appendChild(grandParent);
     expect(isInline(el)).toBe(true);
   });
 });
diff --git a/src/content/dom/tips/isSmallTarget.test.ts b/src/content/dom/tips/isSmallTarget.test.ts
new file mode 100644
index 0000000..9cb4e00
--- /dev/null
+++ b/src/content/dom/tips/isSmallTarget.test.ts
@@ -0,0 +1,43 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { isSmallTarget } from "./isSmallTarget";
+
+describe("isSmallTarget", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("small inline-block", () => {
+    const el = document.createElement("span");
+    el.style.display = "inline-block";
+    el.getBoundingClientRect = () => ({ width: 20, height: 20 }) as DOMRect;
+    document.body.appendChild(el);
+    expect(isSmallTarget(el)).toBe(true);
+  });
+
+  test("big inline-block", () => {
+    const el = document.createElement("span");
+    el.style.display = "inline-block";
+    el.getBoundingClientRect = () => ({ width: 24, height: 24 }) as DOMRect;
+    document.body.appendChild(el);
+    expect(isSmallTarget(el)).toBe(false);
+  });
+
+  test("small inline", () => {
+    const el = document.createElement("span");
+    el.style.display = "inline";
+    el.getBoundingClientRect = () => ({ width: 0, height: 20 }) as DOMRect;
+    document.body.appendChild(el);
+    expect(isSmallTarget(el)).toBe(true);
+  });
+
+  test("small inline but has big child", () => {
+    const el = document.createElement("span");
+    el.style.display = "inline";
+    el.getBoundingClientRect = () => ({ width: 24, height: 20 }) as DOMRect;
+    const child = document.createElement("span");
+    child.getBoundingClientRect = () => ({ width: 24, height: 24 }) as DOMRect;
+    el.appendChild(child);
+    document.body.appendChild(el);
+    expect(isSmallTarget(el)).toBe(false);
+  });
+});