셀 편집 시작하기 (Cell Editing)
편집 가능 셀 선언부터 클릭·키보드 진입, IME, 저장·취소·이동까지 셀 편집의 기본 흐름을 설명합니다.
import * as React from 'react';
import { AXDataGrid, type AXDGCellAddress, type AXDGColumn } from '@axboot/datagrid';
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<AXDGCellAddress>({ rowIndex: 0, columnIndex: 1 });
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<AXDGColumn<EditingOrder>[]>(
() => withEditingCellClasses<EditingOrder>([
{ key: 'orderCode', label: '주문 코드', width: 150, editable: false },
{
key: 'customerName',
label: '고객명 · 더블클릭',
width: 190,
editable: true,
editor: { type: 'text', inputProps: { maxLength: 50, autoComplete: 'off' } },
},
{
key: 'note',
label: '메모 · 한 번 클릭',
width: 210,
editable: true,
editTrigger: 'click',
editor: { type: 'text', inputProps: { maxLength: 80, autoComplete: 'off' } },
},
{ key: 'status', label: '상태 · 읽기 전용', 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>마우스:</strong> 고객명은 Grid 기본값인 더블클릭, 메모는 컬럼의 <code>editTrigger='click'</code>으로
편집합니다.
<br />
<strong>키보드:</strong> 방향키로 셀을 이동하고 바로 입력하면 기존 값을 대체합니다. <kbd>Enter</kbd> 또는{' '}
<kbd>F2</kbd>는 기존 값을 유지하며 시작하고, <kbd>Tab</kbd>은 저장 후 이동, <kbd>Escape</kbd>는 취소합니다.
<output aria-live='polite' className='mt-1 block font-mono text-xs text-blue-700'>
활성 셀: 행 {activeCell.rowIndex + 1}, 열 {activeCell.columnIndex + 1}
</output>
</div>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<AXDataGrid<EditingOrder>
width={width}
height={height}
data={data}
columns={columns}
rowKey='id'
editable
variant='vertical-bordered'
editTrigger='dblclick'
showLineNumber
cellSelectionOptions={{ enabled: true }}
cellNavigationOptions={{
enabled: true,
editOnEnter: true,
activeCell,
onActiveCellChange: cell => cell && setActiveCell(cell),
}}
onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
}}
/>
</DataGridContainer>
</div>
);
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* AXDataGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .axdg-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}import type { AXDGChangeDataMeta, AXDGColumn, AXDGDataItem } from '@axboot/datagrid';
import './editingExamples.css';
export interface EditingOrder {
id: string;
orderCode: string;
customerCode: string;
customerName: string;
customerGrade: '일반' | '우수' | 'VIP';
status: '접수' | '진행' | '완료';
deliveryDate: string;
quantity: number;
unitPrice: number;
amount: number;
note: string;
mergeGroup: string;
}
export const editingOrders: AXDGDataItem<EditingOrder>[] = [
{
values: {
id: 'ORDER-001',
orderCode: 'ORD-2601',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '접수',
deliveryDate: '2026-08-25',
quantity: 2,
unitPrice: 12000,
amount: 24000,
note: '오전 배송',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-002',
orderCode: 'ORD-2602',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '진행',
deliveryDate: '2026-08-26',
quantity: 3,
unitPrice: 18000,
amount: 54000,
note: '담당자 확인',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-003',
orderCode: 'ORD-2603',
customerCode: 'C002',
customerName: '한빛물산',
customerGrade: '우수',
status: '완료',
deliveryDate: '2026-08-28',
quantity: 1,
unitPrice: 32000,
amount: 32000,
note: '',
mergeGroup: 'B',
},
},
{
values: {
id: 'ORDER-004',
orderCode: 'ORD-2604',
customerCode: 'C003',
customerName: 'Northwind',
customerGrade: '일반',
status: '접수',
deliveryDate: '2026-09-01',
quantity: 5,
unitPrice: 9000,
amount: 45000,
note: '영문 송장',
mergeGroup: 'C',
},
},
];
export const cloneEditingOrders = () =>
editingOrders.map(item => ({
...item,
values: { ...item.values },
editedColumnIds: item.editedColumnIds ? [...item.editedColumnIds] : undefined,
changedKeys: item.changedKeys ? [...item.changedKeys] : undefined,
}));
export const applyEditingDataChange = <T,>(
current: AXDGDataItem<T>[],
sourceIndex: number,
values: T,
meta?: AXDGChangeDataMeta<T>,
): AXDGDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: AXDGColumn<T>[]): AXDGColumn<T>[] =>
columns.map(column => ({
...column,
className: [
column.className,
column.editable === false ? 'editing-example-cell-readonly' : 'editing-example-cell-editable',
]
.filter(Boolean)
.join(' '),
}));.editing-example-cell-editable {
--editing-example-bg: #ffffff;
--editing-example-hover-bg: #dbeafe;
}
.editing-example-cell-readonly {
--editing-example-color: #525252;
--editing-example-bg: #f5f5f5;
--editing-example-hover-bg: #e5e5e5;
}
.axdg-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.axdg-body-table tr.axdg-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
background-color: var(--editing-example-hover-bg);
}셀 편집은 Grid의 editable과 대상 컬럼의 editable을 모두 켠 뒤 column.editor를 지정하면 시작할 수 있습니다. 이 페이지에서 마우스 진입 조건부터 키보드·IME·저장과 이동까지 기본 흐름을 함께 다루며, Select·lookup·이벤트 확장은 독립 가이드로 이어집니다.
1. 최소 설정
const columns: AXDGColumn<Order>[] = [
{
key: 'customerName',
label: '고객명',
width: 180,
editable: true,
editor: { type: 'text' },
},
];
<AXDataGrid<Order>
width={720}
height={360}
data={data}
columns={columns}
rowKey='id'
editable
/>
itemRender는 평상시 표시를, editor는 편집 상태의 입력 UI를 담당합니다. 표시 형식을 바꾸기 위해 editor를 만들 필요는 없습니다.
2. Grid 기본값과 컬럼 예외
기본 진입 방식은 더블클릭입니다. Grid의 editTrigger로 전체 기본값을 바꾸고, Select처럼 즉시 열려야 하는 컬럼만 다시 지정할 수 있습니다.
<AXDataGrid editTrigger='dblclick' {...props} />
const columns: AXDGColumn<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 동작도 함께 확인해야 합니다.
<AXDataGrid
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,
),
);
}}
실제 행 데이터는 항상 AXDGDataItem<T>.values에 있습니다. meta.dataItem을 저장하면 직접 편집한 컬럼의 editedColumnIds와 값이 변경된 데이터 key의 changedKeys가 함께 유지됩니다. 직접 편집한 셀에는 axdg-cell-edited, 같은 key를 공유하는 모든 셀에는 axdg-cell-value-changed 스타일이 적용됩니다.
7. 다음 가이드 선택
| 하고 싶은 일 | 다음 문서 |
|---|---|
| text, Select, Date 사용 | 내장·기본 제공 에디터 |
| Ant Design 등 외부 UI 연결 | 외부 에디터 플러그인 |
| 평상시 셀에 화살표·검색 아이콘 표시 | 에디터 아이콘 |
| autocomplete 입력과 lookup 모달 함께 사용 | Lookup 에디터 |
| 연관 셀 변경과 검증 | 편집 이벤트와 트랜잭션 |
| 병합 셀과 frozen 경계 편집 | 병합 셀 편집 |