Repository: ymrl/a11y-visualizer (ts)

Issue:
ボタンやリンクが、ボタンやリンクの内部にある場合にエラーを表示する
以下のような使用例はHTMLやWAI-ARIAの仕様に違反しているうえ、スクリーンリーダー等の挙動がおかしくなることがある（気がする。調べたわけではない）ので、警告を出したい。

- `<a>` 要素の内側に `<button>` 要素を入れてボタン風の見た目のリンクにしている（そういうテクニックとして知られてしまっているらしい）
- 大きな面積をもつ `<div>` を `<a>` 要素でラップしているが、その内部にリンクやボタンもある

Pull request description:
:+1: add error message to nested interactive elements
close #136 

Patch:
diff --git a/src/content/dom/hasInteractiveDescendant.ts b/src/content/dom/hasInteractiveDescendant.ts
new file mode 100644
index 0000000..6a4001e
--- /dev/null
+++ b/src/content/dom/hasInteractiveDescendant.ts
@@ -0,0 +1,18 @@
+export const hasInteractiveDescendant = (el: Element): boolean => {
+  return !!el.querySelector(
+    [
+      "a[href]",
+      "audio[controls]",
+      "button",
+      "details",
+      "embed",
+      "iframe",
+      "img[usemap]",
+      'input:not([type="hidden"])',
+      "label",
+      "select",
+      "textarea",
+      "video",
+    ].join(","),
+  );
+};
diff --git a/src/content/dom/hasTabIndexDescendant.ts b/src/content/dom/hasTabIndexDescendant.ts
new file mode 100644
index 0000000..547b73a
--- /dev/null
+++ b/src/content/dom/hasTabIndexDescendant.ts
@@ -0,0 +1,3 @@
+export const hasTabIndexDescendant = (el: Element): boolean => {
+  return !!el.querySelector("[tabindex]");
+};
diff --git a/src/content/dom/tips/buttonTips.ts b/src/content/dom/tips/buttonTips.ts
index 4fcd3bc..bf1c3b2 100644
--- a/src/content/dom/tips/buttonTips.ts
+++ b/src/content/dom/tips/buttonTips.ts
@@ -2,6 +2,8 @@ import { computeAccessibleName } from "dom-accessibility-api";
 import { ElementTip } from "../../types";
 import { isAriaHidden } from "../isAriaHidden";
 import { isFocusable } from "../isFocusable";
+import { hasInteractiveDescendant } from "../hasInteractiveDescendant";
+import { hasTabIndexDescendant } from "../hasTabIndexDescendant";
 
 export const ButtonSelectors = [
   "button",
@@ -41,6 +43,9 @@ export const buttonTips = (
     if (!isFocusable(el)) {
       result.push({ type: "error", content: "messages.notFocusable" });
     }
+    if (hasInteractiveDescendant(el) || hasTabIndexDescendant(el)) {
+      result.push({ type: "error", content: "messages.nestedInteractive" });
+    }
   }
   return result;
 };
diff --git a/src/content/dom/tips/linkTips.ts b/src/content/dom/tips/linkTips.ts
index f99ad50..e0a1fc1 100644
--- a/src/content/dom/tips/linkTips.ts
+++ b/src/content/dom/tips/linkTips.ts
@@ -2,6 +2,8 @@ import { computeAccessibleName } from "dom-accessibility-api";
 import { ElementTip } from "../../types";
 import { isAriaHidden } from "../isAriaHidden";
 import { isFocusable } from "../isFocusable";
+import { hasInteractiveDescendant } from "../hasInteractiveDescendant";
+import { hasTabIndexDescendant } from "../hasTabIndexDescendant";
 
 export const LinkSelectors = ["a", "area", '[role="link"]'] as const;
 
@@ -29,6 +31,13 @@ export const linkTips = (
     if (!name && (hasRole || href) && !hidden) {
       result.push({ type: "error", content: "messages.noName" });
     }
+    if (
+      hasInteractiveDescendant(el) ||
+      hasTabIndexDescendant(el) ||
+      (el.tagName.toLowerCase() === "a" && el.querySelector("a"))
+    ) {
+      result.push({ type: "error", content: "messages.nestedInteractive" });
+    }
   }
   if (hasTag) {
     if (!el.hasAttribute("href")) {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 0302736..d110474 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -99,6 +99,7 @@
     "mayBeSkipped": "May be skipped",
     "noName": "No name (label)",
     "noHref": "No href attribute",
+    "nestedInteractive": "No nested interactive element",
     "emptyHeading": "Empty heading",
     "noHeadingLevel": "No heading level",
     "ariaHidden": "aria-hidden",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index d71d2a7..87dd2a0 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -105,6 +105,7 @@
     "mayBeSkipped": "読み飛ばされる可能性あり",
     "noName": "名前（ラベル）なし",
     "noHref": "href属性なし",
+    "nestedInteractive": "インタラクティブ要素の入れ子",
     "emptyHeading": "空の見出し",
     "noHeadingLevel": "見出しレベルなし",
     "ariaHidden": "aria-hidden",


Test patch:
diff --git a/src/content/dom/hasInteractiveDescendant.test.ts b/src/content/dom/hasInteractiveDescendant.test.ts
new file mode 100644
index 0000000..700123a
--- /dev/null
+++ b/src/content/dom/hasInteractiveDescendant.test.ts
@@ -0,0 +1,17 @@
+import { describe, test, expect } from "vitest";
+import { hasInteractiveDescendant } from "./hasInteractiveDescendant";
+
+describe("hasInteractiveDescendant", () => {
+  test("single element", () => {
+    const el = document.createElement("button");
+    expect(hasInteractiveDescendant(el)).toBe(false);
+  });
+
+  test("nested element", () => {
+    const el = document.createElement("button");
+    const link = document.createElement("a");
+    link.href = "https://example.com";
+    el.appendChild(link);
+    expect(hasInteractiveDescendant(el)).toBe(true);
+  });
+});
diff --git a/src/content/dom/hasTabIndexDescendant.test.ts b/src/content/dom/hasTabIndexDescendant.test.ts
new file mode 100644
index 0000000..00c7875
--- /dev/null
+++ b/src/content/dom/hasTabIndexDescendant.test.ts
@@ -0,0 +1,17 @@
+import { describe, test, expect } from "vitest";
+import { hasTabIndexDescendant } from "./hasTabIndexDescendant";
+
+describe("hasTabIndexDescendant", () => {
+  test("single element", () => {
+    const el = document.createElement("button");
+    expect(hasTabIndexDescendant(el)).toBe(false);
+  });
+
+  test("nested element", () => {
+    const el = document.createElement("button");
+    const child = document.createElement("span");
+    child.tabIndex = 0;
+    el.appendChild(child);
+    expect(hasTabIndexDescendant(el)).toBe(true);
+  });
+});
diff --git a/src/content/dom/tips/buttonTips.test.ts b/src/content/dom/tips/buttonTips.test.ts
index e18921f..595e5aa 100644
--- a/src/content/dom/tips/buttonTips.test.ts
+++ b/src/content/dom/tips/buttonTips.test.ts
@@ -248,4 +248,33 @@ describe("buttonTips()", () => {
     expect(result[0].type).toBe("error");
     expect(result[0].content).toBe("messages.noName");
   });
+
+  test("nested interactive", () => {
+    const element = document.createElement("div");
+    element.role = "button";
+    element.tabIndex = 0;
+    element.appendChild(new Text("Hello"));
+    const button = document.createElement("button");
+    button.textContent = "world";
+    element.appendChild(button);
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0].type).toBe("error");
+    expect(result[0].content).toBe("messages.nestedInteractive");
+  });
+
+  test("nested tabindex", () => {
+    const element = document.createElement("button");
+    element.appendChild(new Text("Hello"));
+    const span = document.createElement("span");
+    span.tabIndex = 0;
+    span.textContent = "world";
+    element.appendChild(span);
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0].type).toBe("error");
+    expect(result[0].content).toBe("messages.nestedInteractive");
+  });
 });
diff --git a/src/content/dom/tips/linkTips.test.ts b/src/content/dom/tips/linkTips.test.ts
index c2b5a6b..4fa5ea1 100644
--- a/src/content/dom/tips/linkTips.test.ts
+++ b/src/content/dom/tips/linkTips.test.ts
@@ -138,4 +138,55 @@ describe("linkTips()", () => {
       result.find((t) => t.type === "error" && t.content === "messages.noName"),
     ).not.toBeUndefined();
   });
+
+  test("nested interactive", () => {
+    const element = document.createElement("a");
+    element.textContent = "Hello";
+    element.href = "https://example.com";
+    const child = document.createElement("button");
+    child.textContent = "world";
+    element.appendChild(child);
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(
+      result.find(
+        (t) => t.type === "error" && t.content === "messages.nestedInteractive",
+      ),
+    ).toBeDefined();
+  });
+
+  test("nested tabindex", () => {
+    const element = document.createElement("a");
+    element.textContent = "Hello";
+    element.href = "https://example.com";
+    const child = document.createElement("div");
+    child.setAttribute("tabindex", "0");
+    element.appendChild(child);
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(
+      result.find(
+        (t) => t.type === "error" && t.content === "messages.nestedInteractive",
+      ),
+    ).toBeDefined();
+  });
+
+  test("nested a", () => {
+    const element = document.createElement("a");
+    element.textContent = "Hello";
+    element.href = "https://example.com";
+    const child = document.createElement("a");
+    child.textContent = "world";
+    element.appendChild(child);
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(
+      result.find(
+        (t) => t.type === "error" && t.content === "messages.nestedInteractive",
+      ),
+    ).toBeDefined();
+  });
 });