에디터 아이콘 (Editor Icons)
셀 값 옆에 드롭다운·달력·검색 아이콘을 표시하고 editor 시작 또는 독립 callback을 연결하는 방법을 설명합니다.
import * as React from 'react';
import { AXDataGrid, type AXDGColumn } from '@axboot/datagrid';
import { createDateEditorPlugin, createSelectEditorPlugin } from '@axboot/datagrid/editors';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { CalendarIcon, 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: '접수', label: '접수' },
{ value: '진행', label: '진행' },
{ value: '완료', label: '완료' },
],
});
const dateEditor = createDateEditorPlugin<EditingOrder>({ id: 'icon-date' });
export default function EditorIconExample() {
const [data, setData] = React.useState(cloneEditingOrders);
const [lastAction, setLastAction] = React.useState('아이콘을 눌러 동작을 확인하세요.');
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<AXDGColumn<EditingOrder>[]>(
() => withEditingCellClasses<EditingOrder>([
{ key: 'orderCode', label: '주문 코드', width: 145, editable: false },
{
key: 'status',
label: '항상 표시',
width: 145,
editable: true,
editor: statusEditor,
editTrigger: 'click',
editorIcon: { render: <ChevronDownIcon />, ariaLabel: '상태 선택', visibility: 'always' },
},
{
key: 'deliveryDate',
label: 'hover 표시',
width: 165,
editable: true,
editor: dateEditor,
editorIcon: { render: <CalendarIcon />, ariaLabel: '납기일 선택', visibility: 'hover' },
},
{
key: 'note',
label: 'callback 아이콘',
width: 210,
editable: true,
editor: { type: 'text' },
editorIcon: {
render: <CheckIcon />,
ariaLabel: '메모 확인 완료',
visibility: 'active',
onClick: async ({ index, commit }) => {
setLastAction(`${index + 1}행 메모에 확인 표시를 추가했습니다.`);
await commit([{ key: 'note', value: '확인 완료' }]);
},
},
},
]),
[],
);
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>이 없는 아이콘은 연결된 editor를 시작합니다. callback 아이콘은 editor 대신 자체 작업을 실행하며
동일한 <code>commit(changes[])</code>으로 값을 저장합니다.
<output aria-live='polite' className='mt-1 block text-xs text-blue-700'>{lastAction}</output>
</div>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<AXDataGrid<EditingOrder>
width={width}
height={height}
data={data}
columns={columns}
rowKey='id'
editable
variant='vertical-bordered'
onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
}}
/>
</DataGridContainer>
</div>
);
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* AXDataGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .axdg-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}import type { AXDGChangeDataMeta, AXDGColumn, AXDGDataItem } from '@axboot/datagrid';
import './editingExamples.css';
export interface EditingOrder {
id: string;
orderCode: string;
customerCode: string;
customerName: string;
customerGrade: '일반' | '우수' | 'VIP';
status: '접수' | '진행' | '완료';
deliveryDate: string;
quantity: number;
unitPrice: number;
amount: number;
note: string;
mergeGroup: string;
}
export const editingOrders: AXDGDataItem<EditingOrder>[] = [
{
values: {
id: 'ORDER-001',
orderCode: 'ORD-2601',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '접수',
deliveryDate: '2026-08-25',
quantity: 2,
unitPrice: 12000,
amount: 24000,
note: '오전 배송',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-002',
orderCode: 'ORD-2602',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '진행',
deliveryDate: '2026-08-26',
quantity: 3,
unitPrice: 18000,
amount: 54000,
note: '담당자 확인',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-003',
orderCode: 'ORD-2603',
customerCode: 'C002',
customerName: '한빛물산',
customerGrade: '우수',
status: '완료',
deliveryDate: '2026-08-28',
quantity: 1,
unitPrice: 32000,
amount: 32000,
note: '',
mergeGroup: 'B',
},
},
{
values: {
id: 'ORDER-004',
orderCode: 'ORD-2604',
customerCode: 'C003',
customerName: 'Northwind',
customerGrade: '일반',
status: '접수',
deliveryDate: '2026-09-01',
quantity: 5,
unitPrice: 9000,
amount: 45000,
note: '영문 송장',
mergeGroup: 'C',
},
},
];
export const cloneEditingOrders = () =>
editingOrders.map(item => ({
...item,
values: { ...item.values },
editedColumnIds: item.editedColumnIds ? [...item.editedColumnIds] : undefined,
changedKeys: item.changedKeys ? [...item.changedKeys] : undefined,
}));
export const applyEditingDataChange = <T,>(
current: AXDGDataItem<T>[],
sourceIndex: number,
values: T,
meta?: AXDGChangeDataMeta<T>,
): AXDGDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: AXDGColumn<T>[]): AXDGColumn<T>[] =>
columns.map(column => ({
...column,
className: [
column.className,
column.editable === false ? 'editing-example-cell-readonly' : 'editing-example-cell-editable',
]
.filter(Boolean)
.join(' '),
}));.editing-example-cell-editable {
--editing-example-bg: #ffffff;
--editing-example-hover-bg: #dbeafe;
}
.editing-example-cell-readonly {
--editing-example-color: #525252;
--editing-example-bg: #f5f5f5;
--editing-example-hover-bg: #e5e5e5;
}
.axdg-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.axdg-body-table tr.axdg-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
background-color: var(--editing-example-hover-bg);
}import * as React from 'react';
const iconProps = {
width: 14,
height: 14,
viewBox: '0 0 16 16',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
focusable: false,
'aria-hidden': true,
};
export function ChevronDownIcon() {
return (
<svg {...iconProps}>
<path d='m4 6 4 4 4-4' />
</svg>
);
}
export function CalendarIcon() {
return (
<svg {...iconProps}>
<rect x='2.5' y='3.5' width='11' height='10' rx='1.5' />
<path d='M5 2.5v2M11 2.5v2M2.5 6.5h11' />
</svg>
);
}
export function ClockIcon() {
return (
<svg {...iconProps}>
<circle cx='8' cy='8' r='5.5' />
<path d='M8 4.75V8l2.25 1.5' />
</svg>
);
}
export function SearchIcon() {
return (
<svg {...iconProps}>
<circle cx='7' cy='7' r='3.75' />
<path d='m10 10 3 3' />
</svg>
);
}
export function CheckIcon() {
return (
<svg {...iconProps}>
<path d='m3 8.25 3 3L13 4.5' />
</svg>
);
}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에 두는 편이 역할이 명확합니다.