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.
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>
);
}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,
};
}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
getSearchTextmatches the value users see. - Provide
rowKeyto 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.