4

I have an angular implementation of ag-grid where I have some custom cell renderers. They render things from buttons to toggle statuses to links to other parts of the application.

I recently ran into a problem where keyboard only users are not able to tab into the contents of these cell renderers to "click" the element. I have tried putting a tabindex="0" and/or an href="#" onto these elements so that they appear in the tab order for the page, but the only way they can receive focus is by clicking with a mouse.

It seems that ag-grid overrides the default behavior of the keyboard, perhaps to add addtl functionality with arrow keys, etc. But it doesn't seem to play well with these custom renderers...

Below is a sample of a simplified version of one of these renderers.

@Component({
  selector: 'fave-renderer',
  template: `
    <i class="icon fave"
       tabindex="0"
       (click)="toggleFave()"></i>
  `,
  styles: [``]
})
export class FaveRendererComponent implements ICellRendererAngularComp {
  public params: any;

  agInit(params: any): void {
    this.params = params;
  }

  constructor(private resultsService: ResultsService) {}

  refresh(): boolean {return false;}

  toggleFave() {/*implementation not important... */}
}

1

2 Answers 2

9

So I ended up doing a sort of hacky workaround to fix this issue. What is happening is that ag-grid implements custom keyboard events. For example, Enter will start editing a cell, the Arrow keys will navigate through the grid, Tab navigates to the next cell, and so on.

Because ag-grid overrides the default keyboard events, any nested cell renderers with focusable elements will never receive focus while tabbing because the Tab key is being handled by ag-grid. To prevent this, I added a callback for the Suppress Keyboard Events handler.

In the callback, I only allowed ag-grid to handle the tab navigation when the currently focused element being tabbed away from is the last focusable child of the cell (or is the cell itself in the case of shift-tab).

suppressKeyboardEvent(params: SuppressKeyboardEventParams) {
  const e = params.event;
  if (e.code == 'Tab' || e.key == 'Tab') {
    //get focusable children of parent cell
    let focusableChildrenOfParent = e.srcElement.closest(".ag-cell")
      .querySelectorAll('button, [href], :not(.ag-hidden) > input, select, textarea, [tabindex]:not([tabindex="-1"])');

    if (focusableChildrenOfParent.length == 0 ||
      (e.shiftKey == false && e.srcElement == focusableChildrenOfParent[focusableChildrenOfParent.length - 1]) ||
      (e.shiftKey == true && e.srcElement == focusableChildrenOfParent[0]) ||
      (e.shiftKey == true && e.srcElement.classList.contains("ag-cell")))
      return false; //do not suppress
    return true; //suppress
  }
  return false; //do not suppress by default
}

1

I have a React implementation, however, since Rich's angular example helped me, I thought I could expand Rich's answer with moving the focus handling to the cell renderer rather than inside the suppressKeyboardEvent handler. This will allow us to give focus to the element immediately when the cell receives focus, rather than having to hit TAB a second time to give focus to the first element inside the cell.

In order to give focus to the element as soon as the cell receives focus, we can add a onCellFocused handler to the grid. When the our desired column receives focus we use an imperativeHandle on our custom cell renderer to hand over control to the renderer.

const cellFocusedHandler = (params) => {
  if (params.column.userProvidedColDef.colId === 3) {
    const rendererParams = {
      rowNodes: [params.api.getDisplayedRowAtIndex(params.rowIndex)],
      columns: [params.column],
    };
    const cellRenderer = params.api.getCellRendererInstances(rendererParams);
    // since our rendererParams only provided for one cell, we've hardcode 0
    cellRenderer[0].setFirstElementFocus();
  }
};
<AgGridReact
  ref={gridRef} 
  rowData={rowData} 
  columnDefs={columnDefs} 
  defaultColDef={defaultColDef} 
  onGridReady={onGridReady} 
  onCellFocused={cellFocusedHandler}
 />

Our custom cell renderer looks like this...

const ActionRenderer = (props, ref) => {
  const anchor1Ref = useRef();
  const anchor2Ref = useRef();

  /* Component Editor Lifecycle methods */
  useImperativeHandle(ref, () => {
    return {
      setFirstElementFocus() {
        anchor1Ref.current.focus();
      },
    };
  });

  // This is just a start... there will be various key navigation that MAY need
  // to be added. But for now, these are the main keys addressed
  // ActionCellKeyboardHandler.js controls which key strokes make it through to
  // the renderer.
  const handleKeyDown = (e) => {
    if (e.key === KeyCode.SPACE) {
      // space key does nothing.
      // prevent default so that the page doesn't scroll
      e.preventDefault();
    } else if (e.key === KeyCode.ENTER) {
      e.stopPropagation();
      // since we are controlling all keystrokes, an enter
      // key triggers the click() event.
      e.target.click();
    } else if (e.key === KeyCode.TAB) {
      e.stopPropagation();
      // if we are tabbing away from the last element in our
      // action cell, then we want to give focus to the
      // next cell.
      // In this example, the action cell is the last column, so
      // we have hard coded the next row first cell
      if (document.activeElement === anchor2Ref.current) {
        // prevent default so that we control the next move
        e.preventDefault();
        // this is a primitive example with no extra logic,
        // but demonstrates the concept. Edge case handling
        // would need to be added.
        props.api.setFocusedCell(
          props.rowIndex + 1,
          props.columnApi.columnModel.getAllDisplayedColumns()[0],
        );
      }
    }
  };

  const handleLikeActionClick = (pEvnt) => {
    alert("Like " + props.data.athlete);
  };

  const handleDislikeActionClick = (pEvnt) => {
    alert("Dislike " + props.data.athlete);
  };

  return (
    <div className="d-flex flex-row justify-content-center h-100">
      <a ref={anchor1Ref}
        onKeyDown={handleKeyDown}
        onClick={handleLikeActionClick}
        tabIndex={0}
      >
        <FontAwesomeIcon icon={faThumbsUp} />
      </a>
      <a ref={anchor2Ref}
        onKeyDown={handleKeyDown}
        onClick={handleDislikeActionClick}
        className="action-spacer"
        tabIndex={0}
      >
        <FontAwesomeIcon icon={faThumbsDown} />
      </a>
    </div>
  );
};
export default forwardRef(ActionRenderer);

Here is a codesandbox example... Custom Cell Renderer with Tab Navigation

Not the answer you're looking for? Browse other questions tagged or ask your own question.