Grid Search

Search cells across the currently loaded visible data and configure highlighting, previous and next navigation, context-menu entry points, and a controlled search UI.

#search#context-menu#keyboard#virtual-scroll#frozen-columns#dataControl
Last reviewed: 2026-08-22
GitHub
import * as React from 'react';
import { Button, Select, Tag } from 'antd';
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
import {
  BGrid,
  type BGridColumn,
  type BGridContextMenuTarget,
  type BGridDataItem,
  type BGridDataQuery,
} from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';

interface SearchRow {
  employeeNo: string;
  name: string;
  department: string;
  location: string;
  project: string;
  allocationRate: number;
  joinedAt: string;
  status: string;
}

const departments = ['플랫폼개발', '디자인시스템', '데이터엔지니어링', '서비스운영'];
const locations = ['서울', '부산', '대전'];

const data: BGridDataItem<SearchRow>[] = Array.from({ length: 200 }, (_, index) => ({
  values: {
    employeeNo: `EMP-${String(index + 1).padStart(4, '0')}`,
    name: `구성원 ${index + 1}`,
    department: departments[index % departments.length],
    location: locations[index % locations.length],
    project: `프로젝트 ${String.fromCharCode(65 + (index % 8))}`,
    allocationRate: 60 + (index % 5) * 10,
    joinedAt: `202${index % 6}-${String((index % 12) + 1).padStart(2, '0')}-15`,
    status: index % 11 === 0 ? '휴직' : index % 5 === 0 ? '휴가' : '재직',
  },
}));

const columns: BGridColumn<SearchRow>[] = [
  { id: 'employeeNo', key: 'employeeNo', label: '사번', width: 110 },
  { id: 'name', key: 'name', label: '이름', width: 120, toolbox: true, filter: { type: 'text' } },
  {
    id: 'department',
    key: 'department',
    label: '부서',
    width: 150,
    toolbox: true,
    filter: { type: 'values' },
  },
  { id: 'location', key: 'location', label: '근무지', width: 100 },
  { id: 'project', key: 'project', label: '담당 프로젝트', width: 140 },
  {
    id: 'allocationRate',
    key: 'allocationRate',
    label: '투입률',
    width: 100,
    align: 'right',
    itemRender: ({ values }) => `${values.allocationRate}%`,
    getSearchText: ({ value }) => `${value}%`,
  },
  { id: 'joinedAt', key: 'joinedAt', label: '입사일', width: 120 },
  { id: 'status', key: 'status', label: '상태', width: 100, align: 'center' },
];

export default function SearchExample() {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);
  const [searchOpen, setSearchOpen] = React.useState(false);
  const [searchQuery, setSearchQuery] = React.useState('');
  const [dataQuery, setDataQuery] = React.useState<BGridDataQuery>({ sortParams: [], filterParams: [] });
  const [inspectedCell, setInspectedCell] = React.useState<BGridContextMenuTarget<SearchRow>>();
  const departmentFilter = dataQuery.filterParams.find(
    filter => filter.columnId === 'department' && filter.type === 'values',
  );
  const department = departmentFilter?.type === 'values' ? String(departmentFilter.values[0] ?? 'all') : 'all';

  return (
    <div className='flex min-h-0 flex-col gap-3'>
      <div className='flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700'>
        <div className='flex flex-wrap items-center gap-2'>
          <Button icon={<Search size={15} />} onClick={() => setSearchOpen(true)}>
            그리드 검색
          </Button>
          <Select
            aria-label='검색 대상 부서 필터'
            value={department}
            style={{ width: 170 }}
            options={[{ value: 'all', label: '전체 부서' }, ...departments.map(value => ({ value, label: value }))]}
            onChange={nextDepartment =>
              setDataQuery(current => ({
                ...current,
                filterParams:
                  nextDepartment === 'all'
                    ? current.filterParams.filter(filter => filter.columnId !== 'department')
                    : [
                        ...current.filterParams.filter(filter => filter.columnId !== 'department'),
                        {
                          columnId: 'department',
                          key: 'department',
                          type: 'values',
                          values: [nextDepartment],
                        },
                      ],
              }))
            }
          />
          <Tag color='blue'>Ctrl/Cmd+F</Tag>
          <span>또는 셀 우클릭 → 검색</span>
        </div>
        <span>검색 범위: {department === 'all' ? '현재 로드된 200개 행' : `${department} 필터 결과`}</span>
      </div>

      {inspectedCell && (
        <div role='status' className='rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600'>
          컨텍스트 메뉴 대상: {inspectedCell.values.employeeNo} · {String(inspectedCell.column.label)} ·{' '}
          {String(inspectedCell.value)} (표시 {inspectedCell.visibleIndex}, 원본 {inspectedCell.sourceIndex})
        </div>
      )}

      <DataGridContainer ref={containerRef} style={{ height: 430 }}>
        <BGrid<SearchRow>
          width={width}
          height={height}
          data={data}
          columns={columns}
          rowKey='employeeNo'
          frozenColumnIndex={2}
          frozenRowCount={2}
          showLineNumber
          dataControl={{ mode: 'client', query: dataQuery, onChange: setDataQuery }}
          cellNavigationOptions={{ defaultActiveCell: { rowIndex: 0, columnIndex: 1 } }}
          searchOptions={{
            open: searchOpen,
            query: searchQuery,
            onOpenChange: setSearchOpen,
            onQueryChange: setSearchQuery,
            icons: {
              search: <Search size={16} aria-hidden='true' />,
              previous: <ChevronUp size={16} aria-hidden='true' />,
              next: <ChevronDown size={16} aria-hidden='true' />,
              close: <X size={16} aria-hidden='true' />,
            },
            labels: {
              placeholder: '현재 로드된 데이터에서 찾기',
              formatResultCount: ({ activeResult, totalResults }) => `${activeResult} / ${totalResults}`,
            },
          }}
          contextMenuOptions={{
            items: target => [
              {
                id: 'inspect-cell',
                label: '이 셀 정보 보기',
                onSelect: () => setInspectedCell(target),
              },
            ],
          }}
        />
      </DataGridContainer>
    </div>
  );
}

1. Search scope and behavior

Pass searchOptions to let users open the search UI from the focused grid with Ctrl+F, or Cmd+F on macOS. The same UI opens when they right-click a body cell—or press Shift+F10 on the active cell—and choose Search from the context menu.

Search runs against all currently loaded visible data in the Grid Store, not just the rendered DOM. Rows outside the virtual-scroll viewport are therefore included, and previous/next navigation scrolls the matching cell into view. When dataControl.mode === 'client', only rows remaining after client-side sorting and filtering are searched. With external pagination or manual server mode, search covers only the current page or the rows currently supplied to the grid.

Searching the entire server dataset or filtering the grid down to search results is outside the scope of this API.

2. Minimum setup

<BGrid columns={columns} data={data} rowKey='employeeNo' searchOptions={{}} />

The following keyboard shortcuts are available while the search UI is open.

Key Action
Enter Move to the next result.
Shift+Enter Move to the previous result.
Escape Close the search UI and clear highlights.
Ctrl/Cmd+F Open the search UI, or select all input text if it is already open.
Shift+F10 / Context Menu key Open the active cell’s context menu.

The grid does not intercept Ctrl/Cmd+F during an editing session or while an input, textarea, select, or contenteditable element has focus. Pressing Enter during IME composition does not navigate to another result.

3. Match search text to the displayed value

By default, the grid reads the value identified by column.key from item.values. It does not inspect rendered DOM text. If itemRender formats an amount, date, or status code as a different string, define getSearchText as well.

const columns = [
  {
    id: 'allocationRate',
    key: 'allocationRate',
    label: 'Allocation Rate',
    width: 100,
    itemRender: ({ values }) => `${values.allocationRate}%`,
    getSearchText: ({ value }) => `${value}%`,
  },
  {
    id: 'privateMemo',
    key: 'privateMemo',
    label: 'Internal notes',
    width: 180,
    searchable: false,
  },
];

A column’s getSearchText takes precedence over the grid-wide searchOptions.getSearchText. The callback must be a side-effect-free synchronous function; Promises are not supported.

4. Control search from an external toolbar

Provide open and query to use the search UI in controlled mode. Return each new value to the grid through props from the corresponding change callback.

const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');

<>
  <button type='button' onClick={() => setOpen(true)}>
    Search Data
  </button>
  <BGrid
    columns={columns}
    data={data}
    searchOptions={{
      open,
      query,
      onOpenChange: setOpen,
      onQueryChange: setQuery,
      labels: {
        placeholder: 'Search currently loaded data',
        formatResultCount: ({ activeResult, totalResults }) => `${activeResult} / ${totalResults}`,
      },
    }}
  />
</>;

Pass React nodes from your application’s icon system to the icons.search, icons.previous, icons.next, and icons.close slots. The library also provides fallbacks that require no separate icon runtime dependency.

5. Add custom context-menu items

contextMenuOptions.items receives an immutable snapshot of the target when a body cell is right-clicked. After client-side sorting or filtering, visibleIndex can differ from the original sourceIndex, so choose the value that matches your intended operation.

<BGrid
  columns={columns}
  data={data}
  searchOptions={{}}
  contextMenuOptions={{
    items: target => [
      {
        id: 'inspect-row',
        label: 'Inspect This Row',
        onSelect: () => {
          console.log({
            visibleIndex: target.visibleIndex,
            sourceIndex: target.sourceIndex,
            values: target.values,
          });
        },
      },
    ],
  }}
/>

If there are no actionable menu items, the browser’s default context menu remains available. Set searchOptions.contextMenu = false to remove the Search item, or contextMenuOptions.enabled = false to disable all custom menu items.

6. Implementation checklist

  • On externally paginated or infinitely loaded screens, explain to users that search covers only currently loaded data.
  • For formatted cells, verify that getSearchText matches the value users see.
  • Provide rowKey to preserve the current result more reliably after data changes or reordering.
  • With narrow grids, frozen rows or columns, and summary rows, verify that previous/next navigation does not place the matching cell behind the search panel.
  • Searching pivot results is not currently supported.