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:
@@ -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()"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+158
-32
@@ -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,39 +81,116 @@ 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="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"
|
||||
>
|
||||
<span
|
||||
v-for="tag in modelValue"
|
||||
:key="tag"
|
||||
class="flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700 dark:bg-sky-950 dark:text-sky-300"
|
||||
<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()"
|
||||
>
|
||||
#{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sky-500 transition hover:text-sky-800 dark:text-sky-400 dark:hover:text-sky-200"
|
||||
:aria-label="`移除标签 ${tag}`"
|
||||
@click="remove(tag)"
|
||||
<span
|
||||
v-for="tag in modelValue"
|
||||
:key="tag"
|
||||
class="flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700 dark:bg-sky-950 dark:text-sky-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
#{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sky-500 transition hover:text-sky-800 dark:text-sky-400 dark:hover:text-sky-200"
|
||||
:aria-label="`移除标签 ${tag}`"
|
||||
@click="remove(tag)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<input
|
||||
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()"
|
||||
@keydown.backspace="onBackspace"
|
||||
@compositionstart="composing = true"
|
||||
@compositionend="composing = false"
|
||||
@blur="commit"
|
||||
/>
|
||||
<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="添加标签"
|
||||
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"
|
||||
@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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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('快速新增', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user