Autocomplete와 Lookup (Lookup Editor)
같은 셀에서 자동완성 입력과 lookup 모달 아이콘을 함께 제공하고 여러 컬럼 값을 원자적으로 저장하는 방법을 설명합니다.
import * as React from 'react';
import { AutoComplete, Input, Modal } from 'antd';
import {
AXDataGrid,
type AXDGColumn,
type AXDGDataItem,
type AXDGEditorIconClickParams,
type AXDGEditorPluginProps,
} from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { SearchIcon } from './editing/editorIcons';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
import './LookupEditorExample.css';
type Customer = Pick<EditingOrder, 'customerCode' | 'customerName' | 'customerGrade'>;
const customers: Customer[] = [
{ customerCode: 'C001', customerName: '서울상사', customerGrade: 'VIP' },
{ customerCode: 'C002', customerName: '한빛물산', customerGrade: '우수' },
{ customerCode: 'C003', customerName: 'Northwind', customerGrade: '일반' },
{ customerCode: 'C004', customerName: 'AxisJ Studio', customerGrade: '우수' },
];
const customerRows: AXDGDataItem<Customer>[] = customers.map(customer => ({ values: customer }));
const toCustomerChanges = (customer: Customer) => [
{ key: 'customerCode', value: customer.customerCode },
{ key: 'customerName', value: customer.customerName },
{ key: 'customerGrade', value: customer.customerGrade },
];
function CustomerAutocompleteEditor({ value, commit, cancel, getPortalContainer }: AXDGEditorPluginProps<EditingOrder>) {
const [text, setText] = React.useState(String(value ?? ''));
const normalizedText = text.trim().toLocaleLowerCase();
const options = customers
.filter(customer => {
if (!normalizedText) return true;
return [customer.customerName, customer.customerCode, customer.customerGrade].some(candidate =>
candidate.toLocaleLowerCase().includes(normalizedText),
);
})
.map(customer => ({
value: customer.customerName,
customerCode: customer.customerCode,
label: (
<span className='lookup-editor-autocomplete-option'>
<strong>{customer.customerName}</strong>
<small>{customer.customerCode} · {customer.customerGrade}</small>
</span>
),
}));
return (
<AutoComplete
aria-label='고객 자동완성'
autoFocus
className='lookup-editor-autocomplete'
classNames={{ popup: { root: 'lookup-editor-autocomplete-popup' } }}
defaultActiveFirstOption
open={options.length > 0}
options={options}
popupMatchSelectWidth
size='small'
value={text}
variant='borderless'
getPopupContainer={getPortalContainer}
onChange={setText}
onSelect={(_nextValue, option) => {
const customer = customers.find(candidate => candidate.customerCode === option.customerCode);
if (customer) void commit(toCustomerChanges(customer));
}}
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
/>
);
}
const customerAutocompleteEditor = defineEditorPlugin<EditingOrder>({
id: 'customer-autocomplete',
component: CustomerAutocompleteEditor,
});
interface CustomerLookupModalProps {
lookup: AXDGEditorIconClickParams<EditingOrder>;
}
function CustomerLookupModal({ lookup }: CustomerLookupModalProps) {
const [query, setQuery] = React.useState('');
const [selectedCode, setSelectedCode] = React.useState<string>(() => String(lookup.values.customerCode ?? ''));
const [confirming, setConfirming] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const filteredRows = React.useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) return customerRows;
return customerRows.filter(({ values }) =>
[values.customerName, values.customerCode, values.customerGrade].some(candidate =>
candidate.toLocaleLowerCase().includes(normalizedQuery),
),
);
}, [query]);
const columns = React.useMemo<AXDGColumn<Customer>[]>(
() => [
{ key: 'customerCode', label: '고객 코드', width: 130 },
{ key: 'customerName', label: '고객명', width: 260 },
{ key: 'customerGrade', label: '고객 등급', width: 120 },
],
[],
);
const confirmSelection = async () => {
const customer = customers.find(candidate => candidate.customerCode === selectedCode);
if (!customer) return;
setConfirming(true);
try {
await lookup.commit(toCustomerChanges(customer));
} finally {
setConfirming(false);
}
};
return (
<Modal
open
title='고객 선택'
width={720}
className='lookup-editor-modal'
okText='확인'
cancelText='취소'
okButtonProps={{ disabled: !selectedCode }}
confirmLoading={confirming}
onCancel={lookup.cancel}
onOk={() => void confirmSelection()}
>
<div className='lookup-editor-modal-content'>
<Input.Search
allowClear
autoFocus
aria-label='고객 검색'
placeholder='고객명, 고객 코드 또는 등급 검색'
value={query}
onChange={event => setQuery(event.currentTarget.value)}
/>
<DataGridContainer ref={containerRef} className='lookup-editor-grid' style={{ height: 280 }}>
<AXDataGrid<Customer>
width={width}
height={height}
data={filteredRows}
columns={columns}
rowKey='customerCode'
selectedRowKey={selectedCode}
rowChecked={{
isRadio: true,
checkedRowKeys: selectedCode ? [selectedCode] : [],
onChange: (_checkedIndexes, checkedRowKeys) => {
const nextCode = checkedRowKeys[0];
setSelectedCode(nextCode === undefined ? '' : String(nextCode));
},
}}
onClick={({ item }) => setSelectedCode(item.customerCode)}
variant='vertical-bordered'
msg={{ emptyList: '검색 결과가 없습니다.' }}
/>
</DataGridContainer>
</div>
</Modal>
);
}
export default function LookupEditorExample() {
const [data, setData] = React.useState(cloneEditingOrders);
const [lookup, setLookup] = React.useState<AXDGEditorIconClickParams<EditingOrder> | null>(null);
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: 'customerCode', label: '고객 코드', width: 130, editable: false },
{
key: 'customerName',
label: '고객명 · 자동완성/lookup',
width: 240,
editable: true,
editTrigger: 'click',
editor: customerAutocompleteEditor,
editorIcon: {
render: <SearchIcon />,
ariaLabel: ({ values }) => `${values.orderCode} 고객 lookup 열기`,
visibility: 'always',
onClick: params => {
setLookup(params);
return () => setLookup(null);
},
},
onChangeValue: async ({ changes, commit }) => {
await commit(changes);
},
},
{ key: 'customerGrade', label: '고객 등급', width: 130, editable: false },
]),
[],
);
return (
<div className='relative 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 AutoComplete가 열리고, 같은 셀의 검색 아이콘은 검색과 단일 선택 그리드를 갖춘
Ant Design Modal을 엽니다. 고객을 확정하면 코드·이름·등급을 하나의 변경 목록으로 원자적으로 저장합니다.
</p>
<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>
{lookup && <CustomerLookupModal lookup={lookup} />}
</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>
);
}.lookup-editor-autocomplete.ant-select {
display: block;
width: 100%;
height: 100%;
min-height: 0;
color: inherit;
font: inherit;
line-height: inherit;
}
.lookup-editor-autocomplete.ant-select .ant-select-selector {
height: 100% !important;
min-height: 0 !important;
max-height: 100%;
padding: 0 !important;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
box-shadow: none !important;
}
.lookup-editor-autocomplete.ant-select .ant-select-selection-wrap,
.lookup-editor-autocomplete.ant-select .ant-select-selection-search,
.lookup-editor-autocomplete.ant-select .ant-select-selection-search-input {
height: 100% !important;
min-height: 0;
max-height: 100%;
color: inherit;
font: inherit !important;
}
.lookup-editor-autocomplete.ant-select .ant-select-selection-wrap {
align-items: center;
}
.lookup-editor-autocomplete.ant-select .ant-select-selection-search-input {
padding: 0;
font-size: inherit !important;
line-height: inherit !important;
}
.lookup-editor-autocomplete-popup.ant-select-dropdown {
margin-top: -3px;
padding: 4px;
border: 1px solid var(--site-border, #e1e7ef);
border-radius: 0 0 var(--site-radius-sm, 8px) var(--site-radius-sm, 8px);
font-family: var(--axdg-font-family, inherit);
font-size: var(--axdg-font-size, 13px);
box-shadow: 0 12px 28px -16px rgb(15 23 42 / 55%);
}
.lookup-editor-autocomplete-option {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.lookup-editor-autocomplete-option strong {
min-width: 0;
overflow: hidden;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.lookup-editor-autocomplete-option small {
flex: 0 0 auto;
color: var(--site-text-muted, #8491a5);
font-size: 11px;
}
.lookup-editor-modal .ant-modal-content {
border: 1px solid var(--site-border, #e1e7ef);
border-radius: var(--site-radius-md, 14px);
box-shadow: var(--site-shadow-soft, 0 24px 60px -44px rgb(15 23 42 / 52%));
}
.lookup-editor-modal .ant-modal-title {
color: var(--site-text-primary, #101828);
font-size: 18px;
}
.lookup-editor-modal-content {
display: grid;
min-width: 0;
gap: 12px;
padding-top: 4px;
}
.lookup-editor-modal-content .ant-input-search {
width: 100%;
}
.lookup-editor-grid {
min-width: 0;
border-radius: var(--site-radius-sm, 8px);
}
@media (max-width: 620px) {
.lookup-editor-modal .ant-modal-content {
padding: 18px;
}
.lookup-editor-grid {
height: 240px !important;
}
}import { AXDGPluginEditorConfig } from '../types';
export function defineEditorPlugin<T>(config: Omit<AXDGPluginEditorConfig<T>, 'type'>): AXDGPluginEditorConfig<T> {
return {
type: 'plugin',
...config,
};
}FACEDM 형태의 고객 입력은 두 진입 경로를 한 컬럼에 함께 구성할 수 있습니다. 셀 영역은 Ant Design AutoComplete plugin을 열고, 같은 셀의 검색 아이콘은 검색바와 단일 선택 DataGrid를 포함한 Ant Design Modal을 엽니다.
{
key: 'customerName',
editable: true,
editTrigger: 'click',
editor: customerAutocompleteEditor,
editorIcon: {
render: <SearchIcon />,
ariaLabel: '고객 lookup 열기',
onClick: ({ commit, cancel }) => {
const close = openCustomerLookup({
onSelect: customer => commit([
{ key: 'customerCode', value: customer.code },
{ key: 'customerName', value: customer.name },
{ key: 'customerGrade', value: customer.grade },
]),
onCancel: cancel,
});
return close;
},
},
}
역할 분담
editor: 입력 문자열, 후보 조회, 키보드 선택을 담당합니다.editorIcon: lookup 모달의 열기와 수명주기를 담당합니다.commit(changes[]): 어느 경로에서 선택하든 동일한 저장 트랜잭션으로 보냅니다.onChangeValue: 두 경로가 제안한 값을 공통으로 검증·보정합니다.
브라우저 기본 자동완성 속성인 text editor의 inputProps.autoComplete와 후보 목록 UI는 다릅니다. 이 예제처럼 Ant Design AutoComplete를 plugin editor로 연결하면 셀 입력과 후보 목록을 하나의 편집 UI로 구성할 수 있습니다. 실제 서버 검색에서는 입력값으로 후보를 비동기 조회하되 최종 선택값은 동일한 commit(changes[]) 경로로 저장하세요.
비동기 lookup 주의사항
callback 세션이 끝난 뒤 늦게 도착한 결과의 commit은 무시됩니다. 그래도 애플리케이션은 cleanup에서 진행 중인 요청을 중단하고 모달을 닫아 불필요한 작업과 화면 깜빡임을 방지해야 합니다.
값 선택에 실패하면 commit() Promise가 reject되고 현재 icon 세션은 종료됩니다. Promise 오류를 사용자에게 표시하고 필요하면 lookup을 다시 열 수 있도록 처리하세요.