diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index df8fe4a23..1fdf23af1 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -282,7 +282,7 @@ --modal-card-border-highlighted: var(--secondary-background-selected); --modal-card-button-surface: var(--color-smoke-300); --modal-card-placeholder-background: var(--color-smoke-600); - --modal-card-tag-background: var(--color-smoke-400); + --modal-card-tag-background: var(--color-smoke-200); --modal-card-tag-foreground: var(--base-foreground); --modal-panel-background: var(--color-white); } diff --git a/src/components/ui/tags-input/TagsInput.stories.ts b/src/components/ui/tags-input/TagsInput.stories.ts new file mode 100644 index 000000000..eeb75a39a --- /dev/null +++ b/src/components/ui/tags-input/TagsInput.stories.ts @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import type { ComponentExposed } from 'vue-component-type-helpers' +import { ref } from 'vue' + +import TagsInput from './TagsInput.vue' +import TagsInputInput from './TagsInputInput.vue' +import TagsInputItem from './TagsInputItem.vue' +import TagsInputItemDelete from './TagsInputItemDelete.vue' +import TagsInputItemText from './TagsInputItemText.vue' + +interface GenericMeta extends Omit, 'component'> { + component: ComponentExposed +} + +const meta: GenericMeta = { + title: 'Components/TagsInput', + component: TagsInput, + tags: ['autodocs'], + argTypes: { + modelValue: { + control: 'object', + description: 'Array of tag values' + }, + disabled: { + control: 'boolean', + description: + 'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.' + }, + 'onUpdate:modelValue': { action: 'update:modelValue' } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref(args.modelValue || ['tag1', 'tag2']) + return { tags, args } + }, + template: ` + + + + + + + +
+ Tags: {{ tags.join(', ') }} +
+ ` + }), + args: { + modelValue: ['Vue', 'TypeScript'], + disabled: false + } +} + +export const Empty: Story = { + args: { + disabled: false + }, + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref([]) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} + +export const ManyTags: Story = { + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref([ + 'JavaScript', + 'TypeScript', + 'Vue', + 'React', + 'Svelte', + 'Node.js', + 'Python', + 'Rust' + ]) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} + +export const Disabled: Story = { + args: { + disabled: true + }, + + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref(['Read', 'Only', 'Tags']) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} + +export const CustomWidth: Story = { + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref(['Full', 'Width']) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} diff --git a/src/components/ui/tags-input/TagsInput.test.ts b/src/components/ui/tags-input/TagsInput.test.ts new file mode 100644 index 000000000..a31ebfd98 --- /dev/null +++ b/src/components/ui/tags-input/TagsInput.test.ts @@ -0,0 +1,161 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { h, nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import TagsInput from './TagsInput.vue' +import TagsInputInput from './TagsInputInput.vue' +import TagsInputItem from './TagsInputItem.vue' +import TagsInputItemDelete from './TagsInputItemDelete.vue' +import TagsInputItemText from './TagsInputItemText.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { removeTag: 'Remove tag' } } } +}) + +describe('TagsInput', () => { + function mountTagsInput(props = {}, slots = {}) { + return mount(TagsInput, { + props: { + modelValue: [], + ...props + }, + slots + }) + } + + it('renders slot content', () => { + const wrapper = mountTagsInput({}, { default: 'Slot Content' }) + + expect(wrapper.text()).toContain('Slot Content') + }) +}) + +describe('TagsInput with child components', () => { + function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) { + return mount(TagsInput, { + global: { plugins: [i18n] }, + props: { + modelValue: tags + }, + slots: { + default: () => [ + ...tags.map((tag) => + h(TagsInputItem, { key: tag, value: tag }, () => [ + h(TagsInputItemText), + h(TagsInputItemDelete) + ]) + ), + h(TagsInputInput, { placeholder: 'Add tag...' }) + ] + } + }) + } + + it('renders tags structure and content', () => { + const tags = ['tag1', 'tag2'] + const wrapper = mountFullTagsInput(tags) + + const items = wrapper.findAllComponents(TagsInputItem) + const textElements = wrapper.findAllComponents(TagsInputItemText) + const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete) + + expect(items).toHaveLength(tags.length) + expect(textElements).toHaveLength(tags.length) + expect(deleteButtons).toHaveLength(tags.length) + + textElements.forEach((el, i) => { + expect(el.text()).toBe(tags[i]) + }) + + expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true) + }) + + it('updates model value when adding a tag', async () => { + let currentTags = ['existing'] + + const wrapper = mount>(TagsInput, { + props: { + modelValue: currentTags, + 'onUpdate:modelValue': (payload) => { + currentTags = payload + } + }, + slots: { + default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) + } + }) + + await wrapper.trigger('click') + await nextTick() + + const input = wrapper.find('input') + await input.setValue('newTag') + await input.trigger('keydown', { key: 'Enter' }) + await nextTick() + + expect(currentTags).toContain('newTag') + }) + + it('does not enter edit mode when disabled', async () => { + const wrapper = mount>(TagsInput, { + props: { + modelValue: ['tag1'], + disabled: true + }, + slots: { + default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) + } + }) + + expect(wrapper.find('input').exists()).toBe(false) + + await wrapper.trigger('click') + await nextTick() + + expect(wrapper.find('input').exists()).toBe(false) + }) + + it('exits edit mode when clicking outside', async () => { + const wrapper = mount>(TagsInput, { + props: { + modelValue: ['tag1'] + }, + slots: { + default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) + }, + attachTo: document.body + }) + + await wrapper.trigger('click') + await nextTick() + expect(wrapper.find('input').exists()).toBe(true) + + document.body.click() + await nextTick() + + expect(wrapper.find('input').exists()).toBe(false) + + wrapper.unmount() + }) + + it('shows placeholder when modelValue is empty', async () => { + const wrapper = mount>(TagsInput, { + props: { + modelValue: [] + }, + slots: { + default: ({ isEmpty }: { isEmpty: boolean }) => + h(TagsInputInput, { placeholder: 'Add tag...', isEmpty }) + } + }) + + await nextTick() + + const input = wrapper.find('input') + expect(input.exists()).toBe(true) + expect(input.attributes('placeholder')).toBe('Add tag...') + }) +}) diff --git a/src/components/ui/tags-input/TagsInput.vue b/src/components/ui/tags-input/TagsInput.vue new file mode 100644 index 000000000..f4a3fa001 --- /dev/null +++ b/src/components/ui/tags-input/TagsInput.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputInput.vue b/src/components/ui/tags-input/TagsInputInput.vue new file mode 100644 index 000000000..62d3f01e8 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputInput.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItem.vue b/src/components/ui/tags-input/TagsInputItem.vue new file mode 100644 index 000000000..40cdab511 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItem.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItemDelete.vue b/src/components/ui/tags-input/TagsInputItemDelete.vue new file mode 100644 index 000000000..d5cc0a933 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItemDelete.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItemText.vue b/src/components/ui/tags-input/TagsInputItemText.vue new file mode 100644 index 000000000..f65119c72 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItemText.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/tags-input/tagsInputContext.ts b/src/components/ui/tags-input/tagsInputContext.ts new file mode 100644 index 000000000..05fff98e5 --- /dev/null +++ b/src/components/ui/tags-input/tagsInputContext.ts @@ -0,0 +1,10 @@ +import type { InjectionKey, Ref } from 'vue' + +export type FocusCallback = (() => void) | undefined + +export const tagsInputFocusKey: InjectionKey< + (callback: FocusCallback) => void +> = Symbol('tagsInputFocus') + +export const tagsInputIsEditingKey: InjectionKey> = + Symbol('tagsInputIsEditing') diff --git a/src/locales/en/main.json b/src/locales/en/main.json index edbad7746..9fd1284fd 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -16,6 +16,7 @@ "increment": "Increment", "removeImage": "Remove image", "removeVideo": "Remove video", + "removeTag": "Remove tag", "chart": "Chart", "chartLowercase": "chart", "file": "file",