Autocomplete and Lookup
Combine autocomplete input and a lookup-modal icon in one cell, then commit values to multiple columns atomically.
import { t } from './i18n';
import * as React from 'react';
import { AutoComplete, Input, Modal } from 'antd';
import {
BGrid,
type BGridColumn,
type BGridDataItem,
type BGridEditorIconClickParams,
type BGridEditorPluginProps,
} from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/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: t('서울상사', 'Seoul Sangsa'), customerGrade: 'VIP' },
{ customerCode: 'C002', customerName: t('한빛물산', 'Hanbit Mulsan'), customerGrade: t('우수', 'Excellent') },
{ customerCode: 'C003', customerName: 'Northwind', customerGrade: t('일반', 'General') },
{ customerCode: 'C004', customerName: 'AxisJ Studio', customerGrade: t('우수', 'Excellent') },
];
const customerRows: BGridDataItem<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 }: BGridEditorPluginProps<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={t('고객 자동완성', 'Customer Autocomplete')}
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: BGridEditorIconClickParams<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<BGridColumn<Customer>[]>(
() => [
{ key: 'customerCode', label: t('고객 코드', 'Customer Code'), width: 130 },
{ key: 'customerName', label: t('고객명', 'Customer Name'), width: 260 },
{ key: 'customerGrade', label: t('고객 등급', 'Customer Level'), 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={t('고객 선택', 'Select Customer')}
width={720}
className='lookup-editor-modal site-grid-theme'
okText={t('확인', 'Confirm')}
cancelText={t('취소', 'Cancelled')}
okButtonProps={{ disabled: !selectedCode }}
confirmLoading={confirming}
onCancel={lookup.cancel}
onOk={() => void confirmSelection()}
>
<div className='lookup-editor-modal-content'>
<Input.Search
allowClear
autoFocus
aria-label={t('고객 검색', 'Search Customer')}
placeholder={t('고객명, 고객 코드 또는 등급 검색', 'Search customer name, code or grade')}
value={query}
onChange={event => setQuery(event.currentTarget.value)}
/>
<DataGridContainer ref={containerRef} className='lookup-editor-grid site-grid-theme' style={{ height: 280 }}>
<BGrid<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: t('검색 결과가 없습니다.', 'No search results.') }}
/>
</DataGridContainer>
</div>
</Modal>
);
}
export default function LookupEditorExample() {
const [data, setData] = React.useState(cloneEditingOrders);
const [lookup, setLookup] = React.useState<BGridEditorIconClickParams<EditingOrder> | null>(null);
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: 'customerCode', label: t('고객 코드', 'Customer Code'), width: 130, editable: false },
{
key: 'customerName',
label: t('고객명 · 자동완성/lookup', 'Customer Name · Autocomplete/Lookup'),
width: 240,
editable: true,
className: 'lookup-editor-customer-cell',
editTrigger: 'dblclick',
editor: customerAutocompleteEditor,
editorIcon: {
render: <SearchIcon />,
ariaLabel: ({ values }) => `${values.orderCode} ${t('고객 lookup 열기', 'Open customer lookup')}`,
visibility: 'always',
onClick: params => {
setLookup(params);
return () => setLookup(null);
},
},
onChangeValue: async ({ changes, commit }) => {
await commit(changes);
},
},
{ key: 'customerGrade', label: t('고객 등급', 'Customer Level'), 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'>
{t('고객명 라벨 영역을 더블클릭하면 Ant Design AutoComplete가 열리고, 같은 셀의 검색 아이콘을 한 번 클릭하면 검색과 단일 선택 그리드를 갖춘 Ant Design Modal을 엽니다. 고객을 확정하면 코드·이름·등급을 하나의 변경 목록으로 원자적으로 저장합니다.', 'Double-clicking the customer name label area opens the Ant Design AutoComplete, and clicking the search icon in the same cell opens the Ant Design Modal with search and single-select grid. Confirming the customer atomically saves the code, name, and level as a single change list.')}
</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>
{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.
*
* 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>
);
}.lookup-editor-autocomplete.ant-select {
display: block;
width: 100%;
height: 100%;
min-height: 0;
color: inherit;
font: inherit;
line-height: inherit;
}
.bgrid-body-table
> tbody
> tr
> td.lookup-editor-customer-cell.bgrid-cell-editing
> .bgrid-cell-content-plugin-editor {
padding: 0 6.5px;
}
.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(--bgrid-font-family, inherit);
font-size: var(--bgrid-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;
}
:root[data-theme='dark'] .lookup-editor-autocomplete-popup.ant-select-dropdown {
border-color: var(--site-border, #26344c);
background-color: var(--site-surface-elevated, #151f33);
color: var(--site-text-primary, #f2f6fc);
box-shadow: 0 12px 28px -16px rgb(0 0 0 / 75%);
}
:root[data-theme='dark'] .lookup-editor-autocomplete-option strong {
color: var(--site-text-primary, #f2f6fc);
}
:root[data-theme='dark'] .lookup-editor-autocomplete-option small {
color: var(--site-text-muted, #8391a8);
}
:root[data-theme='dark'] .lookup-editor-modal .ant-modal-content {
border-color: var(--site-border, #26344c);
background-color: var(--site-surface-elevated, #151f33);
}
:root[data-theme='dark'] .lookup-editor-modal .ant-modal-title {
color: var(--site-text-primary, #f2f6fc);
}
.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 { BGridPluginEditorConfig } from '../types';
export function defineEditorPlugin<T>(config: Omit<BGridPluginEditorConfig<T>, 'type'>): BGridPluginEditorConfig<T> {
return {
type: 'plugin',
...config,
};
}FACEDM-style customer input can provide two entry paths in a single column. Double-clicking the cell label area opens an Ant Design AutoComplete plugin, while a single click on the search icon opens an Ant Design Modal containing a search bar and a single-selection DataGrid. A single click on the label only selects the cell, preventing an accidental autocomplete session when the user intends to use the lookup icon.
{
key: 'customerName',
editable: true,
editTrigger: 'dblclick',
editor: customerAutocompleteEditor,
editorIcon: {
render: <SearchIcon />,
ariaLabel: 'Open customer 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;
},
},
}
Responsibilities
editor: Handles text input, candidate lookup, and keyboard selection.editorIcon: Opens the lookup modal and manages its lifecycle.editTrigger: 'dblclick': Keeps a single click on the label for cell selection and starts autocomplete editing on a double-click.commit(changes[]): Sends either selection path through the same save transaction.onChangeValue: Applies shared validation and normalization to values proposed by either path.
The inputProps.autoComplete option on the text editor controls the browser’s native autocomplete behavior; it is not a candidate-list UI. As shown here, connecting Ant Design AutoComplete as a plugin editor combines cell input and candidate suggestions into one editing interface. For a real server-backed search, fetch candidates asynchronously as the user types, but save the final selection through the same commit(changes[]) path.
Asynchronous lookup considerations
Commits that arrive after the callback session has ended are ignored. Even so, the application should abort in-flight requests during cleanup and close the modal to prevent wasted work and visual flicker.
If saving the selected value fails, the commit() Promise rejects and the current icon session ends. Display the error to the user and allow them to reopen the lookup if necessary.