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#
코드 보기
class CommandDefaultExample extends StatefulComponent {
const CommandDefaultExample({super.key});
@override
State<CommandDefaultExample> createState() => _CommandDefaultExampleState();
}
class _CommandDefaultExampleState extends State<CommandDefaultExample> {
String _query = '';
String? _scope;
@override
Component build(BuildContext context) {
return Command(
query: _query,
onQueryChanged: (q) => setState(() => _query = q),
scopes: const [
CoreCommandScope(value: 'actions', label: 'Actions'),
CoreCommandScope(value: 'pages', label: 'Pages'),
],
scope: _scope,
onScopeChanged: (next) => setState(() => _scope = next),
onSelect: (_) {},
groups: const [
CoreCommandGroup(
heading: 'Suggestions',
scope: 'actions',
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',
scope: 'pages',
items: [
CoreCommandItem(
value: 'profile',
label: 'Profile',
overline: 'Account',
icon: Icon(LucideIcons.user),
shortcut: '⌘P',
),
CoreCommandItem(
value: 'billing',
label: 'Billing',
icon: Icon(LucideIcons.creditCard),
shortcut: '⌘B',
),
],
),
],
);
}
}
class CommandDefaultExample extends StatefulWidget {
const CommandDefaultExample({super.key});
@override
State<CommandDefaultExample> createState() => _CommandDefaultExampleState();
}
class _CommandDefaultExampleState extends State<CommandDefaultExample> {
String _query = '';
String? _scope;
@override
Widget build(BuildContext context) {
return Command(
query: _query,
onQueryChanged: (q) => setState(() => _query = q),
scopes: const [
CoreCommandScope(value: 'actions', label: 'Actions'),
CoreCommandScope(value: 'pages', label: 'Pages'),
],
scope: _scope,
onScopeChanged: (next) => setState(() => _scope = next),
onSelect: (_) {},
groups: const [
CoreCommandGroup(
heading: 'Suggestions',
scope: 'actions',
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',
scope: 'pages',
items: [
CoreCommandItem(
value: 'profile',
label: 'Profile',
overline: 'Account',
icon: Icon(LucideIcons.user),
shortcut: '⌘P',
),
CoreCommandItem(
value: 'billing',
label: 'Billing',
icon: Icon(LucideIcons.creditCard),
shortcut: '⌘B',
),
],
),
],
);
}
}
사용 시기 (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 = 전체) |
placeholder | String? | 로케일 | 검색 입력 플레이스홀더 |
query |
String |
'' |
현재 검색어 (controlled) |
emptyLabel | String? | 로케일 | 결과 없음 제목 |
emptyDescription | String? | 로케일 | 결과 없음 설명 |
emptyIcon | W? | 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 와 대조) |
label | String | 필수 | 칩에 그려지는 텍스트 |
CoreCommandItem#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | String | 필수 | 항목 고유 식별자 |
label | String | 필수 | 표시 텍스트 (검색 필터 1차 대상) |
icon | W? | 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 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor | CoreColor? | 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
).
|
searchPadding | CoreEdgeInsets? | 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. |
footerSpacing | double? | 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. |
footerBackgroundColor | CoreColor? | Footer fill. |
placeholderColor |
CoreColor? |
Search placeholder / glyph colour. |
동작 스펙 (Behavior)#
인터랙션#
-
타이핑: 입력 즉시 라벨 또는
keywords부분 일치로 항목 필터링 (overline은 대상 아님). 필터 중에는 그룹 사이 구분선이 숨겨짐 -
스코프 칩: 선택된 칩은
primary-container채움 ·on-primary-container글자로 낮은 강조(Chip의emphasis: low)로 그려 검색 입력보다 먼저 눈에 띄지 않게 함. 그룹 제목은 12 Mediumon-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) |
Escape | onDismiss 가 있으면 호출 |
Tab |
Flutter: 검색 필드 → 활성 항목 하나하나 → 다음 요소. Web: 검색 필드 → 팔레트 바깥 (항목은 tabindex="-1") |
핸들러가 붙은 위치가 플랫폼마다 다릅니다 — Web 은 검색 입력의 keydown 이라 방향키·Enter
는 검색 입력에 포커스가 있을 때만 동작하고, Flutter 는 팔레트 전체를 감싼 Focus 라 팔레트 안 어디에 포커스가 있어도 동작합니다.
Command 는 열림/닫힘 상태를 소유하지 않습니다. 닫힘 요청은 onDismiss 로 받고,
이를 감싼 Dialog 등 호출부가 실제로 팔레트를 숨깁니다.
브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(
tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 입력의aria-activedescendant가 기대한 옵션을 가리키는지(닫힌 뒤에는 아무것도 가리키지 않는지) 확인합니다. 핸들러가 달렸는지가 아니라 결과를 봅니다.
스크린 리더#
-
검색 입력의 접근 가능한 이름은 플레이스홀더 문자열뿐입니다 — Web 은
placeholderHTML 속성, 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)#
CommandAPI는 Flutter / Web에서 완전히 동일합니다. 아래는 내부 구현 차이점만 나열합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Command | Command |
| 검색 방식 | Dart 라벨·keywords 대소문자 무시 substring | 같은 Dart 필터를 Web으로 컴파일 |
| 키보드 탐색 | Focus + KeyEvent | keydown 이벤트 |
관련 컴포넌트 (Related Components)#
- 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',
),
],
),
],
),
)