# 自动补全

## 使用方法

`Typeahead` 是单选搜索框。输入框是 combobox，下拉列表是 listbox。本地数据用 `createStaticSource`，远程数据自己实现 `search`。

本地搜索按标签和可选关键词做子串匹配。`debounceMs` 设为 `0` 会立刻过滤：

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

const selected = ref(null)
const source = createStaticSource([
  { id: 'apple', label: '苹果' },
  { id: 'grape', label: '葡萄' },
], { keywords: item => item.id === 'grape' ? ['提子'] : [] })
</script>

<template>
  <Typeahead v-model="selected" :search-source="source" label="水果" :debounce-ms="0" show-on-focus />
</template>
```

异步数据源返回 Promise。`useAsyncSource` 会在输入停顿后发起请求，默认等待 150 毫秒。新的一次搜索会取消上一次，过期结果不会写回列表。空查询优先走 `bootstrap`。加载、没有结果、请求失败分别显示加载文案、空状态和错误文案。

方向键移动高亮项，`Enter` 选中，`Escape` 关闭列表。焦点在输入框里时，`Home` 和 `End` 移动光标。选中后结果收成一枚可聚焦的标签，再次聚焦会回到搜索。

内置文案默认是英文。调用 `provideAstryxLocale('zh-CN')` 后改为中文。文档页会按当前语言自动提供。

### 结构

- **字段**: 提供标签和可选辅助内容的独立 Field 外壳。放在 InputGroup 里时不画这一层。
- **输入表面**: 装着可编辑输入或已选标签的着色控件表面。
- **图标渲染的起始图标**: 可选的前导语义图标或图标组件，通过 Icon 绘制。
- **使用方渲染的起始内容**: 直接画在输入表面开头的可选任意插槽内容。
- **已选标签**: 控件不在编辑状态时，用来呈现已选项的 Token。
- **转圈**: 搜索进行中时，输入表面末尾的加载指示。
- **清除按钮**: 共用的清除操作。clearable 打开时去掉已选项。
- **下拉**: 装着当前搜索结果的锚定列表框表面。
- **空状态**: 一次搜索完成但没有结果时的消息。
- **结果行**: 稳定的选项外层，负责高亮、选中、指针和键盘行为。
- **默认项内容**: 没有提供 item 插槽时，结果行里画出的标准标签和辅助内容。
- **使用方渲染的项内容**: 通过 item 插槽放进稳定结果行里的、由使用方拥有的结果内容。
- **结果分组标题**: 一组带标签的结果行的可见标题。
- **已选结果状态**: 当前结果行上的选中样式，以及尾部的勾。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 本地数据把 `debounceMs` 设为 0，远程请求保留默认防抖。 |
| 推荐 | 远程源在 `signal` 被取消时中止请求，避免过期结果覆盖新结果。 |
| 避免 | 用自动补全做多选。多选固定列表用多选器，多选搜索用标签输入框。 |

## 示例

### 限制结果

下拉最多显示三条结果。

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

const selected = ref(null)
const source = computed(() => createStaticSource([
  { id: 'us', label: 'United States' },
  { id: 'uk', label: 'United Kingdom' },
  { id: 'ca', label: 'Canada' },
  { id: 'au', label: 'Australia' },
  { id: 'de', label: 'Germany' },
  { id: 'fr', label: 'France' },
  { id: 'jp', label: 'Japan' },
  { id: 'br', label: 'Brazil' },
]))
</script>

<template>
  <div class="demo-container">
    <Typeahead
      v-model="selected"
      :search-source="source"
      label="Country"
      placeholder="Search countries..."
      :debounce-ms="0"
      :max-menu-items="3"
      has-entries-on-focus
    />
  </div>
</template>

<style scoped>
.demo-container {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 搜索框

带搜索图标，聚焦后就给出建议。

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

const selected = ref(null)
const source = computed(() => createStaticSource([
  { id: 'olivia', label: 'Olivia Martin' },
  { id: 'jackson', label: 'Jackson Lee' },
  { id: 'isabella', label: 'Isabella Nguyen' },
  { id: 'william', label: 'William Kim' },
  { id: 'sofia', label: 'Sofia Davis' },
]))
</script>

<template>
  <div class="demo-container">
    <Typeahead
      v-model="selected"
      :search-source="source"
      label="Team member"
      placeholder="Search people..."
      :debounce-ms="0"
      has-entries-on-focus
    >
      <template #icon>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="4.5" /><path d="m10.5 10.5 3 3" /></svg>
      </template>
    </Typeahead>
  </div>
</template>

<style scoped>
.demo-container {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
.demo-container :deep(svg) {
  width: 1rem;
  height: 1rem;
}
</style>
```

### 辅助说明

标签下面有一段说明。

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

const selected = ref(null)
const source = computed(() => createStaticSource([
  { id: 'eng', label: 'Engineering' },
  { id: 'design', label: 'Design' },
  { id: 'marketing', label: 'Marketing' },
  { id: 'sales', label: 'Sales' },
  { id: 'product', label: 'Product' },
]))
</script>

<template>
  <div class="demo-container">
    <Typeahead
      v-model="selected"
      :search-source="source"
      label="Department"
      placeholder="Search departments..."
      description="Select the department this request should be routed to"
      :debounce-ms="0"
    />
  </div>
</template>

<style scoped>
.demo-container {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 校验

用错误状态提示必须选择一个地点。

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

const selected = ref(null)
const source = computed(() => createStaticSource([
  { id: 'ny', label: 'New York' },
  { id: 'sf', label: 'San Francisco' },
  { id: 'london', label: 'London' },
  { id: 'berlin', label: 'Berlin' },
  { id: 'tokyo', label: 'Tokyo' },
]))
</script>

<template>
  <div class="demo-container">
    <Typeahead
      v-model="selected"
      :search-source="source"
      label="Office"
      placeholder="Search offices..."
      status="error"
      status-message="Please select an office location"
      :debounce-ms="0"
    />
  </div>
</template>

<style scoped>
.demo-container {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 无字段外壳

BaseTypeahead 没有字段标签和说明。命令面板这类已经有自己标题的地方用它，输入框的无障碍名用 label 传入。

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

const selected = ref(null)
const source = computed(() => createStaticSource([
  { id: 'files', label: 'Search files', description: 'Jump to a file' },
  { id: 'settings', label: 'Open settings', description: 'Preferences' },
  { id: 'theme', label: 'Toggle theme', description: 'Light or dark' },
]))
</script>

<template>
  <div class="demo-container">
    <BaseTypeahead
      v-model="selected"
      :search-source="source"
      label="Command"
      placeholder="Type a command"
      :debounce-ms="0"
      has-entries-on-focus
    />
  </div>
</template>

<style scoped>
.demo-container {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

## Typeahead Props

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `T \| null` | `null` | 当前选中的条目。条目至少包含 `id` 和 `label`。 |
| `searchSource` | `SearchSource` | 必需 | 静态或异步搜索源。空查询优先调用 `bootstrap`。 |
| `label` | `string` | 必需 | 字段标签。屏幕阅读器始终读得到。 |
| `description` | `string` | — | 标签下方的说明。 |
| `labelTooltip` | `string` | — | 标签旁边信息图标里的提示文字。 |
| `isLabelHidden` | `boolean` | `false` | 视觉上隐藏标签，读屏仍然读得到。旧名 `labelHidden` 同样可用，两者任一为真就隐藏。 |
| `containerClass` / `controlClass` | `class` | — | 外壳和输入外框。根上的 class 落在外框，不落到原生 input。候选项的 `action` 把菜单变成两列，点操作不会选中这一项。 |
| `startIcon` | `Component` | — | 输入框起始侧的图标。已有 `icon` 插槽时以插槽为准。 |
| `placeholder` | `string` | `Search…` / `搜索…` | 占位符。未设置语言时为英文。 |
| `debounceMs` | `number` | `150` | 输入停顿后再搜索。本地数据可设为 0。 |
| `minQueryLength` | `number` | `1` | 够长才去搜索的下限，按人眼看到的字符数算。太短就完全不搜，下拉也不打开，免得为一个没搜过的查询显示「没有结果」。空查询不算太短，照旧走 bootstrap。 |
| `hasEntriesOnFocus` | `boolean` | `false` | 聚焦后还没输入就展示 bootstrap 结果。 |
| `showOnFocus` | `boolean` | `false` | `hasEntriesOnFocus` 的旧名。两者任一为真即生效。 |
| `hasAutoFocus` | `boolean` | `false` | 挂载后自动聚焦输入框。 |
| `maxMenuItems` | `number` | `10` | 下拉最多显示多少条。 |
| `hasClear` | `boolean` | `true` | 是否显示清除按钮，用来取消当前选中值。 |
| `clearable` | `boolean` | `true` | `hasClear` 的旧名。任一为假就不显示按钮。 |
| `emptySearchText` | `string \| null` | `'No results found'` / `'未找到结果'` | 没有匹配结果时显示的文字。`null` 当作没给，退回默认；空字符串表示什么都不显示。要放链接这类富内容用 `empty` 插槽。 |
| `emptyText` | `string \| null` | — | `emptySearchText` 的旧名。 |
| `disabled` | `boolean` | `false` | 是否禁用。 |
| `disabledMessage` | `string` | — | 说明为什么被禁用。和 `disabled` 一起用时，控件改用 `aria-disabled` 加 `readonly`，因此仍然可聚焦，鼠标悬停或键盘聚焦都能读到这段说明。 |
| `disabledReason` | `string` | — | `disabledMessage` 的旧名。 |
| `status` | `'default' \| 'success' \| 'warning' \| 'error'` | `'default'` | 状态样式。 |
| `statusMessage` | `string` | — | 字段下方的状态说明。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 输入框和标签的尺寸。 |
| `required` | `boolean` | `false` | 标记为必填。 |
| `optional` | `boolean` | `false` | 标签上显示「可选」标记。 |
| `name` | `string` | — | 表单字段名。提交选中条目的 id。 |
| `width` | `number \| string` | — | 整个字段的宽度（数字按像素算，字符串原样使用）。 |

## Typeahead Emits

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: T \| null` | 选中或清除时触发。 |
| `change` | `value: T \| null` | 选中或清除时触发。 |
| `changeQuery` | `query: string` | 查询文字变化时触发。 |
| `search` | `query: string` | `changeQuery` 的旧名。两个都会触发。 |
| `openChange` | `isOpen: boolean` | 列表打开或关闭时触发。 |

## Typeahead Slots

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `icon` | — | 输入框开头的图标。 |
| `item` | `{ item, isActive, isSelected }` | 自定义下拉条目，替换默认的 `TypeaheadItem`。 |
| `empty` | — | 自定义空状态。 |

## createStaticSource

| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `items` | `T[]` | 本地条目。 |
| `keywords` | `(item: T) => string[]` | 除 label 之外还参与子串匹配的词。 |

## 结果分组

把分组名放在 `item.auxiliaryData.group` 上。同名的条目会渲染成一个 `role="group"` 区块，前面带可见的标题，顺序按分组第一次出现的先后。没有分组名的条目留在原位。

```ts
const items = [
  { id: 'apple', label: 'Apple', auxiliaryData: { group: 'Fruit' } },
  { id: 'carrot', label: 'Carrot', auxiliaryData: { group: 'Vegetable' } },
]
```

## BaseTypeahead

没有字段标签、说明和校验文案。适合命令面板这类外面已经有标题的地方。`label` 在这里是输入框的无障碍名，外面已经有 `<label for>` 时不用传。默认下拉行是 `TypeaheadItem`，可以用 `item` 插槽换掉。

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `T \| null` | `null` | 当前选中的条目。 |
| `searchSource` | `SearchSource` | 必需 | 静态或异步搜索源。 |
| `label` | `string` | — | 输入框的无障碍名。 |
| `inputId` | `string` | 自动生成 | 输入框的 id，好让外面的标签用 `for` 对上。 |
| `describedBy` | `string` | — | 外面已经算好的说明和状态 id。 |
| `ariaDescribedBy` | `string` | — | 输入框专用的 `aria-describedby` 别名，给了就盖过原生属性。 |
| `ariaLabelledBy` | `string` | — | 输入框专用的 `aria-labelledby` 别名，给了就盖过原生属性。 |
| `anchorRef` | `HTMLElement \| null` | — | 下拉挂靠并据此量宽度的元素。不传就用输入框所在的外框。 |
| `inputTabIndex` | `number` | — | 输入框在 Tab 序里的位置。选中值的标签盖住输入框时默认取 `-1`，免得留下看不见的停留点。 |
| `isFocusableDisabled` | `boolean` | `false` | 让禁用的输入框改用 `aria-disabled` 加 `readonly`，仍然可聚焦，好让调用方给的禁用说明读得到。输入内容照样被挡住。 |
| `hasAutoFocus` | `boolean` | `false` | 挂载后自动聚焦输入框。 |
| `hasEntriesOnFocus` | `boolean` | `false` | 聚焦后还没输入就展示 bootstrap 结果。 |
| `showOnFocus` | `boolean` | `false` | `hasEntriesOnFocus` 的旧名。两者任一为真即生效。 |
| `minQueryLength` | `number` | `1` | 够长才去搜索的下限，按人眼看到的字符数算。太短就完全不搜，下拉也不打开。 |
| `menuWidth` | `number` | — | 下拉想要的宽度，单位像素。窄不过锚点，再宽会被视口截住。 |
| `maxMenuItems` | `number` | `10` | 下拉最多显示多少条。 |
| `emptySearchText` | `string \| null` | `'No results found'` / `'未找到结果'` | 没有匹配结果时显示的文字。`null` 退回默认，空字符串什么都不显示。 |
| `emptyText` | `string \| null` | — | `emptySearchText` 的旧名。 |
| `placeholder` | `string` | `Search…` | 输入框占位符。 |
| `disabled` | `boolean` | `false` | 是否禁用。 |
| `disabledReason` | `string` | — | 禁用原因。鼠标悬停会显示提示，同时作为说明被读出来，输入框仍可聚焦。 |
| `status` | `'default' \| 'success' \| 'warning' \| 'error'` | `'default'` | 外框的校验色。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 输入框和标签的尺寸。 |
| `clearable` | `boolean` | `true` | 是否显示清除按钮。 |
| `debounceMs` | `number` | `150` | 输入停顿后再搜索。本地数据可设为 0。 |
| `required` | `boolean` | `false` | 标记为必填。 |
| `name` | `string` | — | 表单字段名。提交选中条目的 id。 |

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: T \| null` | 选中或清除时触发。 |
| `change` | `value: T \| null` | 选中或清除时触发。 |
| `changeQuery` | `query: string` | 查询文字变化时触发。 |
| `search` | `query: string` | `changeQuery` 的旧名。两个都会触发。 |
| `openChange` | `isOpen: boolean` | 列表打开或关闭时触发。 |

上面没列出的原生属性和事件监听会落到输入框上，所以 `@keydown`、`@focus`、`@blur` 和 `aria-*` 的用法跟普通 `<input>` 一样。`keydown` 监听先于内部键盘导航执行，调用 `event.preventDefault()` 就能跳过内部处理。

## TypeaheadItem

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `item` | `SearchableItem` | 必需 | 这一行对应的搜索结果。 |
| `description` | `string` | — | 盖过条目自己的说明。 |
| `group` | `string` | — | 为了和同名属性对得上而接受；这一行自己不会画标题。分组由下拉按 `item.auxiliaryData.group` 完成。 |
| `isDisabled` | `boolean` | `false` | 这一行看起来不可选。 |
| `icon`（插槽） | — | — | 标签前面的图标或头像。 |
