Repository: ymrl/a11y-visualizer (ts)

Issue:
先祖がボタンやリンクで、そちらにaccessible nameのあるsvg要素には「名前なし」を出さなくてもいいかも
そういう場所のsvgに名前がなければいけないのか、明確な根拠がなさそうな気がする。

Pull request description:
:sparkles: not show "noName" error if image is descendant of button or link
close #40 

Patch:
diff --git a/src/content/dom/getClosestByRoles.ts b/src/content/dom/getClosestByRoles.ts
new file mode 100644
index 0000000..a4a0e79
--- /dev/null
+++ b/src/content/dom/getClosestByRoles.ts
@@ -0,0 +1,18 @@
+import { getKnownRole, KnownRole } from "./getKnownRole";
+
+export const getClosestByRoles = (
+  el: Element,
+  roles: KnownRole[],
+): Element | null => {
+  const parent = el.parentElement;
+  if (!parent) {
+    return null;
+  }
+  const role = getKnownRole(parent);
+  if (role) {
+    if (roles.includes(role)) {
+      return parent;
+    }
+  }
+  return getClosestByRoles(parent, roles);
+};
diff --git a/src/content/dom/getKnownRole.ts b/src/content/dom/getKnownRole.ts
new file mode 100644
index 0000000..3a49d88
--- /dev/null
+++ b/src/content/dom/getKnownRole.ts
@@ -0,0 +1,113 @@
+import { getRole } from "dom-accessibility-api";
+
+const knownRoles = [
+  "alert",
+  "alertdialog",
+  "application",
+  "article",
+  "banner",
+  "blockquote",
+  "button",
+  "caption",
+  "cell",
+  "checkbox",
+  "code",
+  "columnheader",
+  "combobox",
+  "command",
+  "complementary",
+  "composite",
+  "contentinfo",
+  "definition",
+  "deletion",
+  "dialog",
+  "directory",
+  "document",
+  "emphasis",
+  "feed",
+  "figure",
+  "form",
+  "generic",
+  "grid",
+  "gridcell",
+  "group",
+  "heading",
+  "img",
+  "input",
+  "insertion",
+  "landmark",
+  "link",
+  "list",
+  "listbox",
+  "listitem",
+  "log",
+  "main",
+  "marquee",
+  "math",
+  "meter",
+  "menu",
+  "menubar",
+  "menuitem",
+  "menuitemcheckbox",
+  "menuitemradio",
+  "navigation",
+  "none",
+  "note",
+  "option",
+  "paragraph",
+  "presentation",
+  "progressbar",
+  "radio",
+  "radiogroup",
+  "range",
+  "region",
+  "roletype",
+  "row",
+  "rowgroup",
+  "rowheader",
+  "scrollbar",
+  "search",
+  "searchbox",
+  "section",
+  "sectionhead",
+  "select",
+  "separator",
+  "slider",
+  "spinbutton",
+  "status",
+  "strong",
+  "structure",
+  "subscript",
+  "superscript",
+  "switch",
+  "tab",
+  "table",
+  "tablist",
+  "tabpanel",
+  "term",
+  "textbox",
+  "time",
+  "timer",
+  "toolbar",
+  "tooltip",
+  "tree",
+  "treegrid",
+  "treeitem",
+  "widget",
+  "window",
+] as const;
+
+export type KnownRole = (typeof knownRoles)[number];
+export const getKnownRole = (el: Element): KnownRole | null => {
+  const role = getRole(el);
+  if (role && knownRoles.includes(role as KnownRole)) {
+    return role as KnownRole;
+  }
+  const roleAttr = el.getAttribute("role");
+  const roles = roleAttr?.split(" ") || [];
+  const knownRole = roles.find((e) => knownRoles.includes(e as KnownRole));
+  if (knownRole) {
+    return knownRole as KnownRole;
+  }
+  return null;
+};
diff --git a/src/content/dom/index.ts b/src/content/dom/index.ts
index 36e332e..9e870c9 100644
--- a/src/content/dom/index.ts
+++ b/src/content/dom/index.ts
@@ -3,3 +3,5 @@ export * from "./getPositionBaseElement";
 export * from "./collectElements";
 export * from "./isFocusable";
 export * from "./isAriaHidden";
+export * from "./getClosestByRoles";
+export * from "./getKnownRole";
diff --git a/src/content/dom/tips/imageTips.ts b/src/content/dom/tips/imageTips.ts
index 0c49050..6ca3018 100644
--- a/src/content/dom/tips/imageTips.ts
+++ b/src/content/dom/tips/imageTips.ts
@@ -1,6 +1,6 @@
 import { computeAccessibleName } from "dom-accessibility-api";
 import { ElementTip } from "../../types";
-import { isAriaHidden } from "../index";
+import { getClosestByRoles, isAriaHidden } from "../index";
 
 export const ImageSelectors = ["img", "svg", '[role="img"]'] as const;
 
@@ -29,7 +29,30 @@ export const imageTips = (el: Element): ElementTip[] => {
           result.push({ type: "error", content: "messages.noAltImage" });
         }
       } else {
-        result.push({ type: "error", content: "messages.noName" });
+        const ancestorControl = getClosestByRoles(el, [
+          "link",
+          "button",
+          "checkbox",
+          "img",
+          "menuitemcheckbox",
+          "menuitemradio",
+          "meter",
+          "option",
+          "progressbar",
+          "radio",
+          "scrollbar",
+          "separator",
+          "slider",
+          "switch",
+          "tab",
+        ]);
+        const nameNotRequired = ["scrollbar", "separator", "tab"];
+        const ancestorName = ancestorControl
+          ? computeAccessibleName(ancestorControl)
+          : "";
+        if (!ancestorName && !nameNotRequired.includes(roleAttr)) {
+          result.push({ type: "error", content: "messages.noName" });
+        }
       }
     }
     if (tagName === "svg" && roleAttr === "") {


Test patch:
diff --git a/src/content/dom/getClosestByRoles.test.ts b/src/content/dom/getClosestByRoles.test.ts
new file mode 100644
index 0000000..a84393a
--- /dev/null
+++ b/src/content/dom/getClosestByRoles.test.ts
@@ -0,0 +1,58 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { getClosestByRoles } from "./getClosestByRoles";
+
+describe("getClosestRoles", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("single role attribute", () => {
+    const parent = document.createElement("div");
+    parent.setAttribute("role", "button");
+    const element = document.createElement("div");
+    parent.appendChild(element);
+    document.body.appendChild(parent);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(parent);
+  });
+
+  test("multiple role attributes", () => {
+    const parent = document.createElement("div");
+    parent.setAttribute("role", "unknownbutton button");
+    const element = document.createElement("div");
+    parent.appendChild(element);
+    document.body.appendChild(parent);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(parent);
+  });
+
+  test("implicit role", () => {
+    const parent = document.createElement("button");
+    const element = document.createElement("div");
+    parent.appendChild(element);
+    document.body.appendChild(parent);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(parent);
+  });
+
+  test("grandparent", () => {
+    const grandparent = document.createElement("div");
+    grandparent.setAttribute("role", "button");
+    const parent = document.createElement("div");
+    grandparent.appendChild(parent);
+    const element = document.createElement("div");
+    parent.appendChild(element);
+    document.body.appendChild(grandparent);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(grandparent);
+  });
+
+  test("no role", () => {
+    const parent = document.createElement("div");
+    const element = document.createElement("div");
+    parent.appendChild(element);
+    document.body.appendChild(parent);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(null);
+  });
+});
diff --git a/src/content/dom/getKnownRole.test.ts b/src/content/dom/getKnownRole.test.ts
new file mode 100644
index 0000000..4c2f095
--- /dev/null
+++ b/src/content/dom/getKnownRole.test.ts
@@ -0,0 +1,36 @@
+import { describe, test, expect } from "vitest";
+import { getKnownRole } from "./getKnownRole";
+
+describe("getKnownRole", () => {
+  test("implicit role", () => {
+    const el = document.createElement("button");
+    const role = getKnownRole(el);
+    expect(role).toBe("button");
+  });
+  test("explicit single role", () => {
+    const el = document.createElement("div");
+    el.setAttribute("role", "button");
+    const role = getKnownRole(el);
+    expect(role).toBe("button");
+  });
+
+  test("explicit multiple roles", () => {
+    const el = document.createElement("div");
+    el.setAttribute("role", "unknown button");
+    const role = getKnownRole(el);
+    expect(role).toBe("button");
+  });
+
+  test("unknown role", () => {
+    const el = document.createElement("div");
+    el.setAttribute("role", "unknown");
+    const role = getKnownRole(el);
+    expect(role).toBe(null);
+  });
+
+  test("div", () => {
+    const el = document.createElement("div");
+    const role = getKnownRole(el);
+    expect(role).toBe(null);
+  });
+});
diff --git a/src/content/dom/tips/imageTips.test.ts b/src/content/dom/tips/imageTips.test.ts
index 5889c83..be751ec 100644
--- a/src/content/dom/tips/imageTips.test.ts
+++ b/src/content/dom/tips/imageTips.test.ts
@@ -67,6 +67,27 @@ describe("imageTips()", () => {
     ]);
   });
 
+  test("img inside link", () => {
+    const link = document.createElement("a");
+    link.setAttribute("href", "hello.html");
+    const img = document.createElement("img");
+    link.appendChild(img);
+    document.body.appendChild(link);
+    expect(imageTips(img)).toEqual([
+      { type: "error", content: "messages.noAltImage" },
+    ]);
+  });
+
+  test("svg inside link", () => {
+    const link = document.createElement("a");
+    link.setAttribute("href", "hello.html");
+    link.setAttribute("aria-label", "Hello");
+    const svg = document.createElement("svg");
+    link.appendChild(svg);
+    document.body.appendChild(link);
+    expect(imageTips(svg)).toEqual([{ type: "tagName", content: "svg" }]);
+  });
+
   test("svg", () => {
     const element = document.createElement("svg");
     document.body.appendChild(element);
diff --git a/src/test/TestIframe.tsx b/src/test/TestIframe.tsx
new file mode 100644
index 0000000..32c79cf
--- /dev/null
+++ b/src/test/TestIframe.tsx
@@ -0,0 +1,9 @@
+import { Image } from "./categories";
+export const TestIframe = () => {
+  return (
+    <main className="p-3">
+      <h1 className="text-2xl font-bold">Hello From &lt;iframe&gt;</h1>
+      <Image />
+    </main>
+  );
+};
diff --git a/src/test/categories/Image.tsx b/src/test/categories/Image.tsx
index 4eb3f98..85508a1 100644
--- a/src/test/categories/Image.tsx
+++ b/src/test/categories/Image.tsx
@@ -126,6 +126,17 @@ export const Image = () => (
             </svg>
           ),
         },
+        {
+          title: "inside link",
+          body: (
+            <a href="https://example.com" role="link">
+              <svg width="100" height="100" viewBox="0 0 100 100" id="debug">
+                <circle cx="50" cy="50" r="40" fill="blue" />
+              </svg>
+              example
+            </a>
+          ),
+        },
       ]}
     />
     <CategorySectionTitle>role="img"</CategorySectionTitle>
diff --git a/src/test/iframe.html b/src/test/iframe.html
index 3ce215f..ae92259 100644
--- a/src/test/iframe.html
+++ b/src/test/iframe.html
@@ -7,9 +7,7 @@
   </head>
 
   <body>
-    <main>
-      <h1 class="text-2xl font-bold">Hello From &lt;iframe&gt;</h1>
-      <p class="mt-3">Can you hear me?</p>
-    </main>
+    <div id="root"></div>
+    <script type="module" src="/src/test/iframe.tsx"></script>
   </body>
 </html>
diff --git a/src/test/iframe.tsx b/src/test/iframe.tsx
new file mode 100644
index 0000000..a1ddaf7
--- /dev/null
+++ b/src/test/iframe.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { TestIframe } from "./TestIframe";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+  <React.StrictMode>
+    <TestIframe />
+  </React.StrictMode>,
+);