내장·기본 제공 에디터 (Built-in Editors)
내장 text·checkbox와 기본 제공 Select·Date plugin의 설정, 값 변환, 헤더 제어와 아이콘 연결 방법을 설명합니다.
import { t } from './i18n';
import * as React from 'react';
import { BGrid, type BGridColumn } from 'beautiful-grid';
import { createDateEditorPlugin, createSelectEditorPlugin } from 'beautiful-grid/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: t('주문 상태 선택', 'Select Order Status'),
options: [
{ value: t('접수', 'Receipt'), label: t('접수', 'Receipt') },
{ value: t('진행', 'In Progress'), label: t('진행', 'In Progress') },
{ value: t('완료', 'Completed'), label: t('완료', 'Completed') },
],
});
const deliveryDateEditor = createDateEditorPlugin<EditingOrder>({
id: 'built-in-delivery-date',
ariaLabel: t('납기일 선택', 'Select Delivery Date'),
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<BGridColumn<EditingOrder>[]>(
() => withEditingCellClasses<EditingOrder>([
{ key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 145, editable: false },
{
key: 'customerName',
label: t('내장 text', 'Built-in text'),
width: 180,
editable: true,
editor: {
type: 'text',
inputProps: { maxLength: 50, autoComplete: 'off' },
},
},
{
key: 'status',
label: t('기본 Select', 'Default Select'),
width: 150,
editable: true,
editTrigger: 'click',
editor: statusEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: t('주문 상태 선택', 'Select Order Status'), visibility: 'always' },
},
{
key: 'deliveryDate',
label: t('기본 Date', 'Default Date'),
width: 170,
editable: true,
editTrigger: 'click',
editor: deliveryDateEditor,
itemRender: ({ value }) => formatDate(value),
editorIcon: { render: <CalendarIcon />, ariaLabel: t('납기일 선택', 'Select Delivery Date'), visibility: 'always' },
},
{
key: 'approved',
label: t('승인 권한', 'Approval Permission'),
width: 150,
align: 'center',
headerAlign: 'center',
editable: true,
editor: {
type: 'checkbox',
header: { ariaLabel: t('승인 권한 전체 선택', 'Select All Approval Permissions') },
ariaLabel: ({ values }) => `${values.orderCode} ${t('승인 권한', 'Approval Permission')}`,
label: ({ value }) => (value ? t('허용', 'Allowed') : t('차단', 'Blocked')),
},
},
]),
[],
);
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'>
{t('text와 checkbox는 라이브러리 내장 편집기이며 Select와 Date는', 'Text and checkbox are built-in editors, while Select and Date are provided by')} <code>beautiful-grid/editors</code>{t('가 제공하는 의존성 없는 plugin입니다. 승인 권한 헤더의 checkbox로 현재 행을 한 번에 선택하거나 해제할 수 있습니다.', ' as dependency-free plugins. You can select or deselect all rows at once using the checkbox in the Approval Permission header.')}
</p>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<BGrid<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.
*
* 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 { t } from '../i18n';
import type { BGridChangeDataMeta, BGridColumn, BGridDataItem } from 'beautiful-grid';
import './editingExamples.css';
export interface EditingOrder {
id: string;
orderCode: string;
customerCode: string;
customerName: string;
customerGrade: string;
status: string;
deliveryDate: string;
approved: boolean;
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: t('서울상사', 'Seoul Sangsa'),
customerGrade: 'VIP',
status: t('접수', 'Receipt'),
deliveryDate: '2026-08-25',
approved: true,
quantity: 2,
unitPrice: 12000,
amount: 24000,
note: t('오전 배송', 'Morning Delivery'),
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-002',
orderCode: 'ORD-2602',
customerCode: 'C001',
customerName: t('서울상사', 'Seoul Sangsa'),
customerGrade: 'VIP',
status: t('진행', 'In Progress'),
deliveryDate: '2026-08-26',
approved: false,
quantity: 3,
unitPrice: 18000,
amount: 54000,
note: t('담당자 확인', 'Assignee Confirmation'),
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-003',
orderCode: 'ORD-2603',
customerCode: 'C002',
customerName: t('한빛물산', 'Hanbit Mulsan'),
customerGrade: t('우수', 'Excellent'),
status: t('완료', 'Completed'),
deliveryDate: '2026-08-28',
approved: true,
quantity: 1,
unitPrice: 32000,
amount: 32000,
note: '',
mergeGroup: 'B',
},
},
{
values: {
id: 'ORDER-004',
orderCode: 'ORD-2604',
customerCode: 'C003',
customerName: 'Northwind',
customerGrade: t('일반', 'General'),
status: t('접수', 'Receipt'),
deliveryDate: '2026-09-01',
approved: false,
quantity: 5,
unitPrice: 9000,
amount: 45000,
note: t('영문 송장', 'English Invoice'),
mergeGroup: 'C',
},
},
];
export const cloneEditingOrders = (): BGridDataItem<EditingOrder>[] =>
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-row-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
padding: 6px 12px;
border: 1px solid var(--site-border-strong, #cbd5e1);
border-radius: var(--site-radius-sm, 8px);
background: var(--site-control-bg, #ffffff);
color: var(--site-text-secondary, #526178);
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
cursor: pointer;
transition: background-color 150ms, border-color 150ms, color 150ms;
}
.editing-example-row-button-add {
border-color: var(--site-accent, #2563eb);
background: var(--site-accent, #2563eb);
color: var(--site-accent-contrast, #ffffff);
}
.editing-example-row-button-add:hover {
border-color: var(--site-accent-strong, #1d4ed8);
background: var(--site-accent-strong, #1d4ed8);
}
.editing-example-row-button-delete:not(:disabled) {
border-color: #fca5a5;
color: #dc2626;
}
.editing-example-row-button-delete:hover:not(:disabled) {
border-color: #ef4444;
background: color-mix(in srgb, #ef4444 8%, var(--site-control-bg, #ffffff));
}
.editing-example-row-button:focus-visible {
outline: 2px solid var(--site-focus-ring, #93c5fd);
outline-offset: 2px;
}
.editing-example-row-button:disabled {
border-color: var(--site-border, #e1e7ef);
background: var(--site-surface, #f7f9fc);
color: var(--site-text-muted, #8491a5);
cursor: not-allowed;
}
.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>
);
}import * as React from 'react';
import {
BGridCellClipboardParseParams,
BGridCellClipboardTextParams,
BGridEditorPluginProps,
BGridPluginEditorConfig,
} from '../types';
import { defineEditorPlugin } from './defineEditorPlugin';
import { getColumnId } from '../utils/getColumnId';
export interface BGridSelectEditorOption<Value extends string | number> {
value: Value;
label: React.ReactNode;
disabled?: boolean;
}
export interface BGridSelectEditorPluginOptions<Value extends string | number> {
id: string;
options: BGridSelectEditorOption<Value>[];
ariaLabel?: string;
placeholder?: string;
openOnMount?: boolean;
/**
* Whether to allow pasting values that do not match any option value or label.
* Defaults to false (unmatched values throw an error and report parseValueFailed).
*/
allowCustomValue?: boolean;
/**
* Clipboard text mode when copying cells using this editor.
* 'label' (default): copies the option's label if it is a string or number.
* 'value': copies the option's raw value.
*/
copyMode?: 'label' | 'value';
/**
* Whether to allow clearing cell values by pasting empty or whitespace-only text.
* Defaults to false (unmatched empty text throws parseValueFailed).
* When true, pastes `emptyValue` into the cell.
*/
allowEmpty?: boolean;
/**
* Value written when an empty text is pasted and `allowEmpty` is true.
* Defaults to undefined.
*/
emptyValue?: Value | null;
/**
* Whether to allow pasting options that have `disabled: true`.
* Defaults to false (pasting a disabled option throws an error).
*/
allowDisabledOptions?: boolean;
/**
* Custom clipboard parser for this editor.
* Throw an error to reject the paste for this cell.
*/
parseClipboardText?: <T>(text: string, params: BGridCellClipboardParseParams<T>) => Value | unknown;
/**
* Custom clipboard text getter for this editor.
*/
getClipboardText?: <T>(params: BGridCellClipboardTextParams<T>) => any;
}
export function createSelectEditorPlugin<T, Value extends string | number = string>(
options: BGridSelectEditorPluginOptions<Value>,
): BGridPluginEditorConfig<T> {
function SelectEditor({ value, column, commit, cancel }: BGridEditorPluginProps<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='bgrid-native-select-editor-shell'>
<select
ref={selectRef}
className='bgrid-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='bgrid-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 = `BGridSelectEditor(${options.id})`;
// Precompute lookup maps for O(1) clipboard parsing and copying
const valueMap = new Map<string, BGridSelectEditorOption<Value>>();
const labelMap = new Map<string, BGridSelectEditorOption<Value>>();
const valueToLabelMap = new Map<unknown, string>();
const isNumericValue = options.options.some(opt => typeof opt.value === 'number');
for (const opt of options.options) {
const valKey = String(opt.value);
if (!valueMap.has(valKey)) {
valueMap.set(valKey, opt);
}
if (typeof opt.label === 'string' || typeof opt.label === 'number') {
const labelText = String(opt.label);
const trimmedLabel = labelText.trim();
if (!labelMap.has(trimmedLabel)) {
labelMap.set(trimmedLabel, opt);
}
if (!valueToLabelMap.has(opt.value)) {
valueToLabelMap.set(opt.value, labelText);
}
}
}
const defaultParseClipboardText = (text: string): Value | unknown => {
const trimmed = text.trim();
// Check empty input
if (trimmed === '') {
const emptyOption =
options.copyMode === 'value'
? (valueMap.get('') ?? labelMap.get(''))
: (labelMap.get('') ?? valueMap.get(''));
if (emptyOption) {
if (emptyOption.disabled && !options.allowDisabledOptions) {
throw new Error(`Disabled select option for editor "${options.id}"`);
}
return emptyOption.value;
}
if (options.allowEmpty) {
return options.emptyValue !== undefined ? options.emptyValue : ('' as unknown as Value);
}
}
// Match order depends on copyMode:
// If copyMode is 'label' (default), match by label first, then by value.
// If copyMode is 'value', match by value first, then by label.
const matched =
options.copyMode === 'value'
? (valueMap.get(trimmed) ?? labelMap.get(trimmed))
: (labelMap.get(trimmed) ?? valueMap.get(trimmed));
if (matched) {
if (matched.disabled && !options.allowDisabledOptions) {
throw new Error(`Disabled select option "${text}" for editor "${options.id}"`);
}
return matched.value;
}
if (options.allowCustomValue) {
if (isNumericValue) {
const num = Number(trimmed);
if (!Number.isNaN(num)) {
return num as unknown as Value;
}
}
return trimmed as unknown as Value;
}
throw new Error(`Invalid select value "${text}" for editor "${options.id}"`);
};
const defaultGetClipboardText = (params: BGridCellClipboardTextParams<T>): any => {
const { value } = params;
if (options.copyMode === 'value') {
return value;
}
const label = valueToLabelMap.get(value);
if (label !== undefined) {
return label;
}
return value;
};
return defineEditorPlugin<T>({
id: options.id,
component: SelectEditor,
parseClipboardText: options.parseClipboardText ?? ((text, _params) => defaultParseClipboardText(text)),
getClipboardText: options.getClipboardText ?? defaultGetClipboardText,
});
}import * as React from 'react';
import { BGridEditorPluginProps, BGridPluginEditorConfig } from '../types';
import { defineEditorPlugin } from './defineEditorPlugin';
import { getColumnId } from '../utils/getColumnId';
export interface BGridDateEditorPluginOptions {
id: string;
min?: string;
max?: string;
ariaLabel?: string;
}
export function createDateEditorPlugin<T>(options: BGridDateEditorPluginOptions): BGridPluginEditorConfig<T> {
function DateEditor({ value, column, activation, commit, cancel }: BGridEditorPluginProps<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='bgrid-native-date-editor-shell'>
<input
ref={inputRef}
className='bgrid-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='bgrid-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 = `BGridDateEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: DateEditor,
});
}문자열 입력과 boolean·권한 값 토글은 내장 text·checkbox editor를, 정해진 값과 날짜 선택은 beautiful-grid/editors의 기본 plugin을 사용합니다. 기본 plugin은 별도 UI 프레임워크 의존성을 추가하지 않습니다.
Checkbox와 헤더 일괄 제어
{
key: 'approved',
label: '승인 권한',
width: 150,
align: 'center',
editable: true,
editor: {
type: 'checkbox',
header: { ariaLabel: '승인 권한 전체 선택' },
ariaLabel: ({ values }) => `${values.orderCode} 승인 권한`,
label: ({ value }) => (value ? '허용' : '차단'),
disabled: ({ values }) => values.locked,
},
}
header를 켜면 헤더 checkbox가 선택 가능 행의 전체 선택·해제를 제어하고, 일부만 선택되었을 때 indeterminate 상태를 표시합니다. 현재 필터나 페이지로 Grid에 들어온 행만 대상으로 하며 disabled가 true인 셀과 삭제 상태 행은 상태 계산과 변경 대상에서 제외합니다.
기본 저장 값은 true와 false입니다. 서버 모델이 다른 값을 사용하면 trueValue: 'Y', falseValue: 'N'처럼 매핑할 수 있습니다. 활성 셀에서는 Space, Enter 또는 F2로 값을 토글할 수 있습니다. 셀과 헤더 변경도 onChangeValue 검증을 거쳐 변경된 행마다 onChangeData를 호출하며, 이때 meta.source는 'checkbox'입니다.
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로 명시합니다.
앱의 UI 라이브러리를 그대로 사용하기
내장 editor보다 풍부한 picker나 애플리케이션과 동일한 디자인이 필요하다면 공식 UI 통합을 사용하세요. BeautifulGrid는 Ant Design, Shadcn UI, MUI, Mantine용 editor plugin을 제공하며 popup portal과 저장·취소 수명주기를 미리 연결해 둡니다.