MUI 에디터 플러그인 (MUI Editor Plugins)
@beautifuljs/grid-mui로 MUI Select, DatePicker, ColorPicker, TimePicker를 BeautifulGrid 컬럼에 연결합니다.
LIVE DEMOMUI 에디터 플러그인 (MUI Editor Plugins) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
LIVE DEMOMUI 에디터 플러그인 (MUI Editor Plugins) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
import { t } from './i18n';
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
import {
createMuiColorPickerEditorPlugin,
createMuiDatePickerEditorPlugin,
createMuiSelectEditorPlugin,
createMuiTimePickerEditorPlugin,
} from '@beautifuljs/grid-mui';
import '@beautifuljs/grid-mui/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 MuiEditorOrder = EditingOrder & {
labelColor: string;
deliveryTime: string;
};
const statusEditor = createMuiSelectEditorPlugin<MuiEditorOrder, EditingOrder['status']>({
id: 'mui-status',
ariaLabel: t('MUI 주문 상태 선택', 'MUI Select Order Status'),
options: [
{ value: t('접수', 'Receipt'), label: t('접수', 'Receipt') },
{ value: t('진행', 'In Progress'), label: t('진행', 'In Progress') },
{ value: t('완료', 'Completed'), label: t('완료', 'Completed') },
],
});
const deliveryDateEditor = createMuiDatePickerEditorPlugin<MuiEditorOrder>({
id: 'mui-delivery-date',
ariaLabel: t('MUI 납기일 선택', 'MUI Select Delivery Date'),
});
const labelColorEditor = createMuiColorPickerEditorPlugin<MuiEditorOrder>({
id: 'mui-label-color',
ariaLabel: t('MUI 라벨 색상 선택', 'MUI Select Label Color'),
colors: ['#1976D2', '#00897B', '#7B1FA2', '#ED6C02'],
});
const deliveryTimeEditor = createMuiTimePickerEditorPlugin<MuiEditorOrder>({
id: 'mui-delivery-time',
ariaLabel: t('MUI 배송 시간 선택', 'MUI Select Delivery Time'),
minuteStep: 5,
});
const initialColors = ['#1976D2', '#00897B', '#7B1FA2', '#ED6C02'];
const initialDeliveryTimes = ['09:30', '11:00', '14:30', '16:00'];
const cloneMuiEditorOrders = (): BGridDataItem<MuiEditorOrder>[] =>
cloneEditingOrders().map((item, index) => ({
...item,
values: {
...item.values,
labelColor: initialColors[index % initialColors.length],
deliveryTime: initialDeliveryTimes[index % initialDeliveryTimes.length],
},
}));
export default function ExternalMuiEditorPluginExample() {
const [data, setData] = React.useState(cloneMuiEditorOrders);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<BGridColumn<MuiEditorOrder>[]>(
() =>
withEditingCellClasses<MuiEditorOrder>([
{ key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 140, editable: false },
{
key: 'status',
label: 'MUI Select',
width: 165,
editable: true,
editor: statusEditor,
editorIcon: { render: <ChevronDownIcon />, ariaLabel: t('MUI 상태 선택', 'MUI Select Status') },
},
{
key: 'deliveryDate',
label: 'MUI DatePicker',
width: 175,
editable: true,
editor: deliveryDateEditor,
editorIcon: { render: <CalendarIcon />, ariaLabel: t('MUI 납기일 선택', 'MUI Select Delivery Date') },
},
{
key: 'labelColor',
label: 'MUI 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('MUI 라벨 색상 선택', 'MUI Select Label Color'),
},
},
{
key: 'deliveryTime',
label: 'MUI TimePicker',
width: 165,
editable: true,
editor: deliveryTimeEditor,
editorIcon: { render: <ClockIcon />, ariaLabel: t('MUI 배송 시간 선택', 'MUI 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-mui</code>
{t(
'의 Select, DatePicker, ColorPicker, TimePicker를 사용합니다. 셀을 더블클릭하거나 아이콘을 누르면 Grid 전용 portal 안에서 편집기가 열립니다.',
' provides Select, DatePicker, ColorPicker, and TimePicker. Double-click a cell or use its icon to open the editor in the Grid portal.',
)}
</p>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<BGrid<MuiEditorOrder>
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-mui는 MUI 컴포넌트를 BeautifulGrid plugin editor 수명주기에 연결하는 공식 패키지입니다. Select, DatePicker, ColorPicker, TimePicker를 제공하며 popup은 Grid 전용 portal에 렌더링됩니다.
설치
npm install @beautifuljs/grid-mui @mui/material @mui/x-date-pickers @emotion/react @emotion/styled dayjs
애플리케이션 진입점에서 BeautifulGrid와 plugin 스타일을 한 번 불러옵니다. 앱에 기존 MUI 테마가 있다면 그 ThemeProvider를 그대로 사용하세요.
import { ThemeProvider } from '@mui/material/styles';
import 'beautiful-grid/style.css';
import '@beautifuljs/grid-mui/style.css';
root.render(
<ThemeProvider theme={appTheme}>
<App />
</ThemeProvider>,
);
Editor factory 연결
import {
createMuiColorPickerEditorPlugin,
createMuiDatePickerEditorPlugin,
createMuiSelectEditorPlugin,
createMuiTimePickerEditorPlugin,
} from '@beautifuljs/grid-mui';
const statusEditor = createMuiSelectEditorPlugin<Order, Order['status']>({
id: 'order-status',
ariaLabel: '주문 상태 선택',
options: [
{ value: 'ready', label: '접수' },
{ value: 'done', label: '완료' },
],
});
const dateEditor = createMuiDatePickerEditorPlugin<Order>({
id: 'order-date',
ariaLabel: '납기일 선택',
format: 'YYYY-MM-DD',
});
const colorEditor = createMuiColorPickerEditorPlugin<Order>({
id: 'order-color',
ariaLabel: '라벨 색상 선택',
colors: ['#1976D2', '#00897B', '#ED6C02'],
});
const timeEditor = createMuiTimePickerEditorPlugin<Order>({
id: 'order-time',
ariaLabel: '배송 시간 선택',
minuteStep: 5,
});
생성된 설정을 editable 컬럼에 지정합니다.
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는
string | numberoption 값을 그대로 저장합니다. - DatePicker는 기본적으로
YYYY-MM-DD문자열을 저장하며format으로 변경할 수 있습니다. - ColorPicker는
#RRGGBB를 대문자로 정규화하고 Apply 시점에 저장합니다. - TimePicker는 기본적으로
HH:mm문자열을 저장하고 확인 동작 후 편집을 종료합니다. - Cascader와 TreeSelect는 MUI 공식 통합 범위에 포함되지 않습니다.
각 popup은 getPortalContainer()에 연결되어 Grid의 바깥 클릭 판정과 스크롤 경계를 공유합니다. 사용자는 Escape로 원래 값을 유지한 채 취소할 수 있고, 저장 또는 취소 후 포커스는 활성 셀로 복원됩니다.
패키지 API와 라이선스 정보는 @beautifuljs/grid-mui npm 페이지에서 확인할 수 있습니다.