Skip to content

Commit 1f7334c

Browse files
authored
Allow free text on taginput (#307)
* Allow free text on taginput * Address PR comments * Tests
1 parent f364d8a commit 1f7334c

3 files changed

Lines changed: 148 additions & 1 deletion

File tree

playground/app/pages/controls/taginput.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@
127127
</p>
128128
</t-demo-box>
129129

130+
<!-- Allow New (Free-form) -->
131+
<t-demo-box label="Allow New Tags">
132+
<t-taginput
133+
v-model="allowNewSelected"
134+
:options="fruitOptions"
135+
placeholder="Type and press Enter or comma..."
136+
allow-new
137+
open-on-focus
138+
/>
139+
<p class="has-text-grey mt-3">
140+
Type any text and press Enter or comma to add a custom tag. Selected: {{ allowNewSelected }}
141+
</p>
142+
</t-demo-box>
143+
130144
<!-- Not Closable -->
131145
<t-demo-box label="Non-closable Tags">
132146
<t-taginput
@@ -303,6 +317,7 @@ const readonlySelected = ref<string[]>(['apple', 'cherry', 'grape'])
303317
const maxTagsSelected = ref<string[]>(['apple'])
304318
const notClosableSelected = ref<string[]>(['apple'])
305319
const customSelected = ref<number[]>([1, 3])
320+
const allowNewSelected = ref<string[]>([])
306321
const searchSelected = ref<string[]>([])
307322
const searchText = ref('')
308323
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, it, expect } from 'vitest'
2+
import TTaginput from '../controls/taginput.vue'
3+
import {
4+
mountComponent,
5+
triggerKeyboard
6+
} from '../lib/testutil/component-helpers'
7+
8+
const fruitOptions = [
9+
{ value: 'apple', label: 'Apple' },
10+
{ value: 'banana', label: 'Banana' },
11+
{ value: 'cherry', label: 'Cherry' }
12+
]
13+
14+
describe('TTaginput allowNew', () => {
15+
it('adds a free-form tag on Enter', async () => {
16+
const wrapper = mountComponent(TTaginput, {
17+
props: { modelValue: [], options: fruitOptions, allowNew: true }
18+
})
19+
const input = wrapper.find('input')
20+
await input.setValue('custom')
21+
await triggerKeyboard(wrapper, 'Enter', 'input')
22+
const emitted = wrapper.emitted('update:modelValue') as string[][]
23+
expect(emitted[emitted.length - 1]).toEqual([['custom']])
24+
})
25+
26+
it('adds a free-form tag on separator key', async () => {
27+
const wrapper = mountComponent(TTaginput, {
28+
props: { modelValue: [], options: fruitOptions, allowNew: true }
29+
})
30+
const input = wrapper.find('input')
31+
await input.setValue('newtag')
32+
await triggerKeyboard(wrapper, ',', 'input')
33+
const emitted = wrapper.emitted('update:modelValue') as string[][]
34+
expect(emitted[emitted.length - 1]).toEqual([['newtag']])
35+
})
36+
37+
it('does not add free-form tag when allowNew is false', async () => {
38+
const wrapper = mountComponent(TTaginput, {
39+
props: { modelValue: [], options: fruitOptions, allowNew: false }
40+
})
41+
const input = wrapper.find('input')
42+
await input.setValue('custom')
43+
await triggerKeyboard(wrapper, 'Enter', 'input')
44+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
45+
})
46+
47+
it('does not add duplicate tags', async () => {
48+
const wrapper = mountComponent(TTaginput, {
49+
props: { modelValue: ['existing'], options: fruitOptions, allowNew: true }
50+
})
51+
const input = wrapper.find('input')
52+
await input.setValue('existing')
53+
await triggerKeyboard(wrapper, 'Enter', 'input')
54+
// Should clear input but not emit a new modelValue with duplicate
55+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
56+
})
57+
58+
it('respects maxTags limit', async () => {
59+
const wrapper = mountComponent(TTaginput, {
60+
props: { modelValue: ['a', 'b'], options: fruitOptions, allowNew: true, maxTags: 2 }
61+
})
62+
const input = wrapper.find('input')
63+
await input.setValue('third')
64+
await triggerKeyboard(wrapper, 'Enter', 'input')
65+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
66+
})
67+
68+
it('trims whitespace from new tags', async () => {
69+
const wrapper = mountComponent(TTaginput, {
70+
props: { modelValue: [], options: fruitOptions, allowNew: true }
71+
})
72+
const input = wrapper.find('input')
73+
await input.setValue(' spaced ')
74+
await triggerKeyboard(wrapper, 'Enter', 'input')
75+
const emitted = wrapper.emitted('update:modelValue') as string[][]
76+
expect(emitted[emitted.length - 1]).toEqual([['spaced']])
77+
})
78+
79+
it('does not add empty tags', async () => {
80+
const wrapper = mountComponent(TTaginput, {
81+
props: { modelValue: [], options: fruitOptions, allowNew: true }
82+
})
83+
const input = wrapper.find('input')
84+
await input.setValue(' ')
85+
await triggerKeyboard(wrapper, 'Enter', 'input')
86+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
87+
})
88+
89+
it('selects dropdown option over free-form when highlighted', async () => {
90+
const wrapper = mountComponent(TTaginput, {
91+
props: { modelValue: [], options: fruitOptions, allowNew: true }
92+
})
93+
const input = wrapper.find('input')
94+
await input.setValue('a') // filters to Apple
95+
// Arrow down to highlight first option, then Enter
96+
await triggerKeyboard(wrapper, 'ArrowDown', 'input')
97+
await triggerKeyboard(wrapper, 'Enter', 'input')
98+
const emitted = wrapper.emitted('update:modelValue') as string[][]
99+
expect(emitted[emitted.length - 1]).toEqual([['apple']])
100+
})
101+
})

src/runtime/controls/taginput.vue

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ const props = withDefaults(defineProps<{
170170
emptyText?: string
171171
/** Maximum number of tags that can be selected. When undefined, there is no limit. */
172172
maxTags?: number
173+
/** Allow creating new tags not in the options list. Only for string-typed taginputs. @default false */
174+
allowNew?: boolean
175+
/** Separator keys that trigger creating a new tag (in addition to Enter). @default [','] */
176+
separators?: string[]
173177
}>(), {
174178
options: () => [],
175179
placeholder: '',
@@ -184,7 +188,9 @@ const props = withDefaults(defineProps<{
184188
closable: true,
185189
rounded: false,
186190
emptyText: 'None selected',
187-
maxTags: undefined
191+
maxTags: undefined,
192+
allowNew: false,
193+
separators: () => [',']
188194
})
189195
190196
const emit = defineEmits<{
@@ -361,6 +367,8 @@ function handleKeydown (event: KeyboardEvent) {
361367
if (option) {
362368
selectOption(option)
363369
}
370+
} else {
371+
addNewTag()
364372
}
365373
break
366374
case 'Escape':
@@ -375,7 +383,30 @@ function handleKeydown (event: KeyboardEvent) {
375383
}
376384
}
377385
break
386+
default:
387+
if (props.allowNew && props.separators.includes(event.key)) {
388+
event.preventDefault()
389+
addNewTag()
390+
}
391+
break
392+
}
393+
}
394+
395+
function addNewTag () {
396+
const text = searchText.value.trim()
397+
if (!text || !props.allowNew || isMaxReached.value) return false
398+
// Check if it already exists as a selected value
399+
if (modelValue.value.includes(text as T)) {
400+
searchText.value = ''
401+
return true
378402
}
403+
const option: TagOption = { value: text as T, label: text }
404+
modelValue.value = [...modelValue.value, text as T]
405+
emit('select', option)
406+
searchText.value = ''
407+
highlightedIndex.value = -1
408+
inputRef.value?.focus()
409+
return true
379410
}
380411
381412
function selectOption (option: TagOption) {

0 commit comments

Comments
 (0)