Sorting and Filtering Toolbox
Apply multi-column sorting and header-toolbox filtering to practical data exploration workflows.
import * as React from 'react';
import { useState, useCallback, useMemo } from 'react';
import { BGrid, BGridColumn } from 'beautiful-grid';
import type { BGridDataControl, BGridDataQuery, BGridToolboxIcons } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
import { Button, Segmented, Space, Tag } from 'antd';
import { ChevronDown, ArrowUp, ArrowDown, Filter, X } from 'lucide-react';
const lucideToolboxIcons: BGridToolboxIcons = {
dropdown: <ChevronDown size={13} strokeWidth={2} />,
sortAsc: <ArrowUp size={13} strokeWidth={2.2} />,
sortDesc: <ArrowDown size={13} strokeWidth={2.2} />,
filter: <Filter size={13} strokeWidth={2} />,
filterBadge: <Filter size={9} strokeWidth={2} />,
sortClear: <X size={13} strokeWidth={2} />,
};
const categories = ['Frontend', 'Backend', 'Database', 'DevOps', 'Design', 'Language', 'Security', 'Cloud', 'Mobile'];
const authors = ['Tom', 'Jerry', 'Alice', 'Bob', 'Charlie', 'David', 'Emma'];
const topics = [
'React 18 새로운 기능 살펴보기',
'TypeScript 5.0 마스터 가이드',
'Next.js 14 App Router 실전',
'Zustand와 Recoil 상태관리 비교',
'Node.js 백엔드 아키텍처 패턴',
'PostgreSQL 인덱스 최적화 기법',
'Docker와 K8s 배포 파이프라인 구축',
'Vite 기반 번들 최적화 꿀팁',
'GraphQL vs REST API 완벽 비교',
'Tailwind CSS로 모던 UI 디자인하기',
'Rust 기초부터 웹서버 구현까지',
'웹 접근성(A11y) 가이드라인 준수하기',
'Redis 분산 캐시 설계 및 활용',
'Kafka 대용량 메시지 브로커 실습',
'Kubernetes 클러스터 모니터링 가이드',
'Elasticsearch 검색 엔진 최적화',
'OAuth 2.0 및 JWT 인증 아키텍처',
'Microservices Event-driven 아키텍처',
'Flutter 크로스 플랫폼 앱 제작',
'Kotlin Coroutine 비동기 프로그래밍',
];
const mockData = Array.from({ length: 60 }, (_, index) => {
const id = index + 1;
const topic = topics[index % topics.length];
const category = categories[index % categories.length];
const author = authors[index % authors.length];
const views = Math.floor(500 + Math.sin(index * 1.5 + 1) * 3000 + 4000);
const price = Math.floor(15000 + (index % 12) * 5000);
const month = String((index % 12) + 1).padStart(2, '0');
const day = String((index % 28) + 1).padStart(2, '0');
const date = `2023-${month}-${day}`;
return {
values: {
id,
title: index >= topics.length ? `${topic} (심화 #${Math.floor(index / topics.length) + 1})` : topic,
category,
author,
views,
price,
date,
},
};
});
export default function ToolboxExample() {
const [data, setData] = useState(mockData);
const [iconTheme, setIconTheme] = useState<'lucide' | 'default'>('lucide');
const [query, setQuery] = useState<BGridDataQuery>({
sortParams: [],
filterParams: [],
});
const handleQueryChange = useCallback((nextQuery: BGridDataQuery, action: any) => {
console.log('[ToolboxExample] Query Change:', action, nextQuery);
setQuery(nextQuery);
}, []);
const handleResetQuery = useCallback(() => {
setQuery({
sortParams: [],
filterParams: [],
});
}, []);
const dataControl: BGridDataControl = {
mode: 'client',
query,
onChange: handleQueryChange,
multiSort: true,
};
const columns: BGridColumn<any>[] = useMemo(
() => [
{
id: 'col_id',
key: 'id',
label: 'ID',
width: 60,
align: 'center',
toolbox: true,
filter: {
type: 'number',
},
},
{
id: 'col_title',
key: 'title',
label: '제목',
width: 280,
toolbox: {
sort: true,
filter: true,
extraItems: [
{
id: 'copy-col',
label: '컬럼명 복사',
onClick: ({ column }) => {
navigator.clipboard?.writeText(String(column.label));
alert('컬럼명이 복사되었습니다.');
},
},
],
},
filter: {
type: 'text',
},
},
{
id: 'col_category',
key: 'category',
label: '카테고리',
width: 120,
align: 'center',
toolbox: true,
filter: {
type: 'values',
},
},
{
id: 'col_author',
key: 'author',
label: '작성자',
width: 100,
align: 'center',
toolbox: true,
filter: {
type: 'values',
},
},
{
id: 'col_views',
key: 'views',
label: '조회수',
width: 110,
align: 'right',
toolbox: true,
itemRender: ({ value }) => Number(value).toLocaleString(),
filter: {
type: 'number',
},
},
{
id: 'col_price',
key: 'price',
label: '가격 (원)',
width: 120,
align: 'right',
toolbox: true,
itemRender: ({ value }) => `₩${Number(value).toLocaleString()}`,
filter: {
type: 'number',
},
},
{
id: 'col_date',
key: 'date',
label: '등록일',
width: 110,
align: 'center',
toolbox: true,
filter: {
type: 'text',
},
},
],
[],
);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);
return (
<>
<div className='flex flex-wrap items-center justify-between gap-2 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm'>
<div className='flex items-center gap-3 text-slate-600'>
<span>
<strong>정렬:</strong> {query.sortParams.length}개
{query.sortParams.length > 0 && (
<span className='text-blue-600 ml-1'>
({query.sortParams.map(s => `${s.columnId}:${s.orderBy}`).join(', ')})
</span>
)}
</span>
<span>|</span>
<span>
<strong>필터:</strong> {query.filterParams.length}개
{query.filterParams.length > 0 && (
<span className='text-emerald-600 ml-1'>
({query.filterParams.map(f => `${f.columnId}(${f.type})`).join(', ')})
</span>
)}
</span>
</div>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
<span className='text-xs text-slate-500 font-medium'>아이콘 스타일:</span>
<Segmented
value={iconTheme}
onChange={val => setIconTheme(val as 'lucide' | 'default')}
options={[
{ label: 'Lucide 벡터 아이콘 (커스텀)', value: 'lucide' },
{ label: '기본 불릿/기호 (Fallback)', value: 'default' },
]}
/>
</div>
<Button onClick={handleResetQuery}>정렬 / 필터 전체 초기화</Button>
</div>
</div>
<DataGridContainer ref={containerRef}>
<BGrid
width={containerWidth}
height={containerHeight}
data={data}
columns={columns}
columnSortable
frozenColumnIndex={1}
dataControl={dataControl}
icons={iconTheme === 'lucide' ? lucideToolboxIcons : undefined}
rowKey='id'
rowChecked={{
checkedIndexes: [],
onChange: (checkedIndexes, checkedRowKeys) => {
console.log('[ToolboxExample] Checked:', checkedIndexes, checkedRowKeys);
},
}}
onClick={({ item, index, column }) => {
console.log('[ToolboxExample] Row Clicked:', index, item.title, column.label);
}}
/>
</DataGridContainer>
</>
);
}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. When and why should you use it?
When users explore large datasets, sorting by the highest amount or quickly filtering for a specific department or status is essential.
BeautifulGrid provides three related data-exploration tools:
- Sort from a column header: Clicking a column label cycles through ascending (ASC), descending (DESC), and unsorted states.
- Header toolbox popover: Clicking the filter and sort icon in a column header provides value-list filters, text search, and multi-column sorting.
- Client or manual mode (
dataControl): Inclientmode, the grid processes the currentdata; inmanualmode, the parent retrieves the matching data.
2. Practical example: Client-side multi-filter and sorting
The following example enables the header toolbox and runs data processing in client mode:
import React, { useState } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem, type BGridDataQuery } from 'beautiful-grid';
interface EmployeeItem {
id: number;
name: string;
department: string;
position: string;
salary: number;
joinedAt: string;
}
export default function EmployeeFilterGrid() {
const [query, setQuery] = useState<BGridDataQuery>({ sortParams: [], filterParams: [] });
const [data] = useState<BGridDataItem<EmployeeItem>[]>([
{ values: { id: 1, name: 'Alex Kim', department: 'Engineering', position: 'Manager', salary: 85000000, joinedAt: '2020-03-01' } },
{ values: { id: 2, name: 'Olivia Lee', department: 'Design', position: 'Senior', salary: 52000000, joinedAt: '2022-07-15' } },
{ values: { id: 3, name: 'Daniel Park', department: 'Engineering', position: 'Principal', salary: 92000000, joinedAt: '2018-11-01' } },
{ values: { id: 4, name: 'Jiwon Choi', department: 'Marketing', position: 'Lead', salary: 64000000, joinedAt: '2021-01-10' } },
{ values: { id: 5, name: 'Donghoon Jung', department: 'Engineering', position: 'Associate', salary: 45000000, joinedAt: '2024-02-01' } },
{ values: { id: 6, name: 'Sophie Han', department: 'People Operations', position: 'Senior', salary: 55000000, joinedAt: '2023-05-10' } },
]);
const columns: BGridColumn<EmployeeItem>[] = [
{ id: 'id', key: 'id', label: 'Employee ID', width: 70, align: 'center', toolbox: true, filter: { type: 'number' } },
{ id: 'name', key: 'name', label: 'Name', width: 120, align: 'center', toolbox: true, filter: { type: 'text' } },
{ id: 'department', key: 'department', label: 'Department', width: 140, toolbox: true, filter: { type: 'values' } },
{ id: 'position', key: 'position', label: 'Position', width: 100, align: 'center', toolbox: true, filter: { type: 'values' } },
{
key: 'salary',
label: 'Annual salary',
width: 140,
align: 'right',
toolbox: true,
filter: { type: 'number' },
itemRender: ({ values }) => `${values.salary.toLocaleString()} KRW`,
},
{ id: 'joinedAt', key: 'joinedAt', label: 'Hire date', width: 120, align: 'center', toolbox: true, filter: { type: 'text' } },
];
return (
<div>
<div style={{ marginBottom: 10, fontSize: 13, color: '#475569' }}>
💡 Hover over a column header and click the <strong>filter and sort icon</strong> to filter by department or position.
</div>
<BGrid<EmployeeItem>
width={750}
height={320}
columns={columns}
data={data}
rowKey="id"
dataControl={{
mode: 'client',
multiSort: true,
query,
onChange: setQuery,
}}
showLineNumber={true}
/>
</div>
);
}
3. Choose a dataControl mode
| Mode | Configuration | Behavior | Recommended use |
|---|---|---|---|
| Client mode | { mode: 'client', query, onChange } |
The grid calculates sorting and filtering results from the supplied data. |
Immediate exploration within data already loaded in the browser |
| Manual mode | { mode: 'manual', query, onChange } |
The grid reports only the condition change; the parent queries the server and supplies new data. |
Server-side pagination or database sorting and filtering |
4. Practical tips and gotchas
[!TIP] Use unique column IDs: When the toolbox is enabled, each column must have a unique
keyorid. If multiple columns reuse the samekey, give each one a uniqueid, such asid: 'custom_id_1'.