feat(tags): 编辑任务时可从已有标签里选,不必每次手打

TagInput 增加候选下拉:聚焦即展开(输入框为空列全部、有草稿按子串过滤),
上下键移动高亮、回车选中高亮项、点击也能选;候选项排除已选中的,选完自动
从列表移除。列表没展开或没有匹配时,回车仍把草稿建成新标签,原行为不变。
候选点击用 mousedown.prevent 保住输入框焦点,否则 blur 会先把草稿提交掉。

把「全部标签」从 BoardToolbar 的局部 computed 提到 store 的 allTags,
筛选下拉和标签候选共用同一份,避免两处各算一份导致结果不一致。

测试:TagInput 新增 13 条(候选过滤、键盘选择、失焦/Escape 收起、aria-selected),
store 新增 2 条 allTags,E2E 新增 1 条打通 store → 抽屉 → 组件这条接线。
This commit is contained in:
chenshoulin
2026-10-18 20:26:55 +08:00
parent 48f178e5bb
commit 88ea09e94d
7 changed files with 341 additions and 43 deletions
+30
View File
@@ -62,6 +62,36 @@ test('详情抽屉可编辑并持久化', async ({ page }) => {
await expect(card).toContainText('高')
})
/**
* 标签候选要打通 store → 抽屉 → TagInput 三层,单测覆盖不到接线,
* 所以这里从「另一个任务上已有的标签」出发验证整条链路。
*/
test('编辑任务时可以直接选已有标签', async ({ page }) => {
// 先在一个任务上建出标签
await addTask(page, '任务甲')
await page.locator('article').filter({ hasText: '任务甲' }).click()
const firstInput = page.getByLabel('添加标签')
await firstInput.fill('设计')
await firstInput.press('Enter')
await page.getByRole('button', { name: '保存' }).click()
// 另一个任务:聚焦标签输入框应列出「设计」
await addTask(page, '任务乙')
await page.locator('article').filter({ hasText: '任务乙' }).click()
const tagInput = page.getByLabel('添加标签')
await tagInput.click()
const option = page.locator('[role="option"]').filter({ hasText: '#设计' })
await expect(option).toBeVisible()
await option.click()
await page.getByRole('button', { name: '保存' }).click()
await page.reload()
await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计')
})
test('删除后可撤销', async ({ page }) => {
await addTask(page, '待删除任务')
await page.locator('article').filter({ hasText: '待删除任务' }).click()
+1 -10
View File
@@ -31,15 +31,6 @@ const overdueCount = computed(
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
)
/** 从现有任务里收集标签,作为筛选候选项 */
const allTags = computed(() => {
const collected = new Set<string>()
for (const task of store.tasks) {
for (const tag of task.tags) collected.add(tag)
}
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
})
/** 当前可归档(已完成且超过保留天数)的任务数量 */
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
@@ -96,7 +87,7 @@ defineExpose({ focusSearch })
</div>
<TagFilter
:tags="allTags"
:tags="store.allTags"
:selected="tags"
@toggle="filters.toggleTag($event)"
@clear="filters.clearTags()"
+127
View File
@@ -8,6 +8,10 @@ function mountTags(modelValue: string[] = []) {
return mount(TagInput, { props: { modelValue } })
}
function mountWithSuggestions(modelValue: string[], suggestions: string[]) {
return mount(TagInput, { props: { modelValue, suggestions } })
}
/** 显式指定 key,避免依赖测试工具对修饰符的解析方式 */
async function pressEnter(wrapper: VueWrapper): Promise<void> {
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
@@ -137,3 +141,126 @@ describe('TagInput 删除', () => {
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
/**
* 已有标签候选:编辑任务时应当能直接挑,而不是每次手打,
* 否则同一个标签很容易被写成「前端」「前端 」这种近似重复。
*/
describe('TagInput 候选列表', () => {
const options = (wrapper: VueWrapper) =>
wrapper.findAll('[role="option"]').map((item) => item.text())
it('聚焦后列出已有标签', async () => {
const wrapper = mountWithSuggestions([], ['设计', '前端'])
await wrapper.find('input').trigger('focus')
expect(options(wrapper)).toEqual(['#设计', '#前端'])
})
it('候选里不含已选中的标签', async () => {
const wrapper = mountWithSuggestions(['设计'], ['设计', '前端'])
await wrapper.find('input').trigger('focus')
expect(options(wrapper)).toEqual(['#前端'])
})
it('按草稿做子串过滤', async () => {
const wrapper = mountWithSuggestions([], ['设计', '前端', '前端性能'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.setValue('前端')
expect(options(wrapper)).toEqual(['#前端', '#前端性能'])
})
it('未聚焦时不展开', () => {
expect(mountWithSuggestions([], ['设计']).find('[role="listbox"]').exists()).toBe(false)
})
it('没有候选数据时不展开', async () => {
const wrapper = mountTags([])
await wrapper.find('input').trigger('focus')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('候选全部已选中时不展开', async () => {
const wrapper = mountWithSuggestions(['设计'], ['设计'])
await wrapper.find('input').trigger('focus')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('点击候选项添加该标签并清空草稿', async () => {
const wrapper = mountWithSuggestions([], ['设计', '前端'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.setValue('设')
await wrapper.find('[role="option"] button').trigger('click')
expect(lastEmitted(wrapper)).toEqual(['设计'])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('回车选中高亮的候选,而不是把半截草稿建成新标签', async () => {
const wrapper = mountWithSuggestions([], ['前端'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.setValue('前')
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['前端'])
})
it('下键移动高亮,回车选中第二项', async () => {
const wrapper = mountWithSuggestions([], ['设计', '前端'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.trigger('keydown', { key: 'ArrowDown' })
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['前端'])
})
it('高亮到底后回到第一项', async () => {
const wrapper = mountWithSuggestions([], ['设计', '前端'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.trigger('keydown', { key: 'ArrowDown' })
await input.trigger('keydown', { key: 'ArrowDown' })
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['设计'])
})
it('没有匹配候选时回车仍把草稿建成新标签', async () => {
const wrapper = mountWithSuggestions([], ['设计'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.setValue('全新标签')
await pressEnter(wrapper)
expect(lastEmitted(wrapper)).toEqual(['全新标签'])
})
it('失焦后收起候选列表', async () => {
const wrapper = mountWithSuggestions([], ['设计'])
const input = wrapper.find('input')
await input.trigger('focus')
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
await input.trigger('blur')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('Escape 收起候选列表', async () => {
const wrapper = mountWithSuggestions([], ['设计'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('候选项标记了 aria-selected 以便读屏', async () => {
const wrapper = mountWithSuggestions([], ['设计', '前端'])
const input = wrapper.find('input')
await input.trigger('focus')
await input.trigger('keydown', { key: 'ArrowDown' })
const items = wrapper.findAll('[role="option"]')
expect(items[0]?.attributes('aria-selected')).toBe('false')
expect(items[1]?.attributes('aria-selected')).toBe('true')
})
})
+132 -6
View File
@@ -1,7 +1,25 @@
<script setup lang="ts">
import { ref } from 'vue'
/**
* 标签输入(T10)
*
* 支持两种添加方式:
* 1. 直接输入新标签,回车或失焦时提交
* 2. 从已有标签里选(suggestions)—— 编辑任务时不必每次手打,
* 也避免同一个标签被写成「前端」「前端 」这种近似重复
*
* 候选列表在聚焦时展开:输入框为空就列全部(减去已选中的),
* 有草稿则按子串过滤。上下键移动高亮,回车选中高亮项;
* 列表没展开或没有候选时,回车仍然把草稿当新标签提交。
*/
const props = defineProps<{ modelValue: string[]; placeholder?: string }>()
import { computed, ref, watch } from 'vue'
const props = defineProps<{
modelValue: string[]
placeholder?: string
/** 已有标签候选;不传或为空时退化成纯手输 */
suggestions?: readonly string[]
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
@@ -10,12 +28,43 @@ const draft = ref('')
/** 中文输入法保护:选词的回车不应被当作「添加标签」 */
const composing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const open = ref(false)
const highlighted = ref(0)
/** 候选项:排除已选中的,再按草稿做子串过滤 */
const candidates = computed(() => {
const selected = new Set(props.modelValue)
const keyword = draft.value.trim().toLowerCase()
return (props.suggestions ?? [])
.filter((tag) => !selected.has(tag))
.filter((tag) => keyword === '' || tag.toLowerCase().includes(keyword))
})
const showList = computed(() => open.value && candidates.value.length > 0)
/** 候选变了(输入或选中变化)就把高亮拉回第一项,避免停在越界的位置 */
watch(candidates, () => {
highlighted.value = 0
})
function add(tag: string): void {
const value = tag.trim()
if (!value || props.modelValue.includes(value)) return
emit('update:modelValue', [...props.modelValue, value])
}
function commit(): void {
const value = draft.value.trim()
draft.value = ''
if (!value) return
if (props.modelValue.includes(value)) return
emit('update:modelValue', [...props.modelValue, value])
add(value)
}
/** 选中候选项:清空草稿但保留焦点,方便连续挑几个 */
function pick(tag: string): void {
draft.value = ''
add(tag)
}
function remove(tag: string): void {
@@ -32,11 +81,47 @@ function onBackspace(): void {
if (last === undefined) return
remove(last)
}
function move(step: number): void {
const count = candidates.value.length
if (count === 0) return
highlighted.value = (highlighted.value + step + count) % count
}
function onEnter(): void {
if (composing.value) return
const picked = showList.value ? candidates.value[highlighted.value] : undefined
if (picked === undefined) {
commit()
return
}
pick(picked)
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault()
move(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
move(-1)
} else if (event.key === 'Escape') {
open.value = false
}
}
/** 失焦:收起候选,并把没确认的草稿提交掉 */
function onBlur(): void {
open.value = false
commit()
}
</script>
<template>
<div class="relative">
<div
class="flex flex-wrap items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1.5 focus-within:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:focus-within:border-sky-600"
@click="inputRef?.focus()"
>
<span
v-for="tag in modelValue"
@@ -55,16 +140,57 @@ function onBackspace(): void {
</span>
<input
ref="inputRef"
v-model="draft"
type="text"
class="min-w-24 flex-1 border-none bg-transparent px-1 py-0.5 text-sm text-slate-700 outline-none placeholder:text-slate-400 dark:text-zinc-200 dark:placeholder:text-zinc-500"
:placeholder="modelValue.length === 0 ? (placeholder ?? '回车添加标签') : ''"
aria-label="添加标签"
@keydown.enter.prevent="composing ? undefined : commit()"
role="combobox"
aria-autocomplete="list"
:aria-expanded="showList"
aria-controls="tag-suggestions"
@keydown="onKeydown"
@keydown.enter.prevent="onEnter"
@keydown.backspace="onBackspace"
@compositionstart="composing = true"
@compositionend="composing = false"
@blur="commit"
@focus="open = true"
@blur="onBlur"
/>
</div>
<!--
候选用 mousedown.prevent 保住输入框焦点:否则点击会先触发 blur,
草稿被当成新标签提交,反而挑不中候选项。
-->
<ul
v-if="showList"
id="tag-suggestions"
role="listbox"
aria-label="已有标签"
class="absolute top-full left-0 z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white p-1 shadow-pop dark:border-zinc-700 dark:bg-zinc-900"
>
<li
v-for="(tag, index) in candidates"
:key="tag"
role="option"
:aria-selected="index === highlighted"
>
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm transition"
:class="
index === highlighted
? 'bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300'
: 'text-slate-700 hover:bg-slate-50 dark:text-zinc-300 dark:hover:bg-zinc-800'
"
@mousedown.prevent
@click="pick(tag)"
>
#{{ tag }}
</button>
</li>
</ul>
</div>
</template>
+1 -1
View File
@@ -131,7 +131,7 @@ function remove(): void {
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
<TagInput v-model="tags" />
<TagInput v-model="tags" :suggestions="store.allTags" />
</div>
</form>
+11
View File
@@ -59,6 +59,17 @@ describe('初始化', () => {
store.init({ crossTab: false })
expect(store.tasks).toHaveLength(1)
})
it('allTags 汇总全部任务的标签并去重排序', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端', '设计'] })
store.createTask({ title: 'B', status: 'doing', tags: ['设计', 'Bug'] })
expect(store.allTags).toEqual(['Bug', '前端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')))
})
it('没有任务时 allTags 为空数组', () => {
expect(freshStore().allTags).toEqual([])
})
})
describe('快速新增', () => {
+13
View File
@@ -89,6 +89,18 @@ export const useTaskStore = defineStore('tasks', () => {
return result
})
/**
* 现有任务里出现过的全部标签,按中文排序。
* 筛选下拉和编辑任务时的标签候选都用它,避免两处各算一份、结果不一致。
*/
const allTags = computed(() => {
const collected = new Set<string>()
for (const task of tasks.value) {
for (const tag of task.tags) collected.add(tag)
}
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
})
function persist(): void {
if (!repository) return
try {
@@ -391,6 +403,7 @@ export const useTaskStore = defineStore('tasks', () => {
snapshots,
byColumn,
counts,
allTags,
init,
createTask,
updateTask,