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