Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
85b0c98
feat(ui): scaffold TagsInput component via shadcn-vue CLI
DrJKL Jan 14, 2026
2e0ab2d
style(TagsInput): apply design system tokens and project conventions
DrJKL Jan 14, 2026
159f387
feat(TagsInput): add Storybook stories
DrJKL Jan 14, 2026
1cd06bb
test(TagsInput): add unit tests
DrJKL Jan 14, 2026
43f537e
style(TagsInput): align styling with Figma design specs
DrJKL Jan 14, 2026
fd987e2
style(TagsInput): align container with Figma design
DrJKL Jan 14, 2026
24ab810
fix(TagsInput): make disabled prop reactive in Storybook and disable …
DrJKL Jan 15, 2026
ecf55cb
feat(TagsInput): hide delete button and input when disabled with anim…
DrJKL Jan 15, 2026
85d6722
feat(TagsInput): add click-to-edit behavior with provide/inject focus…
DrJKL Jan 15, 2026
59f27fd
refactor(TagsInputInput): use useForwardExpose for proper ref forwarding
DrJKL Jan 15, 2026
820b4b8
feat(TagsInput): simplify disabled prop to control click-to-edit beha…
DrJKL Jan 15, 2026
a038ae3
refactor(TagsInput): add generic typing and improve test quality
DrJKL Jan 15, 2026
0213839
feat(TagsInput): show placeholder when empty via slot prop
DrJKL Jan 15, 2026
012f8b9
Merge branch 'main' into drjkl/some-people-call-it-a-yard-sale
DrJKL Jan 15, 2026
7cfc37f
Merge branch 'main' into drjkl/some-people-call-it-a-yard-sale
DrJKL Jan 15, 2026
c289af0
test(TagsInput): add click-to-edit behavior tests
DrJKL Jan 15, 2026
0d38658
refactor(TagsInput): consolidate tests, add a11y improvements
DrJKL Jan 15, 2026
5d21cfe
Remove right padding change on disable.
DrJKL Jan 15, 2026
fd1ce93
fix(TagsInput): add i18n plugin to test for TagsInputItemDelete
DrJKL Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/components/ui/tags-input/TagsInput.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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<C> extends Omit<Meta<C>, 'component'> {
component: ComponentExposed<C>
}

const meta: GenericMeta<typeof TagsInput> = {
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<typeof meta>

export const Default: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(args.modelValue || ['tag1', 'tag2'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
</TagsInput>
<div class="mt-4 text-sm text-muted-foreground">
Tags: {{ tags.join(', ') }}
</div>
`
}),
args: {
modelValue: ['Vue', 'TypeScript'],
disabled: false
}
}

export const Empty: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref<string[]>([])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Start typing to add tags..." />
</TagsInput>
`
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
</TagsInput>
`
})
}
Comment on lines +96 to +128

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding args for Storybook controls consistency.

The ManyTags story doesn't have an args property, unlike Default, Empty, and Disabled. Adding args would enable the Storybook controls panel to work consistently across all stories.

Suggested fix
 export const ManyTags: Story = {
+  args: {
+    disabled: false
+  },
   render: (args) => ({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
</TagsInput>
`
})
}
export const ManyTags: Story = {
args: {
disabled: false
},
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: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
</TagsInput>
`
})
}
🤖 Prompt for AI Agents
In `@src/components/ui/tags-input/TagsInput.stories.ts` around lines 96 - 128, The
ManyTags story is missing an args property which prevents Storybook controls
from showing consistently; update the export const ManyTags: Story object to
include an args: { disabled: false } (or match default story controls) so the
story provides the same controls as Default/Empty/Disabled, ensuring the render
setup still uses args and v-model behavior remains unchanged.


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: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Cannot add tags..." />
</TagsInput>
`
})
}

export const CustomWidth: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(['Full', 'Width'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-full" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
</TagsInput>
`
})
}
Comment on lines +159 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding args for Storybook controls consistency.

Similar to ManyTags, the CustomWidth story would benefit from an args property for consistent Storybook controls behavior.

Suggested fix
 export const CustomWidth: Story = {
+  args: {
+    disabled: false
+  },
   render: (args) => ({
🤖 Prompt for AI Agents
In `@src/components/ui/tags-input/TagsInput.stories.ts` around lines 159 - 182,
The CustomWidth story lacks an args property for Storybook controls which
`render` already expects (it reads args.disabled); add an args object to the
CustomWidth export (e.g., export const CustomWidth: Story = { args: { disabled:
false }, render: (...) }) so controls work consistently with other stories like
ManyTags and ensure the prop name matches usage (`disabled`) used inside the
render function.

102 changes: 102 additions & 0 deletions src/components/ui/tags-input/TagsInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { h, nextTick } from 'vue'
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use test instead of it per coding guidelines.

The coding guidelines specify using test instead of it for defining test cases in Vitest. This applies throughout the file.

♻️ Suggested change
-import { describe, expect, it } from 'vitest'
+import { describe, expect, test } from 'vitest'

Then replace all it(...) calls with test(...) throughout the file.

🤖 Prompt for AI Agents
In `@src/components/ui/tags-input/TagsInput.test.ts` around lines 1 - 3, The test
file uses "it(...)" to define test cases but your coding guidelines require
"test(...)" instead; update every test case declaration in TagsInput.test.ts by
replacing each "it(" call with "test(" while keeping the same callback
functions, descriptions, and any async/await usage (i.e., preserve the existing
describe blocks, callbacks, and imports such as mount, describe, expect, h,
nextTick) so only the test declaration identifier changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What testing guideline says that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai What guideline says that? It's wrong.


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'

describe('TagsInput', () => {
function mountTagsInput(props = {}, slots = {}) {
return mount(TagsInput, {
props: {
modelValue: [],
...props
},
slots
})
}

it('renders slot content', () => {
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })

expect(wrapper.text()).toContain('Slot Content')
})
})

describe('TagsInput with child components', () => {
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
return mount(TagsInput, {
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 as child items', () => {
const wrapper = mountFullTagsInput(['Vue', 'TypeScript'])

const items = wrapper.findAllComponents(TagsInputItem)
expect(items).toHaveLength(2)
})

it('renders input for adding new tags', () => {
const wrapper = mountFullTagsInput()

const input = wrapper.findComponent(TagsInputInput)
expect(input.exists()).toBe(true)
})

it('renders delete buttons for each tag', () => {
const wrapper = mountFullTagsInput(['tag1', 'tag2'])

const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
expect(deleteButtons).toHaveLength(2)
})

it('renders tag text for each tag', () => {
const wrapper = mountFullTagsInput(['tag1', 'tag2'])

const textElements = wrapper.findAllComponents(TagsInputItemText)
expect(textElements).toHaveLength(2)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it('updates model value when adding a tag', async () => {
let currentTags = ['existing']

const wrapper = mount<typeof TagsInput<string>>(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')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
76 changes: 76 additions & 0 deletions src/components/ui/tags-input/TagsInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script setup lang="ts" generic="T extends AcceptableInputValue = string">
import { onClickOutside, useCurrentElement } from '@vueuse/core'
import type {
AcceptableInputValue,
TagsInputRootEmits,
TagsInputRootProps
} from 'reka-ui'
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, nextTick, provide, ref } from 'vue'
import type { HTMLAttributes } from 'vue'

import { cn } from '@/utils/tailwindUtil'

import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
import type { FocusCallback } from './tagsInputContext'

const {
disabled = false,
class: className,
...restProps
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TagsInputRootEmits<T>>()

const isEditing = ref(false)
const rootEl = useCurrentElement<HTMLElement>()
const focusInput = ref<FocusCallback>()

provide(tagsInputFocusKey, (callback: FocusCallback) => {
focusInput.value = callback
})
provide(tagsInputIsEditingKey, isEditing)

const internalDisabled = computed(() => disabled || !isEditing.value)

const delegatedProps = computed(() => ({
...restProps,
disabled: internalDisabled.value
}))

const forwarded = useForwardPropsEmits(delegatedProps, emits)

async function enableEditing() {
if (!disabled && !isEditing.value) {
isEditing.value = true
await nextTick()
focusInput.value?.()
}
}

onClickOutside(rootEl, () => {
isEditing.value = false
})
</script>

<template>
<TagsInputRoot
v-slot="{ modelValue }"
v-bind="forwarded"
:class="
cn(
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
!internalDisabled &&
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
!disabled && !isEditing && 'cursor-pointer',
className
)
"
@click="enableEditing"
>
<slot :is-empty="modelValue.length === 0" />
<i
v-if="!disabled && !isEditing"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</TagsInputRoot>
</template>
Loading