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:
26
apps/app/src/components-v2/shared/TagsInput.stories.ts
Normal file
26
apps/app/src/components-v2/shared/TagsInput.stories.ts
Normal 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']) }
|
||||||
79
apps/app/src/components-v2/shared/TagsInput.vue
Normal file
79
apps/app/src/components-v2/shared/TagsInput.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user