๐ŸŸข Textarea


Basic

You can use NInput with the textarea" type to create a basic textarea.

This component shares the same API as the NInput component. So you can use all the same props, variants, slots, events, etc. as the input component.
<script setup lang="ts">
const value = ref('')
</script>

<template>
<div class="max-w-lg">
  <NInput
    v-model="value"
    type="textarea"
    placeholder="Write your message here..."
  />
</div>
</template>

Rows and Cols

rows={value} - Set the number of rows for the textarea.

cols={value} - Set the number of columns for the textarea.

<script setup lang="ts">
const value = ref('')
</script>

<template>
<div class="max-w-lg">
  <NInput
    v-model="value"
    type="textarea"
    placeholder="Write your message here..."
    :rows="10"
    :cols="20"
  />
</div>
</template>

Autosizing

autoresize={value} - Enable autosizing of the textarea, you can also pass a number to set the maximum number of rows.

ValueDescription
trueThe textarea will automatically adjust its height to fit the content.
falseThe textarea will not automatically adjust its height to fit the content.
numberThe textarea will automatically adjust its height to fit the content, but will not exceed the specified number of rows.
<script setup lang="ts">
import { ref } from 'vue'

const value = ref('Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10')
</script>

<template>
<div class="max-w-lg">
  <NInput
    v-model="value"
    type="textarea"
    placeholder="Write your message here..."
    autoresize
  />
</div>
</template>

Resizing

resize="{value}" - change the resize behavior of the textarea.

ValueDescription
noneThe textarea will not be resizable. (Default)
nullThe textarea will be resizable vertically and horizontally.
yThe textarea will be resizable vertically.
xThe textarea will be resizable horizontally.
The examples below utilize NFormGroup. Refer to the FormGroup section for more details.
<script setup lang="ts">
const value = ref('')
</script>

<template>
  <div class="grid max-w-xl gap-2">
    <NFormGroup label="Resizable both">
      <NInput
        v-model="value"
        type="textarea"
        placeholder="Write your message here..."
        resize
      />
    </NFormGroup>

    <NFormGroup label="Resizable horizontal">
      <NInput
        v-model="value"
        type="textarea"
        placeholder="Write your message here..."
        resize="x"
      />
    </NFormGroup>

    <NFormGroup label="Resizable vertical">
      <NInput
        v-model="value"
        type="textarea"
        placeholder="Write your message here..."
        resize="y"
      />
    </NFormGroup>
  </div>
</template>

Props

export interface NInputProps {
  /**
   *
   * @default null
   */
  type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'textarea' | ''

  /**
   * Update the input status.
   * Useful for validations.
   *
   * @default null
   */
  status?: 'info' | 'success' | 'warning' | 'error'

  /**
   * Add loading state to the input.
   *
   * @default false
   */
  loading?: boolean
  /**
   * Swap the position of the leading and trailing icons.
   *
   * @default false
   */
  reverse?: boolean

  /**
   * Value of the input.
   *
   * @default null
   */
  modelValue?: string | number
  /**
   * Display leading icon.
   *
   * @default null
   */
  leading?: string
  /**
   * Display trailing icon.
   *
   * @default null
   */
  trailing?: string
  /**
   * Allows you to add `UnaUI` input preset properties,
   * Think of it as a shortcut for adding options or variants to the preset if available.
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/input.ts
   * @example
   * input="solid-green"
   */
  input?: string
  /**
   * Allows you to change the size of the input.
   *
   * @default sm
   *
   * @example
   * size="sm" | size="2cm" | size="2rem" | size="2px"
   */
  size?: string

  /**
   * Manually set the id attribute.
   *
   * By default, the id attribute is generated randomly for accessibility reasons.
   *
   * @default randomId
   * @example
   * id="email"
   */
  id?: string

  /**
   * Automatically resize the textarea to fit the content.
   * This property only works with the `textarea` type.
   *
   * @default false
   */
  autoresize?: boolean | number

  /**
   * This property only works with the `textarea` type.
   * You can add your own resize preset or use the default one.
   *
   * @default none
   *
   * @example
   * resize="x" | resize="y" | resize="none" | null
   */
  resize?: string | null

  /**
   * This property only works with the `textarea` type.
   *
   * @default 3
   */
  rows?: number

  /**
   * This property only works with the `textarea` type.
   *
   * @default 3
   */
  cols?: number

  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/input.ts
   */
  una?: {
    // base
    input?: string
    inputLoading?: string
    inputTrailing?: string
    inputLeading?: string

    // wrappers
    inputWrapper?: string
    inputLeadingWrapper?: string
    inputTrailingWrapper?: string

    // icons
    inputWarningIcon?: string
    inputErrorIcon?: string
    inputSuccessIcon?: string
    inputInfoIcon?: string
    inputLoadingIcon?: string
  }
}

Presets

type InputPrefix = 'input'

export const staticInput: Record<`${InputPrefix}-${string}` | InputPrefix, string> = {
  // config
  'input-default-variant': 'input-outline',
  'input-loading-icon': 'i-loading',
  'input-info-icon': 'i-info',
  'input-error-icon': 'i-error',
  'input-success-icon': 'i-success',
  'input-warning-icon': 'i-warning',
  'input-leading-padding': 'pl-2.9em',
  'input-trailing-padding': 'pr-2.9em',

  // base
  'input': 'text-0.875em leading-6 px-0.8571428571428571em py-0.42857142857142855em w-full input-disabled ring-base ring-inset placeholder:text-$c-gray-400 block outline-none rounded-md border-0 shadow-sm bg-transparent',
  'input-disabled': 'disabled:(n-disabled)',
  'input-status-ring': 'ring-opacity-50 dark:ring-opacity-40',
  'input-status-icon-base': 'text-1.042em',
  'input-leading': 'text-1.042em',
  'input-trailing': 'text-1.042em',
  'input-loading': 'animate-spin text-1.042em',

  // wrappers
  'input-wrapper': 'relative flex items-center',
  'input-leading-wrapper': 'pointer-events-none absolute inset-y-0 left-0 flex items-center pl-0.75em text-$c-gray-400',
  'input-trailing-wrapper': 'pointer-events-none absolute inset-y-0 right-0 flex items-center pr-0.75em text-$c-gray-400',

  // variants
  'input-outline-gray': 'focus:ring-2 ring-1',
  'input-outline-black': 'ring-1 focus:ring-$c-foreground',
}

export const dynamicInput: [RegExp, (params: RegExpExecArray) => string][] = [
  // config
  [/^input-focus(-(\S+))?$/, ([, , c = 'primary']) => `focus:ring-2 focus:ring-${c}-500 dark:focus:ring-${c}-400`],
  [/^input-status(-(\S+))?$/, ([, , c = 'info']) => `text-${c}-700 dark:text-${c}-200 placeholder-${c}-400/70 dark:placeholder-${c}-300/70`],

  // variants
  [/^input-outline(-(\S+))?$/, ([, , c = 'primary']) => `ring-1 input-focus-${c}`],
  [/^input-solid(-(\S+))?$/, ([, , c = 'primary']) => ` ring-1 input-focus-${c} ring-${c}-500 dark:ring-${c}-400`],
]

export const input = [
  ...dynamicInput,
  staticInput,
]

Component

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import NIcon from '../elements/Icon.vue'
import type { NInputProps } from '../../types'
import { randomId } from '../../utils'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(defineProps<NInputProps>(), {
  type: 'text',
  resize: 'none',
  rows: 3,
})

const emit = defineEmits<{ (...args: any): void }>()

const slots = defineSlots<{
  leading?: any
  trailing?: any
}>()

const id = computed(() => props.id ?? randomId('input'))

const isLeading = computed(() => props.leading || slots.leading)
const isTrailing = computed(() => props.trailing || slots.trailing || props.status || props.loading)

const inputVariants = ['outline', 'solid'] as const
const hasVariant = computed(() => inputVariants.some(inputVariants => props.input?.includes(inputVariants)))
const isBaseVariant = computed(() => props.input?.includes('~'))

const statusClassVariants = computed(() => {
  const input = {
    info: 'input-status-info input-solid-info input-status-ring',
    success: 'input-status-success input-solid-success input-status-ring',
    warning: 'input-status-warning input-solid-warning input-status-ring',
    error: 'input-status-error input-solid-error input-status-ring',
    default: !hasVariant.value && !isBaseVariant.value ? 'input-default-variant' : '',
  }

  const text = {
    info: 'text-info',
    success: 'text-success',
    warning: 'text-warning',
    error: 'text-error',
    default: '',
  }

  const icon = {
    info: props.una?.inputWarningIcon ?? 'input-info-icon',
    success: props.una?.inputSuccessIcon ?? 'input-success-icon',
    warning: props.una?.inputWarningIcon ?? 'input-warning-icon',
    error: props.una?.inputErrorIcon ?? 'input-error-icon',
    default: '',
  }

  return {
    input: input[props.status ?? 'default'],
    text: text[props.status ?? 'default'],
    icon: icon[props.status ?? 'default'],
  }
})

const reverseClassVariants = computed(() => {
  const input = {
    false: [{ 'input-leading-padding': isLeading.value }, { 'input-trailing-padding': isTrailing.value }],
    true: [{ 'input-trailing-padding': isLeading.value }, { 'input-leading-padding': isTrailing.value }],
  }

  return {
    input: input[props.reverse ? 'true' : 'false'],
    leadingWrapper: props.reverse ? 'input-trailing-wrapper' : 'input-leading-wrapper',
    trailingWrapper: props.reverse ? 'input-leading-wrapper' : 'input-trailing-wrapper',
  }
})

// html refs
const textarea = ref<HTMLTextAreaElement>()

function resizeTextarea() {
  if (!(props.type === 'textarea' && props.autoresize) || !textarea.value)
    return

  textarea.value.rows = props.rows

  const styles = window.getComputedStyle(textarea.value)
  const paddingTop = Number.parseInt(styles.paddingTop)
  const paddingBottom = Number.parseInt(styles.paddingBottom)
  const padding = paddingTop + paddingBottom
  const lineHeight = Number.parseInt(styles.lineHeight)
  const { scrollHeight } = textarea.value
  const newRows = (scrollHeight - padding) / lineHeight

  if (newRows > props.rows)
    textarea.value.rows = newRows

  const maxAutoresizeRows = typeof props.autoresize === 'number' ? props.autoresize : Number.POSITIVE_INFINITY
  if (textarea.value.rows > maxAutoresizeRows)
    textarea.value.rows = maxAutoresizeRows
}

function onInput(event: Event) {
  emit('update:modelValue', (event.target as HTMLInputElement).value)

  resizeTextarea()
}

onMounted(() => {
  resizeTextarea()
})
</script>

<template>
  <div
    input="wrapper"
    :size="size"
    :class="una?.inputWrapper"
  >
    <div
      v-if="isLeading"
      :class="[
        una?.inputLeadingWrapper,
        reverseClassVariants.leadingWrapper,
        statusClassVariants.text,
      ]"
    >
      <slot name="leading">
        <NIcon
          v-if="leading"
          :name="leading"
          input="leading"
          :class="una?.inputLeading"
          @click="emit('leading')"
        />
      </slot>
    </div>

    <Component
      :is="props.type !== 'textarea' ? 'input' : 'textarea'"
      :id="id"
      ref="textarea"
      :value="modelValue"
      :type="props.type !== 'textarea' ? props.type : undefined"
      class="input"
      :class="[
        statusClassVariants.input,
        reverseClassVariants.input,
        una?.input,
      ]"
      :input="input"
      :resize="type === 'textarea' ? resize : undefined"
      :rows="type === 'textarea' ? rows : undefined"
      :cols="type === 'textarea' ? cols : undefined"
      v-bind="$attrs"
      @input="onInput"
    />

    <div
      v-if="isTrailing"
      :class="[
        una?.inputTrailingWrapper,
        reverseClassVariants.trailingWrapper,
        statusClassVariants.text,
      ]"
    >
      <NIcon
        v-if="loading"
        input="loading"
        :name="una?.inputLoadingIcon ?? 'input-loading-icon'"
        :class="una?.inputLoading"
      />

      <NIcon
        v-else-if="status"
        input="status-icon-base"
        :name="statusClassVariants.icon"
      />

      <slot v-else name="trailing">
        <NIcon
          v-if="trailing"
          input="trailing"
          :class="una?.inputTrailing"
          :name="trailing"
          @click="emit('trailing')"
        />
      </slot>
    </div>
  </div>
</template>