# 命令面板

## 使用方法

`CommandPalette`（命令面板）是一个可以通过键盘全局唤起、支持即时搜索的指令弹窗，类似于 VS Code 或 Mac 上的 Spotlight 搜索。它把居中弹窗、输入搜索框、指令分组、键盘上下选行、以及搜不到时的空状态都封装在了一起。只要传入数据源 `SearchSource`，输入框为空时它会展示推荐列表（`bootstrap`），用户打字时则会即时进行关键词搜索（`search`）。

**什么时候用：**
- 需要为高级用户提供全局快捷键（如 `Cmd + K` 或 `Ctrl + K`），让他们能够秒级搜索页面、执行快捷操作或跳转路由时。

**什么时候不要用：**
- 普通表单里的局部下拉选择，请使用 [Selector](/components/selector) 或下拉菜单。
- 站内文章的全文搜索结果页，更适合使用带翻页的专属搜索大页面。

通过快捷键打开面板非常简单，配合 `useHotkeys` 监听快捷键，控制 `isOpen` 开关即可：

```vue
<script setup lang="ts">
import { createStaticSource, useHotkeys } from '@astryx-vue/core'
import { CommandPalette } from '@astryx-vue/core/CommandPalette'
import { ref } from 'vue'

const isOpen = ref(false)
const source = createStaticSource([
  { id: 'home', label: 'Home' },
  { id: 'settings', label: 'Settings' },
])

function openPalette() {
  isOpen.value = true
}

useHotkeys([{ keys: 'mod+k', onPress: openPalette }])
</script>

<template>
  <CommandPalette
    :is-open="isOpen"
    :search-source="source"
    @open-change="isOpen = $event"
  />
</template>
```

其他好用的配置包括：

- `v-model:value`：可以把命令面板变成单选选择器，当前选中的项会保留打勾标记。
- 在数据项里配置 `auxiliaryData.group`，组件就会自动将菜单整理成带标题的分类区域。
- 上下方向键在选项间移动高亮，到头就停住。Home 和 End 留在输入框里，不改高亮。Page Up 跳到第一项可用项，Page Down 跳到最后一项可用项。Enter 执行当前高亮行。Escape 关掉面板，并丢掉还没回来的异步搜索。鼠标划过只会移动高亮，不会把列表滚走。
- 只有在做组件文档演示或特殊内嵌时才开启 `isInline`（将面板平铺在页面里），正常的实际业务项目中请保持默认的模态弹窗形式。

### 结构

- **对话框**: 装着命令面板的模态表面。
- **输入区**: 装着查询框和配套视觉的搜索区域。
- **搜索符号**: 默认输入区里由 Icon 画出的搜索符号。
- **查询框**: 用来输入搜索词的原生文本框。
- **加载转圈**: 搜索进行中时，默认输入区里的转圈。
- **列表**: 可滚动的列表框，装着当前结果或空状态。
- **项**: 列表里可选的一条命令结果。
- **分组**: 共享一个标题的一组项，可选。
- **分组标题**: 分组显示出来的标题。
- **空状态**: 当前没有结果时显示的消息。
- **页脚**: 放默认的键盘提示，或使用方自己的内容。
- **快捷键**: 默认页脚提示里由 Kbd 画出的按键徽章。

## 示例

### 异步搜索

搜索会稍等一下再按文件名过滤。还没输入时提示输入文件名。

```vue
<script setup lang="ts">
import type { SearchSource } from '@astryx-vue/core'
import { CommandPalette, CommandPaletteInput } from '@astryx-vue/core/CommandPalette'

const files = [
  { id: 'readme', label: 'README.md' },
  { id: 'package', label: 'package.json' },
  { id: 'tsconfig', label: 'tsconfig.json' },
  { id: 'index', label: 'src/index.ts' },
  { id: 'app', label: 'src/App.tsx' },
]

const source: SearchSource = {
  async search(query: string) {
    await new Promise(resolve => setTimeout(resolve, 400))
    const normalized = query.toLowerCase()
    return files.filter(file => file.label.toLowerCase().includes(normalized))
  },
  bootstrap() {
    return []
  },
}
</script>

<template>
  <CommandPalette
    empty-bootstrap-text="Type a filename to search"
    empty-search-text="No files found"
    is-inline
    is-open
    :search-source="source"
  >
    <template #input>
      <CommandPaletteInput placeholder="Search files..." />
    </template>
  </CommandPalette>
</template>
```

### 分组

Home 和 Settings 在 Navigation 下，New File 和 Save 在 Actions 下。

```vue
<script setup lang="ts">
import { createStaticSource } from '@astryx-vue/core'
import { CommandPalette } from '@astryx-vue/core/CommandPalette'

const source = createStaticSource([
  { id: 'home', label: 'Home', auxiliaryData: { group: 'Navigation' } },
  { id: 'settings', label: 'Settings', auxiliaryData: { group: 'Navigation' } },
  { id: 'new-file', label: 'New File', auxiliaryData: { group: 'Actions' } },
  { id: 'save', label: 'Save', auxiliaryData: { group: 'Actions' } },
])
</script>

<template>
  <CommandPalette is-inline is-open :search-source="source" />
</template>
```

### 自定义页脚

底部提示换成一句说明。

```vue
<script setup lang="ts">
import { createStaticSource } from '@astryx-vue/core'
import { CommandPalette, CommandPaletteFooter } from '@astryx-vue/core/CommandPalette'
import { Text } from '@astryx-vue/core/Text'

const source = createStaticSource([
  { id: 'home', label: 'Home' },
  { id: 'settings', label: 'Settings' },
])
</script>

<template>
  <CommandPalette is-inline is-open :search-source="source">
    <template #footer>
      <CommandPaletteFooter>
        <Text type="supporting">
          Pro tip: use ⌘K to open anywhere
        </Text>
      </CommandPaletteFooter>
    </template>
  </CommandPalette>
</template>
```

### 选择模式

Light、Dark、System。当前主题保留勾选标记。

```vue
<script setup lang="ts">
import { createStaticSource } from '@astryx-vue/core'
import { CommandPalette } from '@astryx-vue/core/CommandPalette'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const theme = ref('light')
const source = createStaticSource([
  { id: 'light', label: 'Light' },
  { id: 'dark', label: 'Dark' },
  { id: 'system', label: 'System' },
])
</script>

<template>
  <CommandPalette v-model:value="theme" is-inline is-open :search-source="source">
    <template #item="{ item, isSelected }">
      <Text type="body" style="flex: 1">
        {{ item.label }}
      </Text>
      <span v-if="isSelected" class="css-icon css-icon--sm i-carbon-checkmark" aria-hidden="true" />
    </template>
  </CommandPalette>
</template>
```

### 带附加信息的条目

行上显示命令；设置了快捷键时，右侧会显示按键。

```vue
<script setup lang="ts">
import type { SearchableItem } from '@astryx-vue/core'
import { createStaticSource } from '@astryx-vue/core'
import { CommandPalette } from '@astryx-vue/core/CommandPalette'
import { Kbd } from '@astryx-vue/core/Kbd'
import { Text } from '@astryx-vue/core/Text'

interface RichCommand extends SearchableItem<{ group?: string, shortcut?: string }> {}

const commands: RichCommand[] = [
  { id: 'settings', label: 'Open Settings', auxiliaryData: { group: 'Navigation', shortcut: 'mod+,' } },
  { id: 'profile', label: 'View Profile', auxiliaryData: { group: 'Navigation' } },
  { id: 'new-file', label: 'Create New File', auxiliaryData: { group: 'Actions', shortcut: 'mod+n' } },
  { id: 'search', label: 'Search Files', auxiliaryData: { group: 'Actions', shortcut: 'mod+p' } },
]

const source = createStaticSource(commands)

function shortcut(item: { auxiliaryData?: unknown }): string | undefined {
  const data = item.auxiliaryData
  if (data != null && typeof data === 'object' && 'shortcut' in data && typeof data.shortcut === 'string')
    return data.shortcut
  return undefined
}
</script>

<template>
  <CommandPalette is-inline is-open :search-source="source">
    <template #item="{ item }">
      <Text type="body" style="flex: 1">
        {{ item.label }}
      </Text>
      <Kbd v-if="shortcut(item)" :keys="shortcut(item)!" />
    </template>
  </CommandPalette>
</template>
```

## 属性

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `isOpen` | `boolean` | - | 面板是否打开。 |
| `searchSource` | `SearchSource` | - | 命令从哪来。空查询会调用 `bootstrap`。 |
| `isInline` | `boolean` | `false` | 画在页面流里，而不是模态对话框。 |
| `label` | `string` | 命令面板 | 对话框的无障碍名称。 |
| `width` | `number \| string` | `640` | 对话框宽度。数字按像素。 |
| `maxHeight` | `number \| string` | `480` | 对话框最大高度。数字按像素。 |
| `emptySearchText` | `string` | 没有结果 | 查询没有命中时显示的文字。 |
| `emptyBootstrapText` | `string` | 输入以搜索 | 数据源还没有可列内容时显示的文字。 |
| `value` | `string` | - | 选择模式下的选中 id。用 `v-model:value` 绑定。 |

## 事件

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `openChange` | `boolean` | 下一次的打开状态。Escape 和选中一行都会关闭面板。 |
| `update:value` | `string` | 刚选中的那一行的 id。 |
| `valueChange` | `string` | 同一次选中，用原版的回调名字，方便从 React 迁过来的代码。 |

## 插槽

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `input` | - | 替换 `CommandPaletteInput`。 |
| `item` | `{ item, isSelected }` | 行内容。`isSelected` 是选择模式的匹配，不是键盘高亮。 |
| `footer` | - | 替换默认提示行。替换内容还要保留页脚外观时，放一个 `CommandPaletteFooter`。 |

## CommandPaletteInput

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `placeholder` | `string` | 搜索… | 占位文字，也是没有单独标签时的无障碍名称。 |
| `label` | `string` | - | 组合框的无障碍名称。不传时用 `placeholder`。 |
| `hasAutoFocus` | `boolean` | `true` | 面板打开时聚焦输入框。内联面板会跳过。 |
| `value` | `string` | - | 搜索词，用 `v-model:value` 绑定。不传时读写外层 `CommandPalette` 的搜索词。 |

| 名称 | 说明 |
| --- | --- |
| `valueChange` | 下一个搜索词。只在绑定了 `value` 时触发，否则输入框直接写到面板里。 |

<code>endContent</code> 插槽（也可以写成 <code>end</code>）是转圈图标后面的内容。

## CommandPaletteList

`label` 是列表框的名称，默认是「命令」。

## CommandPaletteGroup

`heading` 是分组标题，也是这一组的无障碍名称。

## CommandPaletteItem

`value` 是命令 id。`isDisabled` 会挡住选择。`isHighlighted` 和 `isSelected` 会盖掉从面板读到的状态，单独展示某一行时用得上。鼠标点击激活时发出 <code>select</code>，参数是这一行的值。默认插槽是行内容。

## CommandPaletteEmpty

默认插槽是空状态文字。

## CommandPaletteFooter

默认插槽替换「移动 / 选择 / 关闭」这组提示，页脚外观保留。
