Autocomplete and Lookup

Combine autocomplete input and a lookup-modal icon in one cell, then commit values to multiple columns atomically.

#autocomplete#lookup#editorIcon#multi-cell-commit
Last reviewed: 2026-08-29
LIVE DEMOAutocomplete and Lookup interactive example
Loading the interactive demo…
GitHub
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>
  );
}

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.