mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-21 07:21:46 +01:00
feat(Textarea): new component (#62)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
175
src/runtime/components/Textarea.vue
Normal file
175
src/runtime/components/Textarea.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/textarea'
|
||||
import { looseToNumber } from '#ui/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { textarea: Partial<typeof theme> } }
|
||||
|
||||
const textarea = tv({ extend: tv(theme), ...(appConfig.ui?.textarea || {}) })
|
||||
|
||||
type TextareaVariants = VariantProps<typeof textarea>
|
||||
|
||||
export interface TextareaProps {
|
||||
id?: string
|
||||
name?: string
|
||||
placeholder?: string
|
||||
color?: TextareaVariants['color']
|
||||
variant?: TextareaVariants['variant']
|
||||
size?: TextareaVariants['size']
|
||||
required?: boolean
|
||||
autofocus?: boolean
|
||||
autofocusDelay?: number
|
||||
disabled?: boolean
|
||||
class?: any
|
||||
rows?: number
|
||||
maxrows?: number
|
||||
autoresize?: boolean
|
||||
ui?: Partial<typeof textarea.slots>
|
||||
}
|
||||
export interface TextareaEmits {
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
}
|
||||
|
||||
export interface TextareaSlots {
|
||||
default(): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useFormField } from '#ui/composables/useFormField'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
rows: 3,
|
||||
maxrows: 0,
|
||||
autofocusDelay: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<TextareaEmits>()
|
||||
defineSlots<TextareaSlots>()
|
||||
|
||||
const [modelValue, modelModifiers] = defineModel<string | number>()
|
||||
|
||||
const { emitFormBlur, emitFormInput, size, color, inputId, name, disabled } = useFormField<TextareaProps>(props)
|
||||
|
||||
const ui = computed(() => tv({ extend: textarea, slots: props.ui })({
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: size?.value
|
||||
}))
|
||||
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function autoFocus () {
|
||||
if (props.autofocus) {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Custom function to handle the v-model properties
|
||||
function updateInput (value: string) {
|
||||
if (modelModifiers.trim) {
|
||||
value = value.trim()
|
||||
}
|
||||
|
||||
if (modelModifiers.number) {
|
||||
value = looseToNumber(value)
|
||||
}
|
||||
|
||||
modelValue.value = value
|
||||
emitFormInput()
|
||||
}
|
||||
|
||||
function onInput (event: Event) {
|
||||
autoResize()
|
||||
|
||||
if (!modelModifiers.lazy) {
|
||||
updateInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
}
|
||||
|
||||
function onChange (event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
|
||||
if (modelModifiers.lazy) {
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed textarea so that it has same behavior as native textarea https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
if (modelModifiers.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur (event: FocusEvent) {
|
||||
emitFormBlur()
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
const autoResize = () => {
|
||||
if (props.autoresize) {
|
||||
|
||||
if (!inputRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.value.rows = props.rows
|
||||
|
||||
const styles = window.getComputedStyle(inputRef.value)
|
||||
const paddingTop = parseInt(styles.paddingTop)
|
||||
const paddingBottom = parseInt(styles.paddingBottom)
|
||||
const padding = paddingTop + paddingBottom
|
||||
const lineHeight = parseInt(styles.lineHeight)
|
||||
const { scrollHeight } = inputRef.value
|
||||
const newRows = (scrollHeight - padding) / lineHeight
|
||||
|
||||
if (newRows > props.rows) {
|
||||
inputRef.value.rows = props.maxrows ? Math.min(newRows, props.maxrows) : newRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
watch(() => modelValue, () => {
|
||||
nextTick(autoResize)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoResize()
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="ui.root({ class: props.class })">
|
||||
<textarea
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
:name="name"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:class="ui.base()"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
v-bind="$attrs"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@change="onChange"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,3 +20,4 @@ export { default as slideover } from './slideover'
|
||||
export { default as switch } from './switch'
|
||||
export { default as tabs } from './tabs'
|
||||
export { default as tooltip } from './tooltip'
|
||||
export { default as textarea } from './textarea'
|
||||
|
||||
63
src/theme/textarea.ts
Normal file
63
src/theme/textarea.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export default (config: { colors: string[] }) => ({
|
||||
slots: {
|
||||
root: 'relative',
|
||||
base: 'relative block w-full disabled:cursor-not-allowed disabled:opacity-75 focus:outline-none border-0 rounded-md placeholder-gray-400 dark:placeholder-gray-500'
|
||||
},
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
'2xs': {
|
||||
base: 'text-xs gap-x-1 px-2 py-1'
|
||||
},
|
||||
xs: {
|
||||
base: 'text-sm gap-x-1.5 px-2.5 py-1.5'
|
||||
},
|
||||
sm: {
|
||||
base: 'text-sm gap-x-1.5 px-2.5 py-1.5'
|
||||
},
|
||||
md: {
|
||||
base: 'text-sm gap-x-1.5 px-3 py-2'
|
||||
},
|
||||
lg: {
|
||||
base: 'text-sm gap-x-2.5 px-3.5 py-2.5'
|
||||
},
|
||||
xl: {
|
||||
base: 'text-base gap-x-2.5 px-3.5 py-2.5'
|
||||
}
|
||||
},
|
||||
|
||||
variant: {
|
||||
outline: '',
|
||||
none: 'bg-transparent focus:ring-0 focus:shadow-none'
|
||||
},
|
||||
|
||||
color: {
|
||||
...Object.fromEntries(config.colors.map((color: string) => [color, ''])),
|
||||
white: '',
|
||||
gray: ''
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
compoundVariants: [
|
||||
...config.colors.map((color: string) => ({
|
||||
color,
|
||||
variant: 'outline',
|
||||
class: `shadow-sm bg-transparent text-gray-900 dark:text-white ring ring-inset ring-${color}-500 dark:ring-${color}-400 focus:ring-2 focus:ring-${color}-500 dark:focus:ring-${color}-400`
|
||||
})), {
|
||||
color: 'white',
|
||||
variant: 'outline',
|
||||
class: 'shadow-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-white ring ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400'
|
||||
}, {
|
||||
color: 'gray',
|
||||
variant: 'outline',
|
||||
class: 'shadow-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white ring ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400'
|
||||
}
|
||||
],
|
||||
|
||||
defaultVariants: {
|
||||
size: 'sm',
|
||||
color: 'white',
|
||||
variant: 'outline'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user