에디터 아이콘 (Editor Icons)
셀 값 옆에 드롭다운·달력·검색 아이콘을 표시하고 editor 시작 또는 독립 callback을 연결하는 방법을 설명합니다.
LIVE DEMO에디터 아이콘 (Editor Icons) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
LIVE DEMO에디터 아이콘 (Editor Icons) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
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, CheckIcon, ChevronDownIcon } from './editing/editorIcons';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
const statusEditor = createSelectEditorPlugin<EditingOrder, EditingOrder['status']>({
id: 'icon-status',
options: [
{ value: t('접수', 'Receipt'), label: t('접수', 'Receipt') },
{ value: t('진행', 'In Progress'), label: t('진행', 'In Progress') },
{ value: t('완료', 'Completed'), label: t('완료', 'Completed') },
],
});
const dateEditor = createDateEditorPlugin<EditingOrder>({ id: 'icon-date' });
export default function EditorIconExample() {
const [data, setData] = React.useState(cloneEditingOrders);
const [lastAction, setLastAction] = React.useState(t('아이콘을 눌러 동작을 확인하세요.', 'Click on the icon to check its behavior.'));
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: 'status',
label: t('항상 표시', 'Always Show'),
width: 145,
editable: true,
editor: statusEditor,
editTrigger: 'click',
editorIcon: { render: <ChevronDownIcon />, ariaLabel: t('상태 선택', 'Select Status'), visibility: 'always' },
},
{
key: 'deliveryDate',
label: t('hover 표시', 'Show on hover'),
width: 165,
editable: true,
editor: dateEditor,
editorIcon: { render: <CalendarIcon />, ariaLabel: t('납기일 선택', 'Select Delivery Date'), visibility: 'hover' },
},
{
key: 'note',
label: t('callback 아이콘', 'callback icon'),
width: 210,
editable: true,
editor: { type: 'text' },
editorIcon: {
render: <CheckIcon />,
ariaLabel: t('메모 확인 완료', 'Memo Checked'),
visibility: 'active',
onClick: async ({ index, commit }) => {
setLastAction(`${index + 1}${t('행 메모에 확인 표시를 추가했습니다.', ' row note check mark added.')}`);
await commit([{ key: 'note', value: t('확인 완료', 'Confirmation Completed') }]);
},
},
},
]),
[],
);
return (
<div className='flex min-h-0 flex-col gap-3'>
<div className='rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
<code>onClick</code>{t('이 없는 아이콘은 연결된 editor를 시작합니다. callback 아이콘은 editor 대신 자체 작업을 실행하며 동일한', ' icon without onClick will start the linked editor. The callback icon executes its own action instead of the editor and uses the same')} <code>commit(changes[])</code>{t('으로 값을 저장합니다.', ' to save the value.')}
<output aria-live='polite' className='mt-1 block text-xs text-blue-700'>{lastAction}</output>
</div>
<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>
);
}editorIcon은 편집 중이 아닐 때도 셀 값 옆에 입력 가능성을 보여주는 핸들입니다. Select 화살표와 lookup 검색 아이콘을 별도 API로 나누지 않고 같은 설정을 사용합니다.
Editor를 여는 아이콘
onClick을 생략하면 아이콘 클릭이 기존 column.editor를 시작합니다.
{
key: 'status',
editable: true,
editTrigger: 'click',
editor: statusEditor,
editorIcon: {
render: <ChevronDownIcon />,
ariaLabel: '상태 선택',
visibility: 'always',
},
}
Callback을 실행하는 아이콘
onClick이 있으면 기본 editor 대신 callback 세션을 시작합니다. callback에는 DOM 이벤트가 아니라 셀 문맥과 공통 commit/cancel이 전달됩니다.
editorIcon: {
render: <SearchIcon />,
ariaLabel: ({ values }) => `${values.customerName} lookup 열기`,
onClick: ({ commit, cancel }) => {
openLookup({
onSelect: customer => commit([
{ key: 'customerCode', value: customer.code },
{ key: 'customerName', value: customer.name },
]),
onClose: cancel,
});
return () => closeLookup();
},
}
반환 함수는 commit, cancel, 새 상호작용, unmount로 세션이 끝날 때 한 번 호출되는 cleanup입니다.
표시 조건
visibility |
동작 |
|---|---|
always |
항상 표시, 기본값 |
hover |
셀을 가리킬 때 표시 |
active |
활성 셀일 때 표시 |
아이콘 종류를 editor에서 자동 추론하지 않습니다. 같은 Select라도 제품마다 아이콘과 접근성 이름이 다르므로 render는 필수입니다. 편집과 무관한 삭제·상세 이동 버튼은 itemRender에 두는 편이 역할이 명확합니다.