Command | CoUI
LogoCoUI

Command

검색 가능한 커맨드 팔레트 컴포넌트

Command#

키보드 중심의 커맨드 팔레트 컴포넌트입니다. 빠른 탐색, 검색, 액션 실행에 사용됩니다. 검색 입력, 그룹화된 항목 목록, 키보드 힌트 푸터로 구성되며, query에 대해 클라이언트 측 필터링을 수행합니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 앱 전체의 기능이나 페이지를 빠르게 탐색하는 커맨드 팔레트가 필요할 때
  • 많은 수의 항목 중 키보드 입력으로 필터링하여 선택해야 할 때
  • 개발자 도구나 고급 사용자를 위한 단축키 중심 인터페이스를 구현할 때
  • 검색어 기반으로 그룹화된 액션 목록을 제공할 때

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

  • Select: 정해진 목록에서 단순히 하나를 선택할 때
  • DropdownMenu: 컨텍스트 메뉴나 단순한 옵션 선택이 필요할 때
  • TextField: 자유 입력 텍스트 필드만 필요할 때

기본 사용법 (Basic Usage)#

Flutter와 Web 모두 동일한 Command API를 사용합니다. groups로 그룹화된 항목을 선언하고, controlled query/onQueryChanged로 검색어를 관리합니다.

Command(
  query: query,
  onQueryChanged: (q) => setState(() => query = q),
  onSelect: (value) => runCommand(value),
  groups: const [
    CoreCommandGroup(
      heading: 'Suggestions',
      items: [
        CoreCommandItem(value: 'calendar', label: 'Calendar'),
        CoreCommandItem(value: 'search', label: 'Search Emoji'),
        CoreCommandItem(value: 'calculator', label: 'Calculator'),
      ],
    ),
    CoreCommandGroup(
      heading: 'Settings',
      items: [
        CoreCommandItem(value: 'profile', label: 'Profile', shortcut: '⌘P'),
        CoreCommandItem(value: 'billing', label: 'Billing', shortcut: '⌘B'),
      ],
    ),
  ],
)

빠른 오버라이드 (Chain)#

이미 만든 Command 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius24처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius24 == CoreRadius.radius24) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

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

  @override
  State<CommandChainExample> createState() => _CommandChainExampleState();
}

class _CommandChainExampleState extends State<CommandChainExample> {
  String _query = '';

  static const List<CoreCommandGroup<Widget>> _groups = [
    CoreCommandGroup(
      heading: 'Suggestions',
      items: [
        CoreCommandItem(
          value: 'calendar',
          label: 'Calendar',
          icon: Icon(LucideIcons.calendar),
        ),
        CoreCommandItem(
          value: 'search',
          label: 'Search Emoji',
          icon: Icon(LucideIcons.smile),
        ),
        CoreCommandItem(
          value: 'calculator',
          label: 'Calculator',
          icon: Icon(LucideIcons.calculator),
        ),
      ],
    ),
    CoreCommandGroup(
      heading: 'Settings',
      items: [
        CoreCommandItem(
          value: 'profile',
          label: 'Profile',
          icon: Icon(LucideIcons.user),
          shortcut: '⌘P',
        ),
        CoreCommandItem(
          value: 'billing',
          label: 'Billing',
          icon: Icon(LucideIcons.creditCard),
          shortcut: '⌘B',
        ),
      ],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Command(
          query: _query,
          onQueryChanged: (q) => setState(() => _query = q),
          onSelect: (_) {},
          groups: _groups,
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(searchPadding)까지 한 번에.
        Command(
          query: _query,
          onQueryChanged: (q) => setState(() => _query = q),
          onSelect: (_) {},
          groups: _groups,
        ).withStyle(
          const CoreCommandStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
            searchPadding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
    );
  }
}
class CommandChainExample extends StatefulComponent {
  const CommandChainExample({super.key});

  @override
  State<CommandChainExample> createState() => _CommandChainExampleState();
}

class _CommandChainExampleState extends State<CommandChainExample> {
  String _query = '';

  static const List<CoreCommandGroup<Component>> _groups = [
    CoreCommandGroup(
      heading: 'Suggestions',
      items: [
        CoreCommandItem(
          value: 'calendar',
          label: 'Calendar',
          icon: Icon(LucideIcons.calendar),
        ),
        CoreCommandItem(
          value: 'search',
          label: 'Search Emoji',
          icon: Icon(LucideIcons.smile),
        ),
        CoreCommandItem(
          value: 'calculator',
          label: 'Calculator',
          icon: Icon(LucideIcons.calculator),
        ),
      ],
    ),
    CoreCommandGroup(
      heading: 'Settings',
      items: [
        CoreCommandItem(
          value: 'profile',
          label: 'Profile',
          icon: Icon(LucideIcons.user),
          shortcut: '⌘P',
        ),
        CoreCommandItem(
          value: 'billing',
          label: 'Billing',
          icon: Icon(LucideIcons.creditCard),
          shortcut: '⌘B',
        ),
      ],
    ),
  ];

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Command(
          query: _query,
          onQueryChanged: (q) => setState(() => _query = q),
          onSelect: (_) {},
          groups: _groups,
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(searchPadding)까지 한 번에.
        Command(
          query: _query,
          onQueryChanged: (q) => setState(() => _query = q),
          onSelect: (_) {},
          groups: _groups,
        ).withStyle(
          const CoreCommandStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
            searchPadding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Props / Parameters#

Command (Flutter / Web 동일)#

속성타입기본값설명
groups List<CoreCommandGroup> 필수 표시할 커맨드 그룹 목록
variant CoreCommandVariant standard 시각 variant
placeholder String 'Type a command or search...' 검색 입력 플레이스홀더
query String '' 현재 검색어 (controlled)
emptyLabel String 'No results found.' 검색 결과 없을 때 메시지
autofocus bool true 검색 입력 자동 포커스 여부
onQueryChanged void Function(String)? null 검색어 변경 핸들러
onSelect void Function(String)? null 항목 선택 시 value 콜백
commandStyle CoreCommandStyle? null 팔레트 / 항목 chrome 스타일 슬롯

CoreCommandGroup#

속성타입기본값설명
heading String? null 그룹 헤딩 텍스트
items List<CoreCommandItem> 필수 그룹 내 항목 목록

CoreCommandItem#

속성타입기본값설명
valueString필수항목 고유 식별자
labelString필수표시 텍스트 (검색 필터 대상)
icon Widget? / Component? null 선택적 leading 아이콘
shortcut String? null 키보드 단축키 표시
enabled bool true 활성화 여부 (비활성 시 키보드 탐색 skip)

스타일 시스템 (Style System)#

CoreCommandStyle 필드#

필드타입설명
backgroundColor CoreColor? Palette background fill colour override.
borderColor CoreColor? Palette border stroke colour override.
borderRadius CoreBorderRadius? Palette corner radius override.
borderWidth double? Palette / divider border stroke width override (logical px).
searchPadding CoreEdgeInsets? Search row padding override.
searchGapStyle CoreGapStyle? Nested gap style between search-row leading icon and the <input> — forwarded to Gap(gapStyle: …) .
searchIconStyle CoreIconStyle? Search leading icon style override — forwarded straight to Icon(iconStyle: …) by the resolver.
searchInputHeight double? Search input row height override (logical px).
listPadding CoreEdgeInsets? Item-list outer padding override.
headingPadding CoreEdgeInsets? Group-heading padding override.
itemPadding CoreEdgeInsets? Item inner padding override.
itemBorderRadius CoreBorderRadius? Item corner radius override.
itemGapStyle CoreGapStyle? Nested gap style between an item's icon ↔ label and label ↔ shortcut — forwarded to Gap(gapStyle: …) .
itemIconStyle CoreIconStyle? Per-item icon style override (nested slot — ambient-forwarded to caller-supplied item.icon widgets).
itemHighlightColor CoreColor? Item highlight (hover / keyboard-focus) background override.
itemTextStyle CoreTextStyle? Item label text style override. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw itemForegroundColor field removed). Defaults to [defaultItemTextStyle].
itemForegroundColor CoreColor? Foreground colour for an item in its resting (not-highlighted) state — label text and the ambient icon tint. The highlighted cell's foreground comes from [itemTextStyle]'s colour (painted together with [itemHighlightColor]); this field owns the idle state so a light theme doesn't paint on-accent (near-white) text on the plain panel background.
itemClickableStyle CoreClickableStyle? Nested style for the composed per-item Clickable — carries any press / focus-ring / cursor chrome overrides for the item affordance. Merged on top of [defaultItemClickableStyle] and raw-forwarded to Clickable(clickableStyle:) .
highlightDuration Duration? Item highlight transition duration override.
footerPadding CoreEdgeInsets? Footer padding override.
footerSpacing double? Spacing between footer keyboard-hint groups (logical px) — flows into native Row.spacing (Flutter) / flex gap (Web), so the spacer renders via the native flexbox API.
emptyPadding CoreEdgeInsets? Empty-state padding override.
disabledAlpha double? Disabled item content alpha override (0..1).
listMaxHeight double? Results-list scroll-container max height override (logical px).
kbdStyle CoreKbdStyle? Footer keyboard-hint Kbd style override (nested slot, merged on top of [defaultKbdStyle] and raw-forwarded to the composed unified Kbd — its own resolver fills the remaining defaults).
footerKbdIconStyle CoreIconStyle? Footer keyboard-hint icon style override (nested slot — forwarded to the Icon glyphs inside the footer Kbd chips).
labelStyle CoreTextStyle? Item label text style override.
headingStyle CoreTextStyle? Group heading text style override.
shortcutStyle CoreTextStyle? Item-shortcut text style override. null → [defaultShortcutStyle].
emptyStyle CoreTextStyle? Empty-state text style override. null → [defaultEmptyStyle].
footerStyle CoreTextStyle? Footer keyboard-hint label text style override. null → [defaultFooterStyle].
footerBackgroundColor CoreColor? Footer bar background colour override. null → [defaultFooterBackgroundColor].
placeholderColor CoreColor? Search <input> placeholder text colour override. null → [defaultPlaceholderColor].

동작 스펙 (Behavior)#

인터랙션#

  • 타이핑: 입력 즉시 실시간 퍼지(fuzzy) 검색으로 항목 필터링
  • 키보드 탐색: / 화살표로 항목 이동, Enter로 선택
  • 마우스 호버: 항목 하이라이트, 클릭으로 선택

상태 전환#

  • 입력창 포커스 → 목록 표시
  • 검색어 입력 → 실시간 필터링
  • 항목 선택 → onSelect 콜백 실행 → 닫기
  • 빈 결과 → emptyMessage 표시

애니메이션#

  • 목록 열기/닫기: 150ms fade + slide
  • 항목 하이라이트 전환: 즉시 (인스턴트)
  • 필터링 결과 변경: 100ms ease

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

✅ Do#

Dialog와 함께 사용하여 전역 커맨드 팔레트 구현

// Ctrl+K 단축키로 커맨드 팔레트 열기
Dialog(
  isOpen: isCommandOpen,
  onClose: handleCloseCommand,
  child: Command(
    placeholder: '명령어 또는 페이지 검색...',
    query: query,
    onQueryChanged: handleQueryChanged,
    groups: appCommandGroups,
  ),
)

커맨드 팔레트는 일반적으로 전체 화면 오버레이로 표시되어 빠른 접근성을 제공합니다.


❌ Don't#

너무 많은 그룹과 항목을 평탄하게 나열 금지

// ❌ 그룹 없이 50개 항목을 모두 나열
Command(
  groups: [
    CoreCommandGroup(
      items: allFiftyItems,
    ),
  ],
)

그룹 없이 많은 항목을 나열하면 탐색이 어렵습니다. 관련 항목을 의미 있는 그룹으로 분류하세요.

✅ Do#

단축키를 shortcut prop으로 표시

CoreCommandItem(
  value: 'save',
  label: '저장',
  shortcut: 'Ctrl+S',
)

커맨드 팔레트에서 단축키를 보여주면 사용자가 점차 단축키를 익혀 생산성을 높일 수 있습니다.


❌ Don't#

파괴적인 액션을 확인 없이 즉시 실행 금지

// ❌ 삭제 액션을 바로 실행
Command(
  onSelect: handleDeleteAll, // 'delete-all' 선택 시 확인 없이 즉시 삭제
  groups: const [
    CoreCommandGroup(
      items: [CoreCommandItem(value: 'delete-all', label: '모두 삭제')],
    ),
  ],
)

커맨드 팔레트에서 파괴적인 액션을 선택하면 실수로 실행될 수 있습니다. 확인 다이얼로그를 거치도록 구현하세요.

✅ Do#

자주 사용하는 명령어를 상단에 배치하세요.

Command(
  groups: [
    CoreCommandGroup(
      heading: '최근 사용',
      items: recentCommands,  // 자주 쓰는 항목 우선
    ),
    CoreCommandGroup(
      heading: '전체 명령어',
      items: allCommands,
    ),
  ],
)

사용 빈도 높은 명령어를 상단에 배치하면 사용자가 빠르게 접근하여 생산성을 높일 수 있습니다.


❌ Don't#

Command 팔레트를 일반 폼 입력으로 사용하지 마세요.

// ❌ Command를 단순 검색 입력으로 대체
Command(
  onSelect: handleSearchResult,  // 검색 전용으로만 사용
  groups: [CoreCommandGroup(items: searchResults)],
)

Command 팔레트는 단축키(⌘K)로 접근하는 글로벌 액션 도구입니다. 단순 검색에는 Input 또는 AutoComplete를 사용하세요.

접근성 (Accessibility)#

키보드 인터랙션#

동작
/ 이전/다음 항목으로 이동
Enter선택된 항목 실행
Escape커맨드 팔레트 닫기
Tab다음 포커스 가능 요소로 이동

스크린 리더#

  • Flutter: Semantics 위젯으로 combobox 역할 전달, 활성 항목 상태 알림
  • Web: role="combobox", aria-expanded, aria-activedescendant 자동 적용

터치 타겟#

  • 각 CommandItem의 최소 높이: 48px

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

Command API는 Flutter / Web에서 완전히 동일합니다. 아래는 내부 구현 차이점만 나열합니다.

항목FlutterWeb
클래스명CommandCommand
검색 방식Dart 문자열 매칭 (대소문자 무시 substring)JS 문자열 매칭 (동일 로직)
키보드 탐색Focus + KeyEventkeydown 이벤트
  • Kbd: CommandItem의 shortcut 표시에 함께 사용
  • Dialog: 전역 커맨드 팔레트를 오버레이로 표시할 때 사용
  • TextField: 단순 텍스트 검색만 필요할 때 대안

조합 예제#

// Command + Dialog 조합으로 전역 커맨드 팔레트 구현
Dialog(
  isOpen: isOpen,
  onClose: handleClose,
  child: Command(
    placeholder: '명령어를 입력하세요...',
    query: query,
    onQueryChanged: handleQueryChanged,
    onSelect: handleCommandSelect,
    groups: [
      CoreCommandGroup(
        heading: '최근 항목',
        items: recentItems
            .map((item) => CoreCommandItem(
                  value: item.id,
                  label: item.title,
                ))
            .toList(),
      ),
      CoreCommandGroup(
        heading: '설정',
        items: const [
          CoreCommandItem(
            value: 'theme',
            label: '테마 변경',
            shortcut: 'Ctrl+T',
          ),
        ],
      ),
    ],
  ),
)