셀 포커스와 키보드 이동 (Cell Navigation)

활성 셀을 제어하고 방향키, Tab, Home/End, PageUp/PageDown으로 이동하며 선택과 인라인 편집을 연결합니다.

#cellNavigationOptions#activeCell#keyboard-navigation#cell-selection#inline-editing
검토일: 2026-08-19
GitHub
import * as React from 'react';
import { AXDataGrid, type AXDGCellAddress, type AXDGColumn, type AXDGDataItem } from '@axboot/datagrid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { applyEditingDataChange } from './editing/shared';

interface OrderRow {
  orderNo: string;
  customer: string;
  category: string;
  product: string;
  status: string;
  quantity: number;
  unitPrice: number;
  total: number;
}

const categories = ['오피스', '디자인', '분석', '자동화'];
const products = ['Workspace Pro', 'Design System', 'Analytics Seat', 'Automation Pack'];
const customers = ['AxisJ Studio', 'Northwind', 'Paperworks', 'Seoul Labs', 'Mono Office', 'Orbit Works'];

const initialData: AXDGDataItem<OrderRow>[] = Array.from({ length: 48 }, (_, index) => {
  const quantity = (index % 9) + 1;
  const unitPrice = 12000 + (index % 6) * 4500;
  const groupIndex = Math.floor(index / 4) % categories.length;

  return {
    values: {
      orderNo: `A-${String(2401 + index).padStart(4, '0')}`,
      customer: customers[index % customers.length],
      category: categories[groupIndex],
      product: products[groupIndex],
      status: ['완료', '배송 중', '준비'][index % 3],
      quantity,
      unitPrice,
      total: quantity * unitPrice,
    },
  };
});

export default function CellNavigationExample() {
  const [data, setData] = React.useState(initialData);
  const [activeCell, setActiveCell] = React.useState<AXDGCellAddress>({ rowIndex: 0, columnIndex: 1 });
  const [navigationEnabled, setNavigationEnabled] = React.useState(true);
  const [selectionEnabled, setSelectionEnabled] = React.useState(true);
  const [wrap, setWrap] = React.useState(false);
  const [editOnEnter, setEditOnEnter] = React.useState(true);
  const [lastActivation, setLastActivation] = React.useState('없음');
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);

  const columns = React.useMemo<AXDGColumn<OrderRow>[]>(
    () => [
      { key: 'orderNo', label: '주문 번호', width: 110, editable: false },
      {
        key: 'customer',
        label: '고객 · 편집 가능',
        width: 170,
        editable: true,
        editor: {
          type: 'text',
          ariaLabel: '고객 편집',
          inputProps: { maxLength: 50, autoComplete: 'off' },
        },
      },
      { key: 'category', label: '분류 · 병합', width: 110, editable: false },
      {
        key: 'product',
        label: '상품 · 편집 가능',
        width: 180,
        editable: true,
        editor: {
          type: 'text',
          ariaLabel: '상품 편집',
          inputProps: { maxLength: 80, autoComplete: 'off' },
        },
      },
      { key: 'status', label: '상태', width: 100, align: 'center', editable: false },
      { key: 'quantity', label: '수량', width: 80, align: 'right', editable: false },
      {
        key: 'unitPrice',
        label: '단가',
        width: 110,
        align: 'right',
        editable: false,
        itemRender: ({ value }) => <>{Number(value).toLocaleString()}원</>,
      },
      {
        key: 'total',
        label: '합계',
        width: 120,
        align: 'right',
        editable: false,
        itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}원</strong>,
      },
    ],
    [],
  );

  return (
    <div className='flex min-h-0 flex-col gap-3'>
      <div className='rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700'>
        <div className='mb-2 flex flex-wrap items-center gap-x-4 gap-y-2'>
          <Toggle label='키보드 이동' checked={navigationEnabled} onChange={setNavigationEnabled} />
          <Toggle label='범위 선택' checked={selectionEnabled} onChange={setSelectionEnabled} />
          <Toggle label='경계 순환' checked={wrap} onChange={setWrap} />
          <Toggle label='Enter로 편집' checked={editOnEnter} onChange={setEditOnEnter} />
        </div>
        <p className='m-0 leading-6'>
          셀 클릭 후 <kbd>방향키</kbd>, <kbd>Home</kbd>/<kbd>End</kbd>, <kbd>PageUp</kbd>/<kbd>PageDown</kbd>,{' '}
          <kbd>Tab</kbd>을 사용하세요. <kbd>Shift</kbd>+방향키는 범위를 확장합니다. 편집 가능한 셀에서는{' '}
          <kbd>Enter</kbd> 또는 <kbd>F2</kbd>로 편집을 시작하고, 그 외 셀에서는 <kbd>Enter</kbd> 또는 <kbd>Space</kbd>로
          클릭 콜백을 실행합니다.
        </p>
        <output aria-live='polite' className='mt-2 block font-mono text-xs text-blue-700'>
          활성 셀: 행 {activeCell.rowIndex + 1}, 열 {activeCell.columnIndex + 1}
        </output>
        <output aria-live='polite' className='mt-1 block font-mono text-xs text-slate-600'>
          마지막 클릭 활성화: {lastActivation}
        </output>
      </div>

      <DataGridContainer ref={containerRef} style={{ height: 420 }}>
        <AXDataGrid<OrderRow>
          width={width}
          height={height}
          columns={columns}
          data={data}
          frozenColumnIndex={1}
          editable
          editTrigger='dblclick'
          variant='vertical-bordered'
          cellMergeOptions={{ columnsMap: { 2: { mergeBy: 'category' } } }}
          cellSelectionOptions={{ enabled: selectionEnabled }}
          cellNavigationOptions={{
            enabled: navigationEnabled,
            activeCell,
            onActiveCellChange: cell => {
              if (cell) setActiveCell(cell);
            },
            wrap,
            editOnEnter,
          }}
          onChangeData={(rowIndex, _columnIndex, values, _column, meta) => {
            setData(current => applyEditingDataChange(current, rowIndex, values, meta));
          }}
          onClick={({ index, columnIndex, item, column }) => {
            setLastActivation(`${item.orderNo} · 행 ${index + 1}, 열 ${columnIndex + 1} (${String(column.label)})`);
          }}
        />
      </DataGridContainer>
    </div>
  );
}

function Toggle({
  label,
  checked,
  onChange,
}: {
  label: string;
  checked: boolean;
  onChange: (checked: boolean) => void;
}) {
  return (
    <label className='inline-flex cursor-pointer items-center gap-1.5 font-medium'>
      <input type='checkbox' checked={checked} onChange={event => onChange(event.target.checked)} />
      <span>{label}</span>
    </label>
  );
}

1. 언제 사용하나요?

마우스보다 키보드 입력이 많은 주문, 재고, 정산 화면에서는 현재 작업 중인 셀이 분명하게 보여야 하고 다음 셀로 빠르게 이동할 수 있어야 합니다. cellNavigationOptions는 그리드 인스턴스별 활성 셀과 이동 정책을 설정합니다.

위 라이브 데모에서 셀을 클릭한 뒤 방향키를 눌러 보세요. 고정 컬럼과 스크롤 영역을 오갈 때도 같은 절대 컬럼 인덱스를 사용하며, 화면 밖 셀로 이동하면 그리드 내부 스크롤이 자동으로 따라갑니다.


2. 지원 키

키 입력 동작
인접 셀로 이동
Shift + 방향키 활성 셀을 이동하며 선택 범위 확장
Ctrl/Cmd + 방향키 현재 행 또는 컬럼의 경계로 이동
Home / End 현재 행의 첫 번째/마지막 컬럼으로 이동
Ctrl/Cmd + Home/End 그리드의 첫 번째/마지막 셀로 이동
PageUp / PageDown 현재 viewport 높이를 기준으로 페이지 이동
Tab / Shift + Tab 다음/이전 셀로 이동
Enter 편집 가능한 셀이면 편집을 시작하고, 그 외에는 현재 셀의 onClick 콜백 실행
Space 현재 셀의 onClick 콜백 실행
F2 편집 가능한 활성 셀에서 편집 시작
Escape 편집 취소 또는 셀 선택 해제

input, textarea, select, button, contenteditable 요소가 이벤트 대상이면 그리드 단축키가 입력을 가로채지 않습니다. 커스텀 편집기는 필요한 키를 자체적으로 처리하고 handleSave, handleCancel, handleMove를 호출해야 합니다.


3. 기본값과 제어형 상태

초기 셀만 지정하려면 defaultActiveCell을 사용합니다.

<AXDataGrid
  width={800}
  height={420}
  columns={columns}
  data={data}
  cellNavigationOptions={{
    defaultActiveCell: { rowIndex: 0, columnIndex: 0 },
    wrap: false,
    editOnEnter: true,
  }}
/>

외부 화면 상태와 활성 셀을 연결하려면 activeCellonActiveCellChange를 함께 전달합니다. 제어형 모드에서는 콜백으로 받은 값을 다시 전달하기 전까지 화면의 활성 셀이 바뀌지 않습니다.

const [activeCell, setActiveCell] = useState({ rowIndex: 0, columnIndex: 1 });

<AXDataGrid
  width={800}
  height={420}
  columns={columns}
  data={data}
  cellNavigationOptions={{
    activeCell,
    onActiveCellChange: cell => {
      if (cell) setActiveCell(cell);
    },
    wrap: true,
  }}
/>

데이터나 컬럼 수가 줄어 활성 셀이 범위를 벗어나면 현재 마지막 유효 셀로 보정됩니다. 빈 데이터에서는 활성 셀이 해제되고 데이터가 다시 생기면 제어값 또는 기본값을 기준으로 복원됩니다.


4. 선택·병합·편집과의 관계

  • 셀 선택은 기본 활성화됩니다. 필요하지 않으면 cellSelectionOptions={{ enabled: false }}로 끌 수 있으며, 키보드 포커스 이동은 독립적으로 계속 사용할 수 있습니다.
  • Shift + 방향키는 cellSelectionOptions.enabled가 활성화된 경우에만 범위를 만듭니다.
  • 병합 컬럼으로 이동하면 병합 그룹의 첫 행이 활성 셀이 됩니다. 위아래 이동은 현재 병합 그룹을 건너 다음 그룹으로 진행합니다.
  • editable과 커스텀 itemRender를 사용하면 F2 또는 Enter로 편집을 시작할 수 있습니다.
  • editOnEnter: false이면 편집 가능한 셀에서도 Enter가 편집을 시작하지 않고 현재 셀의 onClick 콜백을 실행합니다.
  • 읽기 전용 셀에서는 EnterSpace가 마우스 클릭과 같은 AXDGProps.onClick 인자를 전달합니다. 활성 셀은 이동하지 않으므로 위아래 이동에는 방향키를 사용합니다.
  • wrap: true이면 방향키와 Tab 이동이 그리드 경계를 넘어 반대쪽 끝으로 순환합니다.

셀 포커스는 행 강조용 selectedRowKey와 별개의 상태입니다. 행과 상세 화면을 연결하려면 포커스 및 선택 가이드, 셀 입력 구현은 인라인 셀 편집 가이드를 함께 확인하세요.


5. 적용 전 확인 사항

  • 그리드 루트에 실제 포커스가 있을 때만 키보드 이동이 동작합니다. 셀 클릭은 그리드에 포커스를 전달합니다.
  • 활성 셀과 선택 영역은 현재 표시 데이터의 인덱스를 사용합니다. 정렬·필터 후 외부 상태가 특정 원본 행을 계속 가리켜야 한다면 rowKey 기반 애플리케이션 상태와 함께 관리하세요.
  • DOM 기반 셀과 키보드 이동을 제공하지만 이것만으로 완전한 WAI-ARIA Grid 적합성을 보장하지는 않습니다. 목표 접근성 수준에 맞춰 실제 브라우저와 보조 기술로 별도 검증하세요.