SplitView

Layout

탭을 드래그해 화면을 분할하는 에디터형 패널 레이아웃.

Usage

VS Code처럼 탭을 드래그해 좌우·상하로 패널을 나누는 편집 화면 외부 목록 항목을 패널로 드래그해 새 탭으로 여는 화면

import

import

import { SplitView } from '@mildang/design-system/unofficial/SplitView';

SplitView 하나만 가져오면 SplitView.Root · SplitView.DndProvider · SplitView.Panel · SplitView.TabBar · SplitView.Item · SplitView.DragSource · SplitView.Overlay · SplitView.DropZone 를 그 아래에서 쓸 수 있다.

예제를 복사해 쓸 때 필요한 준비

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

Anatomy

tsx

import { SplitView } from '@mildang/design-system/unofficial/SplitView';

export default function Example() {
  return (
    <SplitView.DndProvider>
      <SplitView>
        <SplitView.Panel>
          <SplitView.TabBar>
            <SplitView.Item />
          </SplitView.TabBar>
          <SplitView.DropZone />
        </SplitView.Panel>
      </SplitView>
      <SplitView.DragSource />
      <SplitView.Overlay />
    </SplitView.DndProvider>
  );
}

부품

필수 여부

반복

위치

SplitView.DndProvider

필수

SplitView.Root

필수

SplitView.DndProvider 안에 둔다.

SplitView.Panel

필수

여러 개 가능

renderPanel이 반환하는 패널 콘텐츠를 감싼다.

SplitView.TabBar

선택

SplitView.Panel 안에 둔다.

SplitView.Item

필수

여러 개 가능

SplitView.TabBar 안에 둔다.

SplitView.DropZone

선택

직접 배치하지 않는다 — Panel의 showSplitDropZones로 노출 여부만 제어한다.

SplitView.DragSource

선택

SplitView.DndProvider 안, Root 바깥 어디든 둔다.

SplitView.Overlay

선택

SplitView.DndProvider 안에 둔다.

  • DndProvider로 감싸지 않은 Root·DragSource·Overlay는 드래그 상태를 공유하지 못한다.
  • DropZone은 Panel이 자동으로 렌더하므로 직접 조립할 필요가 없다.

API Reference

SplitView.DndProvider

드래그 컨텍스트·레이아웃·아이템 배치 state를 자손에 공급한다. maxHorizontal·maxVertical로 분할 상한을 제어한다.

Prop

Type

Default

children
필수

ReactNode

지정 안 함

layout
필수

PanelLayout

지정 안 함

generateLayoutId

() => string

지정 안 함

generatePanelId

() => PanelId

지정 안 함

items

TItem[]

지정 안 함

maxHorizontal

number

DEFAULT_SPLIT_CONSTRAINTS.maxHorizontal

maxVertical

number

DEFAULT_SPLIT_CONSTRAINTS.maxVertical

onDragEnd

() => void

지정 안 함

onDragMove

(event: SplitViewDragMoveEvent<TData>) => void

지정 안 함

onDragStart

(event: SplitViewDragStartEvent<TData>) => void

지정 안 함

onDrop

(event: SplitViewDropEvent<TData>) => void

지정 안 함

onTargetChange

(event: SplitViewTargetChangeEvent<TData>) => void

지정 안 함

SplitView.Panel

패널 영역 자체를 drop target으로 등록하고, 드래그 중 4방향 split drop zone을 노출한다.

Prop

Type

Default

children

ReactNode

지정 안 함

className

string

지정 안 함

onActivate

() => void

지정 안 함

showSplitDropZones

boolean

true

SplitView.TabBar

패널 상단의 탭 컨테이너이자 drop target이다.

Prop

Type

Default

children

ReactNode

지정 안 함

className

string

지정 안 함

panelId

string

지정 안 함

SplitView.Item

탭 하나를 표현한다. 드래그 소스이자 재정렬 target으로 동시에 동작한다.

Prop

Type

Default

data
필수

TData

지정 안 함

id
필수

string

지정 안 함

panelId
필수

string

지정 안 함

children

ReactNode

지정 안 함

className

string

지정 안 함

index

number

지정 안 함

SplitView.DropZone

패널을 4방향으로 나누는 분할 드롭존이다. SplitView.Panel이 내부에서 자동으로 렌더한다.

Prop

Type

Default

direction
필수

SplitDirection

지정 안 함

panelId
필수

string

지정 안 함

position
필수

"bottom" | "left" | "right" | "top"

지정 안 함

splitPosition
필수

SplitPosition

지정 안 함

className

string

지정 안 함

SplitView.DragSource

사이드바·팔레트처럼 SplitView 바깥에서 탭으로 끌어오는 드래그 소스다.

Prop

Type

Default

data
필수

TData

지정 안 함

id
필수

string

지정 안 함

children

ReactNode

지정 안 함

className

string

지정 안 함

style

CSSProperties

지정 안 함

SplitView.Overlay

드래그 중 커서 옆에 표시되는 고스트 프리뷰 슬롯이다.

Prop

Type

Default

children

ReactNode

지정 안 함

예제

기본 사용

왼쪽 목록의 아이템을 패널로 드래그하면 탭으로 열리고, 탭을 다시 드래그하면 패널이 분할된다.



type SplitViewStoryArgs = { maxHorizontal: number; maxVertical: number };

import { useCallback, useState } from 'react';
import { SplitView, applyItemPlacement, getDropPanelId, useSplitViewItemIds, type PanelLayout, type SplitViewDropEvent } from '@mildang/design-system/unofficial/SplitView';
import { css } from '@mildang/styled-system/css';

interface Item {
  id: string;
  title: string;
  panelId: string;
}

let counter = 0;

const nextId = (prefix: string) => `${prefix}${++counter}`;

const navItems: Item[] = [
  { id: 'nav-a-1', title: '아이템 1', panelId: '' },
  { id: 'nav-a-2', title: '아이템 2', panelId: '' },
  { id: 'nav-a-3', title: '아이템 3', panelId: '' },
  { id: 'nav-a-4', title: '아이템 4', panelId: '' },
  { id: 'nav-a-5', title: '아이템 5', panelId: '' },
];

const STORY_DEFAULT_ARGS = { ...({ maxHorizontal: 3, maxVertical: 2 }), ...({}) } as SplitViewStoryArgs;

const SplitViewDefaultExampleRender = function Render(args: SplitViewStoryArgs) {
    const [layout, setLayout] = useState<PanelLayout>({ id: 'root', type: 'panel', panelId: 'p0' });
    const [items, setItems] = useState<Item[]>([]);
    const [activePanelId, setActivePanelId] = useState('p0');

    function PanelContent({ panelId, isActivePanel }: { panelId: string; isActivePanel: boolean }) {
      const itemIds = useSplitViewItemIds(panelId);
      const panelItems = itemIds
        .map((id) => items.find((it) => it.id === id))
        .filter((it): it is Item => it != null);

      return (
        <SplitView.Panel onActivate={() => setActivePanelId(panelId)}>
          <SplitView.TabBar
            panelId={panelId}
            className={css({ display: 'flex', minH: '24', bg: 'neutral.surface.high', '&[data-drop-target]': { bg: 'neutral.surface.highest' } })}
          >
            {panelItems.map((item) => (
              <SplitView.Item
                key={item.id}
                id={item.id}
                panelId={panelId}
                data={item}
                className={css({ display: 'flex', gap: '4', alignItems: 'center', bg: 'neutral.surface.highest', p: '6' })}
              >
                <span>{item.title}</span>
                <button
                  type="button"
                  onClick={() => setItems((prev) => prev.filter((it) => it.id !== item.id))}
                >

                </button>
              </SplitView.Item>
            ))}
          </SplitView.TabBar>
          <div className={css({ p: '16', flex: 1 })}>{isActivePanel && <span>active</span>}</div>
        </SplitView.Panel>
      );
    }

    const handleDrop = useCallback((event: SplitViewDropEvent<Item>) => {
      const { source, target, placementByPanel, nextLayout } = event;
      const destinationPanelId = getDropPanelId(target);
      if (!destinationPanelId) return;

      const committedLayout = nextLayout ?? layout;
      if (nextLayout) setLayout(nextLayout);

      setItems((prev) => {
        const existing = prev.find((it) => it.id === source.id);
        const effectiveId = existing ? existing.id : nextId('item-');
        const itemsWithNew = existing
          ? prev
          : [...prev, { ...source.data, id: effectiveId, panelId: destinationPanelId }];

        return applyItemPlacement({
          items: itemsWithNew,
          layout: committedLayout,
          itemIdsByPanel: placementByPanel,
        });
      });

      setActivePanelId(destinationPanelId);
    }, [layout]);

    return (
      <SplitView.DndProvider
        items={items}
        layout={layout}
        onDrop={handleDrop}
        maxHorizontal={args.maxHorizontal}
        maxVertical={args.maxVertical}
      >
        <div className={css({ display: 'flex', height: '100vh' })}>
          <aside className={css({ width: '150px', p: '12', display: 'flex', flexDirection: 'column', gap: '8' })}>
            <div>드래그 가능 요소</div>
            {navItems.map((item) => (
              <SplitView.DragSource
                key={item.id}
                id={item.id}
                data={item}
                className={css({ px: '10', py: '6', bg: 'neutral.surface.highest', cursor: 'grab', userSelect: 'none' })}
              >
                {item.title}
              </SplitView.DragSource>
            ))}
          </aside>
          <main className={css({ flex: 1, background: 'neutral.surface.high' })}>
            <SplitView.Root
              activePanelId={activePanelId}
              onLayoutChange={setLayout}
              renderPanel={({ panelId, isActivePanel }) => (
                <PanelContent panelId={panelId} isActivePanel={isActivePanel} />
              )}
            />
          </main>
        </div>
      </SplitView.DndProvider>
    );
  };

export default function SplitViewDefaultExample(props: Partial<SplitViewStoryArgs>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as SplitViewStoryArgs;
  return SplitViewDefaultExampleRender(mergedProps);
}