feat(Textarea): new component (#62)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2024-03-22 17:53:43 +01:00
committed by GitHub
parent d23e3e1c76
commit 2ca6973337
7 changed files with 369 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ const components = [
'slideover',
'switch',
'tabs',
'textarea',
'tooltip'
]

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import textarea from '#build/ui/textarea'
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex gap-4">
<UTextarea />
</div>
<div class="flex gap-4">
<UTextarea placeholder="Search..." autofocus />
<UTextarea placeholder="Search..." color="gray" />
<UTextarea placeholder="Search..." color="primary" />
<UTextarea placeholder="Search..." disabled />
</div>
<div class="flex items-center gap-4">
<UTextarea
v-for="size in Object.keys(textarea.variants.size)"
:key="size"
placeholder="Search..."
:size="(size as any)"
/>
</div>
<div class="flex gap-4">
<UTextarea autofocus />
<UTextarea autofocus :autofocus-delay="500" />
<UTextarea autoresize />
<UTextarea autoresize :maxrows="5" :rows="1" />
</div>
<div class="flex gap-4">
<UTextarea variant="none" placeholder="You can't see me" />
</div>
</div>
</template>

View 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>

View File

@@ -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
View 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'
}
})

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, test } from 'vitest'
import Textarea, { type TextareaProps } from '../../src/runtime/components/Textarea.vue'
import ComponentRender from '../component-render'
import { mount } from '@vue/test-utils'
describe('Textarea', () => {
it.each([
['basic case', {}],
['with id', { props: { id: 'exampleId' } }],
['with name', { props: { name: 'exampleName' } }],
['with placeholder', { props: { placeholder: 'examplePlaceholder' } }],
['with required', { props: { required: true } }],
['with disabled', { props: { disabled: true } }],
['with rows', { props: { rows: 5 } }],
['with size', { props: { size: 'sm' } }],
['with color', { props: { color: 'blue' } }],
['with size 2xs', { props: { size: '2xs' as const } }],
['with size xs', { props: { size: 'xs' as const } }],
['with size sm', { props: { size: 'sm' as const } }],
['with size md', { props: { size: 'md' as const } }],
['with size lg', { props: { size: 'lg' as const } }],
['with size xl', { props: { size: 'xl' as const } }],
['with variant', { variant: 'outline' }],
['with default slot', { slots: { default: () => 'Default slot' } }]
// @ts-ignore
])('renders %s correctly', async (nameOrHtml: string, options: { props: TextareaProps, slots?: any }) => {
const html = await ComponentRender(nameOrHtml, options, Textarea)
expect(html).toMatchSnapshot()
})
it.each([
['with .trim modifier', { props: { modelModifiers: { trim: true } } }, { input: 'input ', expected: 'input' } ],
['with .number modifier', { props: { modelModifiers: { number: true } } }, { input: '42', expected: 42 } ],
['with .lazy modifier', { props: { modelModifiers: { lazy: true } } }, { input: 'input', expected: 'input' } ]
])('%s works', async (_nameOrHtml: string, options: { props?: any, slots?: any }, spec: { input: any, expected: any }) => {
const wrapper = await mount(Textarea, {
...options
})
const input = wrapper.find('textarea')
await input.setValue(spec.input)
expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[spec.expected]] })
})
test('with .lazy modifier updates on change only', async () => {
const wrapper = mount(Textarea, {
props: {
modelModifiers: { lazy: true }
}
})
const input = wrapper.find('textarea')
await input.trigger('update')
expect(wrapper.emitted()).toMatchObject({ })
await input.trigger('change')
expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [['']] })
})
})

View File

@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Textarea > renders basic case correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with color correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 shadow-sm bg-transparent text-gray-900 dark:text-white ring ring-inset ring-blue-500 dark:ring-blue-400 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"></textarea></div>"`;
exports[`Textarea > renders with default slot correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea>Default slot</div>"`;
exports[`Textarea > renders with disabled correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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" disabled=""></textarea></div>"`;
exports[`Textarea > renders with id correctly 1`] = `"<div class="relative"><textarea id="exampleId" rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with name correctly 1`] = `"<div class="relative"><textarea name="exampleName" rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with placeholder correctly 1`] = `"<div class="relative"><textarea rows="3" placeholder="examplePlaceholder" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with required correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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" required=""></textarea></div>"`;
exports[`Textarea > renders with rows correctly 1`] = `"<div class="relative"><textarea rows="5" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with size 2xs correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-xs gap-x-1 px-2 py-1 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"></textarea></div>"`;
exports[`Textarea > renders with size correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with size lg correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-2.5 px-3.5 py-2.5 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"></textarea></div>"`;
exports[`Textarea > renders with size md correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-3 py-2 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"></textarea></div>"`;
exports[`Textarea > renders with size sm correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with size xl correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-base gap-x-2.5 px-3.5 py-2.5 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"></textarea></div>"`;
exports[`Textarea > renders with size xs correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;
exports[`Textarea > renders with variant correctly 1`] = `"<div class="relative"><textarea rows="3" class="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 text-sm gap-x-1.5 px-2.5 py-1.5 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"></textarea></div>"`;