Repository: ymrl/a11y-visualizer
Pull request #25 (2024-04-27 14:14:46)

:tada: Add tests


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 49bd276..17f373f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,14 +25,14 @@ jobs:
       - name: Install dependencies
         run: npm ci
 
+      - name: Release
+        run: npm run release -- ${{ github.event.inputs.release_type }}
+
       - name: Git config
         run: |
           git config --global user.name 'github-actions[bot]'
           git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
 
-      - name: Release
-        run: npm run release -- ${{ github.event.inputs.release_type }}
-
       - name: Push changes
         uses: ad-m/github-push-action@v0.6.0
         with:
@@ -42,7 +42,7 @@ jobs:
 
       - name: Get version
         id: get_version
-        run: echo "::set-output name=VERSION::$(jq -r .version package.json)"
+        run: echo "::set-output name=VERSION::$(node -e \"console.log(require('./package.json').version)\")"
 
       - name: Create Release
         id: create_release
diff --git a/manifest.json b/manifest.json
index 70024e6..5bd4e31 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "manifest_version": 3,
   "name": "Accessibility Visualizer",
-  "version": "0.4.0",
+  "version": "0.3.0",
   "action": {
     "default_popup": "src/popup/index.html"
   },
diff --git a/package-lock.json b/package-lock.json
index 2ebfbe2..d000fc1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "a11y-visualizer",
-  "version": "0.4.0",
+  "version": "0.3.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "a11y-visualizer",
-      "version": "0.4.0",
+      "version": "0.3.0",
       "dependencies": {
         "dom-accessibility-api": "^0.6.3",
         "i18next": "^23.10.1",
diff --git a/package.json b/package.json
index f263465..49f1bd3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "a11y-visualizer",
   "private": true,
-  "version": "0.4.0",
+  "version": "0.3.0",
   "type": "module",
   "scripts": {
     "dev": "vite",
diff --git a/src/content/dom/collectElements.ts b/src/content/dom/collectElements.ts
index 40ef108..1107f5b 100644
--- a/src/content/dom/collectElements.ts
+++ b/src/content/dom/collectElements.ts
@@ -1,33 +1,14 @@
-import {
-  computeAccessibleName,
-  computeAccessibleDescription,
-} from "dom-accessibility-api";
-import { ElementMeta } from "../types";
+import { ElementMeta, Category } from "../types";
 import { getPositionBaseElement } from "./getPositionBaseElement";
 import { isHidden } from "./isHidden";
-import { getElementPosition } from "./getElementPoistion";
-
-const FOCUSABLE_TAGNAMES = [
-  "a",
-  "area",
-  "button",
-  "input",
-  "object",
-  "select",
-  "textarea",
-  "summary",
-];
-
-const LABELABLE_SELECTORS = [
-  "button",
-  "input:not([type='hidden'])",
-  "meter",
-  "output",
-  "progress",
-  "select",
-  "textarea",
-] as const;
-const LABELABLE_SELECTOR = LABELABLE_SELECTORS.join(",");
+import { getElementPosition } from "./getElementPosition";
+import { globalTips } from "./tips/globalTips";
+import { ariaHiddenTips } from "./tips/ariaHiddenTips";
+import { headingTips } from "./tips/headingTips";
+import { linkTips } from "./tips/linkTips";
+import { buttonTips } from "./tips/buttonTips";
+import { formTips } from "./tips/formTips";
+import { imageTips } from "./tips/imageTips";
 
 const Selector = [
   // images
@@ -102,6 +83,7 @@ export const collectElements = (
   const offsetRect = positionBaseElement?.getBoundingClientRect();
   const offsetX = offsetRect?.left || 0;
   const offsetY = offsetRect?.top || 0;
+
   return {
     rootHeight,
     rootWidth,
@@ -109,321 +91,35 @@ export const collectElements = (
       .map((el: Element) => {
         if (excludes.some((exclude: Element) => exclude.contains(el)))
           return null;
-        const meta: ElementMeta = {
+        const images = imageTips(el);
+        const forms = formTips(el);
+        const buttons = buttonTips(el);
+        const links = linkTips(el);
+        const heading = headingTips(el);
+        const ariaHidden = ariaHiddenTips(el);
+        const global = globalTips(el);
+        return {
           ...getElementPosition(el, w, offsetX, offsetY),
           hidden: isHidden(el),
-          categories: [],
-          tips: [],
+          categories: [
+            images.length > 0 ? "image" : "",
+            forms.length > 0 ? "formControl" : "",
+            buttons.length > 0 ? "button" : "",
+            links.length > 0 ? "link" : "",
+            heading.length > 0 ? "heading" : "",
+            ariaHidden.length > 0 ? "ariaHidden" : "",
+          ].filter(Boolean) as Category[],
+          tips: [
+            ...images,
+            ...forms,
+            ...buttons,
+            ...links,
+            ...heading,
+            ...ariaHidden,
+            ...global,
+          ],
         };
-        const name = computeAccessibleName(el);
-        const description = computeAccessibleDescription(el);
-        const roleAttr = el.getAttribute("role") || "";
-        const isAriaHidden = el.getAttribute("aria-hidden") === "true";
-        const tagName = el.tagName.toLowerCase();
-        addImageInfo({ meta, el, tagName, name, roleAttr, isAriaHidden });
-        addFormControlInfo({ meta, el, tagName, name, roleAttr, isAriaHidden });
-        addButtonInfo({ meta, el, tagName, name, roleAttr, isAriaHidden });
-        addLinkInfo({ meta, el, tagName, name, roleAttr, isAriaHidden });
-        addHeadingInfo({ meta, el, tagName, name, roleAttr, isAriaHidden });
-        addAriaHiddenInfo({ meta, isAriaHidden });
-
-        if (roleAttr) {
-          meta.tips.push({ type: "role", content: roleAttr });
-        }
-        if (description) {
-          meta.tips.push({
-            type: "description",
-            content: description,
-          });
-        }
-        return meta;
       })
       .filter((el): el is ElementMeta => el !== null),
   };
 };
-
-const addImageInfo = ({
-  meta,
-  el,
-  name,
-  tagName,
-  roleAttr,
-  isAriaHidden,
-}: {
-  meta: ElementMeta;
-  el: Element;
-  tagName: string;
-  name: string;
-  roleAttr: string;
-  isAriaHidden: boolean;
-}) => {
-  if (tagName === "img") {
-    meta.categories.push("image");
-    const hasAlt = el.hasAttribute("alt");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (hasAlt) {
-      meta.tips.push({
-        type: "warning",
-        content: "messages.emptyAltImage",
-      });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noAltImage" });
-    }
-  } else if (roleAttr === "img") {
-    meta.categories.push("image");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    meta.tips.push({ type: "tagName", content: tagName });
-  } else if (el.tagName === "svg") {
-    meta.categories.push("image");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    meta.tips.push({ type: "tagName", content: "svg" });
-  }
-};
-
-const addFormControlInfo = ({
-  meta,
-  el,
-  tagName,
-  name,
-  roleAttr,
-  isAriaHidden,
-}: {
-  meta: ElementMeta;
-  el: Element;
-  tagName: string;
-  name: string;
-  roleAttr: string;
-  isAriaHidden: boolean;
-}) => {
-  const typeAttr = el.getAttribute("type");
-  if (
-    (tagName === "input" &&
-      typeAttr &&
-      !["button", "submit", "reset", "image", "hidden"].includes(typeAttr)) ||
-    tagName === "textarea" ||
-    tagName === "select"
-  ) {
-    meta.categories.push("formControl");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-
-    if (typeAttr === "radio") {
-      const nameAttr = el.getAttribute("name");
-      if (!nameAttr) {
-        meta.tips.push({ type: "error", content: "messages.noNameAttr" });
-      } else {
-        const form = el.closest("form");
-        const radios = (form || el.ownerDocument).querySelectorAll(
-          `input[type="radio"][name="${nameAttr}"]`,
-        );
-        if (radios.length < 2) {
-          meta.tips.push({ type: "error", content: "messages.noRadioGroup" });
-        }
-      }
-    }
-  } else if (
-    [
-      "checkbox",
-      "combobox",
-      "radio",
-      "searchbox",
-      "slider",
-      "spinbutton",
-      "switch",
-      "textbox",
-      "menuitemcheckbox",
-      "menuitemradio",
-    ].includes(roleAttr)
-  ) {
-    meta.categories.push("formControl");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    meta.tips.push({ type: "tagName", content: tagName });
-    if (
-      (tagName === "a" && !el.hasAttribute("href")) ||
-      (tagName === "area" && el.closest("map") && !el.hasAttribute("href")) ||
-      (!FOCUSABLE_TAGNAMES.includes(tagName) && !el.hasAttribute("tabindex"))
-    ) {
-      meta.tips.push({ type: "error", content: "messages.notFocusable" });
-    }
-  } else if (tagName === "label") {
-    const forAttr = el.getAttribute("for");
-    const forElement = forAttr && el.ownerDocument.getElementById(forAttr);
-    const controlByFor =
-      forElement && forElement.matches(LABELABLE_SELECTOR) ? forElement : null;
-    const controlInside = el.querySelector(LABELABLE_SELECTOR);
-    if (
-      (!controlByFor && !controlInside) ||
-      (controlByFor && isHidden(controlByFor)) ||
-      (controlInside && isHidden(controlInside))
-    ) {
-      meta.categories.push("formControl");
-      meta.tips.push({
-        type: "warning",
-        content: "messages.noControlForLabel",
-      });
-    }
-  }
-};
-
-const addButtonInfo = ({
-  meta,
-  el,
-  tagName,
-  name,
-  roleAttr,
-  isAriaHidden,
-}: {
-  meta: ElementMeta;
-  el: Element;
-  tagName: string;
-  name: string;
-  roleAttr: string;
-  isAriaHidden: boolean;
-}) => {
-  const typeAttr = el.getAttribute("type");
-  if (
-    tagName === "button" ||
-    (tagName === "input" &&
-      typeAttr &&
-      ["button", "submit", "reset", "image"].includes(typeAttr))
-  ) {
-    meta.categories.push("button");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-  } else if (roleAttr === "button") {
-    meta.categories.push("button");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    if (
-      (tagName === "a" && !el.hasAttribute("href")) ||
-      (tagName === "area" && !el.hasAttribute("href")) ||
-      (!FOCUSABLE_TAGNAMES.includes(tagName) && !el.hasAttribute("tabindex"))
-    ) {
-      meta.tips.push({ type: "error", content: "messages.notFocusable" });
-    }
-  }
-};
-
-const addLinkInfo = ({
-  meta,
-  el,
-  tagName,
-  name,
-  roleAttr,
-  isAriaHidden,
-}: {
-  meta: ElementMeta;
-  el: Element;
-  tagName: string;
-  name: string;
-  roleAttr: string;
-  isAriaHidden: boolean;
-}) => {
-  if (tagName === "a") {
-    meta.categories.push("link");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    if (!el.hasAttribute("href")) {
-      meta.tips.push({ type: "warning", content: "messages.noHref" });
-    }
-  } else if (tagName === "area") {
-    meta.categories.push("link");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    if (!el.hasAttribute("href")) {
-      meta.tips.push({ type: "warning", content: "messages.noHref" });
-    }
-  } else if (roleAttr === "link") {
-    meta.categories.push("link");
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    meta.tips.push({ type: "tagName", content: tagName });
-  }
-};
-
-const addHeadingInfo = ({
-  meta,
-  el,
-  tagName,
-  name,
-  roleAttr,
-  isAriaHidden,
-}: {
-  meta: ElementMeta;
-  el: Element;
-  tagName: string;
-  name: string;
-  roleAttr: string;
-  isAriaHidden: boolean;
-}) => {
-  if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName)) {
-    meta.categories.push("heading");
-    meta.tips.push({ type: "level", content: `${tagName.slice(1)}` });
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-  } else if (roleAttr === "heading") {
-    meta.categories.push("heading");
-    const ariaLevel = el.getAttribute("aria-level");
-    if (ariaLevel) {
-      meta.tips.push({ type: "level", content: `${ariaLevel}` });
-    } else {
-      meta.tips.push({
-        type: "error",
-        content: "messages.noHeadingLevel",
-      });
-    }
-    if (name) {
-      meta.tips.push({ type: "name", content: name });
-    } else if (!isAriaHidden) {
-      meta.tips.push({ type: "error", content: "messages.noName" });
-    }
-    meta.tips.push({ type: "tagName", content: tagName });
-  }
-};
-
-const addAriaHiddenInfo = ({
-  meta,
-  isAriaHidden,
-}: {
-  meta: ElementMeta;
-  isAriaHidden: boolean;
-}) => {
-  if (isAriaHidden) {
-    meta.categories.push("ariaHidden");
-    meta.tips.push({ type: "warning", content: "messages.ariaHidden" });
-  }
-};
diff --git a/src/content/dom/getElementPoistion.ts b/src/content/dom/getElementPosition.ts
similarity index 80%
rename from src/content/dom/getElementPoistion.ts
rename to src/content/dom/getElementPosition.ts
index 02a2b0c..789d9d2 100644
--- a/src/content/dom/getElementPoistion.ts
+++ b/src/content/dom/getElementPosition.ts
@@ -1,14 +1,10 @@
+import { ElementPosition } from "../types";
 export const getElementPosition = (
   el: Element,
   w: Window,
   offsetX: number = 0,
   offsetY: number = 0,
-): {
-  x: number;
-  y: number;
-  width: number;
-  height: number;
-} => {
+): ElementPosition => {
   if (el.tagName.toLowerCase() === "area") {
     return getAreaElementPosition(el, w, offsetX, offsetY);
   }
@@ -26,12 +22,7 @@ const getAreaElementPosition = (
   w: Window,
   offsetX: number = 0,
   offsetY: number = 0,
-): {
-  x: number;
-  y: number;
-  width: number;
-  height: number;
-} => {
+): ElementPosition => {
   const map = el.closest("map");
   const img = map?.id
     ? map.ownerDocument.querySelector(`img[usemap="#${map.id}"]`)
@@ -42,15 +33,7 @@ const getAreaElementPosition = (
   const coords = el.getAttribute("coords")?.split(",").map(Number);
   const shape = el.getAttribute("shape");
 
-  if (!coords || !shape) {
-    return {
-      x: rect.x + w.scrollX - offsetX,
-      y: rect.y + w.scrollY - offsetY,
-      width: rect.width,
-      height: rect.height,
-    };
-  }
-  if (shape === "rect" && coords.length === 4) {
+  if (coords && (shape === "rect" || !shape) && coords.length >= 4) {
     return {
       x: rect.x + coords[0] + w.scrollX - offsetX,
       y: rect.y + coords[1] + w.scrollY - offsetY,
@@ -58,7 +41,7 @@ const getAreaElementPosition = (
       height: coords[3] - coords[1],
     };
   }
-  if (shape === "circle" && coords.length === 3) {
+  if (coords && shape === "circle" && coords.length >= 3) {
     return {
       x: rect.x + coords[0] - coords[2] + w.scrollX - offsetX,
       y: rect.y + coords[1] - coords[2] + w.scrollY - offsetY,
@@ -66,7 +49,7 @@ const getAreaElementPosition = (
       height: coords[2] * 2,
     };
   }
-  if (shape === "poly" && coords.length >= 6) {
+  if (coords && shape === "poly" && coords.length >= 6) {
     return {
       x:
         rect.x +
diff --git a/src/content/dom/index.ts b/src/content/dom/index.ts
index 8f35750..36e332e 100644
--- a/src/content/dom/index.ts
+++ b/src/content/dom/index.ts
@@ -1,3 +1,5 @@
 export * from "./isHidden";
 export * from "./getPositionBaseElement";
 export * from "./collectElements";
+export * from "./isFocusable";
+export * from "./isAriaHidden";
diff --git a/src/content/dom/isAriaHidden.ts b/src/content/dom/isAriaHidden.ts
new file mode 100644
index 0000000..934c739
--- /dev/null
+++ b/src/content/dom/isAriaHidden.ts
@@ -0,0 +1,2 @@
+export const isAriaHidden = (el: Element): boolean =>
+  el.getAttribute("aria-hidden") === "true";
diff --git a/src/content/dom/isFocusable.ts b/src/content/dom/isFocusable.ts
new file mode 100644
index 0000000..05105d9
--- /dev/null
+++ b/src/content/dom/isFocusable.ts
@@ -0,0 +1,22 @@
+const FOCUSABLE_SELECTOR = [
+  "a[href]",
+  "map > area[href]",
+  "button",
+  "input:not([type=hidden])",
+  "object",
+  "select",
+  "textarea",
+  "details > summary",
+  "[tabindex]",
+].join(", ");
+export const isFocusable = (
+  element: Element,
+  keyboard: boolean = false,
+): boolean => {
+  const matches = element.matches(FOCUSABLE_SELECTOR);
+  if (keyboard && matches) {
+    const tabindex = element.getAttribute("tabindex");
+    return tabindex === null || parseInt(tabindex) >= 0;
+  }
+  return matches;
+};
diff --git a/src/content/dom/tips/ariaHiddenTips.ts b/src/content/dom/tips/ariaHiddenTips.ts
new file mode 100644
index 0000000..8374dc3
--- /dev/null
+++ b/src/content/dom/tips/ariaHiddenTips.ts
@@ -0,0 +1,5 @@
+import { ElementTip } from "../../types";
+import { isAriaHidden } from "../isAriaHidden";
+
+export const ariaHiddenTips = (el: Element): ElementTip[] =>
+  isAriaHidden(el) ? [{ type: "warning", content: "messages.ariaHidden" }] : [];
diff --git a/src/content/dom/tips/buttonTips.ts b/src/content/dom/tips/buttonTips.ts
new file mode 100644
index 0000000..215bc66
--- /dev/null
+++ b/src/content/dom/tips/buttonTips.ts
@@ -0,0 +1,29 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+import { isAriaHidden } from "../isAriaHidden";
+import { isFocusable } from "../isFocusable";
+
+export const buttonTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  const tagName = el.tagName.toLowerCase();
+  const typeAttr = el.getAttribute("type");
+  const hasButtonTag =
+    tagName === "button" ||
+    (tagName === "input" &&
+      !!typeAttr &&
+      ["button", "submit", "reset", "image"].includes(typeAttr));
+  const hasButtonRole = el.getAttribute("role") === "button";
+
+  if (hasButtonTag || hasButtonRole) {
+    const name = computeAccessibleName(el);
+    if (name) {
+      result.push({ type: "name", content: name });
+    } else if (!isAriaHidden(el)) {
+      result.push({ type: "error", content: "messages.noName" });
+    }
+    if (!isFocusable(el)) {
+      result.push({ type: "error", content: "messages.notFocusable" });
+    }
+  }
+  return result;
+};
diff --git a/src/content/dom/tips/formTips.ts b/src/content/dom/tips/formTips.ts
new file mode 100644
index 0000000..99c573d
--- /dev/null
+++ b/src/content/dom/tips/formTips.ts
@@ -0,0 +1,84 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+import { isAriaHidden, isFocusable, isHidden } from "../index";
+
+const LABELABLE_SELECTOR = [
+  "button",
+  "input:not([type='hidden'])",
+  "meter",
+  "output",
+  "progress",
+  "select",
+  "textarea",
+].join(",");
+
+export const formTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  const tagName = el.tagName.toLowerCase();
+  const typeAttr = el.getAttribute("type");
+  const roleAttr = el.getAttribute("role") || "";
+  const hasInputTag =
+    (tagName === "input" &&
+      (typeAttr === null ||
+        (typeAttr &&
+          !["button", "submit", "reset", "image", "hidden"].includes(
+            typeAttr,
+          )))) ||
+    tagName === "textarea" ||
+    tagName === "select";
+
+  const hasInputRole = [
+    "checkbox",
+    "combobox",
+    "radio",
+    "searchbox",
+    "slider",
+    "spinbutton",
+    "switch",
+    "textbox",
+    "menuitemcheckbox",
+    "menuitemradio",
+  ].includes(roleAttr);
+
+  if (hasInputTag || hasInputRole) {
+    const name = computeAccessibleName(el);
+    if (name) {
+      result.push({ type: "name", content: name });
+    } else if (!isAriaHidden(el)) {
+      result.push({ type: "error", content: "messages.noName" });
+    }
+    if (!isFocusable(el)) {
+      result.push({ type: "error", content: "messages.notFocusable" });
+    }
+  }
+  if (tagName === "input" && typeAttr === "radio") {
+    const nameAttr = el.getAttribute("name");
+    if (!nameAttr) {
+      result.push({ type: "error", content: "messages.noNameAttr" });
+    } else {
+      const form = el.closest("form");
+      const radios = (form || el.ownerDocument).querySelectorAll(
+        `input[type="radio"][name="${nameAttr}"]`,
+      );
+      if (radios.length < 2) {
+        result.push({ type: "error", content: "messages.noRadioGroup" });
+      }
+    }
+  }
+
+  if (tagName === "label") {
+    const forAttr = el.getAttribute("for");
+    const forElement = forAttr && el.ownerDocument.getElementById(forAttr);
+    const controlByFor =
+      forElement && forElement.matches(LABELABLE_SELECTOR) ? forElement : null;
+    const controlInside = el.querySelector(LABELABLE_SELECTOR);
+    if (
+      (!controlByFor && !controlInside) ||
+      (controlByFor && isHidden(controlByFor)) ||
+      (controlInside && isHidden(controlInside))
+    ) {
+      result.push({ type: "warning", content: "messages.noControlForLabel" });
+    }
+  }
+  return result;
+};
diff --git a/src/content/dom/tips/globalTips.ts b/src/content/dom/tips/globalTips.ts
new file mode 100644
index 0000000..054ab4d
--- /dev/null
+++ b/src/content/dom/tips/globalTips.ts
@@ -0,0 +1,18 @@
+import { computeAccessibleDescription } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+
+export const globalTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  const description = computeAccessibleDescription(el);
+  const roleAttr = el.getAttribute("role") || "";
+  if (roleAttr) {
+    const tagName = el.tagName.toLowerCase();
+    result.push({ type: "tagName", content: tagName });
+    result.push({ type: "role", content: roleAttr });
+  }
+  if (description) {
+    result.push({ type: "description", content: description });
+  }
+
+  return result;
+};
diff --git a/src/content/dom/tips/headingTips.ts b/src/content/dom/tips/headingTips.ts
new file mode 100644
index 0000000..522fb33
--- /dev/null
+++ b/src/content/dom/tips/headingTips.ts
@@ -0,0 +1,36 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+import { isAriaHidden } from "../isAriaHidden";
+
+export const headingTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  const tagName = el.tagName.toLowerCase();
+  const hidden = isAriaHidden(el);
+  const hasHeadingTag = ["h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
+  const hasHeadingRole = el.getAttribute("role") === "heading";
+
+  if (hasHeadingTag) {
+    result.push({ type: "level", content: `${tagName.slice(1)}` });
+  }
+  if (hasHeadingRole) {
+    const ariaLevel = el.getAttribute("aria-level");
+    if (ariaLevel) {
+      result.push({ type: "level", content: `${ariaLevel}` });
+    } else if (!hidden) {
+      result.push({
+        type: "error",
+        content: "messages.noHeadingLevel",
+      });
+    }
+  }
+  if (hasHeadingTag || hasHeadingRole) {
+    const name = computeAccessibleName(el);
+    if (name) {
+      result.push({ type: "name", content: name });
+    } else if (!hidden) {
+      result.push({ type: "error", content: "messages.noName" });
+    }
+  }
+
+  return result;
+};
diff --git a/src/content/dom/tips/imageTips.ts b/src/content/dom/tips/imageTips.ts
new file mode 100644
index 0000000..e576901
--- /dev/null
+++ b/src/content/dom/tips/imageTips.ts
@@ -0,0 +1,33 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+import { isAriaHidden } from "../index";
+
+export const imageTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  const tagName = el.tagName.toLowerCase();
+  const roleAttr = el.getAttribute("role") || "";
+  if (tagName === "img" || roleAttr === "img" || tagName === "svg") {
+    const name = computeAccessibleName(el);
+    if (name) {
+      result.push({ type: "name", content: name });
+    } else if (!isAriaHidden(el) || roleAttr !== "presentation") {
+      if (tagName === "img") {
+        const hasAlt = el.hasAttribute("alt");
+        if (hasAlt) {
+          result.push({
+            type: "warning",
+            content: "messages.emptyAltImage",
+          });
+        } else {
+          result.push({ type: "error", content: "messages.noAltImage" });
+        }
+      } else {
+        result.push({ type: "error", content: "messages.noName" });
+      }
+    }
+    if (tagName === "svg" && roleAttr === "") {
+      result.push({ type: "tagName", content: "svg" });
+    }
+  }
+  return result;
+};
diff --git a/src/content/dom/tips/linkTips.ts b/src/content/dom/tips/linkTips.ts
new file mode 100644
index 0000000..b296625
--- /dev/null
+++ b/src/content/dom/tips/linkTips.ts
@@ -0,0 +1,30 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+import { isAriaHidden } from "../isAriaHidden";
+import { isFocusable } from "../isFocusable";
+export const linkTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  const tagName = el.tagName.toLowerCase();
+  const hidden = isAriaHidden(el);
+  const hasLinkTag = ["a", "area"].includes(tagName);
+  const hasLinkRole = el.getAttribute("role") === "link";
+  const href = el.getAttribute("href");
+
+  if (hasLinkTag || hasLinkRole) {
+    const name = computeAccessibleName(el);
+    if (name) {
+      result.push({ type: "name", content: name });
+    } else if ((hasLinkRole || href) && !hidden) {
+      result.push({ type: "error", content: "messages.noName" });
+    }
+  }
+  if (hasLinkTag) {
+    if (!el.hasAttribute("href")) {
+      result.push({ type: "warning", content: "messages.noHref" });
+    }
+  }
+  if (hasLinkRole && !isFocusable(el)) {
+    result.push({ type: "error", content: "messages.notFocusable" });
+  }
+  return result;
+};
diff --git a/src/content/types.ts b/src/content/types.ts
index a8cb185..5ce4983 100644
--- a/src/content/types.ts
+++ b/src/content/types.ts
@@ -1,25 +1,14 @@
-export type ElementInfo = {
-  tagName: string;
-  hasAlt: boolean | undefined;
-  x: number;
-  y: number;
-  width: number;
-  height: number;
-  type: "input" | "textarea" | "select" | "button";
-  name: string;
-  role: string;
-  description: string;
+export type ElementMeta = {
+  tips: ElementTip[];
   hidden: boolean;
-};
+  categories: Category[];
+} & ElementPosition;
 
-export type ElementMeta = {
+export type ElementPosition = {
   x: number;
   y: number;
   width: number;
   height: number;
-  tips: ElementTip[];
-  hidden: boolean;
-  categories: Category[];
 };
 
 export type TipType =


Test patch:
diff --git a/src/content/dom/getElementPosition.test.ts b/src/content/dom/getElementPosition.test.ts
new file mode 100644
index 0000000..304d8c1
--- /dev/null
+++ b/src/content/dom/getElementPosition.test.ts
@@ -0,0 +1,125 @@
+import { test, expect, describe } from "vitest";
+import { getElementPosition } from "./getElementPosition";
+
+const position = {
+  x: 10,
+  y: 20,
+  width: 30,
+  height: 40,
+};
+describe("getElementPosition", () => {
+  const w = { scrollX: 100, scrollY: 200 } as Window;
+  const el = document.createElement("div");
+  el.getBoundingClientRect = () => ({
+    ...position,
+    top: position.y,
+    left: position.x,
+    right: 40,
+    bottom: 60,
+    toJSON: () => "",
+  });
+  test("normal element", () => {
+    expect(getElementPosition(el, w)).toEqual({
+      x: 110,
+      y: 220,
+      width: 30,
+      height: 40,
+    });
+  });
+
+  test("with offset", () => {
+    const offsetX = 10;
+    const offsetY = 20;
+    expect(getElementPosition(el, w, offsetX, offsetY)).toEqual({
+      x: 100,
+      y: 200,
+      width: 30,
+      height: 40,
+    });
+  });
+
+  describe("area element", () => {
+    const img = document.createElement("img");
+    img.getBoundingClientRect = () => ({
+      ...position,
+      top: position.y,
+      left: position.x,
+      right: 40,
+      bottom: 60,
+      toJSON: () => "",
+    });
+    img.id = "img";
+    img.useMap = "#map";
+    document.body.appendChild(img);
+    const map = document.createElement("map");
+    map.id = "map";
+    document.body.appendChild(map);
+    const area = document.createElement("area");
+    map.appendChild(area);
+
+    test("without coords or shape", () => {
+      const w = { scrollX: 0, scrollY: 0 } as Window;
+      const area = document.createElement("area");
+      map.appendChild(area);
+      expect(getElementPosition(area, w)).toEqual(position);
+    });
+    test("without shape", () => {
+      const w = { scrollX: 0, scrollY: 0 } as Window;
+      const area = document.createElement("area");
+      map.appendChild(area);
+      area.setAttribute("coords", "5,6,10,12");
+      expect(getElementPosition(area, w)).toEqual({
+        x: position.x + 5,
+        y: position.y + 6,
+        width: 5,
+        height: 6,
+      });
+    });
+    test("without coords", () => {
+      const w = { scrollX: 0, scrollY: 0 } as Window;
+      const area = document.createElement("area");
+      map.appendChild(area);
+      area.setAttribute("shape", "rect");
+      expect(getElementPosition(area, w)).toEqual(position);
+    });
+    test("rect", () => {
+      const w = { scrollX: 0, scrollY: 0 } as Window;
+      const area = document.createElement("area");
+      map.appendChild(area);
+      area.setAttribute("shape", "rect");
+      area.setAttribute("coords", "5,6,10,12");
+      expect(getElementPosition(area, w)).toEqual({
+        x: position.x + 5,
+        y: position.y + 6,
+        width: 5,
+        height: 6,
+      });
+    });
+    test("circle", () => {
+      const w = { scrollX: 0, scrollY: 0 } as Window;
+      const area = document.createElement("area");
+      map.appendChild(area);
+      area.setAttribute("shape", "circle");
+      area.setAttribute("coords", "9,8,7");
+      expect(getElementPosition(area, w)).toEqual({
+        x: position.x + 2,
+        y: position.y + 1,
+        width: 14,
+        height: 14,
+      });
+    });
+    test("poly", () => {
+      const w = { scrollX: 0, scrollY: 0 } as Window;
+      const area = document.createElement("area");
+      map.appendChild(area);
+      area.setAttribute("shape", "poly");
+      area.setAttribute("coords", "5,6,10,6,10,12,5,12");
+      expect(getElementPosition(area, w)).toEqual({
+        x: position.x + 5,
+        y: position.y + 6,
+        width: 5,
+        height: 6,
+      });
+    });
+  });
+});
diff --git a/src/content/dom/isAriraHidden.test.ts b/src/content/dom/isAriraHidden.test.ts
new file mode 100644
index 0000000..c671207
--- /dev/null
+++ b/src/content/dom/isAriraHidden.test.ts
@@ -0,0 +1,24 @@
+import { describe, test, expect } from "vitest";
+import { isAriaHidden } from "./isAriaHidden";
+
+describe("isAriaHidden()", () => {
+  test("empty", () => {
+    const element = document.createElement("div");
+    expect(isAriaHidden(element)).toBe(false);
+  });
+  test("aria-hidden = true", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-hidden", "true");
+    expect(isAriaHidden(element)).toBe(true);
+  });
+  test("aria-hidden = false", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-hidden", "false");
+    expect(isAriaHidden(element)).toBe(false);
+  });
+  test("invalid aria-hidden", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-hidden", "invalid");
+    expect(isAriaHidden(element)).toBe(false);
+  });
+});
diff --git a/src/content/dom/isFocusable.test.ts b/src/content/dom/isFocusable.test.ts
new file mode 100644
index 0000000..443f7b0
--- /dev/null
+++ b/src/content/dom/isFocusable.test.ts
@@ -0,0 +1,122 @@
+import { describe, test, expect } from "vitest";
+import { isFocusable } from "./isFocusable";
+
+describe("isFocusable", () => {
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("link without href", () => {
+    const element = document.createElement("a");
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("link with href", () => {
+    const element = document.createElement("a");
+    element.href = "https://example.com";
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("area without map", () => {
+    const element = document.createElement("area");
+    element.href = "https://example.com";
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("area with map", () => {
+    const element = document.createElement("area");
+    element.href = "https://example.com";
+    const mapElement = document.createElement("map");
+    mapElement.appendChild(element);
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("area without href", () => {
+    const element = document.createElement("area");
+    const mapElement = document.createElement("map");
+    mapElement.appendChild(element);
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("wrong area", () => {
+    const element = document.createElement("area");
+    element.href = "https://example.com";
+    const mapElement = document.createElement("map");
+    const divElement = document.createElement("div");
+    mapElement.appendChild(divElement);
+    divElement.appendChild(element);
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("button", () => {
+    const element = document.createElement("button");
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("input", () => {
+    const element = document.createElement("input");
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("hidden input", () => {
+    const element = document.createElement("input");
+    element.type = "hidden";
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("object", () => {
+    const element = document.createElement("object");
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("select", () => {
+    const element = document.createElement("select");
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("textarea", () => {
+    const element = document.createElement("textarea");
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("summary", () => {
+    const element = document.createElement("summary");
+    const detailsElement = document.createElement("details");
+    detailsElement.appendChild(element);
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("summary without details", () => {
+    const element = document.createElement("summary");
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("wrong summary", () => {
+    const element = document.createElement("summary");
+    const detailsElement = document.createElement("details");
+    const divElement = document.createElement("div");
+    divElement.appendChild(element);
+    detailsElement.appendChild(divElement);
+    expect(isFocusable(element)).toBe(false);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("tabindex", () => {
+    const element = document.createElement("div");
+    element.tabIndex = 0;
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(true);
+  });
+  test("tabindex -1", () => {
+    const element = document.createElement("div");
+    element.tabIndex = -1;
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+  test("button tabindex -1", () => {
+    const element = document.createElement("button");
+    element.tabIndex = -1;
+    expect(isFocusable(element)).toBe(true);
+    expect(isFocusable(element, true)).toBe(false);
+  });
+});
diff --git a/src/content/dom/isHidden.test.ts b/src/content/dom/isHidden.test.ts
index cecbe1c..52fd0e8 100644
--- a/src/content/dom/isHidden.test.ts
+++ b/src/content/dom/isHidden.test.ts
@@ -32,6 +32,11 @@ const dom = new JSDOM(`<!DOCTYPE html>
       <summary id="details-summary">Summary</summary>
       <div id="details-content">Details</div>
     </details>
+
+    <details id="details-open" open>
+      <summary id="details-open-summary">Summary</summary>
+      <div id="details-open-content">Details</div>
+    </details>
   </body>
 `);
 
@@ -79,7 +84,27 @@ test("isHidden", async () => {
   expect(details).not.toBeNull();
   details && expect(isHidden(details)).toBe(false);
 
+  const summary = dom.window.document.getElementById("details-summary");
+  expect(summary).not.toBeNull();
+  summary && expect(isHidden(summary)).toBe(false);
+
   const detailsContent = dom.window.document.getElementById("details-content");
   expect(detailsContent).not.toBeNull();
   detailsContent && expect(isHidden(detailsContent)).toBe(true);
+
+  const detailsOpen = dom.window.document.getElementById("details-open");
+  expect(detailsOpen).not.toBeNull();
+  detailsOpen && expect(isHidden(detailsOpen)).toBe(false);
+
+  const detailsOpenSummary = dom.window.document.getElementById(
+    "details-open-summary",
+  );
+  expect(detailsOpenSummary).not.toBeNull();
+  detailsOpenSummary && expect(isHidden(detailsOpenSummary)).toBe(false);
+
+  const detailsOpenContent = dom.window.document.getElementById(
+    "details-open-content",
+  );
+  expect(detailsOpenContent).not.toBeNull();
+  detailsOpenContent && expect(isHidden(detailsOpenContent)).toBe(false);
 });
diff --git a/src/content/dom/tips/ariaHiddenTips.test.ts b/src/content/dom/tips/ariaHiddenTips.test.ts
new file mode 100644
index 0000000..464bebf
--- /dev/null
+++ b/src/content/dom/tips/ariaHiddenTips.test.ts
@@ -0,0 +1,23 @@
+import { describe, test, expect } from "vitest";
+import { ariaHiddenTips } from "./ariaHiddenTips";
+
+describe("ariaHiddenTips()", () => {
+  test("empty", () => {
+    const element = document.createElement("div");
+    expect(ariaHiddenTips(element)).toEqual([]);
+  });
+
+  test("aria-hidden attribute", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-hidden", "true");
+    expect(ariaHiddenTips(element)).toEqual([
+      { type: "warning", content: "messages.ariaHidden" },
+    ]);
+  });
+
+  test("aria-hidden invalid", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-hidden", "false");
+    expect(ariaHiddenTips(element)).toEqual([]);
+  });
+});
diff --git a/src/content/dom/tips/buttonTips.test.ts b/src/content/dom/tips/buttonTips.test.ts
new file mode 100644
index 0000000..186625f
--- /dev/null
+++ b/src/content/dom/tips/buttonTips.test.ts
@@ -0,0 +1,201 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { buttonTips } from "./buttonTips";
+
+describe("buttonTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(buttonTips(element)).toEqual([]);
+  });
+
+  test("empty button", () => {
+    const element = document.createElement("button");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("button element", () => {
+    const element = document.createElement("button");
+    element.textContent = "Hello";
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+    expect(result.find((t) => t.type === "error")).toBeUndefined();
+  });
+
+  test("input type = button", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "button");
+    element.value = "Hello";
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("input type = button without name", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "button");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("input type = submit", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "submit");
+    element.value = "Hello";
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("input type = reset", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "reset");
+    element.value = "Hello";
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("input type = submit without value", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "submit");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).not.toBeUndefined;
+  });
+
+  test("input type = reset without value", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "reset");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).not.toBeUndefined;
+  });
+
+  test("input type = image", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "image");
+    element.setAttribute("alt", "Hello");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("input type = image without alt", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "image");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).not.toBeUndefined;
+  });
+
+  test("role = button", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    element.textContent = "Hello";
+    element.tabIndex = 0;
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("role = button without name", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    element.tabIndex = 0;
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("role = button without tabindex", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    element.textContent = "Hello";
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(2);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.notFocusable",
+    });
+  });
+
+  test("role = button without namae and tabindex", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(2);
+    expect(
+      result.find((t) => t.type === "error" && t.content === "messages.noName"),
+    ).not.toBeUndefined();
+    expect(
+      result.find(
+        (t) => t.type === "error" && t.content === "messages.notFocusable",
+      ),
+    ).not.toBeUndefined();
+  });
+
+  test("role = button with aria-hidden", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    element.setAttribute("aria-hidden", "true");
+    element.textContent = "Hello";
+    document.body.appendChild(element);
+    const result = buttonTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+  });
+});
diff --git a/src/content/dom/tips/formTips.test.ts b/src/content/dom/tips/formTips.test.ts
new file mode 100644
index 0000000..bf0187e
--- /dev/null
+++ b/src/content/dom/tips/formTips.test.ts
@@ -0,0 +1,154 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { formTips } from "./formTips";
+
+describe("formTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(formTips(element)).toEqual([]);
+  });
+
+  test("empty input", () => {
+    const element = document.createElement("input");
+    document.body.appendChild(element);
+    const result = formTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("labeled input", () => {
+    const element = document.createElement("input");
+    element.setAttribute("id", "name");
+    document.body.appendChild(element);
+    const label = document.createElement("label");
+    label.textContent = "Name";
+    label.htmlFor = "name";
+    document.body.appendChild(label);
+    const result = formTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Name",
+    });
+  });
+
+  test("input type = radio, noname", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "radio");
+    element.setAttribute("id", "name");
+    document.body.appendChild(element);
+    const label = document.createElement("label");
+    label.textContent = "Name";
+    label.htmlFor = "name";
+    document.body.appendChild(label);
+    const result = formTips(element);
+    expect(result).toHaveLength(2);
+    expect(
+      result.find(
+        (t) => t.type === "error" && t.content === "messages.noNameAttribute",
+      ),
+    ).not.toBeUndefined;
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Name",
+    });
+  });
+  test("input type = radio, nogropup", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "radio");
+    element.setAttribute("name", "name1");
+    element.setAttribute("value", "1");
+    element.setAttribute("id", "name1");
+    document.body.appendChild(element);
+    const other = document.createElement("input");
+    other.setAttribute("type", "radio");
+    other.setAttribute("name", "name2");
+    other.setAttribute("value", "2");
+    document.body.appendChild(other);
+    const label = document.createElement("label");
+    label.textContent = "Name1";
+    label.htmlFor = "name1";
+    document.body.appendChild(label);
+    const result = formTips(element);
+    expect(result).toHaveLength(2);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Name1",
+    });
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noRadioGroup",
+    });
+  });
+
+  test("input type = radio", () => {
+    const element = document.createElement("input");
+    element.setAttribute("type", "radio");
+    element.setAttribute("name", "name");
+    element.setAttribute("value", "1");
+    element.setAttribute("id", "name1");
+    document.body.appendChild(element);
+    const other = document.createElement("input");
+    other.setAttribute("type", "radio");
+    other.setAttribute("name", "name");
+    other.setAttribute("value", "2");
+    document.body.appendChild(other);
+    const label = document.createElement("label");
+    label.textContent = "Name1";
+    label.htmlFor = "name1";
+    document.body.appendChild(label);
+    const result = formTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Name1",
+    });
+  });
+
+  test("label for visible input", () => {
+    const input = document.createElement("input");
+    input.setAttribute("id", "name");
+    document.body.appendChild(input);
+    const label = document.createElement("label");
+    label.textContent = "Name";
+    label.htmlFor = "name";
+    document.body.appendChild(label);
+    const result = formTips(label);
+    expect(result).toHaveLength(0);
+  });
+
+  test("label for hidden input", () => {
+    const input = document.createElement("input");
+    input.setAttribute("id", "name");
+    input.setAttribute("hidden", "hidden");
+    document.body.appendChild(input);
+    const label = document.createElement("label");
+    label.textContent = "Name";
+    label.htmlFor = "name";
+    document.body.appendChild(label);
+    const result = formTips(label);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "warning")).toEqual({
+      type: "warning",
+      content: "messages.noControlForLabel",
+    });
+  });
+
+  test("label for no input", () => {
+    const label = document.createElement("label");
+    label.textContent = "Name";
+    document.body.appendChild(label);
+    const result = formTips(label);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "warning")).toEqual({
+      type: "warning",
+      content: "messages.noControlForLabel",
+    });
+  });
+});
diff --git a/src/content/dom/tips/globalTips.test.ts b/src/content/dom/tips/globalTips.test.ts
new file mode 100644
index 0000000..f4d05f1
--- /dev/null
+++ b/src/content/dom/tips/globalTips.test.ts
@@ -0,0 +1,55 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { globalTips } from "./globalTips";
+
+describe("globalTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("empty", () => {
+    const element = document.createElement("div");
+    expect(globalTips(element)).toEqual([]);
+  });
+
+  test("aria-describedby", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-describedby", "id");
+    document.body.appendChild(element);
+    const description = document.createElement("div");
+    description.id = "id";
+    description.textContent = "Hello";
+    document.body.appendChild(description);
+    expect(globalTips(element)).toEqual([
+      { type: "description", content: "Hello" },
+    ]);
+  });
+
+  test("missing aria-describedby", () => {
+    const element = document.createElement("div");
+    element.setAttribute("aria-describedby", "id");
+    document.body.appendChild(element);
+    expect(globalTips(element)).toEqual([]);
+  });
+
+  test("title attribute", () => {
+    const element = document.createElement("div");
+    element.title = "Hello";
+    expect(globalTips(element)).toEqual([
+      { type: "description", content: "Hello" },
+    ]);
+  });
+
+  test("role attribute", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "button");
+    const result = globalTips(element);
+    expect(result.find((t) => t.type === "role")).toEqual({
+      type: "role",
+      content: "button",
+    });
+    expect(result.find((t) => t.type === "tagName")).toEqual({
+      type: "tagName",
+      content: "div",
+    });
+  });
+});
diff --git a/src/content/dom/tips/headingTips.test.ts b/src/content/dom/tips/headingTips.test.ts
new file mode 100644
index 0000000..bc35fa5
--- /dev/null
+++ b/src/content/dom/tips/headingTips.test.ts
@@ -0,0 +1,115 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { headingTips } from "./headingTips";
+
+describe("headingTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(headingTips(element)).toEqual([]);
+  });
+
+  test("empty h1", () => {
+    const element = document.createElement("h1");
+    document.body.appendChild(element);
+    const result = headingTips(element);
+    expect(result).toHaveLength(2);
+    expect(result.find((t) => t.type === "level")).toEqual({
+      type: "level",
+      content: "1",
+    });
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("h1 with name", () => {
+    const element = document.createElement("h1");
+    element.textContent = "Hello";
+    document.body.appendChild(element);
+    const result = headingTips(element);
+    expect(result).toHaveLength(2);
+    expect(result.find((t) => t.type === "level")).toEqual({
+      type: "level",
+      content: "1",
+    });
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("levels", () => {
+    expect(
+      headingTips(document.createElement("h1")).find((t) => t.type === "level")
+        ?.content,
+    ).toEqual("1");
+    expect(
+      headingTips(document.createElement("h2")).find((t) => t.type === "level")
+        ?.content,
+    ).toEqual("2");
+    expect(
+      headingTips(document.createElement("h3")).find((t) => t.type === "level")
+        ?.content,
+    ).toEqual("3");
+    expect(
+      headingTips(document.createElement("h4")).find((t) => t.type === "level")
+        ?.content,
+    ).toEqual("4");
+    expect(
+      headingTips(document.createElement("h5")).find((t) => t.type === "level")
+        ?.content,
+    ).toEqual("5");
+    expect(
+      headingTips(document.createElement("h6")).find((t) => t.type === "level")
+        ?.content,
+    ).toEqual("6");
+  });
+
+  test("aria-level", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "heading");
+    element.setAttribute("aria-level", "2");
+    document.body.appendChild(element);
+    const result = headingTips(element);
+    expect(result.find((t) => t.type === "level")).toEqual({
+      type: "level",
+      content: "2",
+    });
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("no aria-level", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "heading");
+    document.body.appendChild(element);
+    const result = headingTips(element);
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noHeadingLevel",
+    });
+  });
+
+  test("aria-level, name", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "heading");
+    element.setAttribute("aria-level", "2");
+    element.textContent = "Hello";
+    document.body.appendChild(element);
+    const result = headingTips(element);
+    expect(result.find((t) => t.type === "level")).toEqual({
+      type: "level",
+      content: "2",
+    });
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+});
diff --git a/src/content/dom/tips/imageTips.test.ts b/src/content/dom/tips/imageTips.test.ts
new file mode 100644
index 0000000..28e3cd0
--- /dev/null
+++ b/src/content/dom/tips/imageTips.test.ts
@@ -0,0 +1,54 @@
+import { describe, test, expect, afterEach } from "vitest";
+import { imageTips } from "./imageTips";
+
+describe("imageTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+  test("div", () => {
+    const element = document.createElement("div");
+    document.body.appendChild(element);
+    expect(imageTips(element)).toEqual([]);
+  });
+  test("img with alt", () => {
+    const element = document.createElement("img");
+    element.setAttribute("alt", "Hello");
+    element.setAttribute("src", "hello.png");
+    document.body.appendChild(element);
+    expect(imageTips(element)).toEqual([{ type: "name", content: "Hello" }]);
+  });
+
+  test("img without alt", () => {
+    const element = document.createElement("img");
+    element.setAttribute("src", "hello.png");
+    document.body.appendChild(element);
+    expect(imageTips(element)).toEqual([
+      { type: "error", content: "messages.noAltImage" },
+    ]);
+  });
+
+  test("img with empty alt", () => {
+    const element = document.createElement("img");
+    element.setAttribute("alt", "");
+    element.setAttribute("src", "hello.png");
+    document.body.appendChild(element);
+    expect(imageTips(element)).toEqual([
+      { type: "warning", content: "messages.emptyAltImage" },
+    ]);
+  });
+
+  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 === "name")).toBeUndefined();
+    expect(result.find((e) => e.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+});
diff --git a/src/content/dom/tips/linkTips.test.ts b/src/content/dom/tips/linkTips.test.ts
new file mode 100644
index 0000000..56c12d9
--- /dev/null
+++ b/src/content/dom/tips/linkTips.test.ts
@@ -0,0 +1,126 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { linkTips } from "./linkTips";
+
+describe("linkTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(linkTips(element)).toEqual([]);
+  });
+
+  test("empty a", () => {
+    const element = document.createElement("a");
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+    expect(result.find((t) => t.type === "warning")).toEqual({
+      type: "warning",
+      content: "messages.noHref",
+    });
+  });
+
+  test("a with name and href", () => {
+    const element = document.createElement("a");
+    element.textContent = "Hello";
+    element.href = "https://example.com";
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+    expect(result.find((t) => t.type === "error")).toBeUndefined();
+  });
+
+  test("a without href", () => {
+    const element = document.createElement("a");
+    element.textContent = "Hello";
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "warning")).toEqual({
+      type: "warning",
+      content: "messages.noHref",
+    });
+  });
+  test("a without name", () => {
+    const element = document.createElement("a");
+    element.href = "https://example.com";
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toBeUndefined();
+    expect(result.find((t) => t.type === "error")).toEqual({
+      type: "error",
+      content: "messages.noName",
+    });
+  });
+
+  test("area", () => {
+    const element = document.createElement("area");
+    element.href = "https://example.com";
+    element.alt = "Hello";
+    const mapElement = document.createElement("map");
+    document.body.appendChild(mapElement);
+    mapElement.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("area without href", () => {
+    const element = document.createElement("area");
+    element.alt = "Hello";
+    const mapElement = document.createElement("map");
+    document.body.appendChild(mapElement);
+    mapElement.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(2);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+    expect(result.find((t) => t.type === "warning")).toEqual({
+      type: "warning",
+      content: "messages.noHref",
+    });
+  });
+
+  test("role = link", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "link");
+    document.body.appendChild(element);
+    element.textContent = "Hello";
+    element.tabIndex = 0;
+    const result = linkTips(element);
+    expect(result).toHaveLength(1);
+    expect(result.find((t) => t.type === "name")).toEqual({
+      type: "name",
+      content: "Hello",
+    });
+  });
+
+  test("invalid role = link", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "link");
+    document.body.appendChild(element);
+    const result = linkTips(element);
+    expect(result).toHaveLength(2);
+    expect(
+      result.find(
+        (t) => t.type === "error" && t.content === "messages.notFocusable",
+      ),
+    ).not.toBeUndefined();
+    expect(
+      result.find((t) => t.type === "error" && t.content === "messages.noName"),
+    ).not.toBeUndefined();
+  });
+});