PlCommandPalette
애플리케이션이 할 수 있는 모든 것을 필드 하나 뒤에 둡니다. 메뉴 바가 담을 수 있는 것보다 액션이 많아진 키보드 중심 제품이 취하는 형태입니다. 어디에 뒀는지 기억하는 대신 원하는 것을 칩니다.
import { PlCommandPalette } from 'plass-ui';
<PlCommandPalette
items={[{ value: 'new', label: 'New document', group: 'File', shortcut: 'Mod+N' }]}
onSelect={(item) => run(item.value)}
/>;import 'package:plass_ui/plass_ui.dart';
PlCommandPalette(
open: open,
onOpenChanged: (bool next) => setState(() => open = next),
onSelect: (PlCommandItem item) => run(item.value),
items: const <PlCommandItem>[
PlCommandItem(value: 'new', label: 'New document', group: 'File', shortcut: 'Mod+N'),
],
);Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| items * | readonly PlCommandItem[] | — | 팔레트가 할 수 있는 모든 것 |
| open | boolean | — | 팔레트가 열려 있는지. onOpenChange와 함께 controlled로 씁니다 |
| defaultOpen | boolean | false | 열린 채로 시작할지 |
| onOpenChange | (open: boolean) => void | — | 열리거나 닫힐 때 |
| onSelect | (item: PlCommandItem) => void | — | 명령이 실행됐을 때, 그 명령 자신의 onSelect 다음에. 어느 쪽이든 팔레트는 닫힙니다 |
| shortcut | string | false | 'Mod+K' | 팔레트를 여는 키. window에 바인딩됩니다. PlHotKeys와 같은 표기라 Mod는 Mac에서 Command, 그 외에서 Control입니다. false면 아무것도 바인딩하지 않습니다 |
| width | number | string | — | 시트가 넓어질 수 있는 한계. 픽셀 수 또는 CSS 길이 |
| maxHeight | number | string | 320 | 목록이 스크롤되기 전까지 높아질 수 있는 한계 |
| placeholder | string | 'Search commands' | 필드의 placeholder |
| emptyMessage | ReactNode | 'No commands found' | 아무것도 맞지 않았을 때 행이 있었을 자리에 오는 줄 |
| label | string | 'Command palette' | 보이는 제목이 없는 이 dialog의 접근 가능한 이름 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 시트의 너비, 필드의 높이, 행의 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 하이라이트, caret, focus ring까지 갑니다 — 시트에는 색이 들어가지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 행의 높이만 바꿉니다 |
| className | string | — | 시트에 붙는 class. 컴포넌트 자신의 class를 대체하지 않고 함께 적용됩니다 |
| style | CSSProperties | — | 시트에 붙는 inline style. 컴포넌트가 쓴 custom property 위에 적용됩니다 |
| classNames | { backdrop?: string } | — | className이 닿지 않는 부분에 붙는 class. backdrop은 표면 뒤에 깔리는 scrim입니다 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| items * | List<PlCommandItem> | — | 팔레트가 할 수 있는 모든 것 |
| open * | bool | — | 팔레트가 열려 있는지. uncontrolled 모드는 없습니다 — 팔레트를 여는 것은 앱 전체에 걸린 키이고, 그런 키를 거는 앱은 이미 상태를 쥐고 있습니다 |
| onOpenChanged | ValueChanged<bool>? | — | 열리거나 닫힐 때 |
| onSelect | ValueChanged<PlCommandItem>? | — | 명령이 실행됐을 때, 그 명령 자신의 onSelect 다음에. 어느 쪽이든 팔레트는 닫힙니다 |
| shortcut | String? | 'Mod+K' | 팔레트를 여는 키. 키보드에 바인딩됩니다. PlHotKeys와 같은 표기라 Mod는 Mac에서 Command, 그 외에서 Control입니다. null이면 아무것도 바인딩하지 않습니다 |
| width | double? | — | 시트가 넓어질 수 있는 한계. 픽셀 수 또는 CSS 길이 |
| maxHeight | double | 320 | 목록이 스크롤되기 전까지 높아질 수 있는 한계 |
| placeholder | String | 'Search commands' | 필드의 placeholder |
| emptyMessage | String | 'No commands found' | 아무것도 맞지 않았을 때 행이 있었을 자리에 오는 줄 |
| label | String | 'Command palette' | 보이는 제목이 없는 이 dialog의 접근 가능한 이름 |
| size공통 | PlassSize | PlassSize.md | 시트의 너비, 필드의 높이, 행의 타입 스케일 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할. 하이라이트, caret, focus ring까지 갑니다 — 시트에는 색이 들어가지 않습니다 |
| density공통 | PlassDensity | PlassDensity.standard | 행의 높이만 바꿉니다 |
네이티브 속성은 전달되지 않습니다. 팔레트는 트리 안의 요소가 아니라 portal로 띄운 dialog를 그리므로, 지나가던 id나 onClick이 닿을 곳이 없습니다. 닿는 것은 className과 style 둘이고, 둘 다 시트에 붙습니다. 그 뒤의 scrim에 닿는 것이 classNames.backdrop입니다.
PlCommandItem
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | string | — | 이 명령을 식별하는 것 |
| label * | string | — | 행이 말하는 내용이자, 질의가 맞춰지는 대상 |
| description | ReactNode | — | 그 아래 한 줄 — 명령이 어디로 가는지, 무엇을 바꾸는지 |
| icon | ReactNode | — | 라벨 앞의 글리프 |
| shortcut | string | — | 같은 일을 하는 키. 행 끝에 놓입니다. 팔레트는 그것을 바인딩하지 않습니다 — 애플리케이션이 합니다 |
| group | string | — | 이 명령이 속한 제목. 명령은 주어진 순서대로 그려지고 제목은 그룹이 바뀔 때마다 그려지므로, 한 그룹의 명령은 붙여서 나열해야 합니다 |
| keywords | readonly string[] | — | 질의에는 맞춰지지만 그려지지는 않는 말들 — 다른 제품이 같은 명령에 붙인 이름, 약어, 사람들이 검색했을 단어 |
| disabled | boolean | false | 목록에는 있지만 실행할 수 없습니다 |
| onSelect | () => void | — | 실행하면 무엇을 하는지 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | String | — | 이 명령을 식별하는 것 |
| label * | String | — | 행이 말하는 내용이자, 질의가 맞춰지는 대상 |
| description | String? | — | 그 아래 한 줄 — 명령이 어디로 가는지, 무엇을 바꾸는지 |
| icon | Widget? | — | 라벨 앞의 글리프 |
| shortcut | String? | — | 같은 일을 하는 키. 행 끝에 놓입니다. 팔레트는 그것을 바인딩하지 않습니다 — 애플리케이션이 합니다 |
| group | String? | — | 이 명령이 속한 제목. 명령은 주어진 순서대로 그려지고 제목은 그룹이 바뀔 때마다 그려지므로, 한 그룹의 명령은 붙여서 나열해야 합니다 |
| keywords | List<String> | const [] | 질의에는 맞춰지지만 그려지지는 않는 말들 — 다른 제품이 같은 명령에 붙인 이름, 약어, 사람들이 검색했을 단어 |
| disabled | bool | false | 목록에는 있지만 실행할 수 없습니다 |
| onSelect | VoidCallback? | — | 실행하면 무엇을 하는지 |
공용 축이 라이브러리 전체에서 무엇을 뜻하는지는 prop 규약에 있습니다.
Command palette와 menu
PlMenu가 아닙니다. 메뉴는 한 자리에 있는 짧은 목록이고, 찾으러 가기 전에 모든 행이 이미 보입니다.PlCombobox도 아닙니다. 돌아오는 것은 값이 아니라 일어나는 일입니다.
"그 명령 어디 있더라"의 답이 "기억 안 나"가 됐을 때 쓰세요.
Examples
그룹, 설명, 키워드
명령은 주어진 순서대로 그려지고, group이 바뀔 때마다 제목이 나타납니다. 그래서 한 그룹의 명령은 붙여서 나열해야 합니다. 배치 규칙은 그것이 전부이고, 화면의 순서가 컴포넌트가 몰래 정렬한 것이 아니라 배열의 순서라는 뜻이기도 합니다.
keywords는 맞춰지지만 그려지지 않습니다. 다른 제품이 같은 명령에 붙인 이름, 약어, 사람들이 검색했을 단어입니다.
필터는 대소문자와 결합 문자를 접으므로 cafe가 Café를 찾습니다. 각 명령의 검색 대상 텍스트는 비교마다가 아니라 목록당 한 번 접힙니다. 글자를 칠 때마다 모든 명령에 normalize를 도는 것이 팔레트를 느리게 만드는 바로 그 비용입니다.
import { useState } from 'react';
import { PlButton, PlCommandPalette, type PlCommandItem } from 'plass-ui';
const commands: PlCommandItem[] = [
{ value: 'new', label: 'New document', group: 'File' },
{ value: 'open', label: 'Open…', group: 'File', keywords: ['load', 'import'] },
{ value: 'copy', label: 'Copy', group: 'Edit', description: 'Put it on the clipboard' },
{ value: 'paste', label: 'Paste', group: 'Edit', disabled: true },
{ value: 'zen', label: 'Zen mode', group: 'View', keywords: ['focus', 'distraction free'] }
];
export default function CommandPaletteGroups() {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col items-center gap-2">
<PlButton variant="glass" color="secondary" onClick={() => setOpen(true)}>
Try “load”, or “distraction”
</PlButton>
<PlCommandPalette items={commands} open={open} onOpenChange={setOpen} shortcut={false} />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class CommandPaletteGroups extends StatefulWidget {
const CommandPaletteGroups({super.key});
@override
State<CommandPaletteGroups> createState() => _CommandPaletteGroupsState();
}
class _CommandPaletteGroupsState extends State<CommandPaletteGroups> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: <Widget>[
PlButton(
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () => setState(() => _open = true),
child: const Text('Try “load”, or “distraction”'),
),
PlCommandPalette(
open: _open,
shortcut: null,
onOpenChanged: (bool next) => setState(() => _open = next),
items: const <PlCommandItem>[
PlCommandItem(value: 'new', label: 'New document', group: 'File'),
PlCommandItem(
value: 'open',
label: 'Open…',
group: 'File',
keywords: <String>['load', 'import'],
),
PlCommandItem(
value: 'copy',
label: 'Copy',
group: 'Edit',
description: 'Put it on the clipboard',
),
PlCommandItem(value: 'paste', label: 'Paste', group: 'Edit', disabled: true),
PlCommandItem(
value: 'zen',
label: 'Zen mode',
group: 'View',
keywords: <String>['focus', 'distraction free'],
),
],
),
],
);
}
}shortcut
이름을 공유하는 서로 다른 둘이 있고, 그중 하나만 바인딩됩니다.
행의 shortcut은 행 끝에 PlHotKeys로 표시됩니다. 팔레트는 그것을 바인딩하지 않습니다. 애플리케이션이 이미 했고, 컴포넌트까지 바인딩하면 아무도 요청하지 않은 두 번째 리스너가 됩니다.
팔레트의 shortcut은 window에 바인딩되고 기본값은 Mod+K입니다. PlHotKeys가 그리는 것과 같은 Mod 인식 표기로 읽으므로, 화면의 키 캡과 실제로 동작하는 키가 어긋날 수 없습니다. false면 아무것도 바인딩하지 않습니다.
size
시트의 너비, 필드의 높이, 행의 타입 스케일입니다. 필드는 컨트롤 사다리보다 한 단 위에 앉습니다. md가 48px입니다. 팔레트의 필드는 컨트롤 행 안의 컨트롤이 아니라 시트의 꼭대기이고, 화면에 있는 유일한 것이기 때문입니다.
density는 행 높이만 옮깁니다.
import { useState } from 'react';
import { PlButton, PlCommandPalette, type PlassSize } from 'plass-ui';
const commands = [
{ value: 'new', label: 'New document' },
{ value: 'open', label: 'Open…' },
{ value: 'copy', label: 'Copy' }
];
export default function CommandPaletteSizes() {
const [size, setSize] = useState<PlassSize | null>(null);
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{(['xs', 'sm', 'md', 'lg', 'xl'] as PlassSize[]).map((step) => (
<PlButton
key={step}
size="sm"
variant="glass"
color="secondary"
onClick={() => setSize(step)}
>
{step}
</PlButton>
))}
<PlCommandPalette
items={commands}
size={size ?? 'md'}
open={size !== null}
onOpenChange={(next) => setSize(next ? size : null)}
shortcut={false}
/>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class CommandPaletteSizes extends StatefulWidget {
const CommandPaletteSizes({super.key});
@override
State<CommandPaletteSizes> createState() => _CommandPaletteSizesState();
}
class _CommandPaletteSizesState extends State<CommandPaletteSizes> {
PlassSize? _size;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: <Widget>[
for (final PlassSize size in PlassSize.values)
PlButton(
size: PlassSize.sm,
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () => setState(() => _size = size),
child: Text(size.name),
),
PlCommandPalette(
open: _size != null,
shortcut: null,
size: _size ?? PlassSize.md,
onOpenChanged: (bool next) => setState(() => _size = next ? _size : null),
items: const <PlCommandItem>[
PlCommandItem(value: 'new', label: 'New document'),
PlCommandItem(value: 'open', label: 'Open…'),
PlCommandItem(value: 'copy', label: 'Copy'),
],
),
],
);
}
}Controlled
open을 onOpenChange와 함께 넘기세요. 팔레트는 여전히 묻고(키를 누르면 onOpenChange(true)가 발생하고) 호출하는 쪽이 그렇다고 하기 전까지 열리지 않습니다. route guard나 "에디터가 바쁠 때는 안 됨" 같은 규칙에 필요한 것이 그것입니다.
질의는 들어올 때가 아니라 나갈 때 버려집니다. 그래야 시트가 사라지면서 마지막 검색어를 번쩍 보여 주지 않습니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
open / defaultOpen | 필수인 open | uncontrolled 모드가 없습니다. 팔레트를 여는 것은 앱 전체에 걸린 키이고, 그런 키를 거는 앱은 이미 상태를 쥐고 있습니다. |
shortcut: false | shortcut: null | "아무것도 바인딩하지 않는다"를 Dart가 나타내는 방식입니다. |
| Base UI Autocomplete가 다루는 목록 키 | focus 시스템보다 먼저, 팔레트 자신의 키 핸들러에서 | 필드가 focus를 쥐고 있고 EditableText가 화살표 키와 Enter를 스스로 삼킵니다. 먼저 읽는 것만이 필드가 모든 글자를, 목록이 자기 네 키를 지키는 길입니다. |
aria-activedescendant를 지닌 combobox | 필드 하나와 button 목록, 그중 하나가 selected | Flutter semantics에는 activedescendant가 없습니다. 남는 것은 중요한 쪽입니다. 하이라이트는 하나이고, 그것이 얹힌 행에서 알려집니다. |
| 대소문자 와 결합 문자를 접음 | 대소문자만 | Dart 코어에는 String.normalize가 없고, 이 패키지에는 의존성이 없습니다. |
숫자나 CSS 길이인 width, maxHeight | double | 이름 붙일 두 번째 단위가 없습니다. |
className, style | — | 전달할 class 목록도 style 속성도 없습니다. |
Accessibility
- 시트는 focus trap과 scrim과 Esc를 갖춘 dialog이고, focus는 독자가 있던 자리로 돌아갑니다. 보이는 제목이 없으므로
label이 접근 가능한 이름입니다. - 필드는
combobox이고 목록은 그listbox이며, Base UI가aria-activedescendant로 잇습니다. 화살표 키가 focus를 옮기지 않고 하이라이트만 옮기므로 필드가 모든 키 입력을 그대로 받습니다. - 하이라이트는 하나입니다. 포인터와 화살표 키가 같은 표시를 움직이므로, 하이라이트된 행 둘을 보며 Enter가 어느 쪽을 실행할지 고민할 일이 없습니다.
- 그룹 제목은
role="presentation"입니다. 두 번째 목록이 아니라 같은 목록의 시각적 묶음입니다. disabled명령은 목록에 남고 실행되지 않습니다. 고를 수 없다고 사라지는 항목은 독자가 계속 찾아 헤매게 되는 항목입니다.- 전체가
<body>끝으로 portal되고, backdrop과 viewport가.plass-portal을 지닙니다. CSS reset을 범위 지정한 호스트가 같은 reset을 거는 자리가 그것입니다.