Shadcn UI 에디터 플러그인 (Shadcn UI Editor Plugins)
BeautifulGrid 공식 Shadcn Registry에서 Select, DatePicker, ColorPicker, Cascader, TimePicker, TreeSelect 에디터 소스를 설치하고 사용하는 방법을 설명합니다.
import { t } from './i18n';
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { createShadcnSelectEditorPlugin } from './editor-plugins/createShadcnSelectEditorPlugin';
import { createShadcnDatePickerEditorPlugin } from './editor-plugins/createShadcnDatePickerEditorPlugin';
import { createShadcnColorPickerEditorPlugin } from './editor-plugins/createShadcnColorPickerEditorPlugin';
import { createShadcnCascaderEditorPlugin } from './editor-plugins/createShadcnCascaderEditorPlugin';
import { createShadcnTimePickerEditorPlugin } from './editor-plugins/createShadcnTimePickerEditorPlugin';
import { createShadcnTreeSelectEditorPlugin } from './editor-plugins/createShadcnTreeSelectEditorPlugin';
import { formatCascaderClipboardText, parseCascaderClipboardText } from './editor-plugins/cascaderValue';
import { Calendar, ChevronDown, Clock } from 'lucide-react';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
import './editor-plugins/shadcnEditorPlugins.css';
type ExternalShadcnOrder = EditingOrder & {
labelColor: string;
categoryPath: string[];
deliveryTime: string;
organization: string;
};
const shadcnStatusEditor = createShadcnSelectEditorPlugin<ExternalShadcnOrder, EditingOrder['status']>({
id: 'external-shadcn-status',
ariaLabel: t('Shadcn UI 주문 상태 선택', 'Shadcn UI 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 shadcnDeliveryDateEditor = createShadcnDatePickerEditorPlugin<ExternalShadcnOrder>({
id: 'external-shadcn-delivery-date',
ariaLabel: t('Shadcn UI 납기일 선택', 'Shadcn UI Select Delivery Date'),
});
const shadcnLabelColorEditor = createShadcnColorPickerEditorPlugin<ExternalShadcnOrder>({
id: 'external-shadcn-label-color',
ariaLabel: t('Shadcn UI 라벨 색상 선택', 'Shadcn UI Select Label Color'),
});
const shadcnCategoryEditor = createShadcnCascaderEditorPlugin<ExternalShadcnOrder>({
id: 'external-shadcn-category',
ariaLabel: t('Shadcn UI 분류 경로 선택', 'Shadcn UI Select Category Path'),
options: [
{
value: t('국내', 'Domestic'),
label: t('국내', 'Domestic'),
children: [
{ value: t('서울', 'Seoul'), label: t('서울', 'Seoul') },
{ value: t('부산', 'Busan'), label: t('부산', 'Busan') },
{ value: t('제주', 'Jeju'), label: t('제주', 'Jeju') },
],
},
{
value: t('해외', 'Overseas'),
label: t('해외', 'Overseas'),
children: [
{ value: t('아시아', 'Asia'), label: t('아시아', 'Asia') },
{ value: t('유럽', 'Europe'), label: t('유럽', 'Europe') },
{ value: t('북미', 'North America'), label: t('북미', 'North America') },
],
},
],
});
const shadcnDeliveryTimeEditor = createShadcnTimePickerEditorPlugin<ExternalShadcnOrder>({
id: 'external-shadcn-delivery-time',
ariaLabel: t('Shadcn UI 배송 시간 선택', 'Shadcn UI Select Delivery Time'),
});
const shadcnOrganizationEditor = createShadcnTreeSelectEditorPlugin<ExternalShadcnOrder>({
id: 'external-shadcn-organization',
ariaLabel: t('Shadcn UI 담당 조직 선택', 'Shadcn UI Select Responsible Organization'),
treeData: [
{
title: t('영업본부', 'Sales Headquarters'),
children: [
{ title: t('서울 영업팀', 'Seoul Sales Team'), value: t('서울 영업팀', 'Seoul Sales Team') },
{ title: t('부산 영업팀', 'Busan Sales Team'), value: t('부산 영업팀', 'Busan Sales Team') },
],
},
{
title: t('운영본부', 'Operations Headquarters'),
children: [
{ title: t('물류팀', 'Logistics Team'), value: t('물류팀', 'Logistics Team') },
{ title: t('고객지원팀', 'Customer Support Team'), value: t('고객지원팀', 'Customer Support Team') },
],
},
],
});
const initialColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'];
const initialCategoryPaths = [
[t('국내', 'Domestic'), t('서울', 'Seoul')],
[t('국내', 'Domestic'), t('부산', 'Busan')],
[t('해외', 'Overseas'), t('아시아', 'Asia')],
[t('해외', 'Overseas'), t('유럽', 'Europe')],
];
const initialDeliveryTimes = ['09:30', '11:00', '14:30', '16:00'];
const initialOrganizations = [t('서울 영업팀', 'Seoul Sales Team'), t('부산 영업팀', 'Busan Sales Team'), t('물류팀', 'Logistics Team'), t('고객지원팀', 'Customer Support Team')];
const cloneExternalShadcnOrders = (): BGridDataItem<ExternalShadcnOrder>[] =>
cloneEditingOrders().map((item, index) => ({
...item,
values: {
...item.values,
labelColor: initialColors[index % initialColors.length],
categoryPath: initialCategoryPaths[index % initialCategoryPaths.length],
deliveryTime: initialDeliveryTimes[index % initialDeliveryTimes.length],
organization: initialOrganizations[index % initialOrganizations.length],
},
}));
export default function ExternalShadcnEditorPluginExample() {
const [data, setData] = React.useState(cloneExternalShadcnOrders);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const columns = React.useMemo<BGridColumn<ExternalShadcnOrder>[]>(
() => withEditingCellClasses<ExternalShadcnOrder>([
{ key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 140, editable: false },
{ key: 'customerName', label: t('고객명', 'Customer Name'), width: 160, editable: false },
{
key: 'status',
label: 'Shadcn UI Select',
width: 180,
editable: true,
editor: shadcnStatusEditor,
editorIcon: {
render: <ChevronDown className="h-3.5 w-3.5" />,
ariaLabel: t('Shadcn UI 상태 선택', 'Shadcn UI Select Status'),
},
},
{
key: 'deliveryDate',
label: 'Shadcn UI DatePicker',
width: 200,
editable: true,
editor: shadcnDeliveryDateEditor,
editorIcon: {
render: <Calendar className="h-3.5 w-3.5" />,
ariaLabel: t('Shadcn UI 납기일 선택', 'Shadcn UI Select Delivery Date'),
},
},
{
key: 'labelColor',
label: 'Shadcn UI ColorPicker',
width: 210,
editable: true,
editor: shadcnLabelColorEditor,
itemRender: ({ value }) => <>{String(value ?? '')}</>,
editorIcon: {
render: ({ value }) => (
<span
className='bgrid-color-swatch'
style={{ backgroundColor: typeof value === 'string' ? value : 'transparent' }}
aria-hidden='true'
/>
),
ariaLabel: t('Shadcn UI 라벨 색상 선택', 'Shadcn UI Select Label Color'),
},
},
{
key: 'categoryPath',
label: 'Shadcn UI Cascader',
width: 200,
editable: true,
editor: shadcnCategoryEditor,
itemRender: ({ value }) => <>{Array.isArray(value) ? value.join(' / ') : ''}</>,
getClipboardText: ({ value }) => formatCascaderClipboardText(value),
parseClipboardText: parseCascaderClipboardText,
editorIcon: {
render: <ChevronDown className="h-3.5 w-3.5" />,
ariaLabel: t('Shadcn UI 분류 경로 선택', 'Shadcn UI Select Category Path'),
},
},
{
key: 'deliveryTime',
label: 'Shadcn UI TimePicker',
width: 190,
editable: true,
editor: shadcnDeliveryTimeEditor,
editorIcon: {
render: <Clock className="h-3.5 w-3.5" />,
ariaLabel: t('Shadcn UI 배송 시간 선택', 'Shadcn UI Select Delivery Time'),
},
},
{
key: 'organization',
label: 'Shadcn UI TreeSelect',
width: 210,
editable: true,
editor: shadcnOrganizationEditor,
editorIcon: {
render: <ChevronDown className="h-3.5 w-3.5" />,
ariaLabel: t('Shadcn UI 담당 조직 선택', 'Shadcn UI Select Responsible Organization'),
},
},
]),
[],
);
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'>
{t('Shadcn UI (Radix UI) 기반의 Select, DatePicker, ColorPicker, Cascader, TimePicker, TreeSelect 컴포넌트를', 'Shadcn UI (Radix UI) based Select, DatePicker, ColorPicker, Cascader, TimePicker, TreeSelect components connected via')}{' '}
<code className="dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">defineEditorPlugin()</code>{t('으로 연결했습니다. 각 컴포넌트의 Portal 컨테이너로', '. By passing')} <code className="dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">getPortalContainer()</code>{t('를 전달하여 Grid의 가상 스크롤, 위치 계산, 바깥 클릭 판정 및 라이프사이클과 완벽히 동기화됩니다.', ' as the Portal container for each component, it perfectly synchronizes with the Grid\'s virtual scrolling, position calculation, outside click detection, and lifecycle.')}
</p>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<BGrid<ExternalShadcnOrder>
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>
);
}export function formatCascaderClipboardText(value: unknown): string {
if (!Array.isArray(value) || !value.every(segment => typeof segment === 'string')) return '';
return JSON.stringify(value);
}
export function parseCascaderClipboardText(text: string): string[] {
const value: unknown = JSON.parse(text);
if (!Array.isArray(value) || value.length === 0 || !value.every(segment => typeof segment === 'string')) {
throw new TypeError('Cascader clipboard value must be a non-empty JSON string array.');
}
return value;
}import * as React from 'react';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import './shadcnEditorPlugins.css';
interface Option<Value extends string | number> {
value: Value;
label: React.ReactNode;
}
interface Options<Value extends string | number> {
id: string;
ariaLabel: string;
options: Option<Value>[];
}
export function createShadcnSelectEditorPlugin<T, Value extends string | number>(
options: Options<Value>,
): BGridPluginEditorConfig<T> {
function ShadcnSelectEditor({ value, column, commit, cancel, getPortalContainer }: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const selectedValue = value == null ? undefined : String(value);
return (
<Select
value={selectedValue}
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
onValueChange={nextValue => {
const selectedOption = options.options.find(option => String(option.value) === nextValue);
if (selectedOption) void commit([{ key: column.key, value: selectedOption.value }]);
}}
>
<SelectTrigger
className="bgrid-shadcn-trigger border-0 focus:ring-0 rounded-none bg-transparent"
aria-label={options.ariaLabel}
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<SelectValue className="bgrid-shadcn-trigger-label" />
</SelectTrigger>
<SelectContent container={getPortalContainer()}>
{options.options.map(opt => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
ShadcnSelectEditor.displayName = `ShadcnSelectEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: ShadcnSelectEditor,
});
}import { t } from '../i18n';
import * as React from 'react';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../components/ui/popover';
import { Button } from '../../components/ui/button';
import './shadcnEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
}
export function createShadcnDatePickerEditorPlugin<T>(
options: Options,
): BGridPluginEditorConfig<T> {
function ShadcnDatePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
// Parse current date value (YYYY-MM-DD)
const initialDate = React.useMemo(() => {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
return new Date();
}, [value]);
const [currentMonth, setCurrentMonth] = React.useState(
new Date(initialDate.getFullYear(), initialDate.getMonth(), 1),
);
const handleSelectDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
void commit([{ key: column.key, value: dateStr }]);
};
const handlePrevMonth = (e: React.MouseEvent) => {
e.stopPropagation();
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
const handleNextMonth = (e: React.MouseEvent) => {
e.stopPropagation();
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const handleQuickPreset = (offsetDays: number) => {
const target = new Date();
target.setDate(target.getDate() + offsetDays);
handleSelectDate(target);
};
// Calculate calendar grid days
const calendarDays = React.useMemo(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDayOfWeek = new Date(year, month, 1).getDay(); // 0 = Sun
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrevMonth = new Date(year, month, 0).getDate();
const days: Array<{ date: Date; isCurrentMonth: boolean; dateStr: string }> = [];
// Prev month trailing days
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const d = new Date(year, month - 1, daysInPrevMonth - i);
days.push({
date: d,
isCurrentMonth: false,
dateStr: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
});
}
// Current month days
for (let i = 1; i <= daysInMonth; i++) {
const d = new Date(year, month, i);
days.push({
date: d,
isCurrentMonth: true,
dateStr: `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
});
}
// Next month leading days to complete grid (up to 35 or 42)
const remaining = (7 - (days.length % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
const d = new Date(year, month + 1, i);
days.push({
date: d,
isCurrentMonth: false,
dateStr: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
});
}
return days;
}, [currentMonth]);
const formattedValue = typeof value === 'string' ? value : '';
const todayStr = new Date().toISOString().slice(0, 10);
return (
<Popover
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="bgrid-shadcn-trigger"
aria-label={options.ariaLabel}
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className="bgrid-shadcn-trigger-label">{formattedValue || t('날짜 선택', 'Select Date')}</span>
<span className="bgrid-shadcn-trigger-icon">
<CalendarIcon />
</span>
</button>
</PopoverTrigger>
<PopoverContent
container={getPortalContainer()}
className="w-auto p-3"
align="start"
>
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between px-1">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{currentMonth.getFullYear()}{t('년', '')} {currentMonth.getMonth() + 1}{t('월', '')}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
onClick={handlePrevMonth}
className="h-7 w-7 bg-transparent opacity-70 hover:opacity-100 dark:border-slate-800"
aria-label={t("이전 달", "Previous Month")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleNextMonth}
className="h-7 w-7 bg-transparent opacity-70 hover:opacity-100 dark:border-slate-800"
aria-label={t("다음 달", "Next Month")}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Weekdays */}
<div className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-slate-400 dark:text-slate-500">
<span className="w-8">{t('일', 'Sun')}</span>
<span className="w-8">{t('월', 'Mon')}</span>
<span className="w-8">{t('화', 'Tue')}</span>
<span className="w-8">{t('수', 'Wed')}</span>
<span className="w-8">{t('목', 'Thu')}</span>
<span className="w-8">{t('금', 'Fri')}</span>
<span className="w-8">{t('토', 'Sat')}</span>
</div>
{/* Days Grid */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map(({ date, isCurrentMonth, dateStr }) => {
const isSelected = dateStr === formattedValue;
const isToday = dateStr === todayStr;
return (
<button
key={dateStr}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => handleSelectDate(date)}
className={`bgrid-shadcn-date-item flex h-8 w-8 items-center justify-center rounded-md text-xs transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-bold text-white shadow-sm dark:bg-blue-600 dark:text-white'
: isToday
? 'border border-blue-600 font-bold text-blue-600 bg-transparent dark:border-blue-400 dark:text-blue-400'
: isCurrentMonth
? 'bg-transparent font-medium text-slate-800 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800'
: 'bg-transparent font-normal text-slate-400 opacity-40 hover:bg-slate-50 dark:text-slate-600 dark:hover:bg-slate-900'
}`}
>
{date.getDate()}
</button>
);
})}
</div>
{/* Quick Presets */}
<div className="flex items-center justify-between border-t border-slate-200 pt-2 text-xs dark:border-slate-800">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleQuickPreset(0)}
className="h-7 px-2 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
>
{t('오늘', 'Today')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleQuickPreset(1)}
className="h-7 px-2 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
>
{t('내일', 'Tomorrow')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleQuickPreset(7)}
className="h-7 px-2 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
>
{t('1주일 후', 'In 1 Week')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
ShadcnDatePickerEditor.displayName = `ShadcnDatePickerEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: ShadcnDatePickerEditor,
});
}import { t } from '../i18n';
import * as React from 'react';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import { Check } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../components/ui/popover';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import './shadcnEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
colors?: string[];
}
const DEFAULT_PRESET_COLORS = [
'#0F172A', // Slate 900
'#64748B', // Slate 500
'#EF4444', // Red 500
'#F97316', // Orange 500
'#F59E0B', // Amber 500
'#10B981', // Emerald 500
'#06B6D4', // Cyan 500
'#3B82F6', // Blue 500
'#6366F1', // Indigo 500
'#8B5CF6', // Violet 500
'#D946EF', // Fuchsia 500
'#F43F5E', // Rose 500
'#1677FF', // Primary Blue
'#13C2C2', // Cyan
'#52C41A', // Green
'#FA8C16', // Orange
];
export function createShadcnColorPickerEditorPlugin<T>(
options: Options,
): BGridPluginEditorConfig<T> {
const palette = options.colors || DEFAULT_PRESET_COLORS;
function ShadcnColorPickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const initialHex = typeof value === 'string' && value ? value : '#3B82F6';
const [selectedColor, setSelectedColor] = React.useState(initialHex);
const [customHex, setCustomHex] = React.useState(initialHex);
const handleSelectColor = (color: string) => {
setSelectedColor(color);
setCustomHex(color);
void commit([{ key: column.key, value: color }]);
};
const handleApplyCustom = (e: React.FormEvent) => {
e.preventDefault();
const cleanHex = customHex.startsWith('#') ? customHex : `#${customHex}`;
if (/^#[0-9A-Fa-f]{6}$/.test(cleanHex) || /^#[0-9A-Fa-f]{3}$/.test(cleanHex)) {
void commit([{ key: column.key, value: cleanHex.toUpperCase() }]);
}
};
return (
<Popover
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="bgrid-shadcn-trigger"
aria-label={options.ariaLabel}
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className="bgrid-shadcn-trigger-label">{selectedColor.toUpperCase()}</span>
<span className="bgrid-shadcn-trigger-icon">
<span
className="bgrid-color-swatch"
style={{ backgroundColor: selectedColor }}
aria-hidden="true"
/>
</span>
</button>
</PopoverTrigger>
<PopoverContent
container={getPortalContainer()}
className="w-64 p-3"
align="start"
>
<div className="flex flex-col gap-3">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider dark:text-slate-400">
{t('색상 팔레트', 'Color Palette')}
</div>
{/* Color Swatch Grid */}
<div className="grid grid-cols-4 gap-2">
{palette.map(color => {
const isSelected = selectedColor.toUpperCase() === color.toUpperCase();
return (
<button
key={color}
type="button"
onClick={() => handleSelectColor(color)}
style={{ backgroundColor: color }}
className="relative flex h-8 w-full items-center justify-center rounded-md border border-black/10 shadow-sm transition-transform hover:scale-105 active:scale-95 cursor-pointer dark:border-white/15"
aria-label={`${t('색상', 'Color')} ${color}`}
>
{isSelected && (
<Check
className="h-4 w-4 drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] text-white"
strokeWidth={3}
/>
)}
</button>
);
})}
</div>
{/* Custom HEX Input */}
<form onSubmit={handleApplyCustom} className="flex flex-col gap-1.5 border-t border-slate-200 pt-2 dark:border-slate-800">
<span className="text-xs text-slate-500 font-medium dark:text-slate-400">{t('직접 입력 (HEX)', 'Direct Input (HEX)')}</span>
<div className="flex items-center gap-1.5">
<Input
type="text"
value={customHex}
onChange={e => setCustomHex(e.target.value)}
placeholder="#000000"
className="font-mono bg-white text-slate-900 border-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:border-slate-700"
/>
<Button
type="submit"
size="sm"
className="shrink-0 bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700"
>
{t('적용', 'Apply')}
</Button>
</div>
</form>
</div>
</PopoverContent>
</Popover>
);
}
ShadcnColorPickerEditor.displayName = `ShadcnColorPickerEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: ShadcnColorPickerEditor,
});
}import { t } from '../i18n';
import * as React from 'react';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import { ChevronDown, ChevronRight } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../components/ui/popover';
import './shadcnEditorPlugins.css';
export interface CascaderOption {
value: string;
label: React.ReactNode;
children?: CascaderOption[];
}
interface Options {
id: string;
ariaLabel: string;
options: CascaderOption[];
}
export function createShadcnCascaderEditorPlugin<T>(
options: Options,
): BGridPluginEditorConfig<T> {
function ShadcnCascaderEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const initialPath = Array.isArray(value) ? (value as string[]) : [];
const [selectedPath, setSelectedPath] = React.useState<string[]>(initialPath);
// Get current options at each level
const level1Options = options.options;
const level1Selected = level1Options.find(o => o.value === selectedPath[0]);
const level2Options = level1Selected?.children || [];
const level2Selected = level2Options.find(o => o.value === selectedPath[1]);
const level3Options = level2Selected?.children || [];
const handleSelectLevel = (levelIndex: number, option: CascaderOption) => {
const nextPath = selectedPath.slice(0, levelIndex);
nextPath[levelIndex] = option.value;
setSelectedPath(nextPath);
// If leaf node (no children), commit immediately
if (!option.children || option.children.length === 0) {
void commit([{ key: column.key, value: nextPath }]);
}
};
const displayText = initialPath.length > 0 ? initialPath.join(' / ') : '';
return (
<Popover
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="bgrid-shadcn-trigger"
aria-label={options.ariaLabel}
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className="bgrid-shadcn-trigger-label">{displayText || t('분류 선택', 'Select Category')}</span>
<span className="bgrid-shadcn-trigger-icon">
<ChevronDown />
</span>
</button>
</PopoverTrigger>
<PopoverContent
container={getPortalContainer()}
className="w-auto p-2"
align="start"
>
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold text-slate-500 px-2 pt-1 uppercase tracking-wider dark:text-slate-400">
{t('계층 분류 선택', 'Select Hierarchical Category')}
</div>
<div className="flex divide-x divide-slate-200 rounded-md border border-slate-200 bg-slate-50/40 dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-900/40">
{/* Level 1 Panel */}
<div className="bgrid-shadcn-scroll flex max-h-56 w-32 flex-col gap-0.5 overflow-y-auto p-1 bg-transparent">
{level1Options.map(opt => {
const isSelected = selectedPath[0] === opt.value;
const hasChildren = Boolean(opt.children?.length);
return (
<button
key={opt.value}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => handleSelectLevel(0, opt)}
className={`bgrid-shadcn-cascader-item flex w-full items-center justify-between rounded-sm px-2.5 text-xs transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-semibold text-white dark:bg-blue-600 dark:text-white'
: 'bg-transparent text-slate-700 hover:bg-slate-200/70 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
<span className="truncate">{opt.label}</span>
{hasChildren && (
<ChevronRight className={`h-3.5 w-3.5 shrink-0 ${isSelected ? 'text-white' : 'text-slate-400'}`} />
)}
</button>
);
})}
</div>
{/* Level 2 Panel */}
{level2Options.length > 0 && (
<div className="bgrid-shadcn-scroll flex max-h-56 w-32 flex-col gap-0.5 overflow-y-auto p-1 bg-transparent">
{level2Options.map(opt => {
const isSelected = selectedPath[1] === opt.value;
const hasChildren = Boolean(opt.children?.length);
return (
<button
key={opt.value}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => handleSelectLevel(1, opt)}
className={`bgrid-shadcn-cascader-item flex w-full items-center justify-between rounded-sm px-2.5 text-xs transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-semibold text-white dark:bg-blue-600 dark:text-white'
: 'bg-transparent text-slate-700 hover:bg-slate-200/70 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
<span className="truncate">{opt.label}</span>
{hasChildren && (
<ChevronRight className={`h-3.5 w-3.5 shrink-0 ${isSelected ? 'text-white' : 'text-slate-400'}`} />
)}
</button>
);
})}
</div>
)}
{/* Level 3 Panel if exists */}
{level3Options.length > 0 && (
<div className="bgrid-shadcn-scroll flex max-h-56 w-32 flex-col gap-0.5 overflow-y-auto p-1 bg-transparent">
{level3Options.map(opt => {
const isSelected = selectedPath[2] === opt.value;
return (
<button
key={opt.value}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => handleSelectLevel(2, opt)}
className={`bgrid-shadcn-cascader-item flex w-full items-center justify-between rounded-sm px-2.5 text-xs transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-semibold text-white dark:bg-blue-600 dark:text-white'
: 'bg-transparent text-slate-700 hover:bg-slate-200/70 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
)}
</div>
{selectedPath.length > 0 && (
<div className="flex items-center justify-between border-t border-slate-200 px-2 pt-2 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
<span>{t('선택 경로', 'Selected Path')}: <strong className="text-slate-900 dark:text-slate-100">{selectedPath.join(' > ')}</strong></span>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
ShadcnCascaderEditor.displayName = `ShadcnCascaderEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: ShadcnCascaderEditor,
});
}import { t } from '../i18n';
import * as React from 'react';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import { Clock } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../components/ui/popover';
import { Button } from '../../components/ui/button';
import './shadcnEditorPlugins.css';
interface Options {
id: string;
ariaLabel: string;
minuteStep?: number;
}
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
export function createShadcnTimePickerEditorPlugin<T>(
options: Options,
): BGridPluginEditorConfig<T> {
const step = options.minuteStep || 5;
const MINUTES = Array.from({ length: 60 / step }, (_, i) => String(i * step).padStart(2, '0'));
function ShadcnTimePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const initialTime = React.useMemo(() => {
if (typeof value === 'string' && /^\d{2}:\d{2}$/.test(value)) {
const [h, m] = value.split(':');
return { hour: h, minute: m };
}
return { hour: '09', minute: '00' };
}, [value]);
const [selectedHour, setSelectedHour] = React.useState(initialTime.hour);
const [selectedMinute, setSelectedMinute] = React.useState(initialTime.minute);
const handleConfirm = () => {
const formatted = `${selectedHour}:${selectedMinute}`;
void commit([{ key: column.key, value: formatted }]);
};
const handlePreset = (hour: string, minute: string) => {
setSelectedHour(hour);
setSelectedMinute(minute);
void commit([{ key: column.key, value: `${hour}:${minute}` }]);
};
const displayText = typeof value === 'string' && value ? value : '';
return (
<Popover
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="bgrid-shadcn-trigger"
aria-label={options.ariaLabel}
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className="bgrid-shadcn-trigger-label">{displayText || t('시간 선택', 'Select Time')}</span>
<span className="bgrid-shadcn-trigger-icon">
<Clock />
</span>
</button>
</PopoverTrigger>
<PopoverContent
container={getPortalContainer()}
className="w-64 p-3"
align="start"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider dark:text-slate-400">
{t('시간 선택', 'Select Time')}
</span>
<span className="font-mono text-sm font-bold text-slate-900 dark:text-slate-100">
{selectedHour}:{selectedMinute}
</span>
</div>
{/* Time Columns (Hour & Minute) */}
<div className="grid grid-cols-2 gap-2 rounded-md border border-slate-200 bg-slate-50/40 p-1.5 dark:border-slate-800 dark:bg-slate-900/40">
{/* Hours */}
<div className="flex flex-col">
<span className="text-center text-[10px] font-semibold text-slate-400 pb-1 dark:text-slate-500">{t('시', 'Hour')}</span>
<div className="bgrid-shadcn-scroll flex max-h-40 flex-col gap-0.5 overflow-y-auto pr-1">
{HOURS.map(hour => {
const isSelected = selectedHour === hour;
return (
<button
key={hour}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => setSelectedHour(hour)}
className={`bgrid-shadcn-time-item rounded-sm py-1 text-xs font-mono transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-bold text-white dark:bg-blue-600 dark:text-white'
: 'bg-transparent text-slate-700 hover:bg-slate-200/70 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{hour}
</button>
);
})}
</div>
</div>
{/* Minutes */}
<div className="flex flex-col">
<span className="text-center text-[10px] font-semibold text-slate-400 pb-1 dark:text-slate-500">{t('분', 'Minute')}</span>
<div className="bgrid-shadcn-scroll flex max-h-40 flex-col gap-0.5 overflow-y-auto pr-1">
{MINUTES.map(minute => {
const isSelected = selectedMinute === minute;
return (
<button
key={minute}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => setSelectedMinute(minute)}
className={`bgrid-shadcn-time-item rounded-sm py-1 text-xs font-mono transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-bold text-white dark:bg-blue-600 dark:text-white'
: 'bg-transparent text-slate-700 hover:bg-slate-200/70 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{minute}
</button>
);
})}
</div>
</div>
</div>
{/* Presets */}
<div className="flex flex-wrap gap-1 border-t border-slate-200 pt-2 dark:border-slate-800">
{[
{ label: '09:00', h: '09', m: '00' },
{ label: '11:00', h: '11', m: '00' },
{ label: '14:30', h: '14', m: '30' },
{ label: '16:00', h: '16', m: '00' },
].map(p => (
<Button
key={p.label}
type="button"
variant="outline"
size="sm"
onClick={() => handlePreset(p.h, p.m)}
className="h-6 px-2 text-[11px] font-mono dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
{p.label}
</Button>
))}
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-1.5 border-t border-slate-200 pt-2 dark:border-slate-800">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancel}
className="h-7 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
>
{t('취소', 'Cancel')}
</Button>
<Button
type="button"
variant="default"
size="sm"
onClick={handleConfirm}
className="h-7 text-xs bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700"
>
{t('확인', 'Confirm')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
ShadcnTimePickerEditor.displayName = `ShadcnTimePickerEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: ShadcnTimePickerEditor,
});
}import * as React from 'react';
import type { BGridEditorPluginProps, BGridPluginEditorConfig } from 'beautiful-grid';
import { defineEditorPlugin } from 'beautiful-grid/editors';
import { Check, ChevronDown, ChevronRight, Folder, Search } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../components/ui/popover';
import './shadcnEditorPlugins.css';
import { t } from '../i18n';
export interface TreeNode {
title: string;
value?: string;
children?: TreeNode[];
}
interface Options {
id: string;
ariaLabel: string;
treeData: TreeNode[];
}
export function createShadcnTreeSelectEditorPlugin<T>(
options: Options,
): BGridPluginEditorConfig<T> {
function ShadcnTreeSelectEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<T>) {
const [open, setOpen] = React.useState(true);
const [searchQuery, setSearchQuery] = React.useState('');
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({
[t('영업본부', 'Sales Headquarters')]: true,
[t('운영본부', 'Operations Headquarters')]: true,
});
const toggleGroup = (title: string) => {
setExpandedGroups(prev => ({ ...prev, [title]: !prev[title] }));
};
const handleSelectNode = (nodeValue: string) => {
void commit([{ key: column.key, value: nodeValue }]);
};
const currentValue = typeof value === 'string' ? value : '';
// Filter nodes by query
const filteredTree = React.useMemo(() => {
if (!searchQuery.trim()) return options.treeData;
const query = searchQuery.toLowerCase();
return options.treeData
.map(group => {
const groupMatches = group.title.toLowerCase().includes(query);
const filteredChildren = group.children?.filter(
child =>
child.title.toLowerCase().includes(query) ||
(child.value && child.value.toLowerCase().includes(query)),
);
if (groupMatches || (filteredChildren && filteredChildren.length > 0)) {
return {
...group,
children: filteredChildren ?? group.children,
};
}
return null;
})
.filter(Boolean) as TreeNode[];
}, [searchQuery]);
return (
<Popover
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="bgrid-shadcn-trigger"
aria-label={options.ariaLabel}
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<span className="bgrid-shadcn-trigger-label">{currentValue || t('조직 선택', 'Select Organization')}</span>
<span className="bgrid-shadcn-trigger-icon">
<ChevronDown />
</span>
</button>
</PopoverTrigger>
<PopoverContent
container={getPortalContainer()}
className="w-72 p-3"
align="start"
>
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider dark:text-slate-400">
{t('조직도 선택', 'Select Organization Chart')}
</div>
{/* Search filter */}
<div className="flex h-8 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-2.5 dark:border-slate-700 dark:bg-slate-900">
<Search className="h-3.5 w-3.5 text-slate-400 dark:text-slate-400 shrink-0" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('조직 검색...', 'Search organization...')}
className="w-full bg-transparent text-xs outline-none border-0 p-0 text-slate-900 placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
{/* Tree nodes list */}
<div className="bgrid-shadcn-scroll flex max-h-56 flex-col gap-1 overflow-y-auto pt-1">
{filteredTree.map(group => {
const isExpanded = expandedGroups[group.title] ?? true;
const hasChildren = Boolean(group.children?.length);
return (
<div key={group.title} className="flex flex-col gap-0.5">
{/* Group Header */}
<button
type="button"
onClick={() => toggleGroup(group.title)}
className="bgrid-shadcn-tree-group flex h-7 min-h-[28px] w-full items-center gap-1.5 rounded-md px-2 text-left text-xs font-semibold text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 transition-colors cursor-pointer border-0 bg-transparent"
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-slate-400 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-slate-400 shrink-0" />
)
) : (
<span className="w-3.5 shrink-0" />
)}
<Folder className="h-4 w-4 text-slate-400 dark:text-slate-500 shrink-0" />
<span className="truncate">{group.title}</span>
</button>
{/* Children */}
{hasChildren && isExpanded && (
<div className="ml-3.5 my-0.5 flex flex-col gap-0.5 border-l border-slate-200 pl-2 dark:border-slate-800">
{group.children?.map(child => {
const childVal = child.value || child.title;
const isSelected = currentValue === childVal;
return (
<button
key={childVal}
type="button"
data-selected={isSelected ? 'true' : undefined}
onClick={() => handleSelectNode(childVal)}
className={`bgrid-shadcn-tree-item flex h-7 min-h-[28px] w-full items-center justify-between rounded-md px-2.5 text-left text-xs transition-colors cursor-pointer border-0 ${
isSelected
? 'bg-blue-600 font-medium text-white shadow-sm dark:bg-blue-600 dark:text-white'
: 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100'
}`}
>
<span className="truncate">{child.title}</span>
{isSelected && <Check className="h-3.5 w-3.5 shrink-0 text-white ml-2" />}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
);
}
ShadcnTreeSelectEditor.displayName = `ShadcnTreeSelectEditor(${options.id})`;
return defineEditorPlugin<T>({
id: options.id,
component: ShadcnTreeSelectEditor,
});
}import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "./utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"bgrid-shadcn-select-trigger flex h-full w-full items-center justify-between whitespace-nowrap border-0 bg-transparent px-[6.5px] text-[13px] outline-none cursor-pointer placeholder:text-slate-500 focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<span className="bgrid-shadcn-trigger-icon">
<ChevronDown className="h-3.5 w-3.5" />
</span>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & { container?: HTMLElement | null }
>(({ className, children, position = "popper", container, ...props }, ref) => (
<SelectPrimitive.Portal container={container}>
<SelectPrimitive.Content
ref={ref}
className={cn(
"bgrid-shadcn-select-content relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
position === "popper" &&
"data-[side=bottom]:translate-y-0 data-[side=left]:translate-x-0 data-[side=right]:translate-x-0 data-[side=top]:translate-y-0",
className
)}
position={position}
sideOffset={0}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-semibold text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs outline-none hover:bg-slate-100 hover:text-slate-900 focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[state=checked]:bg-blue-50 data-[state=checked]:text-blue-700 data-[state=checked]:font-semibold dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus:bg-slate-800 dark:focus:text-slate-50 dark:data-[state=checked]:bg-blue-950/50 dark:data-[state=checked]:text-blue-300",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "./utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & { container?: HTMLElement | null }
>(({ className, align = "start", sideOffset = 0, container, ...props }, ref) => (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"bgrid-shadcn-popover z-50 w-auto rounded-md border border-slate-200 bg-white p-3 text-slate-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}import { BGridPluginEditorConfig } from '../types';
export function defineEditorPlugin<T>(config: Omit<BGridPluginEditorConfig<T>, 'type'>): BGridPluginEditorConfig<T> {
return {
type: 'plugin',
...config,
};
}BeautifulGrid는 Shadcn UI 방식에 맞춰 공식 에디터 플러그인을 Shadcn Registry 소스로 제공합니다. npm 패키지 내부의 구현을 불러오는 방식이 아니라, 프로젝트가 설치된 소스를 직접 소유하고 디자인 시스템에 맞게 수정할 수 있습니다.
공식 플러그인에는 Select, DatePicker, ColorPicker, Cascader, TimePicker, TreeSelect factory가 포함됩니다. 각 factory는 Grid의 Portal과 편집 수명주기를 이미 연결하므로 애플리케이션에서는 옵션을 구성한 뒤 컬럼의 editor에 지정하면 됩니다.
text·기본 Select·Date만 필요하다면 내장·기본 제공 에디터를, 다른 공식 통합과의 비교는 공식 에디터 플러그인을 확인하세요.
1. 공식 플러그인 설치와 사용
Shadcn CLI로 BeautifulGrid 공식 Registry 항목을 추가합니다.
npx shadcn@latest add https://raw.githubusercontent.com/axisj/beautiful-grid-plugins/main/public/r/beautiful-grid-editors.json
기본 설정에서는 components/beautiful-grid 아래에 6개 editor factory와 공통 Portal 컴포넌트, 스타일시트가 생성됩니다. 생성된 진입점에서 필요한 factory를 가져옵니다.
import { createShadcnSelectEditorPlugin } from '@/components/beautiful-grid';
type Order = {
status: 'ready' | 'progress' | 'done';
};
const statusEditor = createShadcnSelectEditorPlugin<Order, Order['status']>({
id: 'order-status',
ariaLabel: '주문 상태 선택',
options: [
{ value: 'ready', label: '접수' },
{ value: 'progress', label: '진행' },
{ value: 'done', label: '완료' },
],
});
const columns: BGridColumn<Order>[] = [
{ key: 'status', label: '상태', editable: true, editor: statusEditor },
];
공식 Registry 진입점은 플러그인 스타일도 함께 불러옵니다. 아래 라이브 데모는 6개 에디터의 동작을 보여주며, 실제 프로젝트에서는 위 Registry 설치 명령으로 생성된 소스를 사용합니다.
2. 내부 구현 이해: Select와 Portal
Shadcn UI의 Select 컴포넌트는 SelectContent 팝업을 Portal로 렌더링합니다. getPortalContainer()를 전달하고, onValueChange에서 변경 배열을 commit합니다.
function ShadcnSelectEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<Task>) {
const [open, setOpen] = React.useState(true);
return (
<Select
value={value == null ? undefined : String(value)}
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) cancel(); // 닫힐 때 변경 사항이 없다면 cancel
}}
onValueChange={nextValue => void commit([{ key: column.key, value: nextValue }])}
>
<SelectTrigger
className="h-full w-full border-none focus:ring-0 rounded-none bg-transparent"
autoFocus
onKeyDown={event => {
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
cancel();
}
}}
>
<SelectValue />
</SelectTrigger>
{/* getPortalContainer()를 통해 Grid 전용 팝업 컨테이너에 렌더링 */}
<SelectContent container={getPortalContainer()}>
{statusOptions.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export const shadcnStatusEditor = defineEditorPlugin<Task>({
id: 'shadcn-status',
component: ShadcnSelectEditor,
});
commit은 단일 값도 항상 길이 1의 변경 배열로 받습니다.
3. DatePicker와 ColorPicker 연결 (Radix Popover)
달력 날짜 선택과 색상 팔레트는 Shadcn UI의 Popover 컴포넌트를 기반으로 구성합니다.
DatePicker (Calendar Popover)
날짜는 앱의 저장 형식(예: YYYY-MM-DD)으로 변환한 뒤 commit합니다.
function ShadcnDatePickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<Task>) {
const [open, setOpen] = React.useState(true);
return (
<Popover open={open} onOpenChange={nextOpen => { setOpen(nextOpen); if (!nextOpen) cancel(); }}>
<PopoverTrigger asChild>
<button className="flex h-full w-full items-center justify-between px-2 text-sm">
<span>{String(value || '날짜 선택')}</span>
<CalendarIcon className="h-4 w-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent container={getPortalContainer()} className="w-auto p-3" align="start">
<CalendarView
selectedDate={value ? new Date(String(value)) : new Date()}
onSelect={date => void commit([{ key: column.key, value: formatDate(date) }])}
/>
</PopoverContent>
</Popover>
);
}
ColorPicker (Palette Grid & HEX Input)
색상 팔레트 그리드 클릭 시 즉시 commit하거나, 커스텀 HEX 입력 후 저장할 수 있습니다.
function ShadcnColorPickerEditor({
value,
column,
commit,
cancel,
getPortalContainer,
}: BGridEditorPluginProps<Task>) {
const [open, setOpen] = React.useState(true);
return (
<Popover open={open} onOpenChange={nextOpen => { setOpen(nextOpen); if (!nextOpen) cancel(); }}>
<PopoverTrigger asChild>
<button className="flex h-full w-full items-center gap-2 px-2 text-sm">
<span className="h-4 w-4 rounded-full border" style={{ backgroundColor: String(value) }} />
<span className="font-mono text-[13px]">{String(value)}</span>
</button>
</PopoverTrigger>
<PopoverContent container={getPortalContainer()} className="w-64 p-3" align="start">
<ColorPaletteGrid onSelect={color => void commit([{ key: column.key, value: color }])} />
</PopoverContent>
</Popover>
);
}
4. Cascader, TimePicker, TreeSelect 연결
Cascader (다단계 계층 선택)
카테고리나 지역 분류 등 다단계 계층 구조를 순차 패널로 탐색하고, 최종 리프 노드 선택 시 전체 경로 string[]을 commit합니다.
function ShadcnCascaderEditor({ value, column, commit, cancel, getPortalContainer }: BGridEditorPluginProps<Task>) {
return (
<Popover open onOpenChange={open => { if (!open) cancel(); }}>
<PopoverTrigger asChild>
<button className="flex h-full w-full items-center justify-between px-2 text-sm">
<span>{Array.isArray(value) ? value.join(' / ') : '분류 선택'}</span>
</button>
</PopoverTrigger>
<PopoverContent container={getPortalContainer()} className="w-auto p-2" align="start">
<CascaderPanel
options={categoryTree}
onSelectPath={path => void commit([{ key: column.key, value: path }])}
/>
</PopoverContent>
</Popover>
);
}
Cascader 복사·붙여넣기
Shadcn UI Cascader가 string[]을 commit하더라도 Grid 클립보드는 text/plain만 전달합니다. itemRender의 국내 / 서울 표시는 화면 전용이므로, 붙여넣기 문자열을 배열로 되돌리지 않으면 다음 editor 진입 때 선택 경로가 사라집니다. 라이브 예제는 컬럼에 다음 계약을 둡니다.
{
key: 'categoryPath',
editor: shadcnCategoryEditor,
itemRender: ({ value }) => <>{Array.isArray(value) ? value.join(' / ') : ''}</>,
getClipboardText: ({ value }) => JSON.stringify(value),
parseClipboardText: text => {
const parsed: unknown = JSON.parse(text);
if (!Array.isArray(parsed) || !parsed.every(segment => typeof segment === 'string')) {
throw new TypeError('분류 경로는 JSON 문자열 배열이어야 합니다.');
}
return parsed;
},
}
이렇게 하면 복사된 ["국내","서울"]이 붙여넣기 시 다시 string[]가 되고, idle 셀과 Shadcn trigger가 모두 국내 / 서울을 표시합니다. 일반적인 값 타입별 변환 우선순위와 검증 규칙은 AntD 예제의 복사·붙여넣기 값 변환을 참고하세요. 핵심은 itemRender는 화면 표시, getClipboardText는 직렬화, parseClipboardText는 타입 복원이라는 세 책임을 분리하는 것입니다.
TimePicker (시간·분 선택)
시(0023)와 분(0055) 컬럼 선택 패널과 자주 쓰는 시간 프리셋을 제공하며, 확인 버튼 클릭 시 HH:mm 문자열로 commit합니다.
function ShadcnTimePickerEditor({ value, column, commit, cancel, getPortalContainer }: BGridEditorPluginProps<Task>) {
const [time, setTime] = React.useState(parseTime(value));
return (
<Popover open onOpenChange={open => { if (!open) cancel(); }}>
<PopoverTrigger asChild>
<button className="flex h-full w-full items-center justify-between px-2 text-sm font-mono">
<span>{String(value || '09:00')}</span>
</button>
</PopoverTrigger>
<PopoverContent container={getPortalContainer()} className="w-64 p-3" align="start">
<TimeColumnPicker time={time} onChange={setTime} />
<Button onClick={() => void commit([{ key: column.key, value: formatTime(time) }])}>확인</Button>
</PopoverContent>
</Popover>
);
}
TreeSelect (조직도 검색 및 트리 선택)
검색창이 포함된 트리 구조 조직도 선택기입니다. 검색어 필터링과 그룹 접기/펼치기를 지원하며, 하위 노드 선택 시 해당 부서명을 commit합니다.
function ShadcnTreeSelectEditor({ value, column, commit, cancel, getPortalContainer }: BGridEditorPluginProps<Task>) {
return (
<Popover open onOpenChange={open => { if (!open) cancel(); }}>
<PopoverTrigger asChild>
<button className="flex h-full w-full items-center justify-between px-2 text-sm">
<span>{String(value || '조직 선택')}</span>
</button>
</PopoverTrigger>
<PopoverContent container={getPortalContainer()} className="w-64 p-3" align="start">
<TreeSearchList
data={organizationTree}
selected={String(value)}
onSelect={nodeVal => void commit([{ key: column.key, value: nodeVal }])}
/>
</PopoverContent>
</Popover>
);
}
5. Popup Portal과 Shadcn UI 설정
공식 Registry 소스에는 팝업을 Grid 전용 floating portal root에 렌더링하는 구현이 이미 포함되어 있습니다. 설치된 소스를 디자인 시스템에 맞게 수정할 때도 SelectContent, PopoverContent의 container 전달은 유지해야 합니다.
// components/ui/popover.tsx
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & { container?: HTMLElement | null }
>(({ className, align = "start", sideOffset = 4, container, ...props }, ref) => (
// container 속성을 Radix Portal로 전달
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn("z-50 rounded-md border bg-white p-3 shadow-md", className)}
{...props}
/>
</PopoverPrimitive.Portal>
));
getPortalContainer()가 반환하는 컨테이너는 다음과 같은 이점이 있습니다:
- Grid 테마 CSS 변수(
--bgrid-*) 자동 상속 - 가상 스크롤 및 frozen 컬럼 영역 계산에 포함되어 팝업이 그리드와 함께 정밀하게 동기화
- 팝업 클릭이 Grid 외부 클릭으로 오인되어 세션이 예기치 않게 종료되는 문제 방지
6. 여러 컬럼 원자적 저장과 직접 확장
단일 에디터 조작으로 여러 연관 컬럼을 함께 갱신해야 할 때는 commit에 여러 변경 항목을 배열로 전달합니다.
await commit([
{ key: 'categoryCode', value: selected.code },
{ key: 'categoryName', value: selected.name },
]);
저장 또는 취소 후 DOM 포커스는 Grid가 원래 활성 셀로 안전하게 복원합니다.
공식 factory로 해결되지 않는 앱 전용 입력이나 비동기 자동완성은 defineEditorPlugin()으로 별도 플러그인을 정의할 수 있습니다. 일반 사용은 공식 Registry factory에서 시작하고, 필요한 부분만 설치된 소스에서 확장하는 방식을 권장합니다.