feat(gui-v2): TagsInput re-impl on PrimeVue AutoComplete (5 behavioural rules) + story

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:08:45 +02:00
parent 284fdcc437
commit b64b024166
3 changed files with 166 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import TagsInput from '@/components-v2/shared/TagsInput.vue'
const meta: Meta<typeof TagsInput> = {
title: 'Shared/TagsInput',
component: TagsInput,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof TagsInput>
function model(initial: string[], suggestions: string[]): Story['render'] {
return () => ({
components: { TagsInput },
setup() {
const tags = ref(initial)
return { tags, suggestions }
},
template: '<div class="max-w-md"><TagsInput v-model="tags" :suggestions="suggestions" /><pre class="mt-3 text-xs">{{ tags }}</pre></div>',
})
}
export const Empty: Story = { render: model([], ['rock', 'jazz', 'techno', 'house', 'ambient', 'drum-n-bass']) }
export const Prefilled: Story = { render: model(['rock', 'jazz'], ['rock', 'jazz', 'techno', 'house', 'ambient']) }

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* TagsInput — RE-IMPLEMENTATION (not a port) onto PrimeVue AutoComplete
* `multiple` + `typeahead` per spec §8. crewli-starter's hand-rolled
* <input> is behavioural reference only. The 5 reference rules:
* (a) array model (b) lowercase-dedupe
* (c) Enter/comma adds (d) Backspace removes last
* (e) 5-suggestion cap
* Visual parity criterion: "coherent in Aura/teal aesthetic", NOT a
* pixel match against crewli-starter (constraint #2).
*/
import { ref } from 'vue'
import AutoComplete from 'primevue/autocomplete'
const props = withDefaults(defineProps<{
modelValue?: string[]
suggestions?: string[]
placeholder?: string
}>(), { modelValue: () => [], suggestions: () => [], placeholder: 'Tag toevoegen…' })
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
const filtered = ref<string[]>([])
/** (b) lowercase + dedupe, order-preserving. */
function normalizeAndEmit(next: string[]): void {
const seen = new Set<string>()
const out: string[] = []
for (const raw of next) {
const t = raw.trim().toLowerCase()
if (t && !seen.has(t)) {
seen.add(t)
out.push(t)
}
}
emit('update:modelValue', out)
}
/** (c) split a typed query on comma / newline (Enter). */
function splitQuery(q: string): string[] {
return q.split(/[,\n]/).map(s => s.trim()).filter(Boolean)
}
/** (e) filter suggestions: exclude already-selected, cap at 5. */
function filterSuggestions(q: string): string[] {
const t = q.toLowerCase()
const model = props.modelValue.map(s => s.toLowerCase())
return props.suggestions
.filter(s => !model.includes(s.toLowerCase()) && s.toLowerCase().includes(t))
.slice(0, 5)
}
function onComplete(e: { query: string }): void {
filtered.value = filterSuggestions(e.query)
}
function onModelUpdate(next: unknown): void {
// AutoComplete multiple emits the full array (chips + typed token).
const arr = Array.isArray(next) ? next.flatMap(v => typeof v === 'string' ? splitQuery(v) : [String(v)]) : []
normalizeAndEmit(arr)
}
defineExpose({ normalizeAndEmit, splitQuery, filterSuggestions })
</script>
<template>
<AutoComplete
:model-value="props.modelValue"
:suggestions="filtered"
multiple
typeahead
:placeholder="props.placeholder"
class="w-full"
@complete="onComplete"
@update:model-value="onModelUpdate"
/>
</template>

View File

@@ -0,0 +1,61 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import TagsInput from '@/components-v2/shared/TagsInput.vue'
/**
* AutoCompleteStub mirrors the PrimeVue AutoComplete contract TagsInput
* relies on: v-model (array in multiple mode), `complete` event, and
* exposing the typed query so we can drive add/dedupe logic.
*/
const AutoCompleteStub = defineComponent({
name: 'AutoComplete',
props: { modelValue: { type: Array, default: () => [] }, suggestions: { type: Array, default: () => [] }, multiple: Boolean, typeahead: Boolean },
emits: ['update:modelValue', 'complete'],
methods: {
addRaw(raw: string) { this.$emit('update:modelValue', [...(this.modelValue as string[]), raw]) },
},
template: '<div class="ac-stub" :data-count="modelValue.length" />',
})
const mountTI = (props: { modelValue?: string[]; suggestions?: string[]; placeholder?: string } = {}) =>
mount(TagsInput, { props, global: { stubs: { AutoComplete: AutoCompleteStub } } })
describe('TagsInput — 5 behavioural rules (crewli-starter reference)', () => {
it('(a) array model: modelValue is an array, update:modelValue emits an array', async () => {
const w = mountTI({ modelValue: ['rock'] })
expect(w.get('.ac-stub').attributes('data-count')).toBe('1')
await w.vm.normalizeAndEmit(['rock', 'jazz'])
expect(w.emitted('update:modelValue')![0][0]).toEqual(['rock', 'jazz'])
})
it('(b) lowercase-dedupe: mixed-case duplicates collapse to one lowercase entry', async () => {
const w = mountTI({ modelValue: ['rock'] })
await w.vm.normalizeAndEmit(['rock', 'ROCK', 'Rock', 'jazz'])
expect(w.emitted('update:modelValue')!.at(-1)![0]).toEqual(['rock', 'jazz'])
})
it('(c) Enter or comma adds (separator handling in onComplete query)', async () => {
const w = mountTI({ modelValue: [] })
expect(w.vm.splitQuery('rock,jazz')).toEqual(['rock', 'jazz'])
expect(w.vm.splitQuery('rock\n')).toEqual(['rock'])
})
it('(d) Backspace-remove last is delegated to AutoComplete multiple (chip removal) — model shrinks', async () => {
const w = mountTI({ modelValue: ['rock', 'jazz'] })
await w.vm.normalizeAndEmit(['rock'])
expect(w.emitted('update:modelValue')!.at(-1)![0]).toEqual(['rock'])
})
it('(e) 5-suggestion cap: visibleSuggestions never exceeds 5 filtered, dedup-against-model', () => {
const w = mountTI({ modelValue: ['rock'], suggestions: ['rock', 'rockabilly', 'rocksteady', 'rock-n-roll', 'rockpool', 'rockford', 'rocketry'] })
const out = w.vm.filterSuggestions('rock')
expect(out.length).toBeLessThanOrEqual(5)
expect(out).not.toContain('rock') // already in model
})
})