๐ŸŸข Input


Basic

use the NInput tag to create a basic input.

By default we automatically generate an id for the input for accessibility purposes. If you want to override this behavior, you can define the id attribute manually.
<script setup lang="ts">
const value = ref('')
</script>

<template>
  <div class="flex">
    <NInput
      v-model="value"
      type="text"
      placeholder="Enter your name"
    />
  </div>
</template>

Variants

input="{variant}" - change the variant of the input outline.

VariantDescription
outlineThe default variant.
solidThe solid variant.
~The unstyle or base variant
<template>
  <div class="grid gap-4 sm:cols-2">
    <NInput
      input="outline"
      placeholder="This is the outline variant (default color)"
    />

    <NInput
      input="solid"
      placeholder="This is the solid variant (default color)"
    />

    <NInput
      input="~"
      placeholder="This is the base input"
    />
  </div>
</template>

Color

input="{variant}-{color}" - change the color of the input outline.

You can use any color provided by the Tailwind CSS color palette, the default is primary. You can also add your own colors to the palette through the Configuration section.
Dynamic colors:
Static color:
<template>
  <div class="flex flex-col gap-4">
    <span class="text-sm font-medium">Dynamic colors:</span>

    <div class="grid gap-4 sm:cols-2">
      <NInput
        input="outline-primary"
        placeholder="This is the primary color (default)"
      />
      <NInput
        input="outline-indigo"
        placeholder="This is the indigo color"
      />
      <NInput
        input="outline-rose"
        placeholder="This is the rose color"
      />
      <NInput
        input="outline-lime"
        placeholder="This is the lime color"
      />
    </div>

    <NSeparator />

    <span class="text-sm font-medium">Static color:</span>

    <div>
      <NInput
        input="outline-gray"
        placeholder="This is the gray color"
      />
    </div>
  </div>
</template>

Size

size="{size}" - change the size of the input.

๐Ÿš€ You can freely adjust the size of the input using any size imaginable. No limits exist, and you can use breakpoints such as sm:sm, xs:lg to change size based on screen size or states such as hover:lg, focus:3xl to change size based on input state and more.

The padding, icons, and text-size of the input scale depends on the size. If you want to change the text-size and padding simultaneously, you can always customize it using utility classes.
<template>
  <div class="grid cols-1 gap-4 sm:cols-2">
    <NInput
      size="xs"
      placeholder="This is extra small size"
    />

    <NInput
      size="sm"
      placeholder="This is small size"
    />

    <NInput
      size="md"
      placeholder="This is base or md size (default)"
    />

    <NInput
      size="2xl"
      placeholder="This is 2xl size"
    />

    <NInput
      size="lg"
      class="rounded-full"
      placeholder="This is custom"
    />

    <NInput
      size="26px"
      placeholder="This is custom"
      class="rounded-none"
    />

    <NInput
      :una="{
        inputWrapper: 'sm:col-span-2',
      }"
      size="6rem"
      placeholder="This is 6rem size"
    />
  </div>
</template>

Icon

trailing="{icon}" - add a trailing icon to the input outline.

leading="{icon}" - add a leading icon to the input outline.

By default we use heroicons and tabler for the icons, you can use any icon provided by Iconify through icones, refer to configuration for more information.
<template>
  <div class="grid cols-1 gap-4 sm:cols-2">
    <div class="sm:col-span-1">
      <NInput
        leading="i-heroicons-magnifying-glass-20-solid"
        placeholder="This is leading icon"
      />
    </div>

    <div class="sm:col-span-1">
      <NInput
        trailing="i-heroicons-question-mark-circle-20-solid text-primary"
        placeholder="This is trailing icon with custom class"
      />
    </div>

    <div class="sm:col-span-2">
      <NInput
        input="outline-purple"
        size="1.3rem"
        leading="i-heroicons-paper-clip-20-solid"
        trailing="i-heroicons-chat-bubble-left-ellipsis-20-solid"
        :una="{
          inputLeading: 'text-yellow',
          inputTrailing: 'text-blue',
        }"
        placeholder="You can also use una to add custom class"
      />
    </div>
  </div>
</template>

Loading

loading - add a loading icon to the input outline.

<template>
  <div class="grid cols-1 gap-4 sm:cols-2">
    <NInput
      disabled
      loading
      placeholder="This is the disabled variant with loading indicator"
    />

    <NInput
      :una="{
        inputLoading: 'text-lime',
        inputLoadingIcon: 'i-tabler-fidget-spinner',
      }"
      loading
      placeholder="Custom color loading icon"
    />

    <NInput
      :una="{
        inputLoading: 'text-rose animate-none',
        inputLoadingIcon: 'i-svg-spinners-blocks-shuffle-3',
      }"
      loading
      reverse
      placeholder="Loading icon is on the left side"
    />

    <NInput
      :una="{
        inputLoading: 'animate-pulse text-yellow',
        inputLoadingIcon: 'i-heroicons-ellipsis-horizontal-20-solid',
      }"
      loading
      placeholder="This is possible too"
    />
  </div>
</template>

Status

status="{status}" - change the status of the input outline.

<template>
  <div class="grid gap-4 sm:cols-2">
    <NInput
      status="error"
      placeholder="This is the outline variant with error status"
    />
    <NInput
      type="email"
      status="success"
      placeholder="This is the outline variant with success status"
    />
    <NInput
      type="email"
      status="warning"
      placeholder="This is the outline variant with warning status"
    />
    <NInput
      type="email"
      status="info"
      placeholder="This is the outline variant with info status"
    />
  </div>
</template>

disabled - disable the input.

readonly - make the input readonly.

<template>
  <div class="grid gap-4 sm:cols-2">
    <NInput
      disabled
      placeholder="You can't click here (disabled)"
    />

    <NInput
      readonly
      placeholder="You can't type here (readonly)"
    />
  </div>
</template>

Events

@leading - emit an event when the leading icon is clicked.

@trailing - emit an event when the trailing icon is clicked.

By default, the leading and trailing are wrapped around pointer-events-none class, if you want to remove this behavior, you can use pointer-events-auto class.
<script setup lang="ts">
function click(description: string) {
  // eslint-disable-next-line no-alert
  alert(description)
}

const isPasswordVisible = ref(false)
</script>

<template>
  <div class="grid cols-1 gap-4 sm:cols-2">
    <NInput
      :type="isPasswordVisible ? 'text' : 'password'"
      :trailing="isPasswordVisible ? 'i-heroicons-eye-20-solid' : 'i-heroicons-eye-slash-20-solid'"
      :una="{
        inputTrailing: 'pointer-events-auto cursor-pointer',
      }"
      model-value="Password"
      @trailing="isPasswordVisible = !isPasswordVisible"
    />

    <NInput
      input="outline-purple"
      leading="i-heroicons-hand-thumb-up-20-solid"
      trailing="i-heroicons-arrow-down-tray-20-solid "
      :una="{
        inputLeading: 'active:scale-120 text-blue pointer-events-auto cursor-pointer active:text-green',
        inputTrailing: 'active:scale-90 text-yellow pointer-events-auto cursor-pointer active:text-lime',
      }"
      placeholder="Leading and trailing icons are clickable"
      @leading="click('leading icon is clicked')"
      @trailing="click('trailing icon is clicked')"
    />
  </div>
</template>

Slots

Leading

#leading - add a leading slot to the input.

<template>
  <div class="flex">
    <NInput
      placeholder="Search"
      trailing="i-heroicons-chat-bubble-left-right-20-solid"
      class="pl-12"
    >
      <template #leading>
        <!-- TODO convert to NAvatar soon -->
        <div class="rounded-full bg-base">
          <img
            class="h-6"
            src="https://avatars.githubusercontent.com/u/33350692?s=400&u=49395c835e8197ae2ee42ca02c95e828d8f64239&v=4"
          >
        </div>
      </template>
    </NInput>
  </div>
</template>

Trailing

#trailing - add a trailing slot to the input.

USD
<template>
  <div class="flex">
    <NInput
      leading="i-heroicons-currency-dollar-20-solid"
      placeholder="Search"
      class="pr-12"
    >
      <template #trailing>
        <span class="text-sm">
          USD
        </span>
      </template>
    </NInput>
  </div>
</template>

Props

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

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.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-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 type { NInputProps } from '../../types'
import { computed, onMounted, ref } from 'vue'
import { 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="[
        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>