Shadcn UI 에디터 플러그인 (Shadcn UI Editor Plugins)

BeautifulGrid 공식 Shadcn Registry에서 Select, DatePicker, ColorPicker, Cascader, TimePicker, TreeSelect 에디터 소스를 설치하고 사용하는 방법을 설명합니다.

#editor-plugin#defineEditorPlugin#portal#shadcn-ui#popover#radix-ui
검토일: 2026-09-23
LIVE DEMOShadcn UI 에디터 플러그인 (Shadcn UI Editor Plugins) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
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는 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에서 시작하고, 필요한 부분만 설치된 소스에서 확장하는 방식을 권장합니다.