feat(InputTags): new component (#4261)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
zhong666
2025-06-12 18:10:48 +08:00
committed by GitHub
parent 2a2495a652
commit 54bb2282c5
18 changed files with 1422 additions and 7 deletions

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import type { AppConfig } from '@nuxt/schema'
import type { TagsInputRootProps, AcceptableInputValue } from 'reka-ui'
import theme from '#build/ui/input-tags'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps } from '../types'
import type { ComponentConfig } from '../types/utils'
type InputTags = ComponentConfig<typeof theme, AppConfig, 'inputTags'>
export type InputTagItem = AcceptableInputValue
export interface InputTagsProps<T extends InputTagItem = InputTagItem> extends Pick<TagsInputRootProps<T>, 'modelValue' | 'defaultValue' | 'addOnPaste' | 'addOnTab' | 'addOnBlur' | 'duplicate' | 'disabled' | 'delimiter' | 'max' | 'id' | 'convertValue' | 'displayValue' | 'name' | 'required'>, UseComponentIconsProps {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
/** The placeholder text when the input is empty. */
placeholder?: string
/**
* @defaultValue 'primary'
*/
color?: InputTags['variants']['color']
/**
* @defaultValue 'outline'
*/
variant?: InputTags['variants']['variant']
/**
* @defaultValue 'md'
*/
size?: InputTags['variants']['size']
autofocus?: boolean
autofocusDelay?: number
/**
* The icon displayed to delete a tag.
* @defaultValue appConfig.ui.icons.close
* @IconifyIcon
*/
deleteIcon?: string
/** Highlight the ring color like a focus state. */
highlight?: boolean
class?: any
ui?: InputTags['slots']
}
export type InputTagsEmits<T extends InputTagItem> = {
'update:modelValue': [payload: T[]]
'change': [payload: Event]
'blur': [payload: FocusEvent]
'focus': [payload: FocusEvent]
}
type SlotProps<T extends InputTagItem> = (props: { item: T, index: number }) => any
export interface InputTagsSlots<T extends InputTagItem = InputTagItem> {
'leading'(props?: {}): any
'default'(props?: {}): any
'trailing'(props?: {}): any
'item-text': SlotProps<T>
'item-delete': SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends InputTagItem">
import { computed, ref, onMounted, toRaw } from 'vue'
import { TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { tv } from '../utils/tv'
import UIcon from './Icon.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputTagsProps<T>>(), {
type: 'text',
autofocusDelay: 0
})
const emits = defineEmits<InputTagsEmits<T>>()
const slots = defineSlots<InputTagsSlots<T>>()
const appConfig = useAppConfig() as InputTags['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue', 'required'), emits)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputTagsProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputTagsProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
const inputSize = computed(() => buttonGroupSize.value || formGroupSize.value)
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTags || {}) })({
color: color.value,
variant: props.variant,
size: inputSize?.value,
loading: props.loading,
highlight: highlight.value,
leading: isLeading.value || !!props.avatar || !!slots.leading,
trailing: isTrailing.value || !!slots.trailing,
buttonGroup: orientation.value
}))
const inputRef = ref<InstanceType<typeof TagsInputInput> | null>(null)
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function autoFocus() {
if (props.autofocus) {
inputRef.value?.$el?.focus()
}
}
function onUpdate(value: T[]) {
if (toRaw(props.modelValue) === value) {
return
}
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
emitFormChange()
emitFormInput()
}
function onBlur(event: FocusEvent) {
emits('blur', event)
emitFormBlur()
}
function onFocus(event: FocusEvent) {
emits('focus', event)
emitFormFocus()
}
defineExpose({
inputRef
})
</script>
<!-- eslint-disable vue/no-template-shadow -->
<template>
<TagsInputRoot
:id="id"
v-slot="{ modelValue: tags }"
:model-value="modelValue"
:default-value="defaultValue"
:class="ui.root({ class: [ui.base({ class: props.ui?.base }), props.ui?.root, props.class] })"
v-bind="rootProps"
:name="name"
:disabled="disabled"
@update:model-value="onUpdate"
@blur="onBlur"
@focus="onFocus"
>
<TagsInputItem
v-for="(item, index) in tags"
:key="index"
:value="item"
:class="ui.item({ class: [props.ui?.item] })"
>
<TagsInputItemText :class="ui.itemText({ class: [props.ui?.itemText] })">
<slot v-if="!!slots['item-text']" name="item-text" :item="(item as T)" :index="index" />
</TagsInputItemText>
<TagsInputItemDelete
:class="ui.itemDelete({ class: [props.ui?.itemDelete] })"
:disabled="disabled"
>
<slot name="item-delete" :item="(item as T)" :index="index">
<UIcon :name="deleteIcon || appConfig.ui.icons.close" :class="ui.itemDeleteIcon({ class: [props.ui?.itemDeleteIcon] })" />
</slot>
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput
ref="inputRef"
v-bind="{ ...$attrs, ...ariaAttrs }"
:placeholder="placeholder"
:class="ui.input({ class: props.ui?.input })"
/>
<slot />
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar })" />
</slot>
</span>
<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</span>
</TagsInputRoot>
</template>

View File

@@ -26,6 +26,7 @@ export * from '../components/Icon.vue'
export * from '../components/Input.vue'
export * from '../components/InputMenu.vue'
export * from '../components/InputNumber.vue'
export * from '../components/InputTags.vue'
export * from '../components/Kbd.vue'
export * from '../components/Link.vue'
export * from '../components/Modal.vue'

View File

@@ -24,6 +24,7 @@ export { default as formField } from './form-field'
export { default as input } from './input'
export { default as inputMenu } from './input-menu'
export { default as inputNumber } from './input-number'
export { default as inputTags } from './input-tags'
export { default as kbd } from './kbd'
export { default as link } from './link'
export { default as modal } from './modal'

View File

@@ -26,14 +26,13 @@ export default (options: Required<ModuleOptions>) => {
tagsItem: 'px-1.5 py-0.5 rounded-sm font-medium inline-flex items-center gap-0.5 ring ring-inset ring-accented bg-elevated text-default data-disabled:cursor-not-allowed data-disabled:opacity-75',
tagsItemText: 'truncate',
tagsItemDelete: ['inline-flex items-center rounded-xs text-dimmed hover:text-default hover:bg-accented/75 disabled:pointer-events-none', options.theme.transitions && 'transition-colors'],
tagsItemDeleteIcon: '',
tagsInput: ''
tagsItemDeleteIcon: 'shrink-0',
tagsInput: 'flex-1 border-0 bg-transparent placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75'
},
variants: {
multiple: {
true: {
root: 'flex-wrap',
tagsInput: 'border-0 bg-transparent placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75'
root: 'flex-wrap'
},
false: {
base: 'w-full border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75'
@@ -97,7 +96,15 @@ export default (options: Required<ModuleOptions>) => {
}
}
},
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
compoundVariants: [{
variant: 'soft',
multiple: true,
class: 'has-focus:bg-elevated'
}, {
variant: 'ghost',
multiple: true,
class: 'has-focus:bg-elevated'
}, ...(options.theme.colors || []).map((color: string) => ({
color,
multiple: true,
variant: ['outline', 'subtle'],

54
src/theme/input-tags.ts Normal file
View File

@@ -0,0 +1,54 @@
import { defuFn } from 'defu'
import type { ModuleOptions } from '../module'
import input from './input'
export default (options: Required<ModuleOptions>) => {
return defuFn({
slots: {
root: (prev: string) => [prev, 'flex-wrap'],
base: () => ['rounded-md', options.theme.transitions && 'transition-colors'],
item: 'px-1.5 py-0.5 rounded-sm font-medium inline-flex items-center gap-0.5 ring ring-inset ring-accented bg-elevated text-default data-disabled:cursor-not-allowed data-disabled:opacity-75 wrap-anywhere data-[state="active"]:bg-accented',
itemText: '',
itemDelete: ['inline-flex items-center rounded-xs text-dimmed hover:text-default hover:bg-accented/75 disabled:pointer-events-none', options.theme.transitions && 'transition-colors'],
itemDeleteIcon: 'shrink-0',
input: 'flex-1 border-0 bg-transparent placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75'
},
variants: {
size: {
xs: {
item: 'text-[10px]/3',
itemDeleteIcon: 'size-3'
},
sm: {
item: 'text-[10px]/3',
itemDeleteIcon: 'size-3'
},
md: {
item: 'text-xs',
itemDeleteIcon: 'size-3.5'
},
lg: {
item: 'text-xs',
itemDeleteIcon: 'size-3.5'
},
xl: {
item: 'text-sm',
itemDeleteIcon: 'size-4'
}
},
variant: (prev: Record<string, string>) => Object.fromEntries(
Object.entries(prev).map(([key, value]) => [key, replaceFocus(value)])
)
},
compoundVariants: (prev: Record<string, any>[]) => prev.map(item => ({
...item,
class: typeof item.class === 'string' ? replaceFocus(item.class) : item.class
}))
}, input(options))
}
function replaceFocus(str: string): string {
return str
.replace(/focus:/g, 'has-focus:')
.replace(/focus-visible:/g, 'has-focus-visible:')
}