Shadcn UI Editor Plugins

Install and use the official BeautifulGrid Shadcn Registry source for Select, DatePicker, ColorPicker, Cascader, TimePicker, and TreeSelect editors.

#editor-plugin#defineEditorPlugin#portal#shadcn-ui#popover#radix-ui
Last reviewed: 2026-09-23
LIVE DEMOShadcn UI Editor Plugins interactive example
Loading the interactive demo…
GitHub
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>
  );
}

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.