외부 에디터 플러그인 (Editor Plugins)
Ant Design과 앱 전용 입력 컴포넌트를 plugin으로 연결하고 popup portal, 다중 변경 commit, 종료 수명주기를 관리하는 방법을 설명합니다.
import * as React from 'react';
import { AXDataGrid, type AXDGColumn, type AXDGDataItem } from '@axboot/datagrid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { createAntdCascaderEditorPlugin } from './editor-plugins/createAntdCascaderEditorPlugin';
import { createAntdColorPickerEditorPlugin } from './editor-plugins/createAntdColorPickerEditorPlugin';
import { createAntdDatePickerEditorPlugin } from './editor-plugins/createAntdDatePickerEditorPlugin';
import { createAntdSelectEditorPlugin } from './editor-plugins/createAntdSelectEditorPlugin';
import { createAntdTimePickerEditorPlugin } from './editor-plugins/createAntdTimePickerEditorPlugin';
import { createAntdTreeSelectEditorPlugin } from './editor-plugins/createAntdTreeSelectEditorPlugin';
import { CalendarIcon, ChevronDownIcon, ClockIcon } from './editing/editorIcons';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
type ExternalEditorOrder = EditingOrder & {
labelColor: string;
categoryPath: string[];
deliveryTime: string;
organization: string;
};
const antdStatusEditor = createAntdSelectEditorPlugin<ExternalEditorOrder, EditingOrder['status']>({
id: 'external-antd-status',
ariaLabel: 'Ant Design 주문 상태 선택',
options: [
{ value: '접수', label: '접수' },
{ value: '진행', label: '진행' },
{ value: '완료', label: '완료' },
],
});
const antdDeliveryDateEditor = createAntdDatePickerEditorPlugin<ExternalEditorOrder>({
id: 'external-antd-delivery-date',
ariaLabel: 'Ant Design 납기일 선택',
});
const antdLabelColorEditor = createAntdColorPickerEditorPlugin<ExternalEditorOrder>({
id: 'external-antd-label-color',
ariaLabel: 'Ant Design 라벨 색상 선택',
});
const antdCategoryEditor = createAntdCascaderEditorPlugin<ExternalEditorOrder>({
id: 'external-antd-category',
ariaLabel: 'Ant Design 분류 경로 선택',
options: [
{
value: '국내',
label: '국내',
children: [
{ value: '서울', label: '서울' },
{ value: '부산', label: '부산' },
],
},
{
value: '해외',
label: '해외',
children: [
{ value: '아시아', label: '아시아' },
{ value: '유럽', label: '유럽' },
],
},
],
});
const antdDeliveryTimeEditor = createAntdTimePickerEditorPlugin<ExternalEditorOrder>({
id: 'external-antd-delivery-time',
ariaLabel: 'Ant Design 배송 시간 선택',
});
const antdOrganizationEditor = createAntdTreeSelectEditorPlugin<ExternalEditorOrder>({
id: 'external-antd-organization',
ariaLabel: 'Ant Design 담당 조직 선택',
treeData: [
{
value: '영업본부',
title: '영업본부',
children: [
{ value: '서울 영업팀', title: '서울 영업팀' },
{ value: '부산 영업팀', title: '부산 영업팀' },
],
},
{
value: '운영본부',
title: '운영본부',
children: [
{ value: '물류팀', title: '물류팀' },
{ value: '고객지원팀', title: '고객지원팀' },
],
},
],
});
const initialColors = ['#1677FF', '#13C2C2', '#52C41A', '#FA8C16'];
const initialCategoryPaths = [
['국내', '서울'],
['국내', '부산'],
['해외', '아시아'],
['해외', '유럽'],
];
const initialDeliveryTimes = ['09:30', '11:00', '14:30', '16:00'];
const initialOrganizations = ['서울 영업팀', '부산 영업팀', '물류팀', '고객지원팀'];
const cloneExternalEditorOrders = (): AXDGDataItem<ExternalEditorOrder>[] =>
cloneEditingOrders().map((item, index) => ({
...item,
values: {
...item.values,
labelColor: initialColors[index],
categoryPath: initialCategoryPaths[index],
deliveryTime: initialDeliveryTimes[index],
organization: initialOrganizations[index],
},
}));
export default function ExternalEditorPluginExample() {
const [data, setData] = React.useState(cloneExternalEditorOrders);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<AXDGColumn<ExternalEditorOrder>[]>(
() => withEditingCellClasses<ExternalEditorOrder>([
{ key: 'orderCode', label: '주문 코드', width: 140, editable: false },
{ key: 'customerName', label: '고객명', width: 160, editable: false },
{
key: 'status',
label: 'Ant Design Select',
width: 180,
editable: true,
editor: antdStatusEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: 'Ant Design 상태 선택' },
},
{
key: 'deliveryDate',
label: 'Ant Design DatePicker',
width: 200,
editable: true,
editor: antdDeliveryDateEditor,
editorIcon: { render: <CalendarIcon />, ariaLabel: 'Ant Design 납기일 선택' },
},
{
key: 'labelColor',
label: 'Ant Design ColorPicker',
width: 210,
editable: true,
editor: antdLabelColorEditor,
itemRender: ({ value }) => <>{String(value ?? '')}</>,
editorIcon: {
render: ({ value }) => (
<span
className='axdg-color-swatch'
style={{ backgroundColor: typeof value === 'string' ? value : 'transparent' }}
aria-hidden='true'
/>
),
ariaLabel: 'Ant Design 라벨 색상 선택',
},
},
{
key: 'categoryPath',
label: 'Ant Design Cascader',
width: 200,
editable: true,
editor: antdCategoryEditor,
itemRender: ({ value }) => <>{Array.isArray(value) ? value.join(' / ') : ''}</>,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: 'Ant Design 분류 경로 선택' },
},
{
key: 'deliveryTime',
label: 'Ant Design TimePicker',
width: 190,
editable: true,
editor: antdDeliveryTimeEditor,
editorIcon: { render: <ClockIcon />, ariaLabel: 'Ant Design 배송 시간 선택' },
},
{
key: 'organization',
label: 'Ant Design TreeSelect',
width: 210,
editable: true,
editor: antdOrganizationEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: 'Ant Design 담당 조직 선택' },
},
]),
[],
);
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'>
Ant Design Select, DatePicker, ColorPicker, Cascader, TimePicker, TreeSelect를{' '}
<code>defineEditorPlugin()</code>으로 연결했습니다. 셀을 더블클릭하거나 각 아이콘과 ColorPicker 색상 박스를 클릭해
편집을 시작합니다. popup은 plugin의 <code>getPortalContainer()</code>에 렌더링하고 값 선택 시{' '}
<code>commit(changes[])</code>을 호출합니다.
</p>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<AXDataGrid<ExternalEditorOrder>
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>
);
}.axdg-antd-select-editor.ant-select,
.axdg-antd-cascader-editor.ant-select,
.axdg-antd-tree-select-editor.ant-select,
.axdg-antd-date-editor.ant-picker,
.axdg-antd-time-editor.ant-picker,
.axdg-antd-color-editor {
color: inherit;
font: inherit;
}
.axdg-antd-select-editor.ant-select .ant-select-selector,
.axdg-antd-date-editor.ant-picker,
.axdg-antd-time-editor.ant-picker {
padding: 0 6px;
border-radius: 0;
color: inherit;
font: inherit;
}
.axdg-antd-cascader-editor.ant-select .ant-select-selector,
.axdg-antd-tree-select-editor.ant-select .ant-select-selector {
padding: 0 5.5px !important;
border-radius: 0;
color: inherit;
font: inherit;
}
.axdg-antd-cascader-editor.ant-select .ant-select-selection-search,
.axdg-antd-tree-select-editor.ant-select .ant-select-selection-search {
inset-inline-start: 0;
inset-inline-end: 0;
}
.axdg-antd-select-editor.ant-select .ant-select-selection-item,
.axdg-antd-select-editor.ant-select .ant-select-selection-placeholder,
.axdg-antd-select-editor.ant-select .ant-select-selection-search-input,
.axdg-antd-cascader-editor.ant-select .ant-select-selection-item,
.axdg-antd-cascader-editor.ant-select .ant-select-selection-search-input,
.axdg-antd-tree-select-editor.ant-select .ant-select-selection-item,
.axdg-antd-tree-select-editor.ant-select .ant-select-selection-search-input,
.axdg-antd-date-editor.ant-picker .ant-picker-input > input,
.axdg-antd-time-editor.ant-picker .ant-picker-input > input {
color: inherit !important;
font: inherit !important;
}
.axdg-antd-color-editor {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 100%;
margin: 0;
padding: 0 9.5px 0 6.5px;
border: 0;
border-radius: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.axdg-antd-color-value {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.axdg-antd-color-editor .axdg-color-swatch {
margin-left: auto;
}
.axdg-color-swatch {
width: 14px;
height: 14px;
border: 1px solid rgb(0 0 0 / 18%);
border-radius: 3px;
flex: 0 0 14px;
}
.axdg-antd-editor-popup,
.axdg-antd-editor-popup .ant-select-item,
.axdg-antd-editor-popup .ant-cascader-menu,
.axdg-antd-editor-popup .ant-cascader-menu-item,
.axdg-antd-editor-popup .ant-select-tree,
.axdg-antd-editor-popup .ant-select-tree-node-content-wrapper,
.axdg-antd-editor-popup .ant-picker-content,
.axdg-antd-editor-popup .ant-picker-time-panel,
.axdg-antd-editor-popup .ant-color-picker-inner-content {
font-family: var(--axdg-font-family, inherit);
font-size: var(--axdg-font-size, 13px);
}import * as React from 'react';
import { Cascader } from 'antd';
import type { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import './antdEditorPlugins.css';
export interface AntdCascaderOption {
value: string;
label: React.ReactNode;
children?: AntdCascaderOption[];
}
interface Options {
id: string;
ariaLabel: string;
options: AntdCascaderOption[];
}
export function createAntdCascaderEditorPlugin<T>(options: Options): AXDGPluginEditorConfig<T> {
function AntdCascaderEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: AXDGEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const initialValue = Array.isArray(value) ? value.map(String) : [];
return (
<Cascader<AntdCascaderOption>
aria-label={options.ariaLabel}
autoFocus
className='axdg-antd-cascader-editor'
classNames={{ popup: { root: 'axdg-antd-editor-popup' } }}
defaultValue={initialValue}
getPopupContainer={getPortalContainer}
open={open}
options={options.options}
size='small'
variant='borderless'
onChange={nextValue =>
void commit([{ key: column.key, value: Array.from(nextValue, String) }])
}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
/>
);
}
AntdCascaderEditor.displayName = `AntdCascaderEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: AntdCascaderEditor,
});
}import * as React from 'react';
import { ColorPicker } from 'antd';
import type { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import './antdEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
fallbackColor?: string;
}
export function createAntdColorPickerEditorPlugin<T>(options: Options): AXDGPluginEditorConfig<T> {
function AntdColorPickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: AXDGEditorPluginProps<T>) {
const initialColor = typeof value === 'string' && value ? value : options.fallbackColor ?? '#1677ff';
const [color, setColor] = React.useState(initialColor);
const [open, setOpen] = React.useState(true);
return (
<ColorPicker
aria-label={options.ariaLabel}
defaultValue={initialColor}
disabledAlpha
format='hex'
getPopupContainer={getPortalContainer}
open={open}
rootClassName='axdg-antd-editor-popup'
onChange={(nextColor, cssColor) => setColor(cssColor || nextColor.toHexString())}
onChangeComplete={nextColor =>
void commit([{ key: column.key, value: nextColor.toHexString().toUpperCase() }])
}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
>
<button
type='button'
autoFocus
className='axdg-antd-color-editor'
aria-label={options.ariaLabel}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className='axdg-antd-color-value'>{color.toUpperCase()}</span>
<span className='axdg-color-swatch' style={{ backgroundColor: color }} aria-hidden='true' />
</button>
</ColorPicker>
);
}
AntdColorPickerEditor.displayName = `AntdColorPickerEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: AntdColorPickerEditor,
});
}import * as React from 'react';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import './antdEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
format?: string;
min?: string;
max?: string;
}
export function createAntdDatePickerEditorPlugin<T>(options: Options): AXDGPluginEditorConfig<T> {
const minDate = options.min ? dayjs(options.min) : undefined;
const maxDate = options.max ? dayjs(options.max) : undefined;
function AntdDatePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: AXDGEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const format = options.format ?? 'YYYY-MM-DD';
const initialValue = typeof value === 'string' && value ? dayjs(value) : null;
return (
<DatePicker
aria-label={options.ariaLabel}
autoFocus
className='axdg-antd-date-editor'
classNames={{ popup: { root: 'axdg-antd-editor-popup' } }}
open={open}
size='small'
variant='borderless'
defaultValue={initialValue}
format={format}
getPopupContainer={getPortalContainer}
maxDate={maxDate}
minDate={minDate}
onChange={nextValue =>
void commit([{ key: column.key, value: nextValue ? nextValue.format(format) : '' }])
}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
/>
);
}
AntdDatePickerEditor.displayName = `AntdDatePickerEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: AntdDatePickerEditor,
});
}import * as React from 'react';
import { Select } from 'antd';
import type { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import './antdEditorPlugins.css';
interface Option<Value extends string | number> {
value: Value;
label: React.ReactNode;
}
interface Options<Value extends string | number> {
id: string;
ariaLabel: string;
options: Option<Value>[];
}
export function createAntdSelectEditorPlugin<T, Value extends string | number>(
options: Options<Value>,
): AXDGPluginEditorConfig<T> {
function AntdSelectEditor({ value, column, commit, cancel, getPortalContainer }: AXDGEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
return (
<Select
aria-label={options.ariaLabel}
autoFocus
className='axdg-antd-select-editor'
classNames={{ popup: { root: 'axdg-antd-editor-popup' } }}
open={open}
size='small'
variant='borderless'
defaultValue={value as Value}
options={options.options}
getPopupContainer={getPortalContainer}
onChange={nextValue => void commit([{ key: column.key, value: nextValue }])}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
/>
);
}
AntdSelectEditor.displayName = `AntdSelectEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: AntdSelectEditor,
});
}import * as React from 'react';
import { TimePicker } from 'antd';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import type { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import './antdEditorPlugins.css';
dayjs.extend(customParseFormat);
interface Options {
id: string;
ariaLabel: string;
format?: string;
}
export function createAntdTimePickerEditorPlugin<T>(options: Options): AXDGPluginEditorConfig<T> {
function AntdTimePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: AXDGEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const format = options.format ?? 'HH:mm';
const initialValue = typeof value === 'string' && value ? dayjs(value, format) : null;
return (
<TimePicker
aria-label={options.ariaLabel}
autoFocus
className='axdg-antd-time-editor'
classNames={{ popup: { root: 'axdg-antd-editor-popup' } }}
defaultValue={initialValue}
format={format}
getPopupContainer={getPortalContainer}
needConfirm
open={open}
size='small'
variant='borderless'
onOk={nextValue =>
void commit([{ key: column.key, value: nextValue ? nextValue.format(format) : '' }])
}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
/>
);
}
AntdTimePickerEditor.displayName = `AntdTimePickerEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: AntdTimePickerEditor,
});
}import * as React from 'react';
import { TreeSelect } from 'antd';
import type { AXDGEditorPluginProps, AXDGPluginEditorConfig } from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import './antdEditorPlugins.css';
export interface AntdTreeSelectNode {
value: string;
title: React.ReactNode;
children?: AntdTreeSelectNode[];
}
interface Options {
id: string;
ariaLabel: string;
treeData: AntdTreeSelectNode[];
}
export function createAntdTreeSelectEditorPlugin<T>(options: Options): AXDGPluginEditorConfig<T> {
function AntdTreeSelectEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: AXDGEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
return (
<TreeSelect<string, AntdTreeSelectNode>
aria-label={options.ariaLabel}
autoFocus
className='axdg-antd-tree-select-editor'
classNames={{ popup: { root: 'axdg-antd-editor-popup' } }}
defaultValue={typeof value === 'string' ? value : undefined}
getPopupContainer={getPortalContainer}
open={open}
size='small'
treeData={options.treeData}
treeDefaultExpandAll
variant='borderless'
onChange={nextValue => void commit([{ key: column.key, value: nextValue }])}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
/>
);
}
AntdTreeSelectEditor.displayName = `AntdTreeSelectEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: AntdTreeSelectEditor,
});
}import { AXDGPluginEditorConfig } from '../types';
export function defineEditorPlugin<T>(config: Omit<AXDGPluginEditorConfig<T>, 'type'>): AXDGPluginEditorConfig<T> {
return {
type: 'plugin',
...config,
};
}Ant Design Select·DatePicker·ColorPicker·Cascader·TimePicker·TreeSelect, 비동기 자동완성처럼 앱이 이미 사용하는 UI 컴포넌트는 defineEditorPlugin()으로 연결합니다. text·기본 Select·Date만 필요하다면 내장·기본 제공 에디터를 먼저 확인하세요.
Plugin 정의
function PriorityEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: AXDGEditorPluginProps<Task>) {
return (
<Select
autoFocus
open
defaultValue={value as Task['priority']}
getPopupContainer={getPortalContainer}
options={priorityOptions}
onChange={nextValue =>
void commit([{ key: column.key, value: nextValue }])
}
onKeyDown={event => {
if (event.key === 'Escape') cancel();
}}
/>
);
}
const priorityEditor = defineEditorPlugin<Task>({
id: 'task-priority',
component: PriorityEditor,
});
commit은 단일 값도 항상 길이 1의 변경 배열로 받습니다. 셀 값 자체가 배열일 수 있으므로 commit(value) 형태와 혼용하지 않습니다.
DatePicker와 ColorPicker 연결
날짜는 앱의 저장 형식으로 변환한 뒤 commit합니다. 예를 들어 dayjs 값을 YYYY-MM-DD 문자열로 보관한다면 다음처럼 연결합니다.
<DatePicker
autoFocus
open
defaultValue={value ? dayjs(String(value)) : null}
getPopupContainer={getPortalContainer}
onChange={date =>
void commit([{
key: column.key,
value: date ? date.format('YYYY-MM-DD') : '',
}])
}
onOpenChange={open => {
if (!open) cancel();
}}
/>
ColorPicker는 드래그 중인 onChange 값은 미리보기에만 사용하고, 조작이 끝나는 onChangeComplete에서 최종 색상을 저장할 수 있습니다.
<ColorPicker
open
defaultValue={String(value)}
disabledAlpha
getPopupContainer={getPortalContainer}
onChange={(_color, css) => setPreviewColor(css)}
onChangeComplete={color =>
void commit([{
key: column.key,
value: color.toHexString().toUpperCase(),
}])
}
/>
Cascader, TimePicker, TreeSelect 연결
Cascader는 마지막 항목만 저장하지 않고 선택된 전체 경로를 string[]로 commit합니다. TimePicker는 시·분을 고르는 중에 편집이 끝나지 않도록 needConfirm을 사용하고, 확인 버튼을 누른 onOk 시점에 앱의 저장 형식으로 변환합니다. TreeSelect는 선택한 노드의 value를 그대로 저장합니다.
<Cascader
open
defaultValue={value as string[]}
options={categoryOptions}
getPopupContainer={getPortalContainer}
onChange={path =>
void commit([{
key: column.key,
value: Array.from(path, String),
}])
}
/>
<TimePicker
open
needConfirm
defaultValue={dayjs(String(value), 'HH:mm')}
format='HH:mm'
getPopupContainer={getPortalContainer}
onOk={time =>
void commit([{
key: column.key,
value: time ? time.format('HH:mm') : '',
}])
}
/>
<TreeSelect
open
defaultValue={String(value)}
treeData={organizationTree}
getPopupContainer={getPortalContainer}
onChange={nodeValue =>
void commit([{
key: column.key,
value: nodeValue,
}])
}
/>
라이브 예제의 여섯 어댑터는 셀의 font, color, 높이를 상속합니다. 외부 UI 라이브러리가 자체 글꼴 크기를 지정한다면 editor root와 선택 값 요소에 font: inherit을 적용하고, popup에도 --axdg-font-family와 --axdg-font-size를 전달하면 활성화 전후의 셀 스타일이 일관됩니다.
여러 컬럼을 한 번에 저장
자동완성에서 코드와 이름이 함께 결정되면 한 요청에 모두 전달합니다.
await commit([
{ key: 'customerCode', value: selected.code },
{ key: 'customerName', value: selected.name },
]);
대상 key 또는 columnId를 찾을 수 없거나 모호하면 부분 저장 없이 전체 commit이 거부됩니다.
Plugin props
value,item,values,column,index,columnIndex: 현재 논리 셀 문맥commit(changes, options?): 변경 목록을 저장하고 세션 종료cancel(): 원래 값을 유지하고 세션 종료move(direction): 저장하지 않고 지정 셀로 이동sessionId: 비동기 callback이 속한 세션 식별자getPortalContainer(): popup UI를 연결할 Grid 전용 floating portal root
Popup과 종료 규칙
popup을 UI 라이브러리의 기본 document.body에 직접 렌더링하면 Grid 바깥 클릭으로 오인될 수 있습니다. 반대로 Grid DOM 내부에 렌더링하면 컨테이너의 overflow: hidden 경계에서 큰 picker가 잘립니다. getPortalContainer()는 Grid가 추적하는 document.body 직속 floating portal을 반환하므로, 외부 컴포넌트가 portal을 지원하면 반드시 이 함수를 연결하세요. 이 portal은 Grid 테마 변수를 복사하며 frozen·스크롤 위치 계산과 바깥 클릭 판정에도 포함됩니다.
한 세션에서는 commit, cancel, move 중 하나만 최종 동작으로 사용합니다. 라이브러리도 첫 번째 완료 요청만 반영하므로 선택 직후 발생한 blur의 cancel()이 저장 결과를 덮어쓰지 않습니다. 비동기 검증이 실패해 commit() Promise가 reject되면 editor는 유지되며 사용자가 수정 후 다시 시도할 수 있습니다.
await commit(changes, { move: 'next' });
저장 또는 취소 뒤 DOM 포커스를 직접 옮기지 마세요. Grid가 활성 셀로 포커스를 복원합니다.