DraggablePanel | CoUI
LogoCoUI

DraggablePanel

화면 위에 떠 있는, 드래그로 옮기고 가장자리에 도킹되는 플로팅 패널 (Apple PencilKit 팔레트 스타일)

DraggablePanel#

화면 전체를 덮는 child 위에 드래그 가능한 핸들을 띄우고, 핸들을 놓으면 가장 가까운 화면 가장자리에 스냅 도킹되는 플로팅 패널입니다. 핸들을 탭하면 호출자가 넘긴 panel 콘텐츠가 펼쳐집니다 — Apple PencilKit 의 떠 있는 도구 팔레트와 같은 동작이며, 하나의 팔레트로 여러 캔버스(예: 도서 뷰어 + 노트)를 제어하는 용도에 적합합니다.

CoUI 는 도메인 무관 generic chrome 만 제공합니다 — 펜/색/두께 같은 도메인 콘텐츠는 panel 슬롯에 호출자가 채웁니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 화면 어디서나 접근 가능해야 하는 떠 있는 도구 팔레트 / 액션 패널
  • 사용자가 위치를 옮기고 가장자리에 붙여 두고 싶은 보조 컨트롤
  • 본문(스크롤/줌되는 캔버스 등) 위에 항상 떠 있어야 하는 패널

대신 다른 컴포넌트를 사용하세요:

  • 트리거에 고정 앵커된 팝업: Popover / DropdownMenu
  • 모달: Dialog / Drawer
  • 고정 위치 액션 버튼: Fab
  • 창 외형(타이틀 바 + 컨트롤): WindowPanel

기본 사용법#

final controller = CoreDraggablePanelController(initiallyOpen: true);

DraggablePanel(
  controller: controller,
  panel: const Column(
    mainAxisSize: MainAxisSize.min,
    children: [Text('Pen'), Text('Eraser'), Text('Color')],
  ),
  child: const Center(child: Text('Canvas')),
)
final controller = CoreDraggablePanelController(initiallyOpen: true);

DraggablePanel(
  controller: controller,
  panel: div([
    const Text('Pen').bodyMedium,
    const Text('Eraser').bodyMedium,
    const Text('Color').bodyMedium,
  ], classes: 'flex flex-col gap-${CoreSpace.scale.space8}'),
  child: div([const Text('Canvas').bodyMedium],
      classes: 'flex items-center justify-center w-full h-full'),
)

빠른 오버라이드 (Chain)#

이미 만든 DraggablePanel 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class DraggablePanelChainExample extends StatefulWidget {
  const DraggablePanelChainExample({super.key});

  @override
  State<DraggablePanelChainExample> createState() => _DraggablePanelChainExampleState();
}

class _DraggablePanelChainExampleState extends State<DraggablePanelChainExample> {
  final CoreDraggablePanelController _controller = CoreDraggablePanelController(initiallyOpen: true);

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return SizedBox(
      width: 360,
      height: 360,
      child:
          DraggablePanel(
            controller: _controller,
            panel: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              spacing: CoreSpace.space8,
              children: [
                const Text('Pen').bodyMedium,
                const Text('Eraser').bodyMedium,
                const Text('Color').bodyMedium,
              ],
            ),
            child: ColoredBox(
              color: theme.colorScheme.surfaceContainerLow.toValue(),
              child: Center(child: const Text('Canvas').bodyMedium),
            ),
          ).withStyle(
            const CoreDraggablePanelStyle(
              panelBackgroundColor: CoreColor.token(
                CoreColors.surfaceContainerHighest,
              ),
              panelBorderColor: CoreColor.token(CoreColors.primary),
              panelBorderWidth: CoreStrokeWidth.stroke2,
              panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
              handleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
              handleBackgroundColor: CoreColor.token(
                CoreColors.primaryContainer,
              ),
              handleIconStyle: CoreIconStyle(
                size: CoreIconSize.size24,
                color: CoreColor.token(CoreColors.onPrimaryContainer),
              ),
            ),
          ),
    );
  }
}
class DraggablePanelChainExample extends StatefulComponent {
  const DraggablePanelChainExample({super.key});

  @override
  State<DraggablePanelChainExample> createState() => _DraggablePanelChainExampleState();
}

class _DraggablePanelChainExampleState extends State<DraggablePanelChainExample> {
  final CoreDraggablePanelController _controller = CoreDraggablePanelController(initiallyOpen: true);

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Component build(BuildContext context) {
    return div(
      [
        DraggablePanel(
          controller: _controller,
          panel: div(
            [
              const Text('Pen').bodyMedium,
              const Text('Eraser').bodyMedium,
              const Text('Color').bodyMedium,
            ],
            classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
          ),
          child: div(
            [const Text('Canvas').bodyMedium],
            classes: 'flex items-center justify-center w-full h-full bg-surface-container-low',
          ),
        ).withStyle(
          const CoreDraggablePanelStyle(
            panelBackgroundColor: CoreColor.token(
              CoreColors.surfaceContainerHighest,
            ),
            panelBorderColor: CoreColor.token(CoreColors.primary),
            panelBorderWidth: CoreStrokeWidth.stroke2,
            panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            handleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
            handleBackgroundColor: CoreColor.token(
              CoreColors.primaryContainer,
            ),
            handleIconStyle: CoreIconStyle(
              size: CoreIconSize.size24,
              color: CoreColor.token(CoreColors.onPrimaryContainer),
            ),
          ),
        ),
      ],
      styles: Styles(
        raw: const {'width': '${360 / 16}rem', 'height': '${360 / 16}rem'},
      ),
    );
  }
}

Props / Parameters#

ParamTypeDefault설명
child Widget? / Component? null 패널이 떠 있는 본문 (보통 화면 전체)
panel Widget? / Component? null 핸들을 열었을 때 펼쳐지는 패널 콘텐츠
handle Widget? / Component? null 커스텀 핸들 콘텐츠 (null 이면 기본 드래그 글리프)
dockType CoreDraggablePanelDockType .inside 가장자리 도킹 방식
enabledbooltrue드래그 가능 여부
closeOnTapOutside bool true 열린 패널 바깥 탭 시 닫힘
dockOffset double CoreSpace.space8 도킹 시 가장자리와의 간격 (logical px)
controller CoreDraggablePanelController? null 위치/열림 상태 프로그래매틱 제어
onPositionChanged void Function(double x, double y)? null 핸들 위치 확정 시 콜백 (영속화용)
onOpenChanged CoreValueChanged<bool>? null 패널 열림 상태 변경 콜백
draggablePanelStyle CoreDraggablePanelStyle? null 인스턴스별 chrome 스타일 (단일 진입점)

스타일 시스템#

모든 시각 chrome 은 단일 draggablePanelStyle (CoreDraggablePanelStyle) 슬롯으로 흐릅니다. 평면 chrome prop 은 노출하지 않습니다.

Resolve chain#

CoreDraggablePanelStyle.defaultX
  → CoreDraggablePanelTheme.style          // 프로젝트 공통
  → widget.draggablePanelStyle             // 인스턴스별

CoreDraggablePanelStyle 필드#

필드타입설명
panelBackgroundColor CoreColor? Background fill of the expanded panel surface.
panelBorderColor CoreColor? Border colour of the expanded panel surface.
panelBorderWidth double? Border width (logical px) of the expanded panel surface.
panelBorderRadius CoreBorderRadius? Corner radius of the expanded panel surface.
panelPadding CoreEdgeInsets? Inner padding around the panel content.
panelWidth double? Fixed width (logical px) of the expanded panel surface.
panelShadow List<CoreShadowLayer>? Drop shadow of the expanded panel surface.
handleSize double? Edge length (logical px) of the square collapsed handle button.
handleBackgroundColor CoreColor? Background fill of the collapsed handle button.
handleForegroundColor CoreColor? Foreground (icon/glyph) colour of the default handle.
handleBorderRadius CoreBorderRadius? Corner radius of the collapsed handle button.
handleIconStyle CoreIconStyle? Nested icon style for the default handle icon (the hamburger affordance drawn when the caller supplies no custom handle ). Raw-forwarded to the unified Icon so both platforms render the same vector glyph — a text glyph would rasterise differently per platform font stack.
handleShadow List<CoreShadowLayer>? Drop shadow of the collapsed handle button.
animationDuration Duration? Duration of the open/close and dock-snap animations.
shadowBaseColor CoreColor? Tint colour override for the panel/handle drop shadows. null → [defaultShadowBaseColor]. [panelShadow] / [handleShadow] only carry geometry (offset/blur/spread/opacity — no colour), so this is the single override path for shadow tint.

동작 스펙#

  • 드래그: 핸들을 끌면 본문 위를 따라 이동합니다 (Flutter GestureDetector, Web document 레벨 mousemove 리스너 — 핸들 밖으로 나가도 드래그 유지).
  • 스냅 도킹: 놓으면 가장 가까운 좌/우 가장자리에 dockOffset 간격으로 스냅됩니다 (animationDuration 으로 애니메이션).
  • 탭으로 열기/닫기: 드래그 없이 탭하면 패널이 토글됩니다.
  • 방향 자동 선택: 패널은 핸들이 도킹된 반대쪽(공간이 있는 쪽)으로 펼쳐집니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

키보드 사용자를 위한 별도 토글 컨트롤을 함께 제공

Button(
  onPressed: () => controller.toggle(),
  child: const Text('도구 패널 열기/닫기'),
)

핸들은 포인터로만 토글되고 Escape도 열린 패널을 닫지 않습니다. 키보드·스위치 사용자에게 패널이 필요하다면 여닫는 별도 컨트롤을 함께 제공해야 합니다.


❌ Don't#

확인이 꼭 필요한 흐름을 패널에 넣지 않기

// ❌ 포커스 트랩·복원·배경 inert 가 전혀 없는 패널에
// 삭제 확인 같은 모달 흐름을 넣음
DraggablePanel(
  panel: ConfirmDeleteForm(onConfirm: deleteAccount),
  child: canvas,
)

열린 패널 뒤의 child 서브트리가 그대로 탭으로 도달되고, 닫을 때 포커스 복원도 없습니다. 모달이 필요하면 Dialog 또는 Drawer를 사용하세요.

접근성 (Accessibility)#

현재 이 컴포넌트는 키보드만으로 열거나 닫을 수 없습니다. 아래 제약을 읽고, 필요한 부분은 소비자 코드에서 직접 보완해야 합니다.

역할 / Semantics#

DraggablePanel 자신은 아무 역할도 내보내지 않습니다. 루트·콘텐츠·스크림·패널은 모두 호출자 attributes 만 실린 평범한 <div>(Web) / Position + DecoratedBox(Flutter) 입니다. 패널에는 dialogregion 도 붙지 않고 이름도 없습니다.

역할이 붙는 곳은 핸들에 합성된 DragItem 하나뿐입니다.

  • Web: role="application" + aria-grabbed + aria-disabled
  • Flutter: Semantics(enabled:) 노드

DragItemlabel / hint 파라미터를 받지만 DraggablePanel 이 이를 넘기지 않고, 대신 지정할 파라미터도 노출하지 않습니다. 그래서 핸들은 양 플랫폼 모두 이름이 없습니다.

패널의 열림/닫힘 상태는 어디에도 노출되지 않습니다 — aria-expanded 도, Flutter 의 expanded 플래그도 없습니다. 핸들과 패널을 잇는 aria-controls / aria-haspopup 도 없습니다.

키보드#

처리되는 키는 DragItem 의 드래그 키뿐이며, 모두 핸들의 이동을 제어할 뿐 패널을 열고 닫지 않습니다.

동작
Space / Enter키보드 드래그 시작. 드래그 중이면 그 자리에 놓기(드롭)
Escape진행 중인 드래그만 취소 — 열린 패널은 닫히지 않습니다
드래그 중 DragItemkeyboardDragStep 만큼 핸들 이동

패널 토글은 포인터 전용입니다 — Flutter 는 GestureDetector(onTap:), Web 은 핸들의 click 핸들러입니다. Web 핸들은 <button> 이 아니라 role="application"<div> 이고 DragItemEnter/SpacepreventDefault() 를 호출하므로 합성 클릭도 발생하지 않습니다. 결과적으로 양 플랫폼 모두 키보드로 패널을 토글할 수 없습니다.

포커스#

포커스를 받는 것은 핸들뿐입니다 — Web 은 활성화 시 tabindex="0"(비활성 시 -1), Flutter 는 DragItemFocus 노드입니다. 핸들에는 포커스 링이 없습니다draggable_panel_style 에 포커스 chrome 자체가 정의되어 있지 않아, 키보드 사용자는 핸들에 포커스가 있는지 시각적으로 알 수 없습니다.

패널 콘텐츠의 포커스는 전혀 관리되지 않습니다.

  • 패널이 열려도 autofocus 없음
  • 포커스 트랩 없음
  • 닫을 때 포커스 복원 없음
  • 배경이 inert / ExcludeFocus / BlockSemantics 로 가려지지 않아, 열린 패널 뒤의 child 서브트리가 그대로 탭으로 도달됩니다

스크린 리더#

핸들은 Web 에서 이름 없는 "application" 영역(grabbed / disabled 상태 포함)으로, Flutter 에서는 이름 없는 활성 노드로 안내됩니다. 패널이 열려도 아무 안내가 발생하지 않으며, 열림/닫힘 상태도, 핸들과 패널의 연결도 읽히지 않습니다 — 패널 콘텐츠가 읽기 순서에 그냥 나타날 뿐입니다.

닫기용 스크림은 포인터 핸들러만 달린 빈 div 라 보조 기술에는 보이지 않습니다. 드래그나 화살표 키로 핸들 위치가 바뀌어도 안내되지 않습니다.

알려진 제약#

소비자가 직접 보완해야 하는 항목입니다.

  • 키보드 토글 경로가 없습니다. 키보드·스위치 사용자에게 패널이 필요하다면 열고 닫는 별도 컨트롤(예: Button)을 함께 제공하고, 그 컨트롤에 aria-expanded 상당 정보를 직접 부여하세요.
  • Escape 로 패널이 닫히지 않습니다. 필요하면 소비자 코드에서 키 핸들러를 직접 붙여야 합니다.
  • 핸들에 접근 가능한 이름이 없고, 이름을 넘길 파라미터도 없습니다. Web 은 루트 passthrough attributes 로 우회할 수 있지만 핸들 자체를 직접 라벨링할 수는 없습니다.
  • 핸들에 포커스 표시가 없습니다.
  • 열림/닫힘 상태와 핸들↔패널 연결이 노출되지 않습니다.
  • 포커스 트랩·복원·배경 inert 가 없습니다. 패널을 모달로 다뤄야 한다면 이 컴포넌트가 아니라 Dialog / Drawer 를 검토하세요.
  • onOpenChanged 는 포인터 토글에서만 발생합니다.

전역으로 적용되는 항목(감소된 모션·고대비·강제 색상·최소 터치 타겟 등)은 전역 접근성 축을 참고하세요.

크로스 플랫폼 차이점 (Platform Differences)#

동작·API 는 양 플랫폼 동일합니다. 위치 좌표는 logical px 단위이며 Web 은 rem 으로 emit 됩니다. 핸들/패널의 런타임 위치(left/top)는 위젯이 드래그 상태에서 계산하고, chrome(색·radius·shadow·크기)은 resolver 가 제공합니다.

관련 컴포넌트#

  • WindowPanel — 타이틀 바 + 컨트롤이 있는 창 외형 패널
  • Fab — 고정 위치 플로팅 액션 버튼 (드래그 없음)
  • Popover — 트리거에 앵커된 팝업