Command | CoUI
본문으로 건너뛰기
LogoCoUI

Command

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

목차

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

기본 사용법 (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',
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

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

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

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

  • Select: 검색 가능한 액션 팔레트 대신 폼 값 하나를 선택할 때
  • DropdownMenu: 검색·그룹·스코프 없이 트리거 기반 액션 목록이 필요할 때
  • TextField: 액션 결과 목록 없이 자유 문자열만 입력받을 때

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W? 는 선택 슬롯입니다.

Command (Flutter / Web 동일)#

속성타입기본값설명
groups List<CoreCommandGroup> 필수 표시할 커맨드 그룹 목록
variant CoreCommandVariant standard 시각 variant
presentation CoreCommandPresentation inline 형태 — inline (페이지 안 패널) / dialog (다이얼로그 안) / fullscreen (전체 화면). chrome·경계만 바꾸며 스크림·라우팅은 호스트 몫
size CoreCommandSize md 밀도 sm (32) · md (40) · lg (48) — 검색 행 높이·아이콘·항목 inset·타이포가 함께 움직임. 합성되는 Chip · TextField · Button 은 같은 이름의 CoreComponentSize 단을 받음
scopes List<CoreCommandScope> [] 스코프 칩 목록. 비어 있으면 스코프 행이 그려지지 않음
scope String? null 현재 스코프 값 (null = 전체)
placeholderString?로케일검색 입력 플레이스홀더
query String '' 현재 검색어 (controlled)
emptyLabelString?로케일결과 없음 제목
emptyDescriptionString?로케일결과 없음 설명
emptyIconW?null결과 없음 아이콘
emptyAction W? null 결과 없음 복구 액션
autofocus bool true 검색 입력 자동 포커스 여부
showFooter bool? 형태별 키보드 힌트 푸터. null 이면 dialog 만 표시
loopNavigation bool false 끝에서 처음으로 순환할지
onQueryChanged void Function(String)? null 검색어 변경 핸들러
onScopeChanged void Function(String?)? null 스코프 변경 핸들러 (칩 탭 · ←/→)
onSelect void Function(String)? null 항목 선택 시 value 콜백
onDismiss void Function()? null Escape · fullscreen 뒤로가기 버튼
commandStyle CoreCommandStyle? null 팔레트 / 항목 chrome 스타일 슬롯

CoreCommandGroup#

속성타입기본값설명
heading String? null 그룹 헤딩 텍스트
items List<CoreCommandItem> 필수 그룹 내 항목 목록
scope String? null 이 그룹이 속한 스코프. null 이면 모든 스코프에 보임

CoreCommandScope#

속성타입기본값설명
value String 필수 스코프 식별자 (CoreCommandGroup.scope 와 대조)
labelString필수칩에 그려지는 텍스트

CoreCommandItem#

속성타입기본값설명
valueString필수항목 고유 식별자
labelString필수표시 텍스트 (검색 필터 1차 대상)
iconW?null선택적 leading 아이콘
shortcut String? null 키보드 단축키 표시
overline String? null 라벨 위 한 줄 (있으면 두 줄 행 — 위 줄은 12 Regular on-surface-variant, 라벨은 본문 크기 Regular). 필터 대상 아님
keywords List<String> [] 검색 alias. 라벨에 없는 단어도 같은 substring 규칙으로 매칭 (cmdk keywords)
enabled bool true 활성화 여부 (비활성 시 키보드 탐색 skip, disabled-content 색)

스타일 시스템 (Style System)#

CoreCommandStyle 필드#

필드타입설명
backgroundColorCoreColor?Panel fill.
borderColor CoreColor? Panel border colour. Also the header / footer divider.
borderRadius CoreBorderRadius? Panel corner radius — a presentation value (12 · 20 · 0).
borderWidth double? Panel border width (logical px). 0 on fullscreen.
panelShadow List<CoreShadowLayer>? Panel cast — a presentation value (none · lg · none).
panelMinWidth double? Panel width floor — inline states 240 ( CoreLayout ); the other shapes state none ( 0 ).
panelMaxWidth double? Panel width ceiling — dialog states 640 ( CoreLayout ); the other shapes are unbounded ( double.infinity ).
searchPaddingCoreEdgeInsets?Search row padding.
searchGapStyle CoreGapStyle? Nested gap between the search glyph and the field.
searchIconStyle CoreIconStyle? Search glyph style — forwarded to Icon(iconStyle:).
backIconStyle CoreIconStyle? Back-affordance glyph in the fullscreen header — forwarded to Icon(iconStyle:) .
searchInputHeight double? Search row height — the size ladder (CoreControlSize).
scopePadding CoreEdgeInsets? Scope chip row padding.
scopeSpacing double? Gap between scope chips (N-sibling, Wrap.spacing).
scopeChipStyle CoreChipStyle? Nested Chip style for the scope chips — raw-forwarded.
searchFieldStyle CoreTextFieldStyle? Style of the embedded search field.
listPadding CoreEdgeInsets? Item-list outer padding — a presentation value (6 · 6 · 8 horizontal).
listMinHeight double? List height floor so the panel does not jump when results vanish ( CoreLayout.layout240 ; 0 on fullscreen ).
listMaxHeight double? List scroll ceiling — a presentation value ( CoreLayout.layout320 · layout400 · double.infinity ).
headingPadding CoreEdgeInsets? Group-heading padding — a size value.
itemPadding CoreEdgeInsets? Row inner padding — a size value.
itemMinHeight double? Row height floor — the size ladder (CoreControlSize). Two-line rows grow past it.
itemBorderRadius CoreBorderRadius? Row highlight radius (8 on every size and presentation — a row the panel hosts, see [defaultItemBorderRadius]).
itemGapStyle CoreGapStyle? Nested gap between a row's icon ↔ label and label ↔ shortcut.
itemIconStyle CoreIconStyle? Row glyph style — ambient-forwarded to a caller-supplied item.icon.
itemHighlightColor CoreColor? Active-row fill — the keyboard cursor ( surface-container-high ), the only mark the active row carries.
itemHoverColor CoreColor? Hover wash over the row — on-surface at opacity/hover.
itemPressedColor CoreColor? Pressed wash over the row — on-surface at opacity/pressed.
itemTextStyle CoreTextStyle? Row label style carrying the row foreground colour.
itemForegroundColor CoreColor? Resting row foreground — label text and the ambient icon tint.
itemDisabledForegroundColor CoreColor? Disabled row foreground — a colour token (disabled-content), not an alpha.
itemClickableStyle CoreClickableStyle? Nested style for the composed per-row Clickable.
highlightDuration Duration? Active-row fill transition.
footerPadding CoreEdgeInsets? Footer padding — a size value.
footerSpacingdouble?Footer hint spacing.
emptyPadding CoreEdgeInsets? Padding around the composed EmptyState.
emptyStateStyle CoreEmptyStateStyle? Nested EmptyState style — raw-forwarded. The minHeight it carries by default is CoreLayout.layout240 , the same floor the list has.
kbdStyle CoreKbdStyle? Nested Kbd style for the footer key caps.
footerKbdIconStyle CoreIconStyle? Footer key-cap glyph style.
labelStyle CoreTextStyle? Row label typography role (colour-free; the colour is per state) — a size value.
overlineStyle CoreTextStyle? Row overline (category above the label) — a size value. Its colour is the field-level default ( on-surface-variant ).
itemTitleStyle CoreTextStyle? Row label when an overline is present — a body role at the same weight as the one-line label (the overline above is the quieter line) — a size value.
headingStyle CoreTextStyle? Group heading typography.
shortcutStyle CoreTextStyle? Shortcut hint typography (sans, bodyXs, on-surface-variant).
footerStyle CoreTextStyle? Footer hint typography.
footerBackgroundColorCoreColor?Footer fill.
placeholderColor CoreColor? Search placeholder / glyph colour.

동작 스펙 (Behavior)#

인터랙션#

  • 타이핑: 입력 즉시 라벨 또는 keywords 부분 일치로 항목 필터링 (overline 은 대상 아님). 필터 중에는 그룹 사이 구분선이 숨겨짐
  • 스코프 칩: 선택된 칩은 primary-container 채움 · on-primary-container 글자로 낮은 강조(Chip 의 emphasis: low)로 그려 검색 입력보다 먼저 눈에 띄지 않게 함. 그룹 제목은 12 Medium on-surface-subtle
  • 키보드 탐색: ↑/↓ 로 항목 이동, ←/→ 로 스코프 이동, Home/End 점프, Enter 선택
  • 마우스 호버: 호버가 곧 커서 — 활성 행이 따라오고 그 위에 hover 워시가 겹침. 클릭으로 선택
  • 활성 행: 행 배경 채움(surfaceContainerHigh) 하나로만 표시 — 앞쪽 표식(accent 바)은 없음

상태 전환#

  • 입력창 포커스 → 목록 표시
  • 검색어 입력 → 실시간 필터링
  • 항목 선택 → onSelect(value) 콜백. 패널 닫기·라우팅은 호출부가 담당
  • query / scope 는 호출부 값입니다. 변경 콜백 뒤 부모가 그 값을 갱신해야 필터·스코프가 바뀝니다. 비활성 항목은 키보드 탐색에서 건너뜁니다
  • Escape 는 onDismiss 가 있을 때만 요청합니다. presentation: dialog/fullscreen 도 오버레이·스크림·포커스 트랩을 자동 생성하지 않습니다
  • 빈 결과 → EmptyState 합성 (emptyLabel / emptyDescription / emptyIcon / emptyAction)

애니메이션#

Command 자체에는 목록 열기/닫기 타이머나 필터링 fade 애니메이션이 없습니다. 강조·필터 결과는 상태 갱신으로 렌더하며, 팝업 등장·퇴장 모션은 이를 호스팅하는 오버레이가 담당합니다.

사용 가이드라인 (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)#

역할#

  • Web: 루트 <div> 에 role="combobox" + aria-expanded="true", 결과 목록에 role="listbox", 항목마다 role="option" + aria-selected, 그룹 헤딩에 role="presentation", 검색 입력에 aria-autocomplete="list" 가 붙습니다. 루트의 role / aria-expanded 는 caller attributes 뒤에 병합되어 호출부가 덮어쓸 수 없습니다.
  • Flutter: 합성된 TextField 가 텍스트 필드 시맨틱을, 결과 행의 Clickable 이 버튼 역할과 하이라이트의 selected 상태를 전달합니다. combobox / listbox 에 대응하는 컨테이너 역할은 플랫폼에 없습니다.

키보드#

키동작
↑ / ↓ 하이라이트를 이전/다음 항목으로 이동 (비활성 항목은 건너뜁니다. 양 끝에서 멈추며 순환하지 않습니다)
← / →이전/다음 스코프로 이동 (scopes 가 있을 때)
Home / End첫/마지막 항목으로 점프
Enter하이라이트된 항목 실행 (onSelect)
EscapeonDismiss 가 있으면 호출
Tab Flutter: 검색 필드 → 활성 항목 하나하나 → 다음 요소. Web: 검색 필드 → 팔레트 바깥 (항목은 tabindex="-1")

핸들러가 붙은 위치가 플랫폼마다 다릅니다 — Web 은 검색 입력의 keydown 이라 방향키·Enter 는 검색 입력에 포커스가 있을 때만 동작하고, Flutter 는 팔레트 전체를 감싼 Focus 라 팔레트 안 어디에 포커스가 있어도 동작합니다.

Command 는 열림/닫힘 상태를 소유하지 않습니다. 닫힘 요청은 onDismiss 로 받고, 이를 감싼 Dialog 등 호출부가 실제로 팔레트를 숨깁니다.

브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 입력의 aria-activedescendant 가 기대한 옵션을 가리키는지(닫힌 뒤에는 아무것도 가리키지 않는지) 확인합니다. 핸들러가 달렸는지가 아니라 결과를 봅니다.

스크린 리더#

  • 검색 입력의 접근 가능한 이름은 플레이스홀더 문자열뿐입니다 — Web 은 placeholder HTML 속성, Flutter 는 힌트 텍스트로 렌더되며 <label> / aria-label / Semantics(label:) 은 어느 쪽에도 붙지 않습니다. 기본 문자열은 CouiLocalizations.commandSearch / CoUILocalizations.commandSearch 에서 옵니다.
  • 항목 이름은 CoreCommandItem.label 에서 오고, shortcut 문자열도 같은 행에 텍스트로 렌더되어 함께 읽힙니다.
  • 결과 개수 변화나 "결과 없음" 문구는 어느 쪽에도 live region 이 없어 통보되지 않습니다.
  • 푸터의 키 힌트는 CoUILocalizations / CouiLocalizations 문자열로 렌더되어 함께 읽힙니다 — ↑ ↓ 한 쌍 뒤에 양방향 이동을 뜻하는 commandMove("이동" / "Navigate") 하나, ← → 뒤에 commandMoveScope, ⏎ 뒤에 commandActivate. 화살표 키 자체는 이름을 읽지 않습니다.
  • Flutter: 각 결과 행이 키보드 하이라이트(Enter 가 실행할 행)를 selected 시맨틱으로 싣습니다 — 팔레트에는 값이 없으므로 행의 선택 상태가 곧 커서입니다. Flutter 에는 combobox / listbox 역할이 없어 컨테이너 역할은 통보되지 않습니다 (플랫폼 capability 차이).
  • Web: 루트가 role="combobox" + aria-expanded 를, 검색 입력이 aria-controls / aria-activedescendant 를 내보냅니다. 각 결과 행은 role="option" + aria-selected 이며 하이라이트 행만 선택 상태입니다.

포커스 관리#

  • 진입: autofocus 기본값이 true 라 마운트 시 검색 입력이 포커스를 받습니다(양 플랫폼). autofocus: false 로 끄면 포커스 이동이 없습니다.
  • 이탈: Command 자체는 열고 닫는 표면이 아니어서 복귀 대상이 없습니다. Dialog 등에 넣었다면 그 오버레이의 복귀 규칙을 따릅니다.
  • 트랩: 없습니다. Tab 으로 팔레트 밖으로 그대로 빠져나갑니다.

알려진 제약#

  • 팔레트 자체에는 닫힌 상태가 없습니다. Web 의 aria-expanded="true" 는 렌더된 팔레트가 펼쳐져 있음을 뜻합니다. 닫힌 오버레이를 만들려면 호출부가 팔레트를 제거해야 합니다. 검색 입력의 aria-controls / aria-activedescendant 는 실제 목록·하이라이트 행에 연결되고, 키보드 이동은 양 플랫폼 모두 그 행을 화면 안으로 스크롤합니다.
  • Flutter 에서는 항목마다 tab stop 이 생기는데 포커스 표시가 그려지지 않습니다 — Web 은 항목에 tabindex="-1" 을 박아 포커스 대상에서 뺐지만, Flutter 는 같은 의도의 주석과 달리 focusOutline: false 만 주어 Clickable 이 여전히 포커스를 받습니다. 결과적으로 Tab 이 항목 수만큼 멈추면서 어디에 있는지 보이지 않습니다(WCAG 2.4.7). — 대안 없음(항목은 Command 내부에서 생성됩니다).
  • Flutter 는 컨테이너 구조 역할을 통보하지 않습니다. 결과 행의 하이라이트는 selected 로 전달되지만 Web 의 combobox / listbox 역할은 플랫폼 capability 차이로 전달되지 않습니다.
  • 비활성 항목이 투명도로만 구분됩니다 — Web 은 항목 박스에 opacity: 0.5, Flutter 는 전경색에 같은 배수의 알파를 곱합니다. OS 의 색 강제(forced-colors) 모드는 색을 치환하되 알파는 건드리지 않으므로 비활성 항목이 활성처럼 읽힐 수 있습니다. Web 은 data-disabled 속성이 함께 붙지만 Flutter 에는 대응 신호가 없습니다.

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

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

항목FlutterWeb
클래스명CommandCommand
검색 방식Dart 라벨·keywords 대소문자 무시 substring같은 Dart 필터를 Web으로 컴파일
키보드 탐색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',
          ),
        ],
      ),
    ],
  ),
)