Repository: ymrl/a11y-visualizer (ts)

Issue:
Children Presentational: Trueなロールの内部にある場合、画像に関するチップを表示しない
tagNameやaria-hiddenなどは不要な気がしてきた。


Pull request description:
:+1: add "mayBeSkipped" tip for svg element
close #110 

Patch:
diff --git a/src/content/dom/getClosestByRoles.ts b/src/content/dom/getClosestByRoles.ts
index a4a0e79..35d5b5a 100644
--- a/src/content/dom/getClosestByRoles.ts
+++ b/src/content/dom/getClosestByRoles.ts
@@ -4,15 +4,16 @@ export const getClosestByRoles = (
   el: Element,
   roles: KnownRole[],
 ): Element | null => {
-  const parent = el.parentElement;
-  if (!parent) {
-    return null;
-  }
-  const role = getKnownRole(parent);
+  const role = getKnownRole(el);
   if (role) {
     if (roles.includes(role)) {
-      return parent;
+      return el;
     }
   }
+
+  const parent = el.parentElement;
+  if (!parent) {
+    return null;
+  }
   return getClosestByRoles(parent, roles);
 };
diff --git a/src/content/dom/index.ts b/src/content/dom/index.ts
index 9e870c9..68beb0f 100644
--- a/src/content/dom/index.ts
+++ b/src/content/dom/index.ts
@@ -5,3 +5,4 @@ export * from "./isFocusable";
 export * from "./isAriaHidden";
 export * from "./getClosestByRoles";
 export * from "./getKnownRole";
+export * from "./isPresentationalChildren";
diff --git a/src/content/dom/isPresentationalChildren.ts b/src/content/dom/isPresentationalChildren.ts
new file mode 100644
index 0000000..b071f80
--- /dev/null
+++ b/src/content/dom/isPresentationalChildren.ts
@@ -0,0 +1,25 @@
+import { getClosestByRoles } from "./getClosestByRoles";
+import { KnownRole } from "./getKnownRole";
+const ChildrenPresentationalRoles: KnownRole[] = [
+  "button",
+  "checkbox",
+  "img",
+  "menuitemcheckbox",
+  "menuitemradio",
+  "meter",
+  "option",
+  "progressbar",
+  "radio",
+  "scrollbar",
+  "separator",
+  "slider",
+  "switch",
+  "tab",
+] as const;
+
+export const isPresentationalChildren = (el: Element): boolean =>
+  !!(
+    el.parentElement &&
+    (getClosestByRoles(el.parentElement, ChildrenPresentationalRoles) ||
+      el.parentElement.closest("button"))
+  );
diff --git a/src/content/dom/tips/imageTips.ts b/src/content/dom/tips/imageTips.ts
index 6ca3018..df0ba73 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 { getClosestByRoles, isAriaHidden } from "../index";
+import { isAriaHidden, isPresentationalChildren } from "../index";
 
 export const ImageSelectors = ["img", "svg", '[role="img"]'] as const;
 
@@ -15,9 +15,17 @@ export const imageTips = (el: Element): ElementTip[] => {
   const result: ElementTip[] = [];
   const tagName = el.tagName.toLowerCase();
   const roleAttr = el.getAttribute("role") || "";
-  if (isImage(el)) {
+  const hasPresentationRole =
+    roleAttr === "presentation" || roleAttr === "none";
+
+  if (tagName === "img" || roleAttr === "img") {
     const name = computeAccessibleName(el);
-    if (!name && !isAriaHidden(el) && roleAttr !== "presentation") {
+    if (
+      !name &&
+      !isAriaHidden(el) &&
+      !hasPresentationRole &&
+      !isPresentationalChildren(el)
+    ) {
       if (tagName === "img") {
         const hasAlt = el.hasAttribute("alt");
         if (hasAlt) {
@@ -29,34 +37,16 @@ export const imageTips = (el: Element): ElementTip[] => {
           result.push({ type: "error", content: "messages.noAltImage" });
         }
       } else {
-        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" });
-        }
+        result.push({ type: "error", content: "messages.noName" });
       }
     }
-    if (tagName === "svg" && roleAttr === "") {
-      result.push({ type: "tagName", content: "svg" });
+  } else if (tagName === "svg") {
+    if (
+      !isAriaHidden(el) &&
+      !hasPresentationRole &&
+      !isPresentationalChildren(el)
+    ) {
+      result.push({ type: "warning", content: "messages.mayBeSkipped" });
     }
   }
   return result;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 1bfcd8a..4c56173 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -85,6 +85,7 @@
   "messages": {
     "noAltImage": "No alt attribute",
     "emptyAltImage": "Image has alt=\"\"",
+    "mayBeSkipped": "May be skipped",
     "noName": "No name (label)",
     "noHref": "No href attribute",
     "emptyHeading": "Empty heading",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index a10e4ce..685cf2f 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -91,6 +91,7 @@
   "messages": {
     "noAltImage": "alt属性のない画像",
     "emptyAltImage": "alt=\"\"の画像",
+    "mayBeSkipped": "読み飛ばされる可能性あり",
     "noName": "名前（ラベル）なし",
     "noHref": "href属性なし",
     "emptyHeading": "空の見出し",


Test patch:
diff --git a/src/content/dom/getClosestByRoles.test.ts b/src/content/dom/getClosestByRoles.test.ts
index a84393a..34db495 100644
--- a/src/content/dom/getClosestByRoles.test.ts
+++ b/src/content/dom/getClosestByRoles.test.ts
@@ -6,6 +6,21 @@ describe("getClosestRoles", () => {
     document.body.innerHTML = "";
   });
 
+  test("element itself with button role", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    document.body.appendChild(element);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(element);
+  });
+
+  test("button element itself", () => {
+    const element = document.createElement("button");
+    document.body.appendChild(element);
+    const result = getClosestByRoles(element, ["button", "link"]);
+    expect(result).toBe(element);
+  });
+
   test("single role attribute", () => {
     const parent = document.createElement("div");
     parent.setAttribute("role", "button");
diff --git a/src/content/dom/isPresentationalChildren.test.ts b/src/content/dom/isPresentationalChildren.test.ts
new file mode 100644
index 0000000..fbddf19
--- /dev/null
+++ b/src/content/dom/isPresentationalChildren.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { isPresentationalChildren } from "./isPresentationalChildren";
+
+describe("isPresentationalChildren", () => {
+  afterEach(() => {
+    document.documentElement.innerHTML = "";
+  });
+
+  test("div", () => {
+    document.body.innerHTML = `<div></div>`;
+    const el = document.querySelector("div")!;
+    expect(isPresentationalChildren(el)).toBe(false);
+  });
+
+  test("img", () => {
+    document.body.innerHTML = `<img>`;
+    const el = document.querySelector("img")!;
+    expect(isPresentationalChildren(el)).toBe(false);
+  });
+
+  test("button", () => {
+    document.body.innerHTML = `<button></button>`;
+    const el = document.querySelector("button")!;
+    expect(isPresentationalChildren(el)).toBe(false);
+  });
+
+  test('div[role="button"]', () => {
+    document.body.innerHTML = `<div role="button"></div>`;
+    const el = document.querySelector("div")!;
+    expect(isPresentationalChildren(el)).toBe(false);
+  });
+
+  test("div > img", () => {
+    document.body.innerHTML = `<div><img></div>`;
+    const el = document.querySelector("img")!;
+    expect(isPresentationalChildren(el)).toBe(false);
+  });
+
+  test("div > button", () => {
+    document.body.innerHTML = `<div><button></div>`;
+    const el = document.querySelector("button")!;
+    expect(isPresentationalChildren(el)).toBe(false);
+  });
+
+  test("div > button > img", () => {
+    document.body.innerHTML = `<div><button><img></button></div>`;
+    const el = document.querySelector("img")!;
+    expect(isPresentationalChildren(el)).toBe(true);
+  });
+
+  test('div > div[role="button"] > img', () => {
+    document.body.innerHTML = `<div><div role="button"><img></div></div>`;
+    const el = document.querySelector("img")!;
+    expect(isPresentationalChildren(el)).toBe(true);
+  });
+});
diff --git a/src/content/dom/tips/imageTips.test.ts b/src/content/dom/tips/imageTips.test.ts
index be751ec..002acc5 100644
--- a/src/content/dom/tips/imageTips.test.ts
+++ b/src/content/dom/tips/imageTips.test.ts
@@ -85,20 +85,27 @@ describe("imageTips()", () => {
     const svg = document.createElement("svg");
     link.appendChild(svg);
     document.body.appendChild(link);
-    expect(imageTips(svg)).toEqual([{ type: "tagName", content: "svg" }]);
+    expect(imageTips(svg)).toEqual([
+      { type: "warning", content: "messages.mayBeSkipped" },
+    ]);
+  });
+
+  test("svg inside button", () => {
+    const button = document.createElement("button");
+    const svg = document.createElement("svg");
+    button.appendChild(svg);
+    document.body.appendChild(button);
+    expect(imageTips(svg)).toHaveLength(0);
   });
 
   test("svg", () => {
     const element = document.createElement("svg");
     document.body.appendChild(element);
     const result = imageTips(element);
-    expect(result.find((e) => e.type === "tagName")).toEqual({
-      type: "tagName",
-      content: "svg",
-    });
-    expect(result.find((e) => e.type === "error")).toEqual({
-      type: "error",
-      content: "messages.noName",
+    expect(result).toHaveLength(1);
+    expect(result.find((e) => e.type === "warning")).toEqual({
+      type: "warning",
+      content: "messages.mayBeSkipped",
     });
   });
 
@@ -107,10 +114,6 @@ describe("imageTips()", () => {
     element.setAttribute("aria-hidden", "true");
     document.body.appendChild(element);
     const result = imageTips(element);
-    expect(result).toHaveLength(1);
-    expect(
-      result.find((e) => e.type === "tagName" && e.content === "svg"),
-    ).toBeDefined();
-    expect(result.find((e) => e.type === "error")).toBeUndefined();
+    expect(result).toHaveLength(0);
   });
 });
diff --git a/src/test/categories/Button.tsx b/src/test/categories/Button.tsx
index 688cef0..08cd324 100644
--- a/src/test/categories/Button.tsx
+++ b/src/test/categories/Button.tsx
@@ -54,6 +54,27 @@ export const Button = () => (
             </B>
           ),
         },
+        {
+          title: "image in button",
+          body: (
+            <button>
+              <img src={buttonImg} alt="Click Me" width={75} height={40} />
+            </button>
+          ),
+        },
+        {
+          title: "image in button without alt",
+          body: (
+            <button>
+              <img
+                src={buttonImg}
+                width={75}
+                height={40}
+                className="intentional-violation"
+              />
+            </button>
+          ),
+        },
         {
           title: 'input type="button"',
           body: (
diff --git a/src/test/categories/Image.tsx b/src/test/categories/Image.tsx
index 23d0b2d..4e910d5 100644
--- a/src/test/categories/Image.tsx
+++ b/src/test/categories/Image.tsx
@@ -161,6 +161,19 @@ export const Image = () => (
             </svg>
           ),
         },
+        {
+          title: "role presentation",
+          body: (
+            <svg
+              width="100"
+              height="100"
+              viewBox="0 0 100 100"
+              role="presentation"
+            >
+              <circle cx="50" cy="50" r="40" fill="blue" />
+            </svg>
+          ),
+        },
         {
           title: "inside link",
           body: (