Repository: microsoft/playwright
File: packages/injected/src/roleUtils.ts (TypeScript)

Code before:
    return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell';
  },
  'TEXTAREA': () => 'textbox',
  'TFOOT': () => 'rowgroup',
  'TH': (e: Element) => {
    if (e.getAttribute('scope') === 'col')
      return 'columnheader';
    if (e.getAttribute('scope') === 'row')
      return 'rowheader';
    const table = closestCrossShadow(e, 'table') as HTMLTableElement | undefined;
    if (!table)
      return 'cell';

    const rows = [...table.rows];
    const position = getCellPosition(table.rows, e as HTMLTableCellElement);

    if (position) {
      const { x, y } = position;

      const containingRow = rows[y];
      if (containingRow) {
        if (everyNodeIsTH(containingRow.cells))
          return 'columnheader';
      }

      const containingColumnCells = rows.map(row => row.cells[x]);
      if (everyNodeIsTH(containingColumnCells))
        return 'rowheader';
    }

    const role = getExplicitAriaRole(table);
    return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell';
  },
  'THEAD': () => 'rowgroup',
  'TIME': () => 'time',
  'TR': () => 'row',
  'UL': () => 'list',
};

function getCellPosition(rows: HTMLCollectionOf<HTMLTableRowElement>, thElement: HTMLTableCellElement): { x: number, y: number } | undefined {
  for (let y = 0; y < rows.length; y++) {
    const row = rows[y];
    for (let x = 0; x < row.cells.length; x++) {
      const cell = row.cells[x];

      if (cell.contains(thElement))
        return { x, y };
    }
  }

  return undefined;

Review comment (suggestion):
Could `row.cells[x]` be `null/undefined` when colspan or rowspan is present? Let's be defensive here.

Code after:
    return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell';
  },
  'TEXTAREA': () => 'textbox',
  'TFOOT': () => 'rowgroup',
  'TH': (e: Element) => {
    const scope = e.getAttribute('scope');
    if (scope === 'col' || scope === 'colgroup')
      return 'columnheader';
    if (scope === 'row' || scope === 'rowgroup')
      return 'rowheader';

    const nextSibling = e.nextElementSibling;
    const prevSibling = e.previousElementSibling;

    const row = !!e.parentElement && elementSafeTagName(e.parentElement) === 'TR' ? e.parentElement : undefined;

    // Chromium/Safari: A TH that is the only cell in a table is not labeling any content, thus it's technically not a header. Do not assign a role.
    // Firefox: Follows the spec and assigns `columnheader`. We prioritize Chrome/Safari semantics.
    if (!nextSibling && !prevSibling) {
      if (row) {
        const table = closestCrossShadow(row, 'table') as HTMLTableElement | undefined;
        // If there's only one row in the table, this TH has no column to head
        if (table && table.rows.length <= 1)
          return null;
      }
      return 'columnheader';
    }

    // Tables are built up incrementally by iterating over them in a particular pattern. In order to emulate this,
    // we check only immediate siblings and occasionally the parent row
    // This doesn't seem to directly follow the spec, but matches Chromium behavior
    // https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/accessibility/ax_node_object.cc;l=1585-1623
    if (isHeaderCell(nextSibling) && isHeaderCell(prevSibling))
      return 'columnheader';

    if (isNonEmptyDataCell(nextSibling) || isNonEmptyDataCell(prevSibling))
      return 'rowheader';

    // As long as we didn't exclude it above, it's still a TH, so default to columnheader
    return 'columnheader';
  },
  'THEAD': () => 'rowgroup',
  'TIME': () => 'time',
  'TR': () => 'row',
  'UL': () => 'list',
};

function isHeaderCell(element: Element | null): boolean {
  return !!element && elementSafeTagName(element) === 'TH';
}