Input

Displays a form input field or a component that looks like an input field.

Examples

Basic

PropDefaultTypeDescription
type-text, password, email, number, tel, url, search, textareaThe type of input field.
reversefalsebooleanSwap the position of the leading and trailing icons.
modelValue-anyValue of the input. Can be a string or a number.
idrandomId()stringManually set the id attribute. By default, the id attribute is generated randomly for accessibility reasons.
readonlyfalsebooleanMake the input readonly.
disabledfalsebooleanDisable the input.
Preview
Code

Variant

PropDefaultTypeDescription
inputoutline{variant}The variant of the input.
VariantDescription
outlineThe default variant.
solidThe solid variant.
~The unstyle or base variant
Preview
Code

Color

PropDefaultTypeDescription
input{variant}-primary{variant}-{color}The color of the input.
Preview
Code
Dynamic colors:
Static colors:

Size

PropDefaultTypeDescription
sizesmstringAllows you to change the size of the input.

🚀 Adjust input size freely using any size, breakpoints (e.g., sm:sm, xs:lg), or states (e.g., hover:lg, focus:3xl).

Preview
Code

Leading and Trailing Icons

PropDefaultTypeDescription
leading-stringDisplay leading icon.
trailing-stringDisplay trailing icon.
Preview
Code

Loading

PropDefaultTypeDescription
loading-booleanDisplay loading state.
Preview
Code

Status

PropDefaultTypeDescription
status-info, success, warning, errorUpdate the input status. Useful for validations.
Preview
Code

Events

EventDescription
@leadingEmit an event when the leading icon is clicked.
@trailingEmit an event when the trailing icon is clicked.
Preview
Code

Slots

NamePropsDescription
default-The content slot.
leading-The leading slot.
trailing-The trailing slot.

Leading

Add a leading slot to the input.

Preview
Code

Trailing

Aad a trailing slot to the input.

Preview
Code
USD

Presets

shortcuts/input.ts
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.5em 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-input': 'h-9', // role='input'
  'input-textarea': '', // role='textarea'
  '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,
]

Props

types/input.ts
export interface NInputProps {
  /**
   *
   * @default null
   */
  type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | '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
  }
}

Components

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

defineOptions({
  inheritAttrs: false,
})

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

const emit = defineEmits(['leading', 'trailing', 'update:modelValue'])

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(): void {
  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): void {
  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="cn(
        type === 'textarea' ? 'input-textarea' : 'input-input',
        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="cn(
        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>