0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AG-gridでもEXCELのようにセル移動したい

0
Last updated at Posted at 2026-08-26

ExcelNavigation.ts

import type {
  CellKeyDownEvent,
  CellPosition,
  ColDef,
  Column,
  GridApi,
  RowNode,
} from 'ag-grid-community';

type SelectionState = {
  /*
   * 選択開始セル。
   */
  anchor: CellPosition;

  /*
   * 現在の縦方向の選択端。
   */
  rowEdge: number;

  /*
   * 現在の横方向の選択端。
   */
  columnEdge: Column;
};

/*
 * GridごとにCtrl+Shift選択状態を保持する。
 *
 * 同一画面に複数AG Gridが存在しても
 * 状態が干渉しない。
 */
const selectionStates =
  new WeakMap<object, SelectionState>();

/*
 * ============================================================
 * AG Grid標準のCtrl+Arrowを抑止
 * ============================================================
 */
export const excelSuppressKeyboardEvent: NonNullable<
  ColDef['suppressKeyboardEvent']
> = (params) => {
  const event = params.event;

  const ctrlOrCommand =
    event.ctrlKey ||
    event.metaKey;

  return (
    ctrlOrCommand &&
    isArrowKey(event.key)
  );
};

/*
 * ============================================================
 * Ctrl / Ctrl+Shift + Arrow
 * ============================================================
 */
export const excelOnCellKeyDown = (
  params: CellKeyDownEvent,
) => {
  const event =
    params.event as KeyboardEvent;

  const api = params.api;

  const ctrlOrCommand =
    event.ctrlKey ||
    event.metaKey;

  /*
   * Ctrl+Arrow以外。
   */
  if (
    !ctrlOrCommand ||
    !isArrowKey(event.key)
  ) {
    /*
     * 通常操作に戻ったら
     * Ctrl+Shift選択状態を解除する。
     */
    if (!event.shiftKey) {
      selectionStates.delete(api);
    }

    return;
  }

  event.preventDefault();
  event.stopPropagation();

  const focused =
    api.getFocusedCell();

  if (!focused) {
    return;
  }

  /*
   * ========================================================
   * Ctrl + Shift + Arrow
   * ========================================================
   */
  if (event.shiftKey) {
    let state =
      selectionStates.get(api);

    /*
     * 最初のCtrl+Shift操作。
     */
    if (!state) {
      state = {
        anchor: focused,

        rowEdge:
          focused.rowIndex,

        columnEdge:
          focused.column,
      };

      selectionStates.set(
        api,
        state,
      );
    }

    /*
     * ------------------------------------------------------
     * Ctrl + Shift + ↑ / ↓
     * ------------------------------------------------------
     */
    if (
      event.key === 'ArrowUp' ||
      event.key === 'ArrowDown'
    ) {
      const direction: -1 | 1 =
        event.key === 'ArrowUp'
          ? -1
          : 1;

      /*
       * 縦方向探索はanchor列を使う。
       *
       * A1:D100 の状態でも
       *
       * Ctrl+Shift+↓
       *
       * はA列を探索する。
       */
      const lookupCell: CellPosition = {
        rowIndex:
          state.rowEdge,

        rowPinned:
          state.anchor.rowPinned,

        column:
          state.anchor.column,
      };

      const destination =
        findVerticalDestination(
          api,
          lookupCell,
          direction,
        );

      state.rowEdge =
        destination.rowIndex;
    }

    /*
     * ------------------------------------------------------
     * Ctrl + Shift + ← / →
     * ------------------------------------------------------
     */
    else if (
      event.key === 'ArrowLeft' ||
      event.key === 'ArrowRight'
    ) {
      const direction: -1 | 1 =
        event.key === 'ArrowLeft'
          ? -1
          : 1;

      /*
       * 横方向探索はanchor行を使う。
       *
       * A1:A100
       *
       * からCtrl+Shift+→の場合、
       *
       * A100ではなくA1から探索する。
       */
      const lookupCell: CellPosition = {
        rowIndex:
          state.anchor.rowIndex,

        rowPinned:
          state.anchor.rowPinned,

        column:
          state.columnEdge,
      };

      const destination =
        findHorizontalDestination(
          api,
          lookupCell,
          direction,
        );

      state.columnEdge =
        destination.column;
    }

    /*
     * =====================================================
     * AG Grid 32.1.0
     *
     * clearCellSelection()ではなく
     * clearRangeSelection()
     * =====================================================
     */
    api.clearRangeSelection();

    api.addCellRange({
      rowStartIndex:
        state.anchor.rowIndex,

      rowStartPinned:
        state.anchor.rowPinned ??
        undefined,

      rowEndIndex:
        state.rowEdge,

      rowEndPinned:
        state.anchor.rowPinned ??
        undefined,

      columnStart:
        state.anchor.column,

      columnEnd:
        state.columnEdge,
    });

    api.ensureIndexVisible(
      state.rowEdge,
    );

    api.ensureColumnVisible(
      state.columnEdge,
    );

    /*
     * active cellはanchorに維持。
     */
    api.setFocusedCell(
      state.anchor.rowIndex,
      state.anchor.column,
      state.anchor.rowPinned ??
        undefined,
    );

    return;
  }

  /*
   * ========================================================
   * Ctrl + Arrow
   * ========================================================
   */

  selectionStates.delete(api);

  const destination =
    findCtrlArrowDestination(
      api,
      focused,
      event.key,
    );

  if (!destination) {
    return;
  }

  /*
   * AG Grid 32.1.0
   */
  api.clearRangeSelection();

  api.ensureIndexVisible(
    destination.rowIndex,
  );

  api.ensureColumnVisible(
    destination.column,
  );

  api.setFocusedCell(
    destination.rowIndex,
    destination.column,
    destination.rowPinned ??
      undefined,
  );
};

/*
 * ============================================================
 * Arrow判定
 * ============================================================
 */
const isArrowKey = (
  key: string,
): boolean =>
  key === 'ArrowUp' ||
  key === 'ArrowDown' ||
  key === 'ArrowLeft' ||
  key === 'ArrowRight';

/*
 * ============================================================
 * Ctrl+Arrow移動先
 * ============================================================
 */
const findCtrlArrowDestination = (
  api: GridApi,
  current: CellPosition,
  key: string,
): CellPosition | null => {
  switch (key) {
    case 'ArrowUp':
      return findVerticalDestination(
        api,
        current,
        -1,
      );

    case 'ArrowDown':
      return findVerticalDestination(
        api,
        current,
        1,
      );

    case 'ArrowLeft':
      return findHorizontalDestination(
        api,
        current,
        -1,
      );

    case 'ArrowRight':
      return findHorizontalDestination(
        api,
        current,
        1,
      );

    default:
      return null;
  }
};

/*
 * ============================================================
 * 縦方向
 * ============================================================
 */
const findVerticalDestination = (
  api: GridApi,
  current: CellPosition,
  direction: -1 | 1,
): CellPosition => {
  const rowCount =
    api.getDisplayedRowCount();

  if (rowCount === 0) {
    return current;
  }

  const currentIndex =
    current.rowIndex;

  const adjacentIndex =
    currentIndex + direction;

  /*
   * すでに端。
   */
  if (
    adjacentIndex < 0 ||
    adjacentIndex >= rowCount
  ) {
    return current;
  }

  const currentRow =
    api.getDisplayedRowAtIndex(
      currentIndex,
    );

  const adjacentRow =
    api.getDisplayedRowAtIndex(
      adjacentIndex,
    );

  if (
    !currentRow ||
    !adjacentRow
  ) {
    return current;
  }

  const currentFilled =
    hasCellValue(
      api,
      currentRow,
      current.column,
    );

  const adjacentFilled =
    hasCellValue(
      api,
      adjacentRow,
      current.column,
    );

  let destinationIndex =
    currentIndex;

  /*
   * 現在値あり
   * +
   * 隣値あり
   *
   * → 連続領域の最後。
   */
  if (
    currentFilled &&
    adjacentFilled
  ) {
    destinationIndex =
      adjacentIndex;

    while (true) {
      const nextIndex =
        destinationIndex +
        direction;

      if (
        nextIndex < 0 ||
        nextIndex >= rowCount
      ) {
        break;
      }

      const nextRow =
        api.getDisplayedRowAtIndex(
          nextIndex,
        );

      if (!nextRow) {
        break;
      }

      if (
        !hasCellValue(
          api,
          nextRow,
          current.column,
        )
      ) {
        break;
      }

      destinationIndex =
        nextIndex;
    }
  }

  /*
   * 現在空白
   *
   * または
   *
   * 隣が空白
   *
   * → 次の値ありまで。
   */
  else {
    let index =
      adjacentIndex;

    while (
      index >= 0 &&
      index < rowCount
    ) {
      const row =
        api.getDisplayedRowAtIndex(
          index,
        );

      if (!row) {
        break;
      }

      destinationIndex =
        index;

      if (
        hasCellValue(
          api,
          row,
          current.column,
        )
      ) {
        break;
      }

      index += direction;
    }
  }

  return {
    rowIndex:
      destinationIndex,

    rowPinned:
      current.rowPinned,

    column:
      current.column,
  };
};

/*
 * ============================================================
 * 横方向
 * ============================================================
 */
const findHorizontalDestination = (
  api: GridApi,
  current: CellPosition,
  direction: -1 | 1,
): CellPosition => {
  const columns =
    api.getAllDisplayedColumns();

  const currentIndex =
    columns.indexOf(
      current.column,
    );

  if (currentIndex < 0) {
    return current;
  }

  const adjacentIndex =
    currentIndex + direction;

  if (
    adjacentIndex < 0 ||
    adjacentIndex >=
      columns.length
  ) {
    return current;
  }

  const row =
    api.getDisplayedRowAtIndex(
      current.rowIndex,
    );

  if (!row) {
    return current;
  }

  const currentFilled =
    hasCellValue(
      api,
      row,
      current.column,
    );

  const adjacentColumn =
    columns[adjacentIndex];

  const adjacentFilled =
    hasCellValue(
      api,
      row,
      adjacentColumn,
    );

  let destinationIndex =
    currentIndex;

  /*
   * 現在値あり
   * +
   * 隣値あり
   *
   * → 連続領域の最後。
   */
  if (
    currentFilled &&
    adjacentFilled
  ) {
    destinationIndex =
      adjacentIndex;

    while (true) {
      const nextIndex =
        destinationIndex +
        direction;

      if (
        nextIndex < 0 ||
        nextIndex >=
          columns.length
      ) {
        break;
      }

      const nextColumn =
        columns[nextIndex];

      if (
        !hasCellValue(
          api,
          row,
          nextColumn,
        )
      ) {
        break;
      }

      destinationIndex =
        nextIndex;
    }
  }

  /*
   * 空白方向
   *
   * → 次の値ありへ。
   */
  else {
    let index =
      adjacentIndex;

    while (
      index >= 0 &&
      index <
        columns.length
    ) {
      const column =
        columns[index];

      destinationIndex =
        index;

      if (
        hasCellValue(
          api,
          row,
          column,
        )
      ) {
        break;
      }

      index += direction;
    }
  }

  return {
    rowIndex:
      current.rowIndex,

    rowPinned:
      current.rowPinned,

    column:
      columns[
        destinationIndex
      ],
  };
};

/*
 * ============================================================
 * セル値判定
 * ============================================================
 */
const hasCellValue = (
  api: GridApi,
  rowNode: RowNode,
  column: Column,
): boolean => {
  const colDef =
    column.getColDef();

  const colId =
    column.getColId();

  const isGroupColumn =
    colDef.showRowGroup === true ||
    typeof colDef.showRowGroup ===
      'string' ||
    colId.startsWith(
      'ag-Grid-AutoColumn',
    );

  /*
   * Auto Group Column。
   */
  if (isGroupColumn) {
    if (rowNode.group) {
      return (
        rowNode.key !== null &&
        rowNode.key !== undefined &&
        rowNode.key !== ''
      );
    }

    return false;
  }

  /*
   * AG Grid 32.1.0でも
   * getCellValue()は利用可能。
   */
  const value =
    api.getCellValue({
      rowNode,
      colKey: column,
    });

  return !isEmptyValue(value);
};

/*
 * ============================================================
 * 空白判定
 * ============================================================
 */
const isEmptyValue = (
  value: unknown,
): boolean =>
  value === null ||
  value === undefined ||
  value === '';

ExcelAggrid.tsx

import {
  AgGridReact,
} from 'ag-grid-react';

import type {
  AgGridReactProps,
} from 'ag-grid-react';

import {
  excelOnCellKeyDown,
  excelSuppressKeyboardEvent,
} from './excelNavigation';

/*
 * ============================================================
 * システム共通AG Grid
 * ============================================================
 *
 * システム内では原則として
 *
 * AgGridReact
 *
 * を直接使わず、
 *
 * SystemAgGrid
 *
 * を使用する。
 *
 *
 * AG Grid 32.1.0前提。
 */
export const SystemAgGrid = <
  TData = any,
>(
  props: AgGridReactProps<TData>,
) => {
  const {
    defaultColDef,
    autoGroupColumnDef,
    onCellKeyDown,

    /*
     * AG Grid 32.1系Range Selection設定。
     */
    enableRangeSelection,
    suppressMultiRangeSelection,

    ...restProps
  } = props;

  return (
    <AgGridReact<TData>
      {...restProps}

      /*
       * ====================================================
       * defaultColDef
       * ====================================================
       */
      defaultColDef={{
        /*
         * 標準ではExcel操作を有効にする。
         */
        suppressKeyboardEvent:
          excelSuppressKeyboardEvent,

        /*
         * 個別画面側で設定されていれば
         * そちらを優先する。
         */
        ...defaultColDef,
      }}

      /*
       * ====================================================
       * Auto Group Column
       * ====================================================
       */
      autoGroupColumnDef={{
        suppressKeyboardEvent:
          excelSuppressKeyboardEvent,

        ...autoGroupColumnDef,
      }}

      /*
       * ====================================================
       * AG Grid 32.1 Range Selection
       * ====================================================
       *
       * v32.1ではcellSelectionではない。
       */
      enableRangeSelection={
        enableRangeSelection ??
        true
      }

      /*
       * Excel通常選択に合わせて
       * 複数Rangeは作らない。
       */
      suppressMultiRangeSelection={
        suppressMultiRangeSelection ??
        true
      }

      /*
       * ====================================================
       * Keyboard
       * ====================================================
       *
       * 個別側に指定があれば
       * 個別処理を優先。
       */
      onCellKeyDown={
        onCellKeyDown ??
        excelOnCellKeyDown
      }
    />
  );
};
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?