External Editor Plugins
Connect Ant Design or application-specific inputs as editor plugins and manage popup portals, multi-value commits, and the editor lifecycle.
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
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 = (): BGridDataItem<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<BGridColumn<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='bgrid-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 }}>
<BGrid<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.
*
* BGrid'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 > .bgrid-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 { BGridChangeDataMeta, BGridColumn, BGridDataItem } from 'beautiful-grid';
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: BGridDataItem<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: BGridDataItem<T>[],
sourceIndex: number,
values: T,
meta?: BGridChangeDataMeta<T>,
): BGridDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: BGridColumn<T>[]): BGridColumn<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;
}
.bgrid-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.bgrid-cell-selected, .bgrid-cell-edited, .bgrid-cell-value-changed, .bgrid-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.bgrid-body-table tr.bgrid-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.bgrid-cell-selected, .bgrid-cell-edited, .bgrid-cell-value-changed, .bgrid-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>
);
}.bgrid-antd-select-editor.ant-select,
.bgrid-antd-cascader-editor.ant-select,
.bgrid-antd-tree-select-editor.ant-select,
.bgrid-antd-date-editor.ant-picker,
.bgrid-antd-time-editor.ant-picker,
.bgrid-antd-color-editor {
color: inherit;
font: inherit;
}
.bgrid-antd-select-editor.ant-select .ant-select-selector,
.bgrid-antd-date-editor.ant-picker,
.bgrid-antd-time-editor.ant-picker {
padding: 0 6px;
border-radius: 0;
color: inherit;
font: inherit;
}
.bgrid-antd-cascader-editor.ant-select .ant-select-selector,
.bgrid-antd-tree-select-editor.ant-select .ant-select-selector {
padding: 0 5.5px !important;
border-radius: 0;
color: inherit;
font: inherit;
}
.bgrid-antd-cascader-editor.ant-select .ant-select-selection-search,
.bgrid-antd-tree-select-editor.ant-select .ant-select-selection-search {
inset-inline-start: 0;
inset-inline-end: 0;
}
.bgrid-antd-select-editor.ant-select .ant-select-selection-item,
.bgrid-antd-select-editor.ant-select .ant-select-selection-placeholder,
.bgrid-antd-select-editor.ant-select .ant-select-selection-search-input,
.bgrid-antd-cascader-editor.ant-select .ant-select-selection-item,
.bgrid-antd-cascader-editor.ant-select .ant-select-selection-search-input,
.bgrid-antd-tree-select-editor.ant-select .ant-select-selection-item,
.bgrid-antd-tree-select-editor.ant-select .ant-select-selection-search-input,
.bgrid-antd-date-editor.ant-picker .ant-picker-input > input,
.bgrid-antd-time-editor.ant-picker .ant-picker-input > input {
color: inherit !important;
font: inherit !important;
}
.bgrid-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;
}
.bgrid-antd-color-value {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.bgrid-antd-color-editor .bgrid-color-swatch {
margin-left: auto;
}
.bgrid-color-swatch {
width: 14px;
height: 14px;
border: 1px solid rgb(0 0 0 / 18%);
border-radius: 3px;
flex: 0 0 14px;
}
.bgrid-antd-editor-popup,
.bgrid-antd-editor-popup .ant-select-item,
.bgrid-antd-editor-popup .ant-cascader-menu,
.bgrid-antd-editor-popup .ant-cascader-menu-item,
.bgrid-antd-editor-popup .ant-select-tree,
.bgrid-antd-editor-popup .ant-select-tree-node-content-wrapper,
.bgrid-antd-editor-popup .ant-picker-content,
.bgrid-antd-editor-popup .ant-picker-time-panel,
.bgrid-antd-editor-popup .ant-color-picker-inner-content {
font-family: var(--bgrid-font-family, inherit);
font-size: var(--bgrid-font-size, 13px);
}import * as React from 'react';
import { Cascader } from 'antd';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/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): BGridPluginEditorConfig<T> {
function AntdCascaderEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const initialValue = Array.isArray(value) ? value.map(String) : [];
return (
<Cascader<AntdCascaderOption>
aria-label={options.ariaLabel}
autoFocus
className='bgrid-antd-cascader-editor'
classNames={{ popup: { root: 'bgrid-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 { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import './antdEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
fallbackColor?: string;
}
export function createAntdColorPickerEditorPlugin<T>(options: Options): BGridPluginEditorConfig<T> {
function AntdColorPickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<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='bgrid-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='bgrid-antd-color-editor'
aria-label={options.ariaLabel}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className='bgrid-antd-color-value'>{color.toUpperCase()}</span>
<span className='bgrid-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 { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import './antdEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
format?: string;
min?: string;
max?: string;
}
export function createAntdDatePickerEditorPlugin<T>(options: Options): BGridPluginEditorConfig<T> {
const minDate = options.min ? dayjs(options.min) : undefined;
const maxDate = options.max ? dayjs(options.max) : undefined;
function AntdDatePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<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='bgrid-antd-date-editor'
classNames={{ popup: { root: 'bgrid-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 { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/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>,
): BGridPluginEditorConfig<T> {
function AntdSelectEditor({ value, column, commit, cancel, getPortalContainer }: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
return (
<Select
aria-label={options.ariaLabel}
autoFocus
className='bgrid-antd-select-editor'
classNames={{ popup: { root: 'bgrid-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 { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import './antdEditorPlugins.css';
dayjs.extend(customParseFormat);
interface Options {
id: string;
ariaLabel: string;
format?: string;
}
export function createAntdTimePickerEditorPlugin<T>(options: Options): BGridPluginEditorConfig<T> {
function AntdTimePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<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='bgrid-antd-time-editor'
classNames={{ popup: { root: 'bgrid-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 { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/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): BGridPluginEditorConfig<T> {
function AntdTreeSelectEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
return (
<TreeSelect<string, AntdTreeSelectNode>
aria-label={options.ariaLabel}
autoFocus
className='bgrid-antd-tree-select-editor'
classNames={{ popup: { root: 'bgrid-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 { BGridPluginEditorConfig } from '../types';
export function defineEditorPlugin<T>(config: Omit<BGridPluginEditorConfig<T>, 'type'>): BGridPluginEditorConfig<T> {
return {
type: 'plugin',
...config,
};
}Connect UI components already used by your application—such as Ant Design Select, DatePicker, ColorPicker, Cascader, TimePicker, and TreeSelect, or an asynchronous autocomplete—with defineEditorPlugin(). If you only need text, basic Select, or Date editing, start with Built-in Editors.
Plugin definition
function PriorityEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<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,
});
Pass even a single value to commit as a change array of length 1. A cell value can itself be an array, so the API deliberately avoids an ambiguous commit(value) form.
Connect DatePicker and ColorPicker
Convert dates to your application’s storage format before committing them. For example, if you store a dayjs value as a YYYY-MM-DD string, connect the picker as follows.
<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();
}}
/>
With ColorPicker, use onChange only to preview the value while dragging, then save the final color from onChangeComplete when the interaction ends.
<ColorPicker
open
defaultValue={String(value)}
disabledAlpha
getPopupContainer={getPortalContainer}
onChange={(_color, css) => setPreviewColor(css)}
onChangeComplete={color =>
void commit([{
key: column.key,
value: color.toHexString().toUpperCase(),
}])
}
/>
Connect Cascader, TimePicker, and TreeSelect
Cascader commits the entire selected path as string[], not just the last item. TimePicker uses needConfirm so editing does not end while the user is choosing an hour and minute; convert the value to your application’s storage format in onOk. TreeSelect stores the selected node’s value directly.
<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,
}])
}
/>
All six adapters in the live example inherit the cell’s font, color, and height. If an external UI library specifies its own font size, apply font: inherit to the editor root and selected-value element. Also pass --bgrid-font-family and --bgrid-font-size to the popup so the cell remains visually consistent before and after activation.
Save multiple columns at once
When an autocomplete resolves both a code and a name, send both changes in one request.
await commit([
{ key: 'customerCode', value: selected.code },
{ key: 'customerName', value: selected.name },
]);
If a target key or columnId is missing or ambiguous, the entire commit is rejected without a partial save.
Plugin props
value,item,values,column,index,columnIndex: context for the current logical cellcommit(changes, options?): save the change list and end the sessioncancel(): keep the original value and end the sessionmove(direction): move to the specified cell without savingsessionId: identifies the session associated with an asynchronous callbackgetPortalContainer(): returns the Grid-specific floating portal root for popup UI
Popup and session-ending rules
Rendering a popup directly in the UI library’s default document.body portal can make the Grid treat popup interaction as an outside click. Rendering it inside the Grid DOM can instead clip a large picker at the container’s overflow: hidden boundary. getPortalContainer() returns a Grid-tracked floating portal directly under document.body, so connect it whenever the external component supports a custom portal. This portal copies the Grid theme variables and participates in frozen/scroll position calculations and outside-click detection.
Use only one of commit, cancel, or move as the final action for a session. The library honors only the first completion request, so a cancel() triggered by blur immediately after selection cannot overwrite a successful save. If asynchronous validation fails and the commit() Promise rejects, the editor remains open so the user can correct the value and try again.
await commit(changes, { move: 'next' });
Do not move DOM focus manually after saving or canceling. The Grid restores focus to the active cell.