# 标签输入框

## 使用方法

`Tokenizer` 把多条搜索结果收成可删除的标签。数据源和自动补全相同，本地用 `createStaticSource`，远程实现 `search`。已选条目不会再次出现在列表里。

`hasCreate` 允许把当前输入收成一条新标签。`hasClear` 一次清空。输入框为空时，`Backspace` 删掉最后一条。方向键可以在标签之间移动焦点，`Backspace` 或 `Delete` 删除当前标签。

`overflow` 控制放不下时的表现：

| 值 | 行为 |
| --- | --- |
| `none` | 标签换行，全部可见。 |
| `unfocused-inline` | 失焦时收成一行，末尾显示 `+N`，聚焦后在原处展开。 |
| `unfocused-layer` | 失焦时同样收成一行，聚焦后浮在字段上展开，不把下面的内容往下推。 |

宽度测量复用 `useOverflow`。`#icon` 插槽放在输入区开头，每条标签也可以用 `#token` 换成自己的 `Token`。

### 结构

- **标签**: 输入上方的可见文字，说明用户在选什么。也用作无障碍名称。
- **标签芯片**: 表示每个已选项的可移除芯片。每片有标签和移除按钮。
- **搜索输入**: 用户输入、用来搜索数据源的文本框。达到 maxEntries 后隐藏。
- **下拉菜单**: 用户输入时出现在输入框下方的搜索结果列表。
- **转圈**: 搜索进行中时，字段末尾的加载指示。
- **尾部内容**: 输入后面的插槽，放操作按钮、计数或其他控件。
- **清除按钮**: 一次去掉所有已选标签的按钮。clearable 打开且已经有标签时显示。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 标签会很多、字段又要保持一行时，用 `unfocused-inline`。 |
| 推荐 | 允许用户自造条目时打开 `hasCreate`，并在变更事件里区分 `add` 和 `create`。 |
| 避免 | 选项是短小的固定清单时用标签输入框。那种场景用多选器。 |

## 示例

### 全部清除

选中之后出现全部清除按钮。

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

const people = computed(() => [
  { id: 'alice', label: 'Alice Johnson' },
  { id: 'bob', label: 'Bob Smith' },
  { id: 'charlie', label: 'Charlie Brown' },
  { id: 'diana', label: 'Diana Prince' },
  { id: 'eve', label: 'Eve Williams' },
])
const source = computed(() => createStaticSource(people.value))
const selected = ref<SearchableItem[]>([])
selected.value = people.value.slice(0, 2)
</script>

<template>
  <div class="demo-container">
    <Tokenizer
      v-model="selected"
      :search-source="source"
      label="Team Members"
      placeholder="Search people..."
      :debounce-ms="0"
      has-clear
    />
  </div>
</template>

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

### 可新建

可以只输入新标签，也可以在搜索结果之外新建一条。

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

const tags = ref<SearchableItem[]>([])
const members = ref<SearchableItem[]>([])
const emptySource = createStaticSource<SearchableItem>([])
const people = computed(() => createStaticSource([
  { id: 'alice', label: 'Alice Johnson' },
  { id: 'bob', label: 'Bob Smith' },
  { id: 'charlie', label: 'Charlie Brown' },
  { id: 'diana', label: 'Diana Prince' },
  { id: 'eve', label: 'Eve Williams' },
]))
</script>

<template>
  <div class="demo-stack">
    <p class="demo-caption">
      Free-text only
    </p>
    <Tokenizer
      v-model="tags"
      :search-source="emptySource"
      label="Tags"
      placeholder="Type a tag and press Enter..."
      has-create
      :debounce-ms="0"
    />
    <p class="demo-caption">
      Create or search
    </p>
    <Tokenizer
      v-model="members"
      :search-source="people"
      label="Team Members"
      placeholder="Search or type a new name..."
      has-create
      has-entries-on-focus
      :debounce-ms="0"
    />
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  width: 25rem;
  max-width: calc(100vw - 4rem);
}
.demo-caption {
  margin: 0;
  font-size: 0.875rem;
  color: var(--vp-c-text-2);
}
</style>
```

### 尾部内容

输入区末尾放一个操作按钮。

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

const people = computed(() => [
  { id: 'alice', label: 'Alice Johnson' },
  { id: 'bob', label: 'Bob Smith' },
  { id: 'charlie', label: 'Charlie Brown' },
  { id: 'diana', label: 'Diana Prince' },
  { id: 'eve', label: 'Eve Williams' },
])
const source = computed(() => createStaticSource(people.value))
const selected = ref<SearchableItem[]>([])
selected.value = [people.value[0]!, people.value[2]!]
</script>

<template>
  <div class="demo-stack">
    <p class="demo-caption">
      Action button in the end slot
    </p>
    <Tokenizer
      v-model="selected"
      :search-source="source"
      label="Team Members"
      placeholder="Search people..."
      :debounce-ms="0"
    >
      <template #end>
        <Button label="Apply" variant="primary" size="sm" />
      </template>
    </Tokenizer>
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: 25rem;
  max-width: calc(100vw - 4rem);
}
.demo-caption {
  margin: 0;
  font-size: 0.875rem;
  color: var(--vp-c-text-2);
}
</style>
```

### 图标

开头的搜索图标说明这里可以搜索。

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

const people = computed(() => [
  { id: 'alice', label: 'Alice Johnson' },
  { id: 'bob', label: 'Bob Smith' },
  { id: 'charlie', label: 'Charlie Brown' },
  { id: 'diana', label: 'Diana Prince' },
  { id: 'eve', label: 'Eve Williams' },
])
const source = computed(() => createStaticSource(people.value))
const selected = ref<SearchableItem[]>([])
selected.value = [people.value[0]!, people.value[2]!]
</script>

<template>
  <div class="demo-stack">
    <p class="demo-caption">
      Leading icon reinforces the search affordance
    </p>
    <Tokenizer
      v-model="selected"
      :search-source="source"
      label="Team Members"
      placeholder="Search people..."
      :debounce-ms="0"
    >
      <template #icon>
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
      </template>
    </Tokenizer>
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: 25rem;
  max-width: calc(100vw - 4rem);
}
.demo-caption {
  margin: 0;
  font-size: 0.875rem;
  color: var(--vp-c-text-2);
}
.demo-stack :deep(svg) {
  width: 1rem;
  height: 1rem;
}
</style>
```

### 数量上限

到达上限后不能再添加。

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

const maxSkills = 3
const skills = computed(() => [
  { id: 'react', label: 'React' },
  { id: 'ts', label: 'TypeScript' },
  { id: 'graphql', label: 'GraphQL' },
  { id: 'node', label: 'Node.js' },
  { id: 'python', label: 'Python' },
])
const source = computed(() => createStaticSource(skills.value))
const selected = ref<SearchableItem[]>([skills.value[0]!, skills.value[1]!])
</script>

<template>
  <div class="demo-stack">
    <p class="demo-caption">
      {{ `Limited to ${maxSkills} selections — ${maxSkills - selected.length} remaining` }}
    </p>
    <Tokenizer
      v-model="selected"
      :search-source="source"
      label="Top Skills"
      placeholder="Search skills..."
      :description="`Choose up to ${maxSkills} skills`"
      :max-entries="maxSkills"
      :debounce-ms="0"
    />
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: 25rem;
  max-width: calc(100vw - 4rem);
}
.demo-caption {
  margin: 0;
  font-size: 0.875rem;
  color: var(--vp-c-text-2);
}
</style>
```

### 溢出

失焦时收成一行。行内模式展开会把下面的内容往下推，浮层模式盖在字段上。

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

const people = computed<SearchableItem[]>(() => [
  { id: 'alice', label: 'Alice Johnson' },
  { id: 'bob', label: 'Bob Smith' },
  { id: 'charlie', label: 'Charlie Brown' },
  { id: 'diana', label: 'Diana Prince' },
  { id: 'eve', label: 'Eve Williams' },
  { id: 'frank', label: 'Frank Miller' },
])
const source = computed(() => createStaticSource(people.value))
const inlineValue = ref<SearchableItem[]>([])
const layerValue = ref<SearchableItem[]>([])
inlineValue.value = people.value.map(item => ({ ...item }))
layerValue.value = people.value.map(item => ({ ...item }))
</script>

<template>
  <div class="demo-stack">
    <p class="demo-caption">
      Inline overflow — content shifts down on expand
    </p>
    <Tokenizer
      v-model="inlineValue"
      :search-source="source"
      label="Inline Overflow"
      placeholder="Add more..."
      overflow="unfocused-inline"
      :debounce-ms="0"
    />
    <p class="demo-caption">
      Layer overflow — expands as overlay, no layout shift
    </p>
    <Tokenizer
      v-model="layerValue"
      :search-source="source"
      label="Layer Overflow"
      placeholder="Add more..."
      overflow="unfocused-layer"
      :debounce-ms="0"
    />
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  width: 25rem;
  max-width: calc(100vw - 4rem);
}
.demo-caption {
  margin: 0;
  font-size: 0.875rem;
  color: var(--vp-c-text-2);
}
</style>
```

### 状态

禁用、错误、警告和成功四种状态。

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

const people = computed(() => [
  { id: 'alice', label: 'Alice Johnson' },
  { id: 'bob', label: 'Bob Smith' },
  { id: 'charlie', label: 'Charlie Brown' },
  { id: 'diana', label: 'Diana Prince' },
])
const source = computed(() => createStaticSource(people.value))
const errorValue = ref<SearchableItem[]>([])
const warningValue = ref<SearchableItem[]>([])
const successValue = ref<SearchableItem[]>([])
warningValue.value = [people.value[0]!]
successValue.value = [people.value[1]!, people.value[3]!]
const disabledValue = computed(() => [people.value[0]!, people.value[2]!])
</script>

<template>
  <div class="demo-stack">
    <Tokenizer
      :model-value="disabledValue"
      :search-source="source"
      label="Disabled field"
      disabled
    />
    <Tokenizer
      v-model="errorValue"
      :search-source="source"
      label="Error message"
      placeholder="Search people..."
      required
      status="error"
      status-message="At least one reviewer is required"
      :debounce-ms="0"
    />
    <Tokenizer
      v-model="warningValue"
      :search-source="source"
      label="Warning message"
      placeholder="Search people..."
      status="warning"
      status-message="Consider adding at least 2 approvers"
      :debounce-ms="0"
    />
    <Tokenizer
      v-model="successValue"
      :search-source="source"
      label="Success message"
      placeholder="Search people..."
      status="success"
      status-message="All required reviewers added"
      :debounce-ms="0"
    />
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 25rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

## Tokenizer Props

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `T[]` | `[]` | 已选条目。 |
| `searchSource` | `SearchSource` | 必需 | 静态或异步搜索源。 |
| `label` | `string` | 必需 | 字段标签。 |
| `description` | `string` | — | 标签下方的说明。 |
| `labelTooltip` | `string` | — | 标签旁边信息图标里的提示文字。 |
| `isLabelHidden` | `boolean` | `false` | 视觉上隐藏标签，读屏仍然读得到。旧名 `labelHidden` 同样可用，两者任一为真就隐藏。 |
| `containerClass` / `controlClass` | `class` | — | 外壳和输入外框。根上的 class 落在外框，不落到原生 input。候选项的 `action` 和自动完成一样。 |
| `placeholder` | `string` | `Add…` / `添加…` | 输入框占位符。只在还没有标签时显示。 |
| `hasCreate` | `boolean` | `false` | 允许把输入的文字收成新标签。没匹配到结果时下拉会多出「新建 …」一项，此时变化的类型是 `create`。 |
| `creatable` | `boolean` | `false` | `hasCreate` 的旧名。两者任一为真即生效。 |
| `overflow` | `'none' \| 'unfocused-inline' \| 'unfocused-layer'` | `'none'` | 失焦时是否收成一行并显示 `+N`。 |
| `maxEntries` | `number` | — | 最多可选几条。达到上限后输入框只读。 |
| `debounceMs` | `number` | `150` | 输入停顿后再搜索。 |
| `minQueryLength` | `number` | `1` | 够长才去搜索的下限，按人眼看到的字符数算。太短就完全不搜，下拉也不打开——除非开了 `hasCreate`：「新建 …」是从输入文字派生的，不用搜。 |
| `hasEntriesOnFocus` | `boolean` | `false` | 聚焦后还没输入就展示结果。 |
| `showOnFocus` | `boolean` | `false` | `hasEntriesOnFocus` 的旧名。两者任一为真即生效。 |
| `hasAutoFocus` | `boolean` | `false` | 挂载后自动聚焦输入框。 |
| `menuWidth` | `number` | — | 下拉想要的宽度，单位像素。窄不过输入区，再宽会被视口截住。 |
| `maxMenuItems` | `number` | `10` | 下拉最多显示多少条结果。「新建 …」这一项是在它们之外另加的。 |
| `hasClear` | `boolean` | `false` | 是否显示全部清除按钮，一次清掉所有标签。 |
| `clearable` | `boolean` | `false` | `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` | — | 整个字段的宽度（数字按像素算，字符串原样使用）。 |

## Tokenizer Emits

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: T[]` | 已选条目变化时触发。 |
| `change` | `value: T[]`、`change: TokenizerChange` | 带上这次变化的类型：`add`、`create`、`remove`、`clear`。 |
| `changeQuery` | `query: string` | 查询文字变化时触发。 |
| `search` | `query: string` | `changeQuery` 的旧名。两个都会触发。 |
| `openChange` | `isOpen: boolean` | 列表打开或关闭时触发。 |
| `focus` | `event: FocusEvent` | 焦点从外面进入整个字段时触发。 |
| `blur` | `event: FocusEvent` | 焦点离开整个字段时触发，不只是离开输入框。 |

## Tokenizer Slots

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `icon` | — | 输入区开头的图标，在所有标签之前。 |
| `end` | — | 输入区末尾的内容，比如按钮或结果计数。 |
| `token` | `{ item, remove }` | 自定义已选标签，替换默认的 `Token`。 |
| `item` | `{ item, isActive, isSelected }` | 自定义下拉条目，替换默认的 `TypeaheadItem`。 |
| `empty` | — | 自定义空状态。 |

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