셀 편집 시작하기 (Cell Editing)

편집 가능 셀 선언부터 클릭·키보드 진입, IME, 저장·취소·이동까지 셀 편집의 기본 흐름을 설명합니다.

#cell-editing#editable#editTrigger#keyboard#IME#text-editor
검토일: 2026-09-03
LIVE DEMO셀 편집 시작하기 (Cell Editing) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
GitHub
import { t } from './i18n';
import * as React from 'react';
import { BGrid, BGridDataItemStatus, type BGridCellAddress, type BGridColumn, type BGridRef } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import {
  applyEditingDataChange,
  cloneEditingOrders,
  type EditingOrder,
  withEditingCellClasses,
} from './editing/shared';

export default function BasicEditingExample() {
  const [data, setData] = React.useState(cloneEditingOrders);
  const [activeCell, setActiveCell] = React.useState<BGridCellAddress | null>({ rowIndex: 0, columnIndex: 1 });
  const [checkedRowKeys, setCheckedRowKeys] = React.useState<React.Key[]>([]);
  const gridRef = React.useRef<BGridRef>(null);
  const nextOrderNumber = React.useRef(5);
  const pendingScrollRowKey = React.useRef<string | null>(null);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);

  React.useEffect(() => {
    const rowIndex = data.findIndex(item => item.values.id === pendingScrollRowKey.current);
    if (rowIndex < 0) return;
    gridRef.current?.scrollToRow(rowIndex, { align: 'end' });
    pendingScrollRowKey.current = null;
  }, [data]);

  const addRow = () => {
    const orderNumber = nextOrderNumber.current++;
    const id = `ORDER-${String(orderNumber).padStart(3, '0')}`;
    pendingScrollRowKey.current = id;
    setData(current => [...current, {
      status: BGridDataItemStatus.new,
      values: {
        id,
        orderCode: `ORD-${2600 + orderNumber}`,
        customerCode: '',
        customerName: '',
        customerGrade: '',
        status: t('접수', 'Receipt'),
        deliveryDate: '',
        approved: false,
        quantity: 0,
        unitPrice: 0,
        amount: 0,
        note: '',
        mergeGroup: id,
      },
    }]);
  };

  const deleteRows = () => {
    const checkedKeys = new Set(checkedRowKeys);
    const remaining = data.filter(item => !checkedKeys.has(item.values.id));
    const activeRowKey = activeCell && data[activeCell.rowIndex]?.values.id;
    const remainingActiveIndex = remaining.findIndex(item => item.values.id === activeRowKey);
    setData(remaining);
    setCheckedRowKeys([]);
    setActiveCell(activeCell && remaining.length ? {
      ...activeCell,
      rowIndex: remainingActiveIndex >= 0
        ? remainingActiveIndex
        : Math.min(activeCell.rowIndex, remaining.length - 1),
    } : null);
  };

  const columns = React.useMemo<BGridColumn<EditingOrder>[]>(
    () => withEditingCellClasses<EditingOrder>([
      { key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 150, editable: false },
      {
        key: 'customerName',
        label: t('고객명 · 더블클릭', 'Customer Name · Double Click'),
        width: 190,
        editable: true,
        editor: { type: 'text', inputProps: { maxLength: 50, autoComplete: 'off' } },
      },
      {
        key: 'note',
        label: t('메모 · 한 번 클릭', 'Note · Click Once'),
        width: 210,
        editable: true,
        editTrigger: 'click',
        editor: { type: 'text', inputProps: { maxLength: 80, autoComplete: 'off' } },
      },
      { key: 'status', label: t('상태 · 읽기 전용', 'Status · Read Only'), width: 130, editable: false },
    ]),
    [],
  );

  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 leading-6 text-slate-700'>
        <strong>{t('마우스:', 'Mouse:')}</strong> {t('고객명은 Grid 기본값인 더블클릭, 메모는 컬럼의', 'Customer name is double-clicked as the Grid default, and memo is edited with the column\'s')} <code>editTrigger='click'</code>{t('으로', '')}
        <br />
        {t('편집합니다.', 'edit.')}
        <br />
        <strong>{t('키보드:', 'Keyboard:')}</strong> {t('방향키로 셀을 이동하고 바로 입력하면 기존 값을 대체합니다.', 'Moving cells with arrow keys and typing directly replaces existing values.')} <kbd>Enter</kbd> {t('또는', 'or')}{' '}
        <kbd>F2</kbd>{t('는 기존 값을 유지하며 시작하고,', ' starts editing keeping the existing value,')} <kbd>Tab</kbd>{t('은 저장 후 이동,', ' saves and moves,')} <kbd>Escape</kbd>{t('는 취소합니다.', ' cancels.')}
        <output aria-live='polite' className='mt-1 block font-mono text-xs text-blue-700'>
          {activeCell
            ? `${t('활성 셀: 행', 'Active cell: Row')} ${activeCell.rowIndex + 1}, ${t('열', 'Col')} ${activeCell.columnIndex + 1}`
            : t('활성 셀: 없음', 'Active cell: None')}
        </output>
      </div>
      <div className='flex flex-wrap items-center gap-2'>
        <button type='button' onClick={addRow} className='editing-example-row-button editing-example-row-button-add'>
          <svg aria-hidden='true' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round'>
            <path d='M12 5v14M5 12h14' />
          </svg>
          {t('행추가', 'Add Row')}
        </button>
        <button type='button' onClick={deleteRows} disabled={checkedRowKeys.length === 0} className='editing-example-row-button editing-example-row-button-delete'>
          <svg aria-hidden='true' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
            <path d='M3 6h18M9 6V4h6v2M5 6l1 14h12l1-14M10 10v6M14 10v6' />
          </svg>
          {t('행삭제', 'Delete Rows')}
        </button>
        <output aria-live='polite' className='text-sm text-slate-600'>
          {t(`선택 ${checkedRowKeys.length}개 / 전체 ${data.length}개`, `${checkedRowKeys.length} selected / ${data.length} total`)}
        </output>
      </div>
      <DataGridContainer ref={containerRef} style={{ height: 340 }}>
        <BGrid<EditingOrder>
          ref={gridRef}
          width={width}
          height={height}
          data={data}
          columns={columns}
          rowKey='id'
          rowChecked={{
            checkedRowKeys,
            onChange: (_indexes, keys) => setCheckedRowKeys(keys),
          }}
          editable
          variant='vertical-bordered'
          editTrigger='dblclick'
          showLineNumber
          cellSelectionOptions={{ enabled: true }}
          cellNavigationOptions={{
            enabled: true,
            editOnEnter: true,
            activeCell,
            onActiveCellChange: setActiveCell,
          }}
          onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
            setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
          }}
        />
      </DataGridContainer>
    </div>
  );
}

셀 편집은 Grid의 editable과 대상 컬럼의 editable을 모두 켠 뒤 column.editor를 지정하면 시작할 수 있습니다. 이 페이지에서 마우스 진입 조건부터 키보드·IME·저장과 이동까지 기본 흐름을 함께 다루며, Select·lookup·이벤트 확장은 독립 가이드로 이어집니다.

1. 최소 설정

const columns: BGridColumn<Order>[] = [
  {
    key: 'customerName',
    label: '고객명',
    width: 180,
    editable: true,
    editor: { type: 'text' },
  },
];

<BGrid<Order>
  width={720}
  height={360}
  data={data}
  columns={columns}
  rowKey='id'
  editable
/>

itemRender는 평상시 표시를, editor는 편집 상태의 입력 UI를 담당합니다. 표시 형식을 바꾸기 위해 editor를 만들 필요는 없습니다.

2. Grid 기본값과 컬럼 예외

기본 진입 방식은 더블클릭입니다. Grid의 editTrigger로 전체 기본값을 바꾸고, Select처럼 즉시 열려야 하는 컬럼만 다시 지정할 수 있습니다.

<BGrid editTrigger='dblclick' {...props} />

const columns: BGridColumn<Order>[] = [
  { key: 'name', editable: true, editor: { type: 'text' } },
  {
    key: 'status',
    editable: true,
    editTrigger: 'click',
    editor: statusEditor,
  },
];

해석 우선순위는 column.editTrigger → grid.editTrigger → 'dblclick'입니다. editTrigger: 'none'은 제공하지 않습니다. 셀을 읽기 전용으로 만들려면 editable: false를 사용하고, 셀 클릭과 별개인 버튼 동작은 editorIcon.onClick 또는 itemRender에 둡니다.

3. 마우스와 키보드로 입력 시작하기

  • 셀 클릭 또는 더블클릭: 설정된 editTrigger에 따라 editor를 엽니다.
  • 문자 직접 입력: text 셀의 기존 값을 대체하며 입력합니다.
  • Enter 또는 F2: 기존 값을 유지한 채 editor를 엽니다.
  • 아이콘 클릭: editorIcon.onClick이 없으면 해당 editor를 엽니다.

셀 포커스와 editor 포커스는 서로 다른 상태입니다. 먼저 셀을 활성화한 뒤 키보드로 editor를 열며, 편집이 끝나면 Grid가 다시 활성 셀에 포커스를 돌려줍니다.

4. 키 동작표

키 셀 포커스 상태 편집 상태
문자 입력 text 셀의 기존 값을 대체하며 시작 일반 입력
Enter / F2 기존 값을 유지하며 시작 Enter는 저장
Tab / Shift+Tab 다음/이전 셀 이동 저장 후 다음/이전 셀 이동
Escape 선택 범위 해제 변경 취소 후 같은 셀 복귀
방향키 활성 셀 이동 입력 컨트롤 기본 동작
Ctrl/Cmd+C, V 선택 범위 복사·붙여넣기 입력 컨트롤 기본 동작

내장 text editor의 startOnInput 기본값은 true입니다. 셀 포커스에서 문자를 바로 입력해 editor를 여는 동작을 끄려면 startOnInput: false를 사용합니다.

editor: {
  type: 'text',
  startOnInput: false,
}

5. IME와 키보드 이동 설정

한글처럼 조합 입력 중인 상태에서는 Enter나 blur가 먼저 발생해도 조합 전 문자열을 저장하지 않습니다. 조합이 완료된 뒤 최종 문자열로 commit합니다. 외부 plugin은 사용하는 UI 컴포넌트의 composition 동작도 함께 확인해야 합니다.

<BGrid
  cellNavigationOptions={{
    enabled: true,
    editOnEnter: true,
    wrap: false,
  }}
/>

활성 셀을 제어형으로 관리하거나 Home/End, PageUp/PageDown 동작까지 확인하려면 셀 포커스와 키보드 이동을 이어서 보세요.

6. 애플리케이션 상태 반영

Grid 내부 저장 후 onChangeData가 호출됩니다. 정렬·필터가 적용되어도 첫 번째 인자는 원본 데이터의 source index입니다.

onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
  setData(current =>
    current.map((item, index) =>
      index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
    ),
  );
}}

실제 행 데이터는 항상 BGridDataItem<T>.values에 있습니다. meta.dataItem을 저장하면 직접 편집한 컬럼의 editedColumnIds와 값이 변경된 데이터 key의 changedKeys가 함께 유지됩니다. 직접 편집한 셀에는 bgrid-cell-edited, 같은 key를 공유하는 모든 셀에는 bgrid-cell-value-changed 스타일이 적용됩니다.

7. 행 추가·선택 삭제와 스크롤

라이브 예제의 행추가는 새 행을 끝에 추가하고 그 위치로 이동합니다. 왼쪽 체크박스로 여러 행을 선택한 다음 행삭제를 누르면 선택된 행만 데이터에서 제거합니다. 선택이 없으면 삭제 버튼은 비활성화됩니다. 새 행은 status: BGridDataItemStatus.new로 표시하며, 삭제는 서버 저장 없이 예제의 React 상태에서 즉시 처리합니다.

행 추가·삭제는 애플리케이션이 data를 갱신하고, 스크롤은 BGridRef.scrollToRow()로 명시적으로 요청합니다. 일반적인 데이터 갱신은 현재 스크롤 위치를 유지합니다. 이 예제는 추가할 행의 고유 키를 기억했다가 데이터 반영 후 이동하므로 셀 편집 때마다 다시 스크롤하지 않습니다.

import { useEffect, useRef, useState, type Key } from 'react';
import { BGrid, type BGridDataItem, type BGridRef } from 'beautiful-grid';

const gridRef = useRef<BGridRef>(null);
const pendingRowKey = useRef<string | null>(null);
const [checkedRowKeys, setCheckedRowKeys] = useState<Key[]>([]);

function addRow(item: BGridDataItem<Order>) {
  pendingRowKey.current = item.values.id;
  setData(current => [...current, item]);
}

useEffect(() => {
  const rowIndex = data.findIndex(item => item.values.id === pendingRowKey.current);
  if (rowIndex < 0) return;
  gridRef.current?.scrollToRow(rowIndex, { align: 'end' });
  pendingRowKey.current = null;
}, [data]);

function deleteCheckedRows() {
  const keys = new Set(checkedRowKeys);
  setData(current => current.filter(item => !keys.has(item.values.id)));
  setCheckedRowKeys([]);
}

<BGrid<Order>
  ref={gridRef}
  width={720}
  height={360}
  data={data}
  columns={columns}
  rowKey='id'
  editable
  rowChecked={{
    checkedRowKeys,
    onChange: (_indexes, keys) => setCheckedRowKeys(keys),
  }}
/>

scrollToRow(rowIndex, { align })의 인덱스는 현재 페이지에서 정렬·필터가 적용된 표시 순서의 0 기반 인덱스입니다. onChangeData의 source index와는 다릅니다. 정렬·필터를 사용한다면 표시 데이터에서 행 위치를 구해야 하며, 필터로 숨겨진 행이나 다른 페이지를 자동으로 열지는 않습니다.

  • nearest (기본값): 행이 이미 보이면 유지하고, 벗어나면 필요한 만큼만 이동합니다.
  • start / center / end: 행을 스크롤 영역의 위 / 가운데 / 아래로 맞추되 스크롤 한계를 넘지 않습니다.
  • 고정 행, 범위 밖 인덱스, 정수가 아닌 인덱스는 무시합니다.
  • 가로 스크롤, 활성 셀과 선택은 변경하지 않습니다. 연속 요청은 마지막 요청을 반영합니다.
  • 같은 이벤트에서 데이터 갱신과 호출을 함께 해도 Grid 내부 데이터 반영 후 처리합니다. 서버 응답 등 비동기 데이터는 도착한 뒤 호출하세요.

8. 다음 가이드 선택

하고 싶은 일 다음 문서
text, Select, Date 사용 내장·기본 제공 에디터
Ant Design 등 외부 UI 연결 외부 에디터 플러그인
평상시 셀에 화살표·검색 아이콘 표시 에디터 아이콘
autocomplete 입력과 lookup 모달 함께 사용 Lookup 에디터
연관 셀 변경과 검증 편집 이벤트와 트랜잭션
병합 셀과 frozen 경계 편집 병합 셀 편집