๐ŸŸข 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 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 grid="~ sm:cols-2" gap-4>
  <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 flex="~ col" gap-4>
  <span class="text-sm font-medium">Dynamic colors:</span>

  <div grid="~ sm:cols-2" gap-4>
    <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>

  <hr border="base">

  <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 grid="~ cols-1 sm:cols-2" gap-4>
  <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 grid="~ cols-1 sm:cols-2" gap-4>
  <div sm:col-span-1>
    <NInput
      leading="i-heroicons-magnifying-glass-20-solid"
      placeholder="This is leading icon"
    />
  </div>

  <div 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 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 grid="~ cols-1 sm:cols-2" gap-4>
  <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 grid="~ sm:cols-2" gap-4>
  <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 grid="~ sm:cols-2" gap-4>
  <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 grid="~ cols-1 sm:cols-2" gap-4>
  <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 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 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' | '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>