mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[backport cloud/1.37] feat(ui): add TagsInput component with click-to-edit behavior (#8236)
Backport of #8066 to `cloud/1.37` Automatically created by backport workflow. Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -282,7 +282,7 @@
|
|||||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||||
--modal-card-button-surface: var(--color-smoke-300);
|
--modal-card-button-surface: var(--color-smoke-300);
|
||||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
--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-card-tag-foreground: var(--base-foreground);
|
||||||
--modal-panel-background: var(--color-white);
|
--modal-panel-background: var(--color-white);
|
||||||
}
|
}
|
||||||
|
|||||||
182
src/components/ui/tags-input/TagsInput.stories.ts
Normal file
182
src/components/ui/tags-input/TagsInput.stories.ts
Normal file
@@ -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<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 = {
|
||||||
|
args: {
|
||||||
|
disabled: false
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
161
src/components/ui/tags-input/TagsInput.test.ts
Normal file
161
src/components/ui/tags-input/TagsInput.test.ts
Normal file
@@ -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: '<span>Slot Content</span>' })
|
||||||
|
|
||||||
|
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<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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not enter edit mode when disabled', async () => {
|
||||||
|
const wrapper = mount<typeof TagsInput<string>>(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<typeof TagsInput<string>>(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<typeof TagsInput<string>>(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...')
|
||||||
|
})
|
||||||
|
})
|
||||||
77
src/components/ui/tags-input/TagsInput.vue
Normal file
77
src/components/ui/tags-input/TagsInput.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<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"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</TagsInputRoot>
|
||||||
|
</template>
|
||||||
48
src/components/ui/tags-input/TagsInputInput.vue
Normal file
48
src/components/ui/tags-input/TagsInputInput.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TagsInputInputProps } from 'reka-ui'
|
||||||
|
import { TagsInputInput, useForwardExpose, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEmpty = false,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
} = defineProps<
|
||||||
|
TagsInputInputProps & { class?: HTMLAttributes['class']; isEmpty?: boolean }
|
||||||
|
>()
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(restProps)
|
||||||
|
const isEditing = inject(tagsInputIsEditingKey, ref(true))
|
||||||
|
const showInput = computed(() => isEditing.value || isEmpty)
|
||||||
|
|
||||||
|
const { forwardRef, currentElement } = useForwardExpose()
|
||||||
|
const registerFocus = inject(tagsInputFocusKey, undefined)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
registerFocus?.(() => currentElement.value?.focus())
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
registerFocus?.(undefined)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TagsInputInput
|
||||||
|
v-if="showInput"
|
||||||
|
:ref="forwardRef"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'min-h-6 flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground focus:outline-none appearance-none border-none',
|
||||||
|
!isEditing && 'pointer-events-none',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
27
src/components/ui/tags-input/TagsInputItem.vue
Normal file
27
src/components/ui/tags-input/TagsInputItem.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TagsInputItemProps } from 'reka-ui'
|
||||||
|
import { TagsInputItem, useForwardProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { class: className, ...restProps } = defineProps<
|
||||||
|
TagsInputItemProps & { class?: HTMLAttributes['class'] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(restProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TagsInputItem
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex h-6 items-center gap-1 rounded-sm bg-modal-card-tag-background py-1 pl-2 pr-1 text-modal-card-tag-foreground backdrop-blur-sm ring-offset-base-background data-[state=active]:ring-2 data-[state=active]:ring-base-foreground data-[state=active]:ring-offset-1',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TagsInputItem>
|
||||||
|
</template>
|
||||||
36
src/components/ui/tags-input/TagsInputItemDelete.vue
Normal file
36
src/components/ui/tags-input/TagsInputItemDelete.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TagsInputItemDeleteProps } from 'reka-ui'
|
||||||
|
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { class: className, ...restProps } = defineProps<
|
||||||
|
TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(restProps)
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TagsInputItemDelete
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:as="Button"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon-sm"
|
||||||
|
:aria-label="t('g.removeTag')"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'opacity-60 hover:bg-transparent hover:opacity-100 transition-[opacity,width] duration-150 w-4 data-[disabled]:w-0 data-[disabled]:opacity-0 data-[disabled]:pointer-events-none overflow-hidden',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<i class="icon-[lucide--x] size-4" />
|
||||||
|
</slot>
|
||||||
|
</TagsInputItemDelete>
|
||||||
|
</template>
|
||||||
20
src/components/ui/tags-input/TagsInputItemText.vue
Normal file
20
src/components/ui/tags-input/TagsInputItemText.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TagsInputItemTextProps } from 'reka-ui'
|
||||||
|
import { TagsInputItemText, useForwardProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { class: className, ...restProps } = defineProps<
|
||||||
|
TagsInputItemTextProps & { class?: HTMLAttributes['class'] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(restProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TagsInputItemText
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-transparent text-xs', className)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
10
src/components/ui/tags-input/tagsInputContext.ts
Normal file
10
src/components/ui/tags-input/tagsInputContext.ts
Normal file
@@ -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<Ref<boolean>> =
|
||||||
|
Symbol('tagsInputIsEditing')
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"increment": "Increment",
|
"increment": "Increment",
|
||||||
"removeImage": "Remove image",
|
"removeImage": "Remove image",
|
||||||
"removeVideo": "Remove video",
|
"removeVideo": "Remove video",
|
||||||
|
"removeTag": "Remove tag",
|
||||||
"chart": "Chart",
|
"chart": "Chart",
|
||||||
"chartLowercase": "chart",
|
"chartLowercase": "chart",
|
||||||
"file": "file",
|
"file": "file",
|
||||||
|
|||||||
Reference in New Issue
Block a user