mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
feat(InputTags): new component (#4261)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
203
src/runtime/components/InputTags.vue
Normal file
203
src/runtime/components/InputTags.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
54
src/theme/input-tags.ts
Normal 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:')
|
||||
}
|
||||
Reference in New Issue
Block a user