diff --git a/apps/app/src/components-v2/shared/TagsInput.stories.ts b/apps/app/src/components-v2/shared/TagsInput.stories.ts new file mode 100644 index 00000000..6fd209ff --- /dev/null +++ b/apps/app/src/components-v2/shared/TagsInput.stories.ts @@ -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 = { + title: 'Shared/TagsInput', + component: TagsInput, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +function model(initial: string[], suggestions: string[]): Story['render'] { + return () => ({ + components: { TagsInput }, + setup() { + const tags = ref(initial) + + return { tags, suggestions } + }, + template: '
{{ tags }}
', + }) +} +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']) } diff --git a/apps/app/src/components-v2/shared/TagsInput.vue b/apps/app/src/components-v2/shared/TagsInput.vue new file mode 100644 index 00000000..66607964 --- /dev/null +++ b/apps/app/src/components-v2/shared/TagsInput.vue @@ -0,0 +1,79 @@ + + + diff --git a/apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts b/apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts new file mode 100644 index 00000000..ea755bce --- /dev/null +++ b/apps/app/src/components-v2/shared/__tests__/TagsInput.spec.ts @@ -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: '
', +}) + +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 + }) +})