qlik-oss/sn-table

View on GitHub
src/table/utils/accessibility-utils.ts

Summary

Maintainability
A
25 mins
Test Coverage
B
88%
import { stardust } from "@nebula.js/stardust";
import { COLUMN_ADJUSTER_CLASS } from "@qlik/nebula-table-utils/lib/constants";
import React from "react";
import { Announce } from "../../types";
import { FIRST_BODY_CELL_COORD, FIRST_HEADER_CELL_COORD, FocusTypes } from "../constants";
import {
  CellFocusProps,
  FocusedCellCoord,
  HandleResetFocusProps,
  MoveFocusWithArrowProps,
  SetFocusedCellCoord,
} from "../types";
import { findCellWithTabStop, getCellCoord, getCellElement, getNextCellCoord } from "./get-element-utils";

export const areTabStopsEnabled = (keyboard: stardust.Keyboard) => !keyboard.enabled || keyboard.active;

/**
 * Add the tab stop for adjuster hit area and focus that
 */
export const setFocusOnColumnAdjuster = (anchorRef: React.RefObject<HTMLDivElement>) => {
  setTimeout(() => {
    const adjusterHitArea = anchorRef.current
      ?.closest(".sn-table-cell")
      ?.querySelector<HTMLElement>(`.${COLUMN_ADJUSTER_CLASS}`);
    adjusterHitArea?.setAttribute("tabIndex", "0");
    adjusterHitArea?.focus();
  }, 0);
};

/**
 * Removes the tab stop for adjuster hit area and focus the head menu button
 */
export const focusBackToHeadCell = (currentTarget: EventTarget & Element, isNewHeadCellMenuEnabled: boolean) => {
  currentTarget.setAttribute("tabIndex", "-1");
  const cellElement = currentTarget.closest<HTMLElement>(".sn-table-cell");

  let targetElementToFocus = null;
  if (isNewHeadCellMenuEnabled) {
    targetElementToFocus = cellElement;
    targetElementToFocus?.setAttribute("tabIndex", "0");
  } else {
    targetElementToFocus = cellElement?.querySelector<HTMLElement>(".sn-table-head-menu-button");
  }

  targetElementToFocus?.focus();
};

/**
 * Removes/adds tab stop and sometimes focus/blurs the cell, depending on focusType
 */
export const updateFocus = ({ focusType, cell }: CellFocusProps) => {
  if (!cell) return;

  switch (focusType) {
    case FocusTypes.FOCUS:
      cell.focus();
      cell.setAttribute("tabIndex", "0");
      break;
    case FocusTypes.FOCUS_BUTTON:
      // eslint-disable-next-line no-case-declarations
      const button = cell.querySelector<HTMLElement>(".sn-table-head-label");
      button?.focus();
      break;
    case FocusTypes.BLUR:
      cell.blur();
      cell.setAttribute("tabIndex", "-1");
      break;
    case FocusTypes.ADD_TAB:
      cell.setAttribute("tabIndex", "0");
      break;
    case FocusTypes.REMOVE_TAB:
      cell.setAttribute("tabIndex", "-1");
      break;
    default:
      break;
  }
};

/**
 * Resets and adds new focus to a table cell based which arrow key is pressed
 */
export const moveFocusWithArrow = ({
  evt,
  rootElement,
  cellCoord,
  setFocusedCellCoord,
  focusType,
  allowedRows,
}: MoveFocusWithArrowProps) => {
  const nextCellCoord = getNextCellCoord(evt, rootElement, cellCoord, allowedRows);
  const nextCell = getCellElement(rootElement, nextCellCoord);
  updateFocus({ focusType, cell: nextCell });
  setFocusedCellCoord(nextCellCoord);

  return nextCell;
};

/**
 * Finds the cell with tab stops and focuses that cell.
 * If no cells has focus, it focuses the first body cell instead
 */
export const focusBodyFromHead = (rootElement: HTMLElement, setFocusedCellCoord: SetFocusedCellCoord) => {
  let cell: HTMLElement | null | undefined = findCellWithTabStop(rootElement);
  const newCellCoord = cell ? getCellCoord(rootElement, cell) : FIRST_BODY_CELL_COORD;
  cell = cell || getCellElement(rootElement, FIRST_BODY_CELL_COORD);
  updateFocus({ cell, focusType: FocusTypes.FOCUS });
  setFocusedCellCoord(newCellCoord);
};

/**
 * Resets and adds new focus to the cell at position newCoord.
 * No need for element.focus() since that is done natively when clicking.
 */
export const removeTabAndFocusCell = (
  newCoord: FocusedCellCoord,
  rootElement: HTMLElement,
  setFocusedCellCoord: SetFocusedCellCoord,
  keyboard: stardust.Keyboard,
) => {
  updateFocus({ focusType: FocusTypes.REMOVE_TAB, cell: findCellWithTabStop(rootElement) });
  setFocusedCellCoord(newCoord);
  if (keyboard.enabled && !keyboard.active) {
    keyboard.focus?.();
  } else {
    updateFocus({ focusType: FocusTypes.ADD_TAB, cell: getCellElement(rootElement, newCoord) });
  }
};

/**
 * Resets the focus when the data size or page is change, only adds a tab stop when shouldRefocus.current is false
 */
export const resetFocus = ({
  focusedCellCoord,
  rootElement,
  shouldRefocus,
  isSelectionMode,
  setFocusedCellCoord,
  keyboard,
  announce,
  totalsPosition,
  isNewHeadCellMenuEnabled,
}: HandleResetFocusProps) => {
  updateFocus({ focusType: FocusTypes.REMOVE_TAB, cell: findCellWithTabStop(rootElement) });
  // If you have selections ongoing, you want to stay on the same column
  const selectionCellCoord: FocusedCellCoord = [totalsPosition.atTop ? 2 : 1, focusedCellCoord[1]];
  const defaultCellCoords: FocusedCellCoord = isNewHeadCellMenuEnabled
    ? FIRST_HEADER_CELL_COORD
    : FIRST_BODY_CELL_COORD;
  const cellCoord = isSelectionMode ? selectionCellCoord : defaultCellCoords;

  if (areTabStopsEnabled(keyboard)) {
    // Only run this if updates come from inside table
    const focusType = shouldRefocus.current ? FocusTypes.FOCUS : FocusTypes.ADD_TAB;
    shouldRefocus.current = false;
    const cell = getCellElement(rootElement, cellCoord);
    updateFocus({ focusType, cell });

    if (isSelectionMode) {
      const hasSelectedClassName = cell?.classList?.contains("selected");
      announce({
        keys: [
          `${cell?.textContent},`,
          hasSelectedClassName ? "SNTable.SelectionLabel.SelectedValue" : "SNTable.SelectionLabel.NotSelectedValue",
        ],
      });
    }
  }

  setFocusedCellCoord(cellCoord);
};

/**
 * When focus is no longer in the table or head cell menu, resets the announcer and calls keyboard.blur
 */
export const handleFocusoutEvent = (
  evt: FocusEvent,
  shouldRefocus: React.MutableRefObject<boolean>,
  keyboard: stardust.Keyboard,
) => {
  const targetElement = evt.currentTarget as HTMLElement | null;
  const relatedTarget = evt.relatedTarget as HTMLElement | null;
  const isInTable = !!targetElement?.contains(relatedTarget);
  const isInHeadCellMenu = relatedTarget?.closest(".sn-table-head-menu");
  if (keyboard.enabled && !isInTable && !isInHeadCellMenu && !shouldRefocus.current && targetElement) {
    const firstAnnouncer = targetElement.querySelector(".sn-table-announcer-1");
    const secondAnnouncer = targetElement.querySelector(".sn-table-announcer-2");
    if (firstAnnouncer && secondAnnouncer) {
      firstAnnouncer.innerHTML = "";
      secondAnnouncer.innerHTML = "";
    }
    // Blur the table but not focus its parent element
    // when keyboard.active is false, this has no effect
    keyboard.blur?.(false);
  }
};

/**
 * Updates the announcer with current cell selection state
 */
export const announceSelectionState = (
  announce: Announce,
  nextCell: HTMLElement | undefined,
  isSelectionMode = false,
) => {
  if (isSelectionMode && nextCell) {
    const hasActiveClassName = nextCell.classList.contains("selected");
    hasActiveClassName
      ? announce({ keys: ["SNTable.SelectionLabel.SelectedValue"] })
      : announce({ keys: ["SNTable.SelectionLabel.NotSelectedValue"] });
  }
};