Shadcn UI Editor Plugins
Install and use the official BeautifulGrid Shadcn Registry source for Select, DatePicker, ColorPicker, Cascader, TimePicker, and TreeSelect editors.
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 distributes its official Shadcn UI editor plugins as Shadcn Registry source. Instead of importing hidden implementation from an npm package, your project owns the installed source and can adapt it to its design system.
The official plugin includes Select, DatePicker, ColorPicker, Cascader, TimePicker, and TreeSelect factories. Each factory already integrates the Grid Portal and editor lifecycle, so application code only configures options and assigns the result to a column’s editor.
If you only need text, basic Select, or Date editing, start with Built-in Editors. Compare every official integration in Editor Plugins Overview.
1. Install and Use the Official Plugin
Add the official BeautifulGrid Registry item with the Shadcn CLI:
npx shadcn@latest add https://raw.githubusercontent.com/axisj/beautiful-grid-plugins/main/public/r/beautiful-grid-editors.json
With the default configuration, six editor factories, shared Portal components, and styles are generated under components/beautiful-grid. Import the factory you need from the generated entry point.
import { createShadcnSelectEditorPlugin } from '@/components/beautiful-grid';
type Order = {
status: 'ready' | 'progress' | 'done';
};
const statusEditor = createShadcnSelectEditorPlugin<Order, Order['status']>({
id: 'order-status',
ariaLabel: 'Select order status',
options: [
{ value: 'ready', label: 'Ready' },
{ value: 'progress', label: 'In progress' },
{ value: 'done', label: 'Done' },
],
});
const columns: BGridColumn<Order>[] = [
{ key: 'status', label: 'Status', editable: true, editor: statusEditor },
];
The generated Registry entry point also imports the plugin stylesheet. The live demo below illustrates all six editor interactions; production projects should use the source generated by the Registry command above.
2. Understanding the Select and Portal Implementation
Shadcn UI’s Select renders its floating SelectContent through a portal. Pass getPortalContainer() and call commit with an array of changes upon value selection.
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 if closed without changes
}}
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>
{/* Render inside the Grid's dedicated floating portal container */}
<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,
});
Always provide changes to commit as an array of changes (commit([{ key, value }])).
3. DatePicker and ColorPicker (Radix Popover)
Calendar date pickers and color swatch pickers are built on Shadcn UI’s Popover primitive.
DatePicker (Calendar Popover)
Dates are formatted into the application’s target string format (e.g. YYYY-MM-DD) before committing.
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 || 'Select date')}</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)
Users can click preset swatch tiles or input a custom hex code.
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, and TreeSelect
Cascader (Hierarchical Multilevel Selection)
Navigate cascading panels across category or geography levels, committing the full selected path as string[].
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(' / ') : 'Select category'}</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 copy and paste
Even though the Shadcn UI Cascader commits a string[], the Grid clipboard carries only text/plain. The Domestic / Seoul output from itemRender is display-only. If paste does not restore the string to an array, the selected path disappears the next time the editor opens. The live example defines the following column contract:
{
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('Category path must be a JSON string array.');
}
return parsed;
},
}
The copied ["Domestic","Seoul"] becomes a string[] again on paste, so both the idle cell and Shadcn trigger display Domestic / Seoul. See copy/paste value conversion in the AntD guide for conversion precedence and validation rules across value types. Keep the three responsibilities separate: itemRender controls display, getClipboardText serializes, and parseClipboardText restores the stored type.
TimePicker (Hour & Minute Selector)
Select hour (00-23) and minute (00-55) columns with quick preset shortcuts, committing HH:mm.
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) }])}>Confirm</Button>
</PopoverContent>
</Popover>
);
}
TreeSelect (Searchable Organization Tree)
A hierarchical tree browser with top search filtering and collapsible branches. Selecting a leaf node commits the branch value.
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 || 'Select team')}</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 Portals and Container Configuration
The official Registry source already renders popups into the Grid’s dedicated floating portal root. Keep the container forwarding on SelectContent and PopoverContent when adapting the installed source to your design system.
// 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) => (
<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>
));
This guarantees:
- Full inheritance of Grid theme variables (
--bgrid-*) - Tight synchronization with virtual scrolling and frozen column bounds
- Accurate outside-click detection without accidental early session termination
6. Atomic Multi-Column Commits and Custom Extensions
When a single editor resolves multiple linked fields (e.g. code and name), pass them together in a single commit invocation:
await commit([
{ key: 'categoryCode', value: selected.code },
{ key: 'categoryName', value: selected.name },
]);
Focus is automatically returned to the active grid cell upon completion or cancellation.
For application-specific inputs or asynchronous autocomplete not covered by the official factories, define a separate plugin with defineEditorPlugin(). Start with the official Registry factories and customize only the installed source your application needs to own.