Repository: ymrl/a11y-visualizer (ts)

Issue:
landmark, sectionに関する情報の表示
landmarkロールやlandmarkロールを作る要素は、特にmainやnavランドマークを手がかりにページを読むケースが一般的に考えられるため、重要であると思っている。
なので、これもAccessibility Visualizerで表示できるようにしたい。

ランドマークの可視化については[Landmark Navigation via Keyboard or Pop-up](https://matatk.agrip.org.uk/landmarks/)拡張機能によって実現できる（私も愛用している）ため、これまで優先度は低く認識していました。しかし、section要素がregionロールを持つ/持たない条件だったり、bannerロールやmainロールの位置がおかしいページなどもたまに見かけるし、初心者向けのWebアクセシビリティ学習ツールとして位置付けるのであれば、landmarkやsectionの表示はやはりほしい。

## 関係しそうなHTML要素
HTML Living Standardの[4.3 Sections](https://html.spec.whatwg.org/#sections)にあげられている要素のうち、body, h1-6, hgroup (#82) を除く。加えてmain, search（これらは[4.4 Grouping content](https://html.spec.whatwg.org/#grouping-content)にある）。ランドマークという意味ではformも？

- article
  - スクリーンリーダーでどう読まれるのかよく知らない
- section
- nav
- aside
- header
- footer
- address?
  - address 要素に関しては使わなかったからどうこう、というのはわからない
- main
- search
- (form)

## 関係しそうなロール

WAI-ARIA[5.3.4 Landmark Roles](https://www.w3.org/TR/wai-aria-1.2/#landmark_roles) に載っているもの

- banner
- complementary
- contentinfo
- main
- form
- navigation
- region
- search

ここにarticleロールないけどどうしよう。

Pull request:
:+1: show section (landmark) tips
close #83 

Patch:
diff --git a/src/components/SettingsEditor.tsx b/src/components/SettingsEditor.tsx
index 1157af2..5fdf250 100644
--- a/src/components/SettingsEditor.tsx
+++ b/src/components/SettingsEditor.tsx
@@ -109,6 +109,15 @@ export const SettingsEditor = ({
               >
                 <span className="text-sm">{t("settings.links")}</span>
               </Checkbox>
+              <Checkbox
+                onChange={(e) => {
+                  handleChangeCheckbox("section", e);
+                }}
+                checked={settings.section}
+                disabled={disabled || !settings.accessibilityInfo}
+              >
+                <span className="text-sm">{t("settings.sections")}</span>
+              </Checkbox>
             </div>
           </fieldset>
         </div>
diff --git a/src/content/Root.tsx b/src/content/Root.tsx
index 2a64f43..35a8c83 100644
--- a/src/content/Root.tsx
+++ b/src/content/Root.tsx
@@ -110,16 +110,19 @@ export const Root = ({
     });
     return () => observer.disconnect();
   }, [updateInfo, parent]);
-
   return (
     enabled && (
-      <div ref={containerRef}>
+      <section
+        aria-label={`Accessibility Visualizer <${parent.tagName.toLowerCase()}>`}
+        aria-hidden="true"
+        ref={containerRef}
+      >
         <ElementList list={metaList} width={width} height={height} />
         <Announcements
           contents={announcements.map((a) => a.content)}
           ref={announcementsRef}
         />
-      </div>
+      </section>
     )
   );
 };
diff --git a/src/content/components/ElementInfo.css b/src/content/components/ElementInfo.css
index 6a5e330..6e68cdf 100644
--- a/src/content/components/ElementInfo.css
+++ b/src/content/components/ElementInfo.css
@@ -40,6 +40,10 @@
   border-color: rgb(0, 90, 255);
 }
 
+.ElementInfo__border--section {
+  border-color: rgb(100, 160, 12);
+}
+
 .ElementInfo__border--ariaHidden {
   border-color: rgb(255, 75, 0);
 }
diff --git a/src/content/components/ElementInfo.tsx b/src/content/components/ElementInfo.tsx
index 04ca9a7..d052f43 100644
--- a/src/content/components/ElementInfo.tsx
+++ b/src/content/components/ElementInfo.tsx
@@ -51,18 +51,21 @@ export const ElementInfo = ({
   if (!categories.some((category) => settings[category])) {
     return;
   }
-  const rightAligned: boolean = width < 160 && x + width > rootWidth - 160;
-  const verticalPosition: VerticalPosition = categories.includes("heading")
-    ? y < 24
-      ? "inner-top"
-      : "outer-top"
-    : categories.includes("image")
-      ? y > 24 && height < 32
-        ? "outer-top"
-        : "inner-top"
-      : y + height > rootHeight - 24
-        ? "inner-bottom"
-        : "outer-bottom";
+  const rightAligned: boolean =
+    (categories.includes("section") && width > 160) ||
+    (width < 160 && x + width > rootWidth - 160);
+  const verticalPosition: VerticalPosition =
+    categories.includes("section") || categories.includes("heading")
+      ? y < 24
+        ? "inner-top"
+        : "outer-top"
+      : categories.includes("image")
+        ? y > 24 && height < 32
+          ? "outer-top"
+          : "inner-top"
+        : y + height > rootHeight - 24
+          ? "inner-bottom"
+          : "outer-bottom";
 
   const disappear = () => {
     setHovered(false);
diff --git a/src/content/components/Tip.css b/src/content/components/Tip.css
index 08c53c7..4cf1042 100644
--- a/src/content/components/Tip.css
+++ b/src/content/components/Tip.css
@@ -24,6 +24,10 @@
   background-color: rgb(201, 172, 230);
   border-color: rgb(153, 0, 153);
 }
+.Tip--landmark {
+  background-color: rgb(216, 242, 85);
+  border-color: rgb(100, 160, 12);
+}
 
 .Tip--description {
   background-color: rgb(200, 200, 203);
diff --git a/src/content/components/Tip.tsx b/src/content/components/Tip.tsx
index 793e359..ebcff69 100644
--- a/src/content/components/Tip.tsx
+++ b/src/content/components/Tip.tsx
@@ -6,6 +6,7 @@ import {
   IoBookmark,
   IoCodeSlash,
   IoDocument,
+  IoFlag,
   IoPricetag,
   IoWarning,
 } from "react-icons/io5";
@@ -37,6 +38,14 @@ const Icon = ({ type }: { type: TipType }) => {
           aria-label={t("tip.element")}
         />
       );
+    case "landmark":
+      return (
+        <IoFlag
+          className="Tip__icon"
+          role="img"
+          aria-label={t("tip.landmark")}
+        />
+      );
     case "description":
       return (
         <IoDocument
diff --git a/src/content/dom/collectElements.ts b/src/content/dom/collectElements.ts
index 758b162..06e65c9 100644
--- a/src/content/dom/collectElements.ts
+++ b/src/content/dom/collectElements.ts
@@ -12,6 +12,7 @@ import { imageTips, isImage, ImageSelectors } from "./tips/imageTips";
 import { computeAccessibleName } from "dom-accessibility-api";
 import { isAriaHidden } from "./isAriaHidden";
 import { CategorySettings } from "../../settings";
+import { SectionSelectors, isSection, sectionTips } from "./tips/sectionTips";
 
 const getSelector = (settings: Partial<CategorySettings>) => {
   return [
@@ -21,6 +22,7 @@ const getSelector = (settings: Partial<CategorySettings>) => {
     ...(settings.heading ? HeadingSelectors : []),
     ...(settings.button ? ButtonSelectors : []),
     ...(settings.ariaHidden ? AriraHiddenSelectors : []),
+    ...(settings.section ? SectionSelectors : []),
   ].join(",");
 };
 
@@ -91,6 +93,7 @@ export const collectElements = (
             const links = linkTips(el);
             const heading = headingTips(el);
             const ariaHidden = ariaHiddenTips(el);
+            const section = sectionTips(el);
             const global = globalTips(el);
             return {
               ...getElementPosition(el, w, offsetX, offsetY),
@@ -102,6 +105,7 @@ export const collectElements = (
                 isLink(el) ? "link" : "",
                 isHeading(el) ? "heading" : "",
                 isAriaHidden(el) ? "ariaHidden" : "",
+                isSection(el) ? "section" : "",
               ].filter(Boolean) as Category[],
               tips: [
                 ...heading,
@@ -111,6 +115,7 @@ export const collectElements = (
                 ...buttons,
                 ...links,
                 ...ariaHidden,
+                ...section,
                 ...global,
               ],
             };
diff --git a/src/content/dom/tips/sectionTips.ts b/src/content/dom/tips/sectionTips.ts
new file mode 100644
index 0000000..f00f404
--- /dev/null
+++ b/src/content/dom/tips/sectionTips.ts
@@ -0,0 +1,82 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+
+const sectionTags = [
+  "article",
+  "section",
+  "nav",
+  "aside",
+  "footer",
+  "header",
+  "main",
+  "form",
+] as const;
+const sectionRoles = [
+  "article",
+  "banner",
+  "complementary",
+  "contentinfo",
+  "main",
+  "form",
+  "navigation",
+  "region",
+  "search",
+  "application",
+] as const;
+
+const tagToRole: Record<string, string> = {
+  article: "article",
+  nav: "navigation",
+  aside: "complementary",
+  footer: "contentinfo",
+  header: "banner",
+  main: "main",
+  form: "form",
+} as const;
+
+export const SectionSelectors = [
+  ...sectionTags,
+  ...sectionRoles.map((role) => `[role="${role}"]`),
+] as const;
+
+export const isSection = (el: Element): boolean =>
+  hasSectionTag(el) || hasSectionRole(el);
+
+const hasSectionRole = (el: Element): boolean =>
+  (sectionRoles as readonly string[]).includes(el.getAttribute("role") || "");
+
+const hasSectionTag = (el: Element): boolean =>
+  (sectionTags as readonly string[]).includes(el.tagName.toLowerCase());
+
+export const sectionTips = (el: Element): ElementTip[] => {
+  const result: ElementTip[] = [];
+  if (hasSectionRole(el)) {
+    const role = el.getAttribute("role");
+    if (role) result.push({ type: "landmark", content: role });
+  } else if (hasSectionTag(el)) {
+    const tagName = el.tagName.toLowerCase();
+    const name = computeAccessibleName(el);
+    if (tagName === "section") {
+      if (name) {
+        result.push({ type: "landmark", content: "region" });
+      } else {
+        result.push({ type: "warning", content: "messages.noNameSection" });
+      }
+    } else if (tagName === "aside") {
+      if (!el.parentElement?.closest("article, aside, nav, section") || name) {
+        result.push({ type: "landmark", content: "complementary" });
+      }
+    } else if (tagName === "header" || tagName === "footer") {
+      if (!el.closest("article, aside, nav, section, main")) {
+        result.push({
+          type: "landmark",
+          content: tagName === "header" ? "banner" : "contentinfo",
+        });
+      }
+    } else {
+      const role = tagToRole[tagName];
+      if (role) result.push({ type: "landmark", content: role });
+    }
+  }
+  return result;
+};
diff --git a/src/content/injectRoot.tsx b/src/content/injectRoot.tsx
index 80aa213..c8a1f30 100644
--- a/src/content/injectRoot.tsx
+++ b/src/content/injectRoot.tsx
@@ -5,8 +5,6 @@ import { Settings, SettingsMessage, loadHostSettings } from "../settings";
 import { SettingsProvider } from "./components/SettingsProvider";
 import { loadEnabled } from "../enabled";
 
-let counter = 0;
-
 export const injectRoot = async (w: Window, parent: Element) => {
   if (!location.href.startsWith("http")) {
     return;
@@ -16,9 +14,6 @@ export const injectRoot = async (w: Window, parent: Element) => {
 
   const rootDiv = w.document.createElement("div");
   parent.append(rootDiv);
-  rootDiv.setAttribute("role", "region");
-  rootDiv.setAttribute("aria-label", `Accessibility Visualizer ${counter++}`);
-  rootDiv.setAttribute("aria-hidden", "true");
 
   const root = ReactDOM.createRoot(rootDiv);
 
diff --git a/src/content/types.ts b/src/content/types.ts
index ca77a29..40577ee 100644
--- a/src/content/types.ts
+++ b/src/content/types.ts
@@ -17,6 +17,7 @@ export type TipType =
   | "name"
   | "role"
   | "tagName"
+  | "landmark"
   | "description"
   | "level"
   | "warning"
@@ -32,4 +33,5 @@ export type Category =
   | "button"
   | "link"
   | "heading"
-  | "ariaHidden";
+  | "ariaHidden"
+  | "section";
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c1c34cd..8273576 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -7,8 +7,7 @@
     "settingsForHost": "Settings for {{host}}",
     "rerun": "Re-run",
     "reset": "Reset to defaults",
-    "hostDesc": "Settings on this popup are saved per host. Default values for new hosts can be set in the extension options.",
-    "openExtensionOptions": "Open extension options"
+    "hostDesc": "Settings on this popup are saved per host. Default values for new hosts can be set in the extension options."
   },
   "settings": {
     "showTips": "Show tips",
@@ -19,6 +18,7 @@
     "links": "Links",
     "headings": "Headings",
     "ariaHidden": "aria-hidden",
+    "sections": "Sections",
     "tipOpacityPercent": "Tips opacity (%)",
     "interactiveMode": "Interactive mode",
     "hideLabels": "Hide labels",
@@ -32,6 +32,7 @@
     "name": "Name",
     "role": "Role",
     "element": "Element",
+    "landmark": "Landmark",
     "description": "Description",
     "heading": "Heading",
     "warning": "Warning",
@@ -49,7 +50,8 @@
     "notFocusable": "Not focusable",
     "noControlForLabel": "No control for label",
     "noNameAttr": "No name attribute",
-    "noRadioGroup": "No radiobutton group"
+    "noRadioGroup": "No radiobutton group",
+    "noNameSection": "No name <section>"
   },
   "optionsPage": {
     "description": "Settings changed from the popup are saved per host of the web page, and will be applied to other pages on the same host. Changing settings here does not affect those values."
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 3d46928..d9cd2fe 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -7,8 +7,7 @@
     "settingsForHost": "{{host}} の設定",
     "rerun": "再実行",
     "reset": "設定をリセット",
-    "hostDesc": "この画面で設定した内容は、Webページのホストごとに保存されます。設定のデフォルト値は、拡張機能のオプションで設定できます。",
-    "openExtensionOptions": "拡張機能のオプションを開く"
+    "hostDesc": "この画面で設定した内容は、Webページのホストごとに保存されます。設定のデフォルト値は、拡張機能のオプションで設定できます。"
   },
   "settings": {
     "showTips": "チップを表示",
@@ -19,6 +18,7 @@
     "links": "リンク",
     "headings": "見出し",
     "ariaHidden": "aria-hidden",
+    "sections": "セクション",
     "tipOpacityPercent": "チップの不透明度（%）",
     "interactiveMode": "インタラクティブモード",
     "hideLabels": "ラベルを隠す",
@@ -32,6 +32,7 @@
     "name": "名前",
     "role": "ロール",
     "element": "要素",
+    "landmark": "ランドマーク",
     "description": "説明",
     "heading": "見出し",
     "warning": "警告",
@@ -49,7 +50,8 @@
     "notFocusable": "フォーカスできない",
     "noControlForLabel": "フォームコントロールなしのラベル",
     "noNameAttr": "name属性なし",
-    "noRadioGroup": "ラジオボタングループなし"
+    "noRadioGroup": "ラジオボタングループなし",
+    "noNameSection": "名前なしの <section>"
   },
   "optionsPage": {
     "description": "拡張機能のポップアップから設定を変更すると、閲覧中のWebページのホストごとに設定が保存され、同じホストのWebページに対して適用されます。この画面で設定を変更しても、それらの設定値は変更されません。"
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 6f19af5..65cd717 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -5,8 +5,7 @@
     "settingsForHost": "{{host}} 설정",
     "rerun": "재실행",
     "reset": "설정 초기화",
-    "hostDesc": "이 화면에서 설정한 내용은 웹페이지의 호스트마다 저장됩니다. 설정 기본값은 확장 프로그램 옵션 설정할 수 있습니다.",
-    "openExtensionOptions": "확장 프로그램 옵션 열기"
+    "hostDesc": "이 화면에서 설정한 내용은 웹페이지의 호스트마다 저장됩니다. 설정 기본값은 확장 기능 옵션으로 설정할 수 있습니다."
   },
   "settings": {
     "showTips": "팁 보기",
diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx
index 2b33b73..bee5c54 100644
--- a/src/popup/Popup.tsx
+++ b/src/popup/Popup.tsx
@@ -62,9 +62,9 @@ export const Popup = () => {
   };
 
   return (
-    <div className="w-80 text-zinc-800" lang={lang}>
-      <div className="p-2 flex flex-row items-center justify-between gap-3 bg-zinc-100">
-        <div className="flex flex-row gap-1 items-center justify-start">
+    <div className="w-72 text-zinc-800" lang={lang}>
+      <div className="p-2 flex flex-row gap-4 items-center justify-between bg-zinc-100">
+        <div className="flex flex-row gap-1 items-center">
           <img
             src={enabled ? icon : iconDisabled}
             alt={enabled ? t("popup.iconEyesOpen") : t("popup.iconEyesClosed")}
@@ -112,7 +112,7 @@ export const Popup = () => {
           }}
           checked={enabled}
         >
-          <span className="text-xs font-bold text-teal-800 shrink-0">
+          <span className="text-xs font-bold text-teal-800">
             {t("popup.enabled")}
           </span>
         </Checkbox>
@@ -153,15 +153,7 @@ export const Popup = () => {
             onChange={updateSettings}
             disabled={!enabled}
           />
-          <p className="text-xs text-zinc-500">
-            {t("popup.hostDesc")}
-            <button
-              className="link text-teal-700 underline hover:enabled:text-teal-900 transition-colors"
-              onClick={() => chrome.runtime.openOptionsPage()}
-            >
-              {t("popup.openExtensionOptions")}
-            </button>
-          </p>
+          <p className="text-xs text-zinc-500">{t("popup.hostDesc")}</p>
         </div>
       )}
     </div>
diff --git a/src/settings/constatns.ts b/src/settings/constatns.ts
index 0b620ff..cefe325 100644
--- a/src/settings/constatns.ts
+++ b/src/settings/constatns.ts
@@ -11,6 +11,7 @@ export const initialSettings: Settings = {
   button: false,
   heading: true,
   ariaHidden: true,
+  section: true,
   interactiveMode: true,
   hideTips: true,
   showLiveRegions: true,
diff --git a/src/settings/types.ts b/src/settings/types.ts
index de2beaf..17a1090 100644
--- a/src/settings/types.ts
+++ b/src/settings/types.ts
@@ -5,6 +5,7 @@ export type CategorySettings = {
   button: boolean;
   heading: boolean;
   ariaHidden: boolean;
+  section: boolean;
 };
 
 export type Settings = {


Test patch:
diff --git a/src/content/dom/tips/sectionTips.test.ts b/src/content/dom/tips/sectionTips.test.ts
new file mode 100644
index 0000000..1ca0e62
--- /dev/null
+++ b/src/content/dom/tips/sectionTips.test.ts
@@ -0,0 +1,144 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { sectionTips, isSection } from "./sectionTips";
+
+describe("isSection()", () => {
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(isSection(element)).toBe(false);
+  });
+  test("section", () => {
+    const element = document.createElement("section");
+    expect(isSection(element)).toBe(true);
+  });
+  test("role=region", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "region");
+    expect(isSection(element)).toBe(true);
+  });
+});
+
+describe("sectionTips()", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  test("div", () => {
+    const element = document.createElement("div");
+    expect(sectionTips(element)).toEqual([]);
+  });
+
+  test("role=region", () => {
+    const element = document.createElement("div");
+    element.setAttribute("role", "region");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "region" });
+  });
+
+  test("main", () => {
+    const element = document.createElement("main");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "main" });
+  });
+
+  test("section with name", () => {
+    const element = document.createElement("section");
+    element.setAttribute("aria-label", "Section");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "region" });
+  });
+
+  test("section without name", () => {
+    const element = document.createElement("section");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({
+      type: "warning",
+      content: "messages.noNameSection",
+    });
+  });
+
+  test("article", () => {
+    const element = document.createElement("article");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "article" });
+  });
+
+  test("nav", () => {
+    const element = document.createElement("nav");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "navigation" });
+  });
+
+  test("aside", () => {
+    const element = document.createElement("aside");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "complementary" });
+  });
+
+  test("aside without name in article", () => {
+    const article = document.createElement("article");
+    const aside = document.createElement("aside");
+    article.appendChild(aside);
+    document.body.appendChild(article);
+    const result = sectionTips(aside);
+    expect(result).toHaveLength(0);
+  });
+
+  test("aside with name in article", () => {
+    const article = document.createElement("article");
+    const aside = document.createElement("aside");
+    aside.setAttribute("aria-label", "Sidebar");
+    article.appendChild(aside);
+    document.body.appendChild(article);
+    const result = sectionTips(aside);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "complementary" });
+  });
+
+  test("header", () => {
+    const element = document.createElement("header");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "banner" });
+  });
+
+  test("header in main", () => {
+    const main = document.createElement("main");
+    const header = document.createElement("header");
+    main.appendChild(header);
+    document.body.appendChild(main);
+    const result = sectionTips(header);
+    expect(result).toHaveLength(0);
+  });
+
+  test("footer", () => {
+    const element = document.createElement("footer");
+    document.body.appendChild(element);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ type: "landmark", content: "contentinfo" });
+  });
+
+  test("footer in main", () => {
+    const element = document.createElement("footer");
+    const main = document.createElement("main");
+    main.appendChild(element);
+    document.body.appendChild(main);
+    const result = sectionTips(element);
+    expect(result).toHaveLength(0);
+  });
+});