# 基础自动补全

## 使用方法

组合框的引擎：一个光板输入框，加上搜索、键盘操作和画好的结果下拉。它不画输入框外壳、不画边框，也不画已选值。`Typeahead` 和 `Tokenizer` 都是在它上面拼出来的标准字段。

```ts
import { BaseTypeahead } from '@astryx-vue/core/Typeahead'
```

这版的分工和原版一样。`BaseTypeahead` 只管输入框、搜索源、键盘模型和下拉；标签、说明、状态文案、已选值的标签都归调用方，由调用方摆在引擎外面。这里的 `label` 只是输入框的无障碍名，外面已经有 `<label for>` 指过来时就不用传。

### 结构

- **输入框**: 一个裸的 combobox 输入框。看得见的外壳和无障碍名由调用方提供。
- **加载状态**: 异步数据源还在跑时，输入框旁边显示一个带无障碍名的转圈。如果外层组件自己接管了这个忙碌提示的位置，就不再显示。
- **下拉**: 挂靠定位的 listbox 面板，装着当前的搜索或者首屏结果。
- **空状态**: 搜索完成但一条结果都没有时，显示一个禁用的 listbox 选项。
- **结果分组标题**: 一组结果选项上方看得见的标题。
- **结果行**: 选项外壳，负责高亮、选中、指针和键盘行为。
- **默认条目内容**: 结果行里默认画的 TypeaheadItem，也就是主文字加可选的辅助内容。
- **调用方画的条目内容**: 调用方通过 renderItem（这版是 item 插槽）塞进稳定结果行里的内容。
- **选中结果状态**: 某条结果和当前值对上时，这一行加粗，并在末尾画一个勾。

## 建议

| 指引 | 实践 |
| --- | --- |
| 推荐 | 标准表单字段直接用 `Typeahead` 或 `Tokenizer`。它们包着 `BaseTypeahead`，把原版故意不画的外壳和已选值都补上了。 |
| 推荐 | 自己给一个看得见的标签或者 `aria-label`，再自己套一层输入框外壳，让这个光板组合框有无障碍名、有聚焦样式、有边框、有布局。 |
| 推荐 | 把你的外壳元素通过 `anchorRef` 传进去，下拉就会贴着你的外壳定位，而不是只贴那个光板输入框。 |
| 避免 | 别指望它画外壳或者画已选值。`BaseTypeahead` 只是引擎，外面那些看得见的东西都是调用方的事。 |
| 避免 | `Typeahead` 或 `Tokenizer` 够用时就不要用 `BaseTypeahead`。多套一层外壳、多写一套样式，只有真要做非常规组合时才值得。 |
| 避免 | 别以为按 Escape 就把还在跑的搜索取消了。它只是把当前弹层藏起来，晚到的结果还能把弹层重新打开。 |

## 示例

常见的用法和不同的状态。

### BaseTypeahead

自己画每一行结果，多给一条旁注，而选中语义和键盘行为仍然由 BaseTypeahead 负责。

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

interface FrameworkItem {
  id: string
  label: string
  auxiliaryData: { category: string }
}

const frameworks: FrameworkItem[] = [
  { id: 'react', label: 'React', auxiliaryData: { category: 'UI library' } },
  { id: 'remix', label: 'Remix', auxiliaryData: { category: 'Web framework' } },
  { id: 'next', label: 'Next.js', auxiliaryData: { category: 'Web framework' } },
]

const source = createStaticSource(frameworks)
const value = ref<FrameworkItem | null>(null)
</script>

<template>
  <div class="base-typeahead-field">
    <BaseTypeahead
      v-model="value"
      :search-source="source"
      label="Search frameworks"
      placeholder="Search frameworks…"
      :debounce-ms="0"
      has-entries-on-focus
    >
      <template #icon>
        <span class="i-carbon-search base-typeahead-field__icon" aria-hidden="true" />
      </template>
      <template #item="{ item }">
        <VStack :gap="0" class="base-typeahead-field__result">
          <span class="base-typeahead-field__label">{{ item.label }}</span>
          <span class="base-typeahead-field__category">{{ item.auxiliaryData.category }}</span>
        </VStack>
      </template>
    </BaseTypeahead>
  </div>
</template>

<style scoped>
.base-typeahead-field {
  box-sizing: border-box;
  width: 22rem;
  max-width: 100%;
}

.base-typeahead-field__icon {
  display: block;
  width: 16px;
  height: 16px;
  color: var(--astryx-color-text-secondary, #525252);
}

.base-typeahead-field__result {
  min-width: 0;
}

.base-typeahead-field__label {
  overflow: hidden;
  color: var(--astryx-color-text-primary, #171717);
  font-size: 0.875rem;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.base-typeahead-field__category {
  overflow: hidden;
  color: var(--astryx-color-text-secondary, #525252);
  font-size: 0.75rem;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>
```

## 属性

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `T \| null` | `null` | 当前选中的条目。空表示还没选。 |
| `searchSource` | `SearchSource` | — | 静态或者异步的搜索源，要有 `search`，可选 `bootstrap`。必填。 |
| `label` | `string` | — | 输入框的无障碍名。外面已经有 `<label for>` 指过来时就不用传。 |
| `inputId` | `string` | 自动生成 | 输入框的 id，好让外面的标签对上。 |
| `describedBy` | `string` | — | 外面已经算好的说明和状态文案的 id。 |
| `ariaDescribedBy` | `string` | — | 专给输入框用的 `aria-describedby` 别名。写了它就以它为准。 |
| `ariaLabelledBy` | `string` | — | 专给输入框用的 `aria-labelledby` 别名。写了它就以它为准。 |
| `anchorRef` | `HTMLElement \| null` | — | 下拉挂靠的元素，也用它来量宽度。不传就用输入框自己那层。 |
| `placeholder` | `string` | `Search…` | 占位文字，还没选东西时显示。 |
| `disabled` | `boolean` | `false` | 禁用输入框。 |
| `disabledReason` | `string` | — | 为什么禁用。悬停或键盘聚焦时弹出提示，同时当说明念出来，输入框保持可聚焦。 |
| `isFocusableDisabled` | `boolean` | `false` | 强制让禁用的输入框还能聚焦（用 `aria-disabled` 加 `readonly`），这样调用方给的禁用原因读得到。文字还是打不进去。 |
| `status` | `'default' \| 'success' \| 'warning' \| 'error'` | `'default'` | 输入框边框的校验色。提示文案由调用方的字段自己负责。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 输入框和下拉每一行的大小。 |
| `clearable` | `boolean` | `false` | 显示清除按钮。 |
| `debounceMs` | `number` | `150` | 打字停多久才开始搜，单位毫秒。本地数据可以设 0。 |
| `maxMenuItems` | `number` | `10` | 下拉最多显示多少行。 |
| `menuWidth` | `number` | — | 下拉想要的宽度，单位像素。不会比挂靠元素窄，也不会超出视口。 |
| `minQueryLength` | `number` | `1` | 至少打几个字才搜，按看得见的字符数算。不够就不搜，下拉也不开。 |
| `showOnFocus` | `boolean` | `false` | 一聚焦、还没打字就把结果列出来。是 `hasEntriesOnFocus` 的旧名字，两个开一个就行。 |
| `hasEntriesOnFocus` | `boolean` | `false` | 一聚焦、还没打字就把结果列出来。 |
| `hasAutoFocus` | `boolean` | `false` | 挂载后自动把光标放进输入框。 |
| `emptyText` | `string \| null` | — | 数据源一条都没有时显示的文字。 |
| `emptySearchText` | `string \| null` | 跟随语言的「没有找到结果」 | 搜不到东西时显示的文字。`null` 当作没传，用默认那句；传空字符串就什么都不画。 |
| `required` | `boolean` | `false` | 把这个输入框标成必填。 |
| `name` | `string` | — | 表单字段名。提交的是选中条目的 id。 |
| `inputTabIndex` | `number` | — | 输入框在 Tab 顺序里的位置。 |

## 事件

- `update:modelValue(value: T | null)` / `change(value: T | null)`：选中、或者清空了。
- `search(query: string)` / `changeQuery(query: string)`：搜索词变了。两个都会发，`search` 是旧名字。
- `openChange(isOpen: boolean)`：下拉开了或者关了。

上面没列的原生属性和监听器都会落到输入框上，所以 `@keydown`、`@focus`、`@blur` 和 `aria-*` 跟写在普通 `<input>` 上一样。`keydown` 监听器跑在内置键盘处理之前，想跳过内置处理就调 `event.preventDefault()`。

## 插槽

- `icon`：输入框开头的图案。这层对读屏是隐藏的。
- `item`：换掉默认那一行，会收到 `item`、`isActive` 和 `isSelected`。负责高亮、选中、指针和键盘的外壳还在。
- `empty`：换掉「没有结果」的内容。
