Mantine 에디터 플러그인 (Mantine Editor Plugins)
@beautifuljs/grid-mantine으로 Mantine Select, DatePicker, ColorPicker, TimePicker를 BeautifulGrid 컬럼에 연결합니다.
LIVE DEMOMantine 에디터 플러그인 (Mantine Editor Plugins) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
LIVE DEMOMantine 에디터 플러그인 (Mantine Editor Plugins) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
import { t } from './i18n';
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
import {
createMantineColorPickerEditorPlugin,
createMantineDatePickerEditorPlugin,
createMantineSelectEditorPlugin,
createMantineTimePickerEditorPlugin,
} from '@beautifuljs/grid-mantine';
import '@beautifuljs/grid-mantine/style.css';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { CalendarIcon, ChevronDownIcon, ClockIcon } from './editing/editorIcons';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
type MantineEditorOrder = EditingOrder & {
labelColor: string;
deliveryTime: string;
};
const statusEditor = createMantineSelectEditorPlugin<MantineEditorOrder, EditingOrder['status']>({
id: 'mantine-status',
ariaLabel: t('Mantine 주문 상태 선택', 'Mantine Select Order Status'),
searchable: true,
options: [
{ value: t('접수', 'Receipt'), label: t('접수', 'Receipt') },
{ value: t('진행', 'In Progress'), label: t('진행', 'In Progress') },
{ value: t('완료', 'Completed'), label: t('완료', 'Completed') },
],
});
const deliveryDateEditor = createMantineDatePickerEditorPlugin<MantineEditorOrder>({
id: 'mantine-delivery-date',
ariaLabel: t('Mantine 납기일 선택', 'Mantine Select Delivery Date'),
});
const labelColorEditor = createMantineColorPickerEditorPlugin<MantineEditorOrder>({
id: 'mantine-label-color',
ariaLabel: t('Mantine 라벨 색상 선택', 'Mantine Select Label Color'),
colors: ['#228BE6', '#12B886', '#7950F2', '#FD7E14'],
});
const deliveryTimeEditor = createMantineTimePickerEditorPlugin<MantineEditorOrder>({
id: 'mantine-delivery-time',
ariaLabel: t('Mantine 배송 시간 선택', 'Mantine Select Delivery Time'),
minuteStep: 5,
});
const initialColors = ['#228BE6', '#12B886', '#7950F2', '#FD7E14'];
const initialDeliveryTimes = ['09:30', '11:00', '14:30', '16:00'];
const cloneMantineEditorOrders = (): BGridDataItem<MantineEditorOrder>[] =>
cloneEditingOrders().map((item, index) => ({
...item,
values: {
...item.values,
labelColor: initialColors[index % initialColors.length],
deliveryTime: initialDeliveryTimes[index % initialDeliveryTimes.length],
},
}));
export default function ExternalMantineEditorPluginExample() {
const [data, setData] = React.useState(cloneMantineEditorOrders);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<BGridColumn<MantineEditorOrder>[]>(
() =>
withEditingCellClasses<MantineEditorOrder>([
{ key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 140, editable: false },
{
key: 'status',
label: 'Mantine Select',
width: 165,
editable: true,
editor: statusEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: t('Mantine 상태 선택', 'Mantine Select Status') },
},
{
key: 'deliveryDate',
label: 'Mantine DatePicker',
width: 175,
editable: true,
editor: deliveryDateEditor,
editorIcon: { render: <CalendarIcon />, ariaLabel: t('Mantine 납기일 선택', 'Mantine Select Delivery Date') },
},
{
key: 'labelColor',
label: 'Mantine ColorPicker',
width: 175,
editable: true,
editor: labelColorEditor,
editorIcon: {
render: ({ value }) => (
<span
className='bgrid-color-swatch'
style={{ backgroundColor: typeof value === 'string' ? value : 'transparent' }}
aria-hidden='true'
/>
),
ariaLabel: t('Mantine 라벨 색상 선택', 'Mantine Select Label Color'),
},
},
{
key: 'deliveryTime',
label: 'Mantine TimePicker',
width: 175,
editable: true,
editor: deliveryTimeEditor,
editorIcon: { render: <ClockIcon />, ariaLabel: t('Mantine 배송 시간 선택', 'Mantine Select Delivery Time') },
},
]),
[],
);
return (
<div className='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 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300'>
<code>@beautifuljs/grid-mantine</code>
{t(
'의 Select, DatePicker, ColorPicker, TimePicker를 사용합니다. Cascader와 TreeSelect는 이 통합의 지원 범위에 포함되지 않습니다.',
' provides Select, DatePicker, ColorPicker, and TimePicker. Cascader and TreeSelect are outside this integration scope.',
)}
</p>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<BGrid<MantineEditorOrder>
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>
</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>
);
}@beautifuljs/grid-mantine은 Mantine 컴포넌트를 BeautifulGrid plugin editor 수명주기에 연결하는 공식 패키지입니다. Select, DatePicker, ColorPicker, TimePicker를 제공하며 각 dropdown과 popover를 Grid 전용 portal에 렌더링합니다.
설치
npm install @beautifuljs/grid-mantine @mantine/core @mantine/dates dayjs
Mantine의 기본 스타일과 plugin 스타일을 애플리케이션 진입점에서 한 번 불러오고, 앱 최상위에 MantineProvider를 둡니다.
import { MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import 'beautiful-grid/style.css';
import '@beautifuljs/grid-mantine/style.css';
root.render(
<MantineProvider>
<App />
</MantineProvider>,
);
Editor factory 연결
import {
createMantineColorPickerEditorPlugin,
createMantineDatePickerEditorPlugin,
createMantineSelectEditorPlugin,
createMantineTimePickerEditorPlugin,
} from '@beautifuljs/grid-mantine';
const statusEditor = createMantineSelectEditorPlugin<Order, Order['status']>({
id: 'order-status',
ariaLabel: '주문 상태 선택',
searchable: true,
options: [
{ value: 'ready', label: '접수' },
{ value: 'done', label: '완료' },
],
});
const dateEditor = createMantineDatePickerEditorPlugin<Order>({
id: 'order-date',
ariaLabel: '납기일 선택',
});
const colorEditor = createMantineColorPickerEditorPlugin<Order>({
id: 'order-color',
ariaLabel: '라벨 색상 선택',
colors: ['#228BE6', '#12B886', '#FD7E14'],
});
const timeEditor = createMantineTimePickerEditorPlugin<Order>({
id: 'order-time',
ariaLabel: '배송 시간 선택',
minuteStep: 5,
});
const columns: BGridColumn<Order>[] = [
{ key: 'status', label: '상태', editable: true, editor: statusEditor },
{ key: 'deliveryDate', label: '납기일', editable: true, editor: dateEditor },
{ key: 'labelColor', label: '색상', editable: true, editor: colorEditor },
{ key: 'deliveryTime', label: '시간', editable: true, editor: timeEditor },
];
지원 범위와 값 형식
- Select는 문자열·숫자 option과 검색 모드를 지원합니다.
- DatePicker는 ISO 형태의
YYYY-MM-DD문자열을 저장합니다. - ColorPicker는 기본적으로
#RRGGBB를 저장하며 alpha 사용 여부를 설정할 수 있습니다. - TimePicker는
HH:mm또는 seconds 옵션을 켠HH:mm:ss문자열을 저장합니다. - Cascader와 TreeSelect는 Mantine 공식 통합 범위에 포함되지 않습니다.
popup이 닫힐 때 아직 저장하지 않았다면 편집을 취소하고, Apply 또는 값 선택으로 저장한 경우 뒤이어 발생하는 close 이벤트가 결과를 덮어쓰지 않습니다.
패키지 API와 라이선스 정보는 @beautifuljs/grid-mantine npm 페이지에서 확인할 수 있습니다.