내장·기본 제공 에디터 (Built-in Editors)
내장 text와 기본 제공 Select·Date plugin의 설정, 값 변환, 아이콘 연결 방법을 설명합니다.
import * as React from 'react';
import { AXDataGrid, type AXDGColumn } from '@axboot/datagrid';
import { createDateEditorPlugin, createSelectEditorPlugin } from '@axboot/datagrid/editors';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { CalendarIcon, ChevronDownIcon } from './editing/editorIcons';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
function formatDate(value: unknown) {
if (typeof value !== 'string') return '';
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
return match ? `${match[1]}.${match[2]}.${match[3]}` : value;
}
const statusEditor = createSelectEditorPlugin<EditingOrder, EditingOrder['status']>({
id: 'built-in-status',
ariaLabel: '주문 상태 선택',
options: [
{ value: '접수', label: '접수' },
{ value: '진행', label: '진행' },
{ value: '완료', label: '완료' },
],
});
const deliveryDateEditor = createDateEditorPlugin<EditingOrder>({
id: 'built-in-delivery-date',
ariaLabel: '납기일 선택',
min: '2026-08-01',
max: '2026-12-31',
});
export default function BuiltInEditorsExample() {
const [data, setData] = React.useState(cloneEditingOrders);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<AXDGColumn<EditingOrder>[]>(
() => withEditingCellClasses<EditingOrder>([
{ key: 'orderCode', label: '주문 코드', width: 145, editable: false },
{
key: 'customerName',
label: '내장 text',
width: 180,
editable: true,
editor: {
type: 'text',
inputProps: { maxLength: 50, autoComplete: 'off' },
},
},
{
key: 'status',
label: '기본 Select',
width: 150,
editable: true,
editTrigger: 'click',
editor: statusEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: '주문 상태 선택', visibility: 'always' },
},
{
key: 'deliveryDate',
label: '기본 Date',
width: 170,
editable: true,
editTrigger: 'click',
editor: deliveryDateEditor,
itemRender: ({ value }) => formatDate(value),
editorIcon: { render: <CalendarIcon />, ariaLabel: '납기일 선택', visibility: 'always' },
},
]),
[],
);
return (
<div className='flex min-h-0 flex-col gap-3'>
<p className='m-0 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
text 입력은 라이브러리 내장 편집기이며 Select와 Date는 <code>@axboot/datagrid/editors</code>가 제공하는 의존성
없는 plugin입니다. 화살표와 달력 아이콘을 누르거나 셀을 한 번 클릭해 선택하세요.
</p>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<AXDataGrid<EditingOrder>
width={width}
height={height}
data={data}
columns={columns}
rowKey='id'
editable
variant='vertical-bordered'
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);
}import * as React from 'react';
const iconProps = {
width: 14,
height: 14,
viewBox: '0 0 16 16',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
focusable: false,
'aria-hidden': true,
};
export function ChevronDownIcon() {
return (
<svg {...iconProps}>
<path d='m4 6 4 4 4-4' />
</svg>
);
}
export function CalendarIcon() {
return (
<svg {...iconProps}>
<rect x='2.5' y='3.5' width='11' height='10' rx='1.5' />
<path d='M5 2.5v2M11 2.5v2M2.5 6.5h11' />
</svg>
);
}
export function ClockIcon() {
return (
<svg {...iconProps}>
<circle cx='8' cy='8' r='5.5' />
<path d='M8 4.75V8l2.25 1.5' />
</svg>
);
}
export function SearchIcon() {
return (
<svg {...iconProps}>
<circle cx='7' cy='7' r='3.75' />
<path d='m10 10 3 3' />
</svg>
);
}
export function CheckIcon() {
return (
<svg {...iconProps}>
<path d='m3 8.25 3 3L13 4.5' />
</svg>
);
}import * as React from 'react';
import { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '../types';
import { defineEditorPlugin } from './defineEditorPlugin';
import { getColumnId } from '../utils/getColumnId';
export interface AXDGSelectEditorOption<Value extends string | number> {
value: Value;
label: React.ReactNode;
disabled?: boolean;
}
export interface AXDGSelectEditorPluginOptions<Value extends string | number> {
id: string;
options: AXDGSelectEditorOption<Value>[];
ariaLabel?: string;
placeholder?: string;
openOnMount?: boolean;
}
export function createSelectEditorPlugin<T, Value extends string | number = string>(
options: AXDGSelectEditorPluginOptions<Value>,
): AXDGPluginEditorConfig<T> {
function SelectEditor({ value, column, commit, cancel }: AXDGEditorPluginProps<T>) {
const selectedIndex = options.options.findIndex(option => Object.is(option.value, value));
const selectRef = React.useRef<HTMLSelectElement>(null);
const pickerOpenedRef = React.useRef(false);
React.useLayoutEffect(() => {
const select = selectRef.current as (HTMLSelectElement & { showPicker?: () => void }) | null;
if (!select) return;
select.focus({ preventScroll: true });
if (options.openOnMount === false || pickerOpenedRef.current || typeof select.showPicker !== 'function') return;
pickerOpenedRef.current = true;
try {
select.showPicker();
} catch {
// Some browsers require a transient user activation. The focused select remains usable.
}
}, []);
return (
<div className='axdg-native-select-editor-shell'>
<select
ref={selectRef}
className='axdg-native-select-editor'
aria-label={options.ariaLabel ?? '셀 선택 편집'}
defaultValue={selectedIndex >= 0 ? String(selectedIndex) : ''}
onChange={event => {
const option = options.options[Number(event.currentTarget.value)];
if (option) void commit([{ columnId: getColumnId(column), value: option.value }]);
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
return;
}
if (event.key === 'Tab' || event.key === 'Enter') {
event.preventDefault();
if (event.currentTarget.value === '') {
cancel();
return;
}
const option = options.options[Number(event.currentTarget.value)];
if (option) {
void commit([{ columnId: getColumnId(column), value: option.value }], {
move: event.key === 'Tab' ? (event.shiftKey ? 'prev' : 'next') : undefined,
});
}
}
}}
>
{selectedIndex < 0 && (
<option value='' disabled>
{options.placeholder ?? '선택'}
</option>
)}
{options.options.map((option, index) => (
<option key={index} value={String(index)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<span className='axdg-native-select-editor-icon' aria-hidden='true'>
<svg width='14' height='14' viewBox='0 0 16 16' fill='none' focusable='false'>
<path d='m4 6 4 4 4-4' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
</svg>
</span>
</div>
);
}
SelectEditor.displayName = `AXDGSelectEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: SelectEditor,
});
}import * as React from 'react';
import { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '../types';
import { defineEditorPlugin } from './defineEditorPlugin';
import { getColumnId } from '../utils/getColumnId';
export interface AXDGDateEditorPluginOptions {
id: string;
min?: string;
max?: string;
ariaLabel?: string;
}
export function createDateEditorPlugin<T>(options: AXDGDateEditorPluginOptions): AXDGPluginEditorConfig<T> {
function DateEditor({ value, column, activation, commit, cancel }: AXDGEditorPluginProps<T>) {
const dateValue = typeof value === 'string' ? value : '';
const inputRef = React.useRef<HTMLInputElement>(null);
const pickerOpenedRef = React.useRef(false);
React.useLayoutEffect(() => {
const input = inputRef.current as (HTMLInputElement & { showPicker?: () => void }) | null;
if (!input) return;
input.focus({ preventScroll: true });
if (activation !== 'editorIcon' || pickerOpenedRef.current || typeof input.showPicker !== 'function') return;
pickerOpenedRef.current = true;
try {
input.showPicker();
} catch {
// Browsers without transient user activation keep the focused numeric date input usable.
}
}, [activation]);
return (
<div className='axdg-native-date-editor-shell'>
<input
ref={inputRef}
className='axdg-native-date-editor'
type='date'
aria-label={options.ariaLabel ?? '셀 날짜 편집'}
min={options.min}
max={options.max}
defaultValue={dateValue}
onChange={event =>
void commit([{ columnId: getColumnId(column), value: event.currentTarget.value }])
}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
return;
}
if (event.key === 'Tab' || event.key === 'Enter') {
event.preventDefault();
void commit([{ columnId: getColumnId(column), value: event.currentTarget.value }], {
move: event.key === 'Tab' ? (event.shiftKey ? 'prev' : 'next') : undefined,
});
}
}}
/>
<span className='axdg-native-date-editor-icon' aria-hidden='true'>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
focusable='false'
>
<rect x='2.5' y='3.5' width='11' height='10' rx='1.5' />
<path d='M5 2.5v2M11 2.5v2M2.5 6.5h11' />
</svg>
</span>
</div>
);
}
DateEditor.displayName = `AXDGDateEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: DateEditor,
});
}문자열 입력은 내장 text editor를, 정해진 값과 날짜 선택은 @axboot/datagrid/editors의 기본 plugin을 사용합니다. 기본 plugin은 별도 UI 프레임워크 의존성을 추가하지 않습니다.
Text
{
key: 'quantity',
editable: true,
editor: {
type: 'text',
inputProps: { inputMode: 'numeric', autoComplete: 'off' },
formatValue: value => String(value ?? ''),
parseValue: text => {
const value = Number(text);
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
return value;
},
},
}
parseValue가 예외를 던지면 저장하지 않고 editor를 유지하며 aria-invalid="true"가 설정됩니다. commitOnBlur: false이면 외부 포커스 이동 시 저장하지 않고 취소합니다.
Select와 Date
const statusEditor = createSelectEditorPlugin<Order, Order['status']>({
id: 'order-status',
options: [
{ value: 'ready', label: '준비' },
{ value: 'done', label: '완료' },
],
});
const dateEditor = createDateEditorPlugin<Order>({
id: 'delivery-date',
min: '2026-01-01',
max: '2026-12-31',
});
factory는 컴포넌트 바깥이나 useMemo 안에서 한 번만 생성하세요. 컬럼 렌더마다 새 plugin 객체를 만들면 입력 컴포넌트가 다시 마운트될 수 있습니다.
기본 Select는 셀 또는 아이콘 클릭으로 editor가 마운트되면 네이티브 옵션 picker를 즉시 엽니다. 자동 열기를 원하지 않으면 factory에 openOnMount: false를 지정할 수 있습니다.
기본 Date는 셀 본문을 클릭하면 숫자 날짜 입력만 활성화하고, editorIcon을 클릭해서 진입한 경우에만 네이티브 달력 picker를 엽니다. 편집 플러그인은 activation 값('cell' | 'editorIcon')으로 두 진입 경로를 구분할 수 있습니다.
{
key: 'status',
editable: true,
editTrigger: 'click',
editor: statusEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: '상태 선택' },
}
아이콘의 모양은 editor 종류로 자동 추론하지 않습니다. 제품 디자인 시스템에 맞는 아이콘을 editorIcon.render로 명시합니다.