Repository: ymrl/a11y-visualizer
Pull request #104 (2024-06-05 15:07:05)

:sparkles: add table Tips
#62 


Patch:
diff --git a/src/components/SettingsEditor.tsx b/src/components/SettingsEditor.tsx
index 8bbeb39..b7f1681 100644
--- a/src/components/SettingsEditor.tsx
+++ b/src/components/SettingsEditor.tsx
@@ -138,6 +138,15 @@ export const SettingsEditor = ({
               >
                 <span className="text-sm">{t("settings.lang")}</span>
               </Checkbox>
+              <Checkbox
+                onChange={(e) => {
+                  handleChangeCheckbox("table", e);
+                }}
+                checked={settings.table}
+                disabled={disabled || !settings.accessibilityInfo}
+              >
+                <span className="text-sm">{t("settings.tables")}</span>
+              </Checkbox>
             </div>
           </fieldset>
         </div>
diff --git a/src/content/components/ElementInfo.css b/src/content/components/ElementInfo.css
index 72db160..3cb5da8 100644
--- a/src/content/components/ElementInfo.css
+++ b/src/content/components/ElementInfo.css
@@ -36,10 +36,15 @@
 .ElementInfo__border--section {
   border-color: var(--accent-skyblue);
 }
+
 .ElementInfo__border--fieldset {
   border-color: var(--accent-brown);
   border-style: dotted;
 }
+.ElementInfo__border--table,
+.ElementInfo__border--tableCell {
+  border-color: var(--accent-skyblue);
+}
 
 .ElementInfo__tips {
   position: absolute;
@@ -49,6 +54,7 @@
   flex-direction: row;
   width: max-content;
   flex-wrap: wrap;
+  gap: 0.2em;
 }
 
 .ElementInfo__tips--left {
diff --git a/src/content/components/ElementInfo.tsx b/src/content/components/ElementInfo.tsx
index 4a9577b..722e240 100644
--- a/src/content/components/ElementInfo.tsx
+++ b/src/content/components/ElementInfo.tsx
@@ -48,11 +48,13 @@ export const ElementInfo = ({
   const verticalPosition: VerticalPosition =
     category === "page"
       ? "inner-top"
-      : category === "section" || category === "heading"
+      : category === "section" || category === "heading" || category === "table"
         ? y < tipFontSize * 2.4
           ? "inner-top"
           : "outer-top"
-        : category === "image" || category === "fieldset"
+        : category === "image" ||
+            category === "fieldset" ||
+            category === "tableCell"
           ? y > tipFontSize * 2.4 && height < tipFontSize * 3.2
             ? "outer-top"
             : "inner-top"
diff --git a/src/content/components/Tip.css b/src/content/components/Tip.css
index 3461ad1..599a956 100644
--- a/src/content/components/Tip.css
+++ b/src/content/components/Tip.css
@@ -7,6 +7,7 @@
   gap: 0.4em;
   color: #000;
   background-color: #fff;
+  border-radius: 0.2em;
 }
 .Tip--name {
   background-color: var(--base-green);
@@ -26,6 +27,15 @@
 .Tip--landmark {
   background-color: var(--base-yellowgreen);
 }
+.Tip--tableHeader {
+  background-color: var(--base-skyblue);
+}
+.Tip--tableSize {
+  background-color: var(--base-beige);
+}
+.Tip--tablePosition {
+  background-color: var(--base-beige);
+}
 
 .Tip--description {
   background-color: var(--light-gray);
diff --git a/src/content/components/Tip.tsx b/src/content/components/Tip.tsx
index 6b8afa6..c471059 100644
--- a/src/content/components/Tip.tsx
+++ b/src/content/components/Tip.tsx
@@ -8,8 +8,11 @@ import {
   IoCodeSlash,
   IoDocument,
   IoFlag,
+  IoGrid,
+  IoGridOutline,
   IoInformationCircle,
   IoLanguage,
+  IoPin,
   IoPricetag,
   IoWarning,
 } from "react-icons/io5";
@@ -89,6 +92,30 @@ const Icon = ({ type }: { type: TipType }) => {
           aria-label={t("tip.ariaStatus")}
         />
       );
+    case "tableHeader":
+      return (
+        <IoGrid
+          className="Tip__icon"
+          role="img"
+          aria-label={t("tip.tableHeader")}
+        />
+      );
+    case "tableSize":
+      return (
+        <IoGridOutline
+          className="Tip__icon"
+          role="img"
+          aria-label={t("tip.tableSize")}
+        />
+      );
+    case "tablePosition":
+      return (
+        <IoPin
+          className="Tip__icon"
+          role="img"
+          aria-label={t("tip.tablePosition")}
+        />
+      );
 
     case "pageTitle":
       return (
diff --git a/src/content/dom/collectElements.ts b/src/content/dom/collectElements.ts
index 8512cec..f17124e 100644
--- a/src/content/dom/collectElements.ts
+++ b/src/content/dom/collectElements.ts
@@ -19,6 +19,14 @@ import { CategorySettings } from "../../settings";
 import { SectionSelectors, isSection, sectionTips } from "./tips/sectionTips";
 import { isPage, pageTips } from "./tips/pageTips";
 import { LangSelectors, langTips, isLang } from "./tips/langTips";
+import {
+  TableSelectors,
+  isTable,
+  isTableCell,
+  tableTips,
+} from "./tips/tableTips";
+import { InternalTable } from "./tips/internalTable";
+import { getClosestByRoles } from "./getClosestByRoles";
 
 const getSelector = (settings: Partial<CategorySettings>) => {
   return [
@@ -30,20 +38,14 @@ const getSelector = (settings: Partial<CategorySettings>) => {
     ...(settings.ariaHidden ? AriraHiddenSelectors : []),
     ...(settings.section ? SectionSelectors : []),
     ...(settings.lang ? LangSelectors : []),
+    ...(settings.table ? TableSelectors : []),
   ].join(",");
 };
 
 export const collectElements = (
   root: Element,
   excludes: Element[],
-  settings: Partial<CategorySettings> = {
-    image: true,
-    formControl: true,
-    link: true,
-    button: true,
-    heading: true,
-    ariaHidden: true,
-  },
+  settings: Partial<CategorySettings>,
   options: {
     srcdoc?: boolean;
   } = {},
@@ -84,61 +86,96 @@ export const collectElements = (
   const visibleHeight = w.innerHeight;
 
   const selector = getSelector(settings);
+  const internalTables: InternalTable[] = [];
 
   return {
     rootHeight,
     rootWidth,
-    elements: selector
-      ? [
-          ...(settings.page && rootTagName === "body" ? [root] : []),
-          ...root.querySelectorAll(getSelector(settings)),
-        ]
-          .filter((el) => !isHidden(el))
-          .map((el: Element) => {
-            if (excludes.some((exclude: Element) => exclude.contains(el)))
-              return null;
-            const elementPosition = getElementPosition(el, w, offsetX, offsetY);
-            if (
-              elementPosition.absoluteX + elementPosition.width < visibleX ||
-              elementPosition.absoluteY + elementPosition.height < visibleY ||
-              elementPosition.absoluteX > visibleX + visibleWidth ||
-              elementPosition.absoluteY > visibleY + visibleHeight
-            ) {
-              return null;
-            }
-
-            const name = computeAccessibleName(el);
-            const nameTips: ElementTip[] = name
-              ? [{ type: "name", content: name }]
-              : [];
-            return {
-              ...elementPosition,
-              category: getElementCategory(el),
-              tips: [
-                ...headingTips(el, name),
-                ...nameTips,
-                ...imageTips(el, name),
-                ...formTips(el, name),
-                ...buttonTips(el, name),
-                ...linkTips(el, name),
-                ...ariaHiddenTips(el),
-                ...sectionTips(el, name),
-                ...langTips(el),
-                ...pageTips(el, !!options.srcdoc),
-                ...globalTips(el),
-              ],
-            };
-          })
-          .filter((el): el is ElementMeta => el !== null)
-      : [],
+    elements: [
+      ...(settings.page && rootTagName === "body" ? [root] : []),
+      ...(selector ? [...root.querySelectorAll(getSelector(settings))] : []),
+    ]
+      .filter((el) => !isHidden(el))
+      .map((el: Element) => {
+        if (excludes.some((exclude: Element) => exclude.contains(el)))
+          return null;
+        const elementPosition = getElementPosition(el, w, offsetX, offsetY);
+        if (
+          elementPosition.absoluteX + elementPosition.width < visibleX ||
+          elementPosition.absoluteY + elementPosition.height < visibleY ||
+          elementPosition.absoluteX > visibleX + visibleWidth ||
+          elementPosition.absoluteY > visibleY + visibleHeight
+        ) {
+          return null;
+        }
+        return {
+          ...elementPosition,
+          category: getElementCategory(el),
+          tips: tipsForElement(el, { ...options, internalTables }),
+        };
+      })
+      .filter((el): el is ElementMeta => el !== null),
   };
 };
 
+const getTableTips = (
+  el: Element,
+  internalTables: InternalTable[],
+): ElementTip[] => {
+  if (isTable(el) || isTableCell(el)) {
+    const tagName = el.tagName.toLowerCase();
+    const tableEl =
+      tagName === "table"
+        ? el
+        : ["th", "td"].includes(tagName)
+          ? el.closest("table")
+          : getClosestByRoles(el, ["table", "grid", "treegrid"]);
+    if (tableEl) {
+      const internalTable = internalTables.find((t) => t.element === tableEl);
+      if (!internalTable) {
+        const newTable = new InternalTable(tableEl);
+        internalTables.push(newTable);
+        return tableTips(el, newTable);
+      }
+      return tableTips(el, internalTable);
+    }
+  }
+  return [];
+};
+
+const tipsForElement = (
+  el: Element,
+  options: {
+    srcdoc?: boolean;
+    internalTables: InternalTable[];
+  },
+): ElementTip[] => {
+  const name = computeAccessibleName(el);
+  const nameTips: ElementTip[] = name ? [{ type: "name", content: name }] : [];
+  return [
+    ...headingTips(el, name),
+    ...nameTips,
+    ...imageTips(el, name),
+    ...formTips(el, name),
+    ...buttonTips(el, name),
+    ...linkTips(el, name),
+    ...ariaHiddenTips(el),
+    ...sectionTips(el, name),
+    ...getTableTips(el, options.internalTables),
+    ...langTips(el),
+    ...pageTips(el, !!options.srcdoc),
+    ...globalTips(el),
+  ];
+};
+
 const getElementCategory = (el: Element): Category => {
   if (isPage(el)) return "page";
   if (isImage(el)) return "image";
   if (isHeading(el)) return "heading";
   if (isFormControl(el) || isLink(el) || isButton(el)) return "control";
+  if (isSection(el)) return "section";
+  if (isTable(el)) return "table";
+  if (isTableCell(el)) return "tableCell";
   if (isFieldset(el)) return "fieldset";
   if (isSection(el) || isLang(el)) return "section";
   return "general";
diff --git a/src/content/dom/tips/internalTable.ts b/src/content/dom/tips/internalTable.ts
new file mode 100644
index 0000000..be58bcc
--- /dev/null
+++ b/src/content/dom/tips/internalTable.ts
@@ -0,0 +1,529 @@
+import { getKnownRole } from "../getKnownRole";
+
+type RowGroup = {
+  positionY: number;
+  sizeY: number;
+  element: Element | null;
+};
+
+type ColGroup = {
+  positionX: number;
+  sizeX: number;
+};
+
+type Scope = "row" | "col" | "rowgroup" | "colgroup" | "auto" | "none";
+type Cell = {
+  element: Element;
+  sizeX: number;
+  sizeY: number;
+  positionX: number;
+  positionY: number;
+  headerScope: Scope;
+};
+
+export class InternalTable {
+  element: Element;
+  rowGroups: RowGroup[];
+  colGroups: ColGroup[];
+  cells: Cell[][];
+  rowCount: number;
+  colCount: number;
+
+  constructor(table: Element) {
+    this.element = table;
+    const tagName = table.tagName.toLowerCase();
+    const rowElements = getRowElements(table);
+    const rowGroupElements = getRowGroupElements(table);
+
+    const { rowGroups } = rowElements.reduce(
+      (prev, row, rowIndex) => {
+        const { rowGroups, groupElements } = prev;
+        const prevGroup =
+          rowGroups.length > 0 ? rowGroups[rowGroups.length - 1] : null;
+        if (
+          prevGroup &&
+          ((prevGroup.element && prevGroup.element.contains(row)) ||
+            (!prevGroup.element && !rowGroupElements[0]) ||
+            (!prevGroup.element && !groupElements[0].contains(row)))
+        ) {
+          prevGroup.sizeY += 1;
+          return prev;
+        }
+        const element = groupElements[0]?.contains(row)
+          ? groupElements[0]
+          : null;
+        const restElements = element ? groupElements.slice(1) : groupElements;
+        return {
+          rowGroups: [...rowGroups, { positionY: rowIndex, sizeY: 1, element }],
+          groupElements: restElements,
+        };
+      },
+      { rowGroups: [] as RowGroup[], groupElements: rowGroupElements },
+    );
+    const cells: Cell[][] = rowElements.reduce((rows, row, rowIndex) => {
+      const cellElements = getCellElements(row);
+      const prevRow = rows.length > 0 ? rows[rows.length - 1] : null;
+      const prevFirstCell = prevRow ? prevRow[0] : null;
+      const ariaRowIndex = row.getAttribute("aria-rowindex");
+      const rowPositionY = ariaRowIndex
+        ? parseInt(ariaRowIndex, 10) - 1
+        : prevFirstCell
+          ? prevFirstCell.positionY + 1
+          : rowIndex;
+      return [
+        ...rows,
+        cellElements.reduce((cells, cell) => {
+          const cellTagName = cell.tagName.toLowerCase();
+          const cellRole = getKnownRole(cell);
+          const ariaRowIndex = row.getAttribute("aria-rowindex");
+          const ariaColIndex = cell.getAttribute("aria-colindex");
+          const positionY = ariaRowIndex
+            ? parseInt(ariaRowIndex, 10) - 1
+            : rowPositionY;
+          const leftCell = cells.length > 0 ? cells[cells.length - 1] : null;
+          let positionX = ariaColIndex
+            ? parseInt(ariaColIndex, 10) - 1
+            : leftCell
+              ? leftCell.positionX + leftCell.sizeX
+              : 0;
+          // let positionX: number = dx;
+          if (!ariaColIndex && rowIndex > 0) {
+            let dy = rowIndex - 1;
+            while (dy >= 0) {
+              for (let i = 0; i < rows[dy].length; i++) {
+                const cell = rows[dy][i];
+                if (cell.positionX + cell.sizeX <= positionX) {
+                  // positionXより左にあるセルは無視
+                  continue;
+                } else if (
+                  cell.positionX <= positionX &&
+                  positionX < cell.positionX + cell.sizeX
+                ) {
+                  // positionXがセルの範囲内にある場合
+                  if (cell.positionY + cell.sizeY > positionY) {
+                    // rowspanで行に割り込んできている: positionXはそれより右
+                    positionX = cell.positionX + cell.sizeX;
+                  } else {
+                    break;
+                  }
+                }
+              }
+              dy--;
+            }
+          }
+          const isNativeTag = ["th", "td"].includes(cellTagName);
+          const colSpan =
+            (!isNativeTag && cell.getAttribute("aria-colspan")) ||
+            cell.getAttribute("colspan");
+          const rowSpan =
+            (!isNativeTag && cell.getAttribute("aria-rowspan")) ||
+            cell.getAttribute("rowspan");
+          const sizeX = colSpan ? parseInt(colSpan, 10) : 1;
+          const sizeY = rowSpan ? parseInt(rowSpan, 10) : 1;
+          const scopeAttr = cell.getAttribute("scope")?.toLowerCase();
+          const headerScope: Scope =
+            cellTagName === "th"
+              ? scopeAttr &&
+                ["row", "col", "rowgroup", "colgroup"].includes(scopeAttr)
+                ? (scopeAttr as Scope)
+                : "auto"
+              : cellRole === "columnheader"
+                ? "col"
+                : cellRole === "rowheader"
+                  ? "row"
+                  : "none";
+          return [
+            ...cells,
+            {
+              element: cell,
+              sizeX,
+              sizeY,
+              positionX,
+              positionY,
+              headerScope,
+            },
+          ];
+        }, [] as Cell[]),
+      ];
+    }, [] as Cell[][]);
+    const ariaRowCount = table.getAttribute("aria-rowcount");
+    const ariaColCount = table.getAttribute("aria-colcount");
+    const rowCount = ariaRowCount
+      ? parseInt(ariaRowCount, 10)
+      : cells[cells.length - 1].reduce(
+          (prev, cell) => Math.max(prev, cell.positionY + cell.sizeY),
+          0,
+        );
+    const colCount = ariaColCount
+      ? parseInt(ariaColCount, 10)
+      : cells.reduce(
+          (prev, row) =>
+            row.reduce(
+              (prev, cell) => Math.max(prev, cell.positionX + cell.sizeX),
+              prev,
+            ),
+          0,
+        );
+    const colGroupElements =
+      tagName === "table"
+        ? [...table.children].filter(
+            (child) => child.tagName.toLowerCase() === "colgroup",
+          )
+        : [];
+    const minPositionX = cells.reduce(
+      (prev, row) => Math.min(prev, row[0]?.positionX || 0),
+      0,
+    );
+    const colGroups: ColGroup[] =
+      colGroupElements.length > 0
+        ? colGroupElements.reduce((groups, group, groupIndex) => {
+            const lastGroup =
+              groups.length > 0 ? groups[groups.length - 1] : null;
+            const positionX = lastGroup
+              ? lastGroup.positionX + lastGroup.sizeX
+              : minPositionX;
+            const span = group.getAttribute("span");
+            const sizeX = span
+              ? parseInt(span, 10)
+              : [...group.querySelectorAll("col")].reduce((prev, col) => {
+                  const span = col.getAttribute("span");
+                  return prev + (span ? parseInt(span, 10) : 1);
+                }, 0);
+            if (
+              groupIndex === colGroupElements.length - 1 &&
+              positionX + sizeX < colCount
+            ) {
+              return [
+                ...groups,
+                { positionX, sizeX },
+                {
+                  positionX: positionX + sizeX,
+                  sizeX: colCount - (positionX + sizeX),
+                },
+              ];
+            }
+            return [...groups, { positionX, sizeX }];
+          }, [] as ColGroup[])
+        : [{ positionX: 0, sizeX: colCount }];
+    this.rowGroups = rowGroups;
+    this.colGroups = colGroups;
+    this.cells = cells;
+    this.rowCount = rowCount;
+    this.colCount = colCount;
+  }
+
+  getCell = (el: Element): Cell | null => {
+    const cell = this.cells
+      .map((row) => row.find((cell) => cell.element === el))
+      .filter((cell): cell is Cell => !!cell);
+    return cell ? cell[0] : null;
+  };
+
+  getHeaderElements = (cell: Cell): Element[] => {
+    const tagName = cell.element.tagName.toLowerCase();
+    return (
+      ["th", "td"].includes(tagName) && cell.element.hasAttribute("headers")
+        ? this.getAttributeHeaderElements(cell)
+        : [
+            ...this.getRowHeaderElements(cell),
+            ...this.getRowGroupHeaderElements(cell),
+            ...this.getColHeaderElements(cell),
+            ...this.getColGroupHeaderElements(cell),
+          ]
+    ).reduce(
+      (prev, el) => (prev.includes(el) ? prev : [...prev, el]),
+      [] as Element[],
+    );
+  };
+
+  getSlotCells = (x: number, y: number): Cell[] => {
+    return this.cells
+      .map((row) =>
+        row.find(
+          (cell) =>
+            cell.positionX <= x &&
+            cell.positionX + cell.sizeX > x &&
+            cell.positionY <= y &&
+            cell.positionY + cell.sizeY > y,
+        ),
+      )
+      .filter((cell): cell is Cell => !!cell);
+  };
+
+  isColHeader = (cell: Cell): boolean => {
+    const { headerScope } = cell;
+    return (
+      headerScope === "col" ||
+      (headerScope === "auto" &&
+        !this.cells.some((row) =>
+          row.some(
+            (c) =>
+              c.headerScope === "none" &&
+              c.positionY < cell.positionY + cell.sizeY &&
+              c.positionY + c.sizeY > cell.positionY,
+          ),
+        ))
+    );
+  };
+
+  isRowHeader = (cell: Cell): boolean => {
+    const { headerScope } = cell;
+    return (
+      headerScope === "row" ||
+      (headerScope === "auto" &&
+        !this.cells.some((row) =>
+          row.some(
+            (c) =>
+              c.headerScope === "none" &&
+              c.positionX < cell.positionX + cell.sizeX &&
+              c.positionX + c.sizeX > cell.positionX,
+          ),
+        ))
+    );
+  };
+
+  getRowHeaderElements = (cell: Cell): Element[] => {
+    const { positionY, sizeY, positionX } = cell;
+    const headers: Element[] = [];
+    Array(sizeY)
+      .fill(0)
+      .map((_, i) => positionY + i)
+      .forEach((y) => {
+        let inHeaderBlock: boolean = cell.headerScope !== "none";
+        let headersFromCurrentHeaderBlock: Cell[] = inHeaderBlock ? [cell] : [];
+        let opaqueHeaders: Cell[] = [];
+        Array(positionX)
+          .fill(0)
+          .map((_, i) => positionX - 1 - i)
+          .forEach((x) => {
+            const candidates = this.getSlotCells(x, y);
+            candidates.map((c) => {
+              if (c.headerScope !== "none") {
+                inHeaderBlock = true;
+                headersFromCurrentHeaderBlock.push(c);
+                const blocked =
+                  opaqueHeaders.some(
+                    (cell) => cell.positionY === y && cell.sizeY === sizeY,
+                  ) || !this.isRowHeader(c);
+                if (!blocked) {
+                  headers.push(c.element);
+                }
+              } else if (inHeaderBlock) {
+                inHeaderBlock = false;
+                opaqueHeaders = [
+                  ...opaqueHeaders,
+                  ...headersFromCurrentHeaderBlock,
+                ];
+                headersFromCurrentHeaderBlock = [];
+              }
+            });
+          });
+      });
+    return headers;
+  };
+
+  getRowGroupHeaderElements = (cell: Cell): Element[] => {
+    const { positionY } = cell;
+    const rowGroup = this.rowGroups.find(
+      (group) =>
+        group.positionY <= positionY &&
+        group.positionY + group.sizeY > positionY,
+    );
+    if (!rowGroup) return [];
+    return this.cells
+      .map((row) =>
+        row.filter(
+          (c) =>
+            c.headerScope === "rowgroup" &&
+            c.positionY < rowGroup.positionY + rowGroup.sizeY &&
+            c.positionY + c.sizeY > rowGroup.positionY,
+        ),
+      )
+      .flat()
+      .map((c) => c.element);
+  };
+
+  getColHeaderElements = (cell: Cell): Element[] => {
+    const { positionY, positionX, sizeX } = cell;
+    const headers: Element[] = [];
+
+    Array(sizeX)
+      .fill(0)
+      .map((_, i) => positionX + i)
+      .forEach((x) => {
+        let inHeaderBlock: boolean = cell.headerScope !== "none";
+        let headersFromCurrentHeaderBlock: Cell[] = inHeaderBlock ? [cell] : [];
+        let opaqueHeaders: Cell[] = [];
+        Array(positionY)
+          .fill(0)
+          .map((_, i) => positionY - 1 - i)
+          .forEach((y) => {
+            const candidates = this.getSlotCells(x, y);
+            candidates.map((c) => {
+              if (c.headerScope !== "none") {
+                inHeaderBlock = true;
+                headersFromCurrentHeaderBlock.push(c);
+                const blocked =
+                  opaqueHeaders.some(
+                    (cell) => cell.positionX === x && cell.sizeX === sizeX,
+                  ) || !this.isColHeader(c);
+                if (!blocked) {
+                  headers.push(c.element);
+                }
+              } else if (inHeaderBlock) {
+                inHeaderBlock = false;
+                opaqueHeaders = [
+                  ...opaqueHeaders,
+                  ...headersFromCurrentHeaderBlock,
+                ];
+                headersFromCurrentHeaderBlock = [];
+              }
+            });
+          });
+      });
+    return headers;
+  };
+
+  getColGroupHeaderElements = (cell: Cell): Element[] => {
+    const { positionY, positionX } = cell;
+    const colGroup = this.colGroups.find(
+      (group) =>
+        group.positionX <= positionX &&
+        group.positionX + group.sizeX > positionX,
+    );
+    if (!colGroup) return [];
+    return this.cells
+      .map((row) =>
+        row.filter(
+          (c) =>
+            c.positionY < positionY &&
+            c.headerScope === "colgroup" &&
+            c.positionX < colGroup.positionX + colGroup.sizeX &&
+            c.positionX + c.sizeX > colGroup.positionX,
+        ),
+      )
+      .flat()
+      .map((c) => c.element);
+  };
+
+  getAttributeHeaderElements = (cell: Cell): Element[] => {
+    const { element } = cell;
+    const tagName = element.tagName.toLowerCase();
+    if (!["th", "td"].includes(tagName)) return [];
+    const headers = element.getAttribute("headers");
+    if (!headers) return [];
+    const headerIds = headers.split(" ");
+    return headerIds
+      .map((id) => this.element.querySelector(`th#${id}, td#${id}`))
+      .filter((el): el is Element => !!el);
+  };
+}
+
+export const getRowElements = (el: Element): Element[] => {
+  const tagName = el.tagName.toLowerCase();
+  const role = getKnownRole(el);
+  if (
+    tagName === "table" ||
+    role === "table" ||
+    role === "grid" ||
+    role === "treegrid"
+  ) {
+    return [...el.children].reduce((prev, child) => {
+      return [...prev, ...getRowElementsInElement(child, tagName === "table")];
+    }, [] as Element[]);
+  }
+
+  return [];
+};
+
+const getRowElementsInElement = (
+  el: Element,
+  inTableElement: boolean,
+): Element[] => {
+  const tagName = el.tagName.toLowerCase();
+  const role = getKnownRole(el);
+  if ((inTableElement && tagName === "tr") || role === "row") {
+    return [el];
+  } else if (
+    (inTableElement && ["thead", "tbody", "tfoot"].includes(tagName)) ||
+    role === "rowgroup" ||
+    role === "presentation" ||
+    role === "none"
+  ) {
+    return [...el.children].reduce((prev, child) => {
+      return [...prev, ...getRowElementsInElement(child, inTableElement)];
+    }, [] as Element[]);
+  }
+  return [];
+};
+
+export const getRowGroupElements = (el: Element): Element[] => {
+  const tagName = el.tagName.toLowerCase();
+  const role = getKnownRole(el);
+  if (
+    tagName === "table" ||
+    role === "table" ||
+    role === "grid" ||
+    role === "treegrid"
+  ) {
+    return [...el.children].reduce((prev, child) => {
+      return [
+        ...prev,
+        ...getRowGroupElementsInElement(child, tagName === "table"),
+      ];
+    }, [] as Element[]);
+  }
+  return [];
+};
+const getRowGroupElementsInElement = (
+  el: Element,
+  inTableElement: boolean,
+): Element[] => {
+  const tagName = el.tagName.toLowerCase();
+  const role = getKnownRole(el);
+  if (
+    (inTableElement && ["thead", "tbody", "tfoot"].includes(tagName)) ||
+    role === "rowgroup"
+  ) {
+    return [el];
+  } else if (
+    (inTableElement && tagName === "tr") ||
+    role === "row" ||
+    role === "presentation" ||
+    role === "none"
+  ) {
+    return [...el.children].reduce((prev, child) => {
+      return [...prev, ...getRowGroupElementsInElement(child, inTableElement)];
+    }, [] as Element[]);
+  }
+  return [];
+};
+
+export const getCellElements = (el: Element): Element[] => {
+  const tagName = el.tagName.toLowerCase();
+  const role = getKnownRole(el);
+  if (tagName === "tr") {
+    return [...el.children].filter((child) =>
+      ["td", "th"].includes(child.tagName.toLowerCase()),
+    );
+  }
+  if (["row", "presentation", "none"].includes(role as string)) {
+    return [...el.children]
+      .map((child) => {
+        const childRole = getKnownRole(child);
+        if (
+          ["cell", "gridcell", "columnheader", "rowheader"].includes(
+            childRole as string,
+          )
+        ) {
+          return child;
+        }
+        if (["presentation", "none"].includes(childRole as string)) {
+          return getCellElements(child);
+        }
+        return [];
+      })
+      .flat();
+  }
+  return [];
+};
diff --git a/src/content/dom/tips/tableTips.ts b/src/content/dom/tips/tableTips.ts
new file mode 100644
index 0000000..67c09e0
--- /dev/null
+++ b/src/content/dom/tips/tableTips.ts
@@ -0,0 +1,80 @@
+import { computeAccessibleName } from "dom-accessibility-api";
+import { ElementTip } from "../../types";
+import { getKnownRole } from "../getKnownRole";
+import { InternalTable } from "./internalTable";
+const TableElements = [
+  "table",
+  // 'thead',
+  // 'tbody',
+  // 'tfoot',
+  // 'tr',
+  "th",
+  "td",
+  // 'caption',
+] as const;
+
+const TableRoles = [
+  "table",
+  "grid",
+  // 'row',
+  "columnheader",
+  "rowheader",
+  "gridcell",
+  "cell",
+] as const;
+
+export const TableSelectors = [
+  ...TableElements,
+  ...TableRoles.map((role) => `[role="${role}"]`),
+] as const;
+
+export const isTable = (el: Element): boolean => {
+  const role = getKnownRole(el);
+  return (
+    el.tagName.toLowerCase() === "table" ||
+    role === "table" ||
+    role === "grid" ||
+    role === "treegrid"
+  );
+};
+
+export const isTableCell = (el: Element): boolean => {
+  const role = getKnownRole(el);
+  const tagName = el.tagName.toLowerCase();
+
+  return (
+    ["th", "td"].includes(tagName) ||
+    (!!role && ["columnheader", "rowheader", "gridcell", "cell"].includes(role))
+  );
+};
+
+export const tableTips = (
+  el: Element,
+  internalTable: InternalTable,
+): ElementTip[] => {
+  const result: ElementTip[] = [];
+
+  if (isTable(el)) {
+    result.push({
+      type: "tableSize",
+      content: `${internalTable.colCount}x${internalTable.rowCount}`,
+    });
+  }
+  if (isTableCell(el)) {
+    const cell = internalTable.getCell(el);
+    if (cell) {
+      internalTable.getHeaderElements(cell).forEach((header) => {
+        result.push({
+          type: "tableHeader",
+          content: computeAccessibleName(header),
+        });
+      });
+      result.push({
+        type: "tablePosition",
+        content: `${cell.positionX + 1},${cell.positionY + 1}`,
+      });
+    }
+  }
+
+  return result;
+};
diff --git a/src/content/types.ts b/src/content/types.ts
index d85c43b..9d4f3ad 100644
--- a/src/content/types.ts
+++ b/src/content/types.ts
@@ -22,6 +22,9 @@ export type TipType =
   | "ariaStatus"
   | "lang"
   | "pageTitle"
+  | "tableHeader"
+  | "tableSize"
+  | "tablePosition"
   | "warning"
   | "error";
 export type ElementTip = {
@@ -34,6 +37,8 @@ export type Category =
   | "heading"
   | "control"
   | "section"
+  | "table"
+  | "tableCell"
   | "fieldset"
   | "page"
   | "general";
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 8645c14..a3a590e 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -22,6 +22,7 @@
     "sections": "Sections",
     "lang": "Langage",
     "page": "Page",
+    "tables": "Tables",
     "tipOpacityPercent": "Tips opacity (%)",
     "interactiveMode": "Interactive mode",
     "hideLabels": "Hide labels",
@@ -42,6 +43,9 @@
     "heading": "Heading",
     "ariaStatus": "WAI-ARIA Status",
     "pageTitle": "Page title",
+    "tableHeader": "Table header",
+    "tableSize": "Table size",
+    "tablePosition": "Position",
     "lang": "Language",
     "warning": "Warning",
     "error": "Error"
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 3ccedda..7c76006 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -22,6 +22,7 @@
     "sections": "セクション",
     "lang": "言語",
     "page": "ページ",
+    "tables": "テーブル",
     "tipOpacityPercent": "チップの不透明度（%）",
     "interactiveMode": "インタラクティブモード",
     "hideLabels": "ラベルを隠す",
@@ -42,6 +43,9 @@
     "heading": "見出し",
     "ariaStatus": "WAI-ARIAステータス",
     "pageTitle": "ページタイトル",
+    "tableHeader": "表見出し",
+    "tableSize": "表の大きさ",
+    "tablePosition": "位置",
     "lang": "言語",
     "warning": "警告",
     "error": "エラー"
diff --git a/src/settings/constatns.ts b/src/settings/constatns.ts
index 072dfce..ce3eefd 100644
--- a/src/settings/constatns.ts
+++ b/src/settings/constatns.ts
@@ -14,6 +14,7 @@ export const initialSettings: Settings = {
   section: true,
   lang: true,
   page: true,
+  table: false,
   interactiveMode: true,
   hideTips: true,
   showLiveRegions: true,
diff --git a/src/settings/types.ts b/src/settings/types.ts
index 93c4c83..11cbab4 100644
--- a/src/settings/types.ts
+++ b/src/settings/types.ts
@@ -8,6 +8,7 @@ export type CategorySettings = {
   section: boolean;
   lang: boolean;
   page: boolean;
+  table: boolean;
 };
 
 export type Settings = {


Test patch:
diff --git a/src/content/dom/tips/internalTable.test.ts b/src/content/dom/tips/internalTable.test.ts
new file mode 100644
index 0000000..3033ba8
--- /dev/null
+++ b/src/content/dom/tips/internalTable.test.ts
@@ -0,0 +1,825 @@
+import { describe, expect, test } from "vitest";
+import {
+  InternalTable,
+  getRowElements,
+  getRowGroupElements,
+  getCellElements,
+} from "./internalTable";
+
+describe("InternalTable", () => {
+  test("table (will be automatically inserted tbody)", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <tr><td>0-0</td><td>0-1</td></tr>
+      <tr><td>1-0</td><td>1-1</td></tr>
+    `;
+    const result = new InternalTable(table);
+    const { rowGroups, cells, rowCount, colCount } = result;
+    expect(rowGroups[0].positionY).toBe(0);
+    expect(rowGroups[0].sizeY).toBe(2);
+    expect(rowGroups[0].element?.tagName.toLowerCase()).toBe("tbody");
+    expect(rowGroups).toHaveLength(1);
+    for (let y = 0; y < 2; y++) {
+      for (let x = 0; x < 2; x++) {
+        expect(cells[y][x].positionX).toBe(x);
+        expect(cells[y][x].positionY).toBe(y);
+        expect(cells[y][x].sizeX).toBe(1);
+        expect(cells[y][x].sizeY).toBe(1);
+        expect(cells[y][x].element.tagName.toLowerCase()).toBe("td");
+        expect(cells[y][x].headerScope).toBe("none");
+      }
+      expect(cells[y]).toHaveLength(2);
+    }
+    expect(cells).toHaveLength(2);
+    expect(rowCount).toBe(2);
+    expect(colCount).toBe(2);
+  });
+
+  test("table has thead, tbody, tfoot", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <thead>
+        <tr><th>0-0</th></tr>
+      </thead>
+      <tbody>
+        <tr><td>1-0</td></tr>
+        <tr><td>2-0</td></tr>
+      </tbody>
+      <tbody>
+        <tr><td>3-0</td></tr>
+      </tbody>
+      <tfoot>
+        <tr><td>4-0</td></tr>
+      </tfoot>
+    `;
+    const result = new InternalTable(table);
+    const { rowGroups } = result;
+    expect(rowGroups[0].positionY).toBe(0);
+    expect(rowGroups[0].sizeY).toBe(1);
+    expect(rowGroups[0].element?.tagName.toLowerCase()).toBe("thead");
+    expect(rowGroups[1].positionY).toBe(1);
+    expect(rowGroups[1].sizeY).toBe(2);
+    expect(rowGroups[1].element?.tagName.toLowerCase()).toBe("tbody");
+    expect(rowGroups[2].positionY).toBe(3);
+    expect(rowGroups[2].sizeY).toBe(1);
+    expect(rowGroups[2].element?.tagName.toLowerCase()).toBe("tbody");
+    expect(rowGroups[3].positionY).toBe(4);
+    expect(rowGroups[3].sizeY).toBe(1);
+    expect(rowGroups[3].element?.tagName.toLowerCase()).toBe("tfoot");
+    expect(rowGroups).toHaveLength(4);
+  });
+  test("table has directly added tr", () => {
+    const table = document.createElement("table");
+    table.appendChild(document.createElement("tr"));
+    table.appendChild(document.createElement("tr"));
+    table.appendChild(document.createElement("tr"));
+    const result = new InternalTable(table);
+    const { rowGroups, cells } = result;
+    expect(rowGroups[0].positionY).toBe(0);
+    expect(rowGroups[0].sizeY).toBe(3);
+    expect(rowGroups[0].element).toBeNull();
+    expect(cells).toHaveLength(3);
+  });
+
+  test("colgroups", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+    <caption>caption</caption>
+    <colgroup id="0-0" span="1"></colgroup>
+    <colgroup id="1-123" span="3"></colgroup>
+    <colgroup id="2-4567">
+      <col span="2"></col>
+      <col></col>
+      <col></col>
+    </colgroup>
+    <tbody>
+      <tr>
+        <td>0</td><td>1</td><td>2</td><td>3</td><td>4</td>
+        <td>5</td><td>6</td><td>7</td><td>8</td>
+      </tr>
+      <tr>
+        <td>0</td><td>1</td><td>2</td><td>3</td><td>4</td>
+        <td>5</td><td>6</td><td>7</td><td>8</td><td>9</td>
+      </tr>
+    </tbody>
+    `;
+    const result = new InternalTable(table);
+    const { colGroups, colCount } = result;
+    expect(colCount).toBe(10);
+    expect(colGroups[0].positionX).toBe(0);
+    expect(colGroups[0].sizeX).toBe(1);
+    expect(colGroups[1].positionX).toBe(1);
+    expect(colGroups[1].sizeX).toBe(3);
+    expect(colGroups[2].positionX).toBe(4);
+    expect(colGroups[2].sizeX).toBe(4);
+    expect(colGroups[3]).toBeDefined();
+    expect(colGroups[3].positionX).toBe(8);
+    expect(colGroups[3].sizeX).toBe(2);
+    expect(colGroups).toHaveLength(4);
+  });
+
+  test("rowspan", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+    <thead>
+      <tr>
+        <th id="cell-0-0">0-0</th>
+        <th id="cell-0-1">0-1</th>
+        <th id="cell-0-2">0-2</th>
+        <th id="cell-0-3">0-3</th>
+        <th id="cell-0-4">0-4</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr>
+        <td id="cell-1-0">1-0</td>
+        <td id="cell-1-1">1-1</td>
+        <td id="cell-1-2" rowspan="5">1-2</td>
+        <td id="cell-1-3">1-3</td>
+        <td id="cell-1-3">1-4</td>
+      </tr>
+      <tr>
+        <td id="cell-2-0" rowspan="2">2-0</td>
+        <td id="cell-2-1" rowspan="3">2-1</td>
+        <td id="cell-2-3">2-3</td>
+        <td id="cell-2-4">2-4</td>
+      </tr>
+      <tr>
+        <td id="cell-3-3">3-3(0)</td>
+        <td id="cell-3-4">3-4(1)</td>
+      </tr>
+      <tr>
+        <td id="cell-4-0">4-0(0)</td>
+        <td id="cell-4-3">4-3(1)</td>
+        <td id="cell-4-4">4-4(2)</td>
+      </tr>
+      <tr>
+        <td id="cell-5-0">5-0(0)</td>
+        <td id="cell-5-1">5-1(1)</td>
+        <td id="cell-5-2">5-3(2)</td>
+        <td id="cell-5-4">5-4(3)</td>
+      </tr>
+      <tr>
+        <td id="cell-6-0">6-0</td>
+        <td id="cell-6-1">6-1</td>
+        <td id="cell-6-2">6-2</td>
+        <td id="cell-6-3">6-3</td>
+        <td id="cell-6-4">6-4</td>
+      </tr>
+    </tbody>
+    `;
+    const result = new InternalTable(table);
+    const { cells } = result;
+    expect(cells[2][0].positionX).toBe(0);
+    expect(cells[2][1].positionX).toBe(1);
+    expect(cells[2][2].positionX).toBe(3);
+    expect(cells[2][3].positionX).toBe(4);
+
+    expect(cells[3][0].positionX).toBe(3);
+    expect(cells[4][0].positionX).toBe(0);
+    expect(cells[4][1].positionX).toBe(3);
+    expect(cells[5][0].positionX).toBe(0);
+    expect(cells[5][1].positionX).toBe(1);
+    expect(cells[5][2].positionX).toBe(3);
+    expect(cells[5][3].positionX).toBe(4);
+    for (let i = 0; i < 5; i++) {
+      expect(cells[6][i].positionX).toBe(i);
+    }
+  });
+
+  test("complex table", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <colgroup></colgroup>
+      <colgroup span="3"></colgroup>
+      <colgroup></colgroup>
+      <thead>
+        <tr><th>0-0</th><th>0-1</th><th>0-2</th><th>0-3</th><th>0-4</th></tr>
+      </thead>
+      <tbody>
+        <tr><td>1-0</td><td colspan="3">1,1-3</td><td>1-4</td></tr>
+        <tr><th scope="row" rowspan="2">2,3-0</th><td>2-1</td><td>2-2</td><td>2-3</td><td>2-4</td></tr>
+        <tr><th scope="row">3-1</th><td>3-2</td><td>3-3</td><td>3-4</td></tr>
+        <tr><td>4-0</td><td>4-1</td><td>4-2</td><td>4-3</td><td>4-4</td></tr>
+      </tbody>
+      <tfoot>
+        <tr><td>5-0</td><td>5-1</td><td>5-2</td><td>5-3</td><td>5-4</td></tr>
+      </tfoot>
+    `;
+    const result = new InternalTable(table);
+    const { rowGroups, cells, rowCount, colCount } = result;
+    expect(rowGroups[0].positionY).toBe(0);
+    expect(rowGroups[0].sizeY).toBe(1);
+    expect(rowGroups[0].element?.tagName.toLowerCase()).toBe("thead");
+    expect(rowGroups[1].positionY).toBe(1);
+    expect(rowGroups[1].sizeY).toBe(4);
+    expect(rowGroups[1].element?.tagName.toLowerCase()).toBe("tbody");
+    expect(rowGroups[2].positionY).toBe(5);
+    expect(rowGroups[2].sizeY).toBe(1);
+    expect(rowGroups[2].element?.tagName.toLowerCase()).toBe("tfoot");
+    expect(rowGroups).toHaveLength(3);
+    for (let x = 0; x < 5; x++) {
+      expect(cells[0][x].positionX).toBe(x);
+      expect(cells[0][x].positionY).toBe(0);
+      expect(cells[0][x].sizeX).toBe(1);
+      expect(cells[0][x].sizeY).toBe(1);
+      expect(cells[0][x].element.tagName.toLowerCase()).toBe("th");
+      expect(cells[0][x].headerScope).toBe("auto");
+    }
+    expect(cells[1][1].positionX).toBe(1);
+    expect(cells[1][1].positionY).toBe(1);
+    expect(cells[1][1].sizeX).toBe(3);
+    expect(cells[1][1].sizeY).toBe(1);
+    expect(cells[1][2].positionX).toBe(4);
+    expect(cells[1][2].positionY).toBe(1);
+    expect(cells[1][2].sizeX).toBe(1);
+    expect(cells[1][2].sizeY).toBe(1);
+    expect(cells[2][0].positionX).toBe(0);
+    expect(cells[2][0].positionY).toBe(2);
+    expect(cells[2][0].sizeX).toBe(1);
+    expect(cells[2][0].sizeY).toBe(2);
+    expect(cells[2][1].positionX).toBe(1);
+    expect(cells[2][1].positionY).toBe(2);
+    expect(cells[2][1].sizeX).toBe(1);
+    expect(cells[2][1].sizeY).toBe(1);
+    expect(cells[3][0].positionX).toBe(1);
+    expect(cells[3][0].positionY).toBe(3);
+    expect(cells[3][0].sizeX).toBe(1);
+    expect(cells[3][0].sizeY).toBe(1);
+    expect(cells[4][0].positionX).toBe(0);
+    expect(cells[4][0].positionY).toBe(4);
+    expect(cells[4][0].sizeX).toBe(1);
+    expect(cells[4][0].sizeY).toBe(1);
+    expect(rowCount).toBe(6);
+    expect(colCount).toBe(5);
+  });
+
+  test("complex div table", () => {
+    const div = document.createElement("div");
+    div.setAttribute("role", "table");
+    div.setAttribute("aria-rowcount", "9");
+    div.setAttribute("aria-colcount", "5");
+    div.innerHTML = `
+      <div role="rowgroup">
+        <div role="row" aria-rowindex="1">
+          <div role="columnheader">0-0</div>
+          <div role="columnheader">0-1</div>
+          <div role="presentation">
+            <div role="columnheader">0-2</div>
+            <div role="columnheader">0-3</div>
+          </div>
+          <div role="columnheader">0-4</div>
+        </div>
+      </div>
+      <div role="rowgroup">
+        <div role="row" aria-rowindex="4">
+          <div role="rowheader">3(1)-0</div>
+          <div role="cell">3(1)-1</div>
+          <div role="cell">3(1)-2</div>
+          <div role="cell">3(1)-3</div>
+          <div role="cell">3(1)-4</div>
+        </div>
+        <div role="row" aria-rowindex="5">
+          <div role="rowheader">4(2)-0</div>
+          <div role="cell" aria-colspan="3">4(2)-1</div>
+          <div role="cell">4(2)-4</div>
+        </div>
+        <div role="row">
+          <div role="rowheader" aria-rowspan="2">5(3)-0</div>
+          <div role="cell">5(3)-1</div>
+          <div role="cell">5(3)-2</div>
+          <div role="cell">5(3)-3</div>
+          <div role="cell">5(3)-4</div>
+        </div>
+        <div role="row" aria-rowindex="7">
+          <div role="cell">6(4)-1</div>
+          <div role="cell">6(4)-2</div>
+          <div role="cell">6(4)-3</div>
+          <div role="cell">6(4)-4</div>
+        </div>
+        <div role="row" aria-rowindex="8">
+          <div role="rowheader">7(5)-0</div>
+          <div role="cell">7(5)-1</div>
+          <div role="cell">7(5)-2</div>
+          <div role="cell">7(5)-3</div>
+          <div role="cell">7(5)-4</div>
+        </div>
+        <div role="row" aria-rowindex="9">
+          <div role="cell" aria-colindex="4">8(6)-3</div>
+        </div>
+      </div>
+    `;
+    const result = new InternalTable(div);
+    const { cells, rowCount, colCount } = result;
+    expect(cells).toHaveLength(7);
+    for (let x = 0; x < 5; x++) {
+      expect(cells[0][x].positionX).toBe(x);
+      expect(cells[0][x].positionY).toBe(0);
+      expect(cells[0][x].sizeX).toBe(1);
+      expect(cells[0][x].sizeY).toBe(1);
+      expect(cells[0][x].element.tagName.toLowerCase()).toBe("div");
+      expect(cells[0][x].headerScope).toBe("col");
+    }
+    for (let x = 0; x < 5; x++) {
+      expect(cells[1][x].positionX).toBe(x);
+      expect(cells[1][x].positionY).toBe(3);
+      expect(cells[1][x].sizeX).toBe(1);
+      expect(cells[1][x].sizeY).toBe(1);
+      expect(cells[1][x].element.tagName.toLowerCase()).toBe("div");
+      expect(cells[1][x].headerScope).toBe(x === 0 ? "row" : "none");
+    }
+    expect(cells[2][1].positionX).toBe(1);
+    expect(cells[2][1].positionY).toBe(4);
+    expect(cells[2][1].sizeX).toBe(3);
+    expect(cells[2][1].sizeY).toBe(1);
+    expect(cells[2][2].positionX).toBe(4);
+    expect(cells[2][2].positionY).toBe(4);
+    expect(cells[2][2].sizeX).toBe(1);
+    expect(cells[2][2].sizeY).toBe(1);
+    expect(cells[3][0].positionX).toBe(0);
+    expect(cells[3][0].positionY).toBe(5);
+    expect(cells[3][0].sizeX).toBe(1);
+    expect(cells[3][0].sizeY).toBe(2);
+    expect(cells[3][1].positionX).toBe(1);
+    expect(cells[3][1].positionY).toBe(5);
+    expect(cells[3][1].sizeX).toBe(1);
+    expect(cells[3][1].sizeY).toBe(1);
+    expect(cells[4][0].positionX).toBe(1);
+    expect(cells[4][0].positionY).toBe(6);
+    expect(cells[4][0].sizeX).toBe(1);
+    expect(cells[4][0].sizeY).toBe(1);
+    expect(cells[5][0].positionX).toBe(0);
+    expect(cells[5][0].positionY).toBe(7);
+    expect(cells[5][0].sizeX).toBe(1);
+    expect(cells[5][0].sizeY).toBe(1);
+    expect(cells[5][1].positionX).toBe(1);
+    expect(cells[5][1].positionY).toBe(7);
+    expect(cells[5][1].sizeX).toBe(1);
+    expect(cells[5][1].sizeY).toBe(1);
+    expect(cells[6][0].positionX).toBe(3);
+    expect(cells[6][0].positionY).toBe(8);
+    expect(cells[6][0].sizeX).toBe(1);
+    expect(cells[6][0].sizeY).toBe(1);
+    expect(rowCount).toBe(9);
+    expect(colCount).toBe(5);
+  });
+
+  describe("getSlotCells", () => {
+    test("table", () => {
+      const table = document.createElement("table");
+      table.innerHTML = `
+      <tr>
+        <th id="cell-0-0">0-0</th>
+        <td id="cell-0-1" rowspan="2" colspan="2">0-1</td>
+      </tr>
+      <tr>
+        <th id="cell-1-0" colspan="3">1-0</th>
+      </tr>
+      <tr>
+        <th id="cell-2-0">2-0</th>
+        <td id="cell-2-1">2-1</td>
+        <td id="cell-2-2">2-2</td>
+      </tr>
+      `;
+      const result = new InternalTable(table);
+      const cell_2_1 = result.getSlotCells(1, 2);
+      expect(cell_2_1).toHaveLength(1);
+      expect(cell_2_1[0].element.id).toBe("cell-2-1");
+      const cell_1_1 = result.getSlotCells(1, 1);
+      expect(cell_1_1).toHaveLength(2);
+      expect(cell_1_1.find((c) => c.element.id === "cell-0-1")).toBeDefined();
+      expect(cell_1_1.find((c) => c.element.id === "cell-1-0")).toBeDefined();
+    });
+  });
+  describe("getCell", () => {
+    test("table", () => {
+      const table = document.createElement("table");
+      table.innerHTML = `
+      <tr>
+        <th id="cell-0-0">0-0</th>
+        <td id="cell-0-1">0-1</td>
+        <td id="cell-0-2">0-2</td>
+      </tr>
+      <tr>
+        <th id="cell-1-0">1-0</th>
+        <td id="cell-1-1">1-1</td>
+        <td id="cell-1-2">1-2</td>
+      </tr>
+      <tr>
+        <th id="cell-2-0">2-0</th>
+        <td id="cell-2-1">2-1</td>
+        <td id="cell-2-2">2-2</td>
+      </tr>
+      `;
+      const result = new InternalTable(table);
+      const el = table.querySelector("#cell-1-2");
+      expect(el).toBeDefined();
+      if (!el) return;
+      const cell = result.getCell(el);
+      expect(cell).toBeDefined();
+      if (!cell) return;
+      expect(cell.element).toBe(el);
+      expect(cell.positionX).toBe(2);
+      expect(cell.positionY).toBe(1);
+      expect(cell.sizeX).toBe(1);
+      expect(cell.sizeY).toBe(1);
+    });
+  });
+
+  describe("getHeaderElements", () => {
+    test("table", () => {
+      const table = document.createElement("table");
+      table.innerHTML = `
+        <colgroup span="1"></colgroup>
+        <colgroup span="2"></colgroup>
+        <colgroup span="2"></colgroup>
+        <thead>
+          <tr>
+            <th id="cell-0-0">0-0</th>
+            <th id="cell-0-1" scope="colgroup">0-1</th>
+            <th id="cell-0-2" scope="col">0-2</th>
+            <th id="cell-0-3" colspan="2">0-3</th>
+            <th id="cell-0-5" colspan="2">0-5</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th scope="row" rowspan="2" id="cell-1-0">1-0</th>
+            <td id="cell-1-1">1-1</td>
+            <td id="cell-1-2">1-2</td>
+            <td id="cell-1-3">1-3</td>
+            <td id="cell-1-4">1-4</td>
+            <td id="cell-1-5">1-5</td>
+          </tr>
+          <tr>
+            <th id="cell-2-1" scope="row">2-1(0)</th>
+            <td id="cell-2-2">2-2(1)</td>
+            <td id="cell-2-3">2-3(2)</td>
+            <td id="cell-2-4" rowspan="2">2-4(3)</td>
+            <td id="cell-2-5">2-5(4)</td>
+          </tr>
+          <tr>
+            <th id="cell-3-0" scope="row">3-0</th>
+            <td id="cell-3-1">3-1</td>
+            <td id="cell-3-2">3-2</td>
+            <td id="cell-3-3">3-3</td>
+            <td id="cell-3-5">3-5(4)</td>
+          </tr>
+        </tbody>
+        <tbody>
+          <tr>
+            <th id="cell-4-0" scope="rowgroup">4-0</th>
+            <td id="cell-4-1">4-1</td>
+            <td id="cell-4-2" headers="cell-4-0 cell-0-1 cell-0-2">4-2</td>
+            <td id="cell-4-3">4-3</td>
+            <td id="cell-4-4">4-4</td>
+          </tr>
+          <tr>
+            <th id="cell-5-0" scope="row">5-0</th>
+            <td id="cell-5-1">5-1</td>
+            <td id="cell-5-2">5-2</td>
+            <td id="cell-5-3">5-3</td>
+            <td id="cell-5-4" headers="cell-0-0 cell-0-1">5-4</td>
+          </tr>
+        </tbody>
+      `;
+      const result = new InternalTable(table);
+      const { cells } = result;
+      const headers_1_2 = result.getHeaderElements(cells[1][2]);
+      expect(headers_1_2.find((e) => e.id === "cell-1-0")).toBeDefined();
+      expect(headers_1_2.find((e) => e.id === "cell-0-1")).toBeDefined();
+      expect(headers_1_2.find((e) => e.id === "cell-0-2")).toBeDefined();
+      expect(headers_1_2).toHaveLength(3);
+      const headers_2_4 = result.getHeaderElements(cells[2][3]);
+      expect(headers_2_4.find((e) => e.id === "cell-2-1")).toBeDefined();
+      expect(headers_2_4.find((e) => e.id === "cell-1-0")).toBeDefined();
+      expect(headers_2_4.find((e) => e.id === "cell-0-3")).toBeDefined();
+      expect(headers_2_4.find((e) => e.id === "cell-3-0")).toBeDefined();
+      expect(headers_2_4).toHaveLength(4);
+      const headers_3_5 = result.getHeaderElements(cells[3][4]);
+      expect(headers_3_5.find((e) => e.id === "cell-3-0")).toBeDefined();
+      expect(headers_3_5.find((e) => e.id === "cell-0-5")).toBeDefined();
+      expect(headers_3_5).toHaveLength(2);
+      const headers_3_0 = result.getHeaderElements(cells[3][0]);
+      expect(headers_3_0.find((e) => e.id === "cell-0-0")).toBeDefined();
+      expect(headers_3_0).toHaveLength(1);
+      const headers_4_2 = result.getHeaderElements(cells[4][2]);
+      expect(headers_4_2.find((e) => e.id === "cell-4-0")).toBeDefined();
+      expect(headers_4_2.find((e) => e.id === "cell-0-1")).toBeDefined();
+      expect(headers_4_2.find((e) => e.id === "cell-0-2")).toBeDefined();
+      expect(headers_4_2).toHaveLength(3);
+      const headers_5_2 = result.getHeaderElements(cells[5][2]);
+      expect(headers_5_2.find((e) => e.id === "cell-4-0")).toBeDefined();
+      expect(headers_5_2.find((e) => e.id === "cell-5-0")).toBeDefined();
+      expect(headers_5_2.find((e) => e.id === "cell-0-1")).toBeDefined();
+      expect(headers_5_2.find((e) => e.id === "cell-0-2")).toBeDefined();
+      expect(headers_5_2).toHaveLength(4);
+      const headers_5_4 = result.getHeaderElements(cells[5][4]);
+      expect(headers_5_4.find((e) => e.id === "cell-0-0")).toBeDefined();
+      expect(headers_5_4.find((e) => e.id === "cell-0-1")).toBeDefined();
+      expect(headers_5_4).toHaveLength(2);
+    });
+
+    test("rowspan", () => {
+      const table = document.createElement("table");
+      table.innerHTML = `
+        <thead>
+          <tr>
+            <th id="cell-0-0">0-0</th>
+            <th id="cell-0-1">0-1</th>
+            <th id="cell-0-2">0-2</th>
+            <th id="cell-0-3">0-3</th>
+            <th id="cell-0-4">0-4</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td id="cell-1-0">1-0</td>
+            <td id="cell-1-1">1-1</td>
+            <td id="cell-1-2" rowspan="5">1-2</td>
+            <td id="cell-1-3">1-3</td>
+            <td id="cell-1-3">1-4</td>
+          </tr>
+          <tr>
+            <td id="cell-2-0" rowspan="2">2-0</td>
+            <td id="cell-2-1" rowspan="3">2-1</td>
+            <td id="cell-2-3">2-3(2)</td>
+            <td id="cell-2-4">2-4(3)</td>
+          </tr>
+          <tr>
+            <td id="cell-3-3">3-3(0)</td>
+            <td id="cell-3-4">3-4(1)</td>
+          </tr>
+          <tr>
+            <td id="cell-4-0">4-0(0)</td>
+            <td id="cell-4-3">4-3(1)</td>
+            <td id="cell-4-4">4-4(2)</td>
+          </tr>
+          <tr>
+            <td id="cell-5-0">5-0(0)</td>
+            <td id="cell-5-1">5-1(1)</td>
+            <td id="cell-5-2">5-3(2)</td>
+            <td id="cell-5-4">5-4(3)</td>
+          </tr>
+          <tr>
+            <td id="cell-6-0">6-0</td>
+            <td id="cell-6-1">6-1</td>
+            <td id="cell-6-2">6-2</td>
+            <td id="cell-6-3">6-3</td>
+            <td id="cell-6-4">6-4</td>
+          </tr>
+        </tbody>
+      `;
+      const result = new InternalTable(table);
+      const { cells } = result;
+      const headers_2_3 = result.getHeaderElements(cells[2][2]);
+      expect(headers_2_3.find((c) => c.id === "cell-0-3")).toBeDefined();
+      expect(headers_2_3).toHaveLength(1);
+      const headers_3_3 = result.getHeaderElements(cells[3][0]);
+      expect(headers_3_3.find((c) => c.id === "cell-0-3")).toBeDefined();
+      expect(headers_3_3).toHaveLength(1);
+      const headers_4_0 = result.getHeaderElements(cells[4][0]);
+      expect(headers_4_0.find((c) => c.id === "cell-0-0")).toBeDefined();
+      expect(headers_4_0).toHaveLength(1);
+      const headers_5_1 = result.getHeaderElements(cells[5][1]);
+      expect(headers_5_1.find((c) => c.id === "cell-0-1")).toBeDefined();
+      expect(headers_5_1).toHaveLength(1);
+    });
+  });
+});
+
+describe("getRowElements", () => {
+  test("table (will be automatically inserted tbody)", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <tr><td>row 0</td></tr>
+      <tr><td>row 1</td></tr>
+    `;
+    const result = getRowElements(table);
+    result.forEach((row) => {
+      expect(row.tagName.toLowerCase()).toBe("tr");
+    });
+    expect(result).toHaveLength(2);
+  });
+
+  test("table has thead, tbody, tfoot", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <thead>
+        <tr><td>thead row 0</td></tr>
+      </thead>
+      <tbody>
+        <tr><td>tbody0 row 0</td></tr>
+        <tr><td>tbody0 row 1</td></tr>
+      </tbody>
+      <tbody>
+        <tr><td>tbody1 row 0</td></tr>
+      </tbody>
+      <tfoot>
+        <tr><td>tfoot row 0</td></tr>
+      </tfoot>
+    `;
+    const result = getRowElements(table);
+    result.forEach((row) => {
+      expect(row.tagName.toLowerCase()).toBe("tr");
+    });
+    expect(result).toHaveLength(5);
+  });
+  test("table has directly added tr", () => {
+    const table = document.createElement("table");
+    table.appendChild(document.createElement("tr"));
+    table.appendChild(document.createElement("tr"));
+    table.appendChild(document.createElement("tr"));
+    const result = getRowElements(table);
+    result.forEach((row) => {
+      expect(row.tagName.toLowerCase()).toBe("tr");
+    });
+    expect(result).toHaveLength(3);
+  });
+
+  test("div", () => {
+    const div = document.createElement("div");
+    const result = getRowElements(div);
+    expect(result).toHaveLength(0);
+  });
+
+  test("div with tr", () => {
+    const div = document.createElement("div");
+    div.innerHTML = "<tr><td>row 0</td></tr>";
+    const result = getRowElements(div);
+    expect(result).toHaveLength(0);
+  });
+
+  test("div role=table", () => {
+    const div = document.createElement("div");
+    div.setAttribute("role", "table");
+    div.innerHTML = `
+      <div role="rowgroup">
+        <div role="row"><div role="cell">row 0</div></div>
+        <div role="row"><div role="cell">row 1</div></div>
+      </div>
+      <div role="row"><div role="cell">row 2</div></div>
+      <div role="presentation">
+        <div role="row"><div role="cell">row 3</div></div>
+        <div role="rowgroup">
+          <div role="row"><div role="cell">row 4</div></div>
+        </div>
+      </div>
+      <div role="none">
+        <div role="row"><div role="cell">row 5</div></div>
+        <div role="rowgroup">
+          <div role="row"><div role="cell">row 6</div></div>
+        </div>
+      </div>
+      `;
+    const result = getRowElements(div);
+    result.forEach((row) => {
+      expect(row.tagName.toLowerCase()).toBe("div");
+      expect(row.getAttribute("role")).toBe("row");
+    });
+    expect(result).toHaveLength(7);
+  });
+});
+
+describe("getRowGroupElements", () => {
+  test("table (will be automatically inserted tbody)", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <tr><td>row 0</td></tr>
+      <tr><td>row 1</td></tr>
+    `;
+    const result = getRowGroupElements(table);
+    expect(result).toHaveLength(1);
+    expect(result[0].tagName.toLowerCase()).toBe("tbody");
+  });
+
+  test("table has thead, tbody, tfoot", () => {
+    const table = document.createElement("table");
+    table.innerHTML = `
+      <caption>caption</caption>
+      <thead>
+        <tr><td>thead row 0</td></tr>
+      </thead>
+      <tbody>
+        <tr><td>tbody0 row 0</td></tr>
+        <tr><td>tbody0 row 1</td></tr>
+      </tbody>
+      <tbody>
+        <tr><td>tbody1 row 0</td></tr>
+      </tbody>
+      <tfoot>
+        <tr><td>tfoot row 0</td></tr>
+      </tfoot>
+    `;
+    const result = getRowGroupElements(table);
+    expect(result[0].tagName.toLowerCase()).toBe("thead");
+    expect(result[1].tagName.toLowerCase()).toBe("tbody");
+    expect(result[2].tagName.toLowerCase()).toBe("tbody");
+    expect(result[3].tagName.toLowerCase()).toBe("tfoot");
+    expect(result).toHaveLength(4);
+  });
+
+  test("table has directly added tr", () => {
+    const table = document.createElement("table");
+    table.appendChild(document.createElement("tr"));
+    table.appendChild(document.createElement("tr"));
+    table.appendChild(document.createElement("tr"));
+    const result = getRowGroupElements(table);
+    expect(result).toHaveLength(0);
+  });
+
+  test("div", () => {
+    const div = document.createElement("div");
+    const result = getRowGroupElements(div);
+    expect(result).toHaveLength(0);
+  });
+
+  test("div with tbody", () => {
+    const div = document.createElement("div");
+    div.innerHTML = "<tbody><tr><td>row 0</td></tr></tbody>";
+    const result = getRowGroupElements(div);
+    expect(result).toHaveLength(0);
+  });
+
+  test("div role=table", () => {
+    const div = document.createElement("div");
+    div.setAttribute("role", "table");
+    div.innerHTML = `
+      <div role="rowgroup" id="rowgroup0">
+        <div role="row"><div role="cell">row 0</div></div>
+        <div role="row"><div role="cell">row 1</div></div>
+      </div>
+      <div role="row"><div role="cell">row 2</div></div>
+      <div role="presentation">
+        <div role="row"><div role="cell">row 3</div></div>
+        <div role="rowgroup" id="rowgroup1">
+          <div role="row"><div role="cell">row 4</div></div>
+        </div>
+      </div>
+      <div role="none">
+        <div role="row"><div role="cell">row 3</div></div>
+        <div role="rowgroup" id="rowgroup2">
+          <div role="row"><div role="cell">row 4</div></div>
+        </div>
+      </div>
+      `;
+    const result = getRowGroupElements(div);
+    result.forEach((row) => {
+      expect(row.tagName.toLowerCase()).toBe("div");
+      expect(row.getAttribute("role")).toBe("rowgroup");
+    });
+    expect(result).toHaveLength(3);
+  });
+});
+
+describe("getCellElements", () => {
+  test("tr", () => {
+    const tr = document.createElement("tr");
+    tr.innerHTML = "<th>cell 0</th><td>cell 1</td><td>cell 2</td>";
+    const result = getCellElements(tr);
+    expect(result[0].tagName.toLowerCase()).toBe("th");
+    expect(result[1].tagName.toLowerCase()).toBe("td");
+    expect(result[2].tagName.toLowerCase()).toBe("td");
+    expect(result).toHaveLength(3);
+  });
+
+  test("div role=row", () => {
+    const div = document.createElement("div");
+    div.setAttribute("role", "row");
+    div.innerHTML = `
+      <div role='columnheader'>cell 0</div>
+      <div role='rowheader'>cell 1</div>";
+      <div role='cell'>cell 2</div>";
+      <div role='gridcell'>cell 3</div>";
+      <div role='presentation'>
+        <div role='columnheader'>cell 4</div>
+        <div role='rowheader'>cell 5</div>";
+        <div role='cell'>cell 6</div>";
+        <div role='gridcell'>cell 7</div>";
+      </div>
+      <div role='none'>
+        <div role='columnheader'>cell 8</div>
+        <div role='rowheader'>cell 9</div>";
+        <div role='cell'>cell 10</div>";
+        <div role='gridcell'>cell 11</div>";
+      </div>
+    `;
+    const result = getCellElements(div);
+    result.forEach((cell) => {
+      expect(cell.tagName.toLowerCase()).toBe("div");
+    });
+    for (let i = 0; i < 3; i++) {
+      expect(result[i * 4].getAttribute("role")).toBe("columnheader");
+      expect(result[i * 4 + 1].getAttribute("role")).toBe("rowheader");
+      expect(result[i * 4 + 2].getAttribute("role")).toBe("cell");
+      expect(result[i * 4 + 3].getAttribute("role")).toBe("gridcell");
+    }
+    expect(result).toHaveLength(12);
+  });
+});