GrInput

Package: @feugene/granularitycoreGroup: forms

A single-line field for text, search and short values.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a string is entered — a name, an address, a search query, a password: the basic control of a form;
  • the field has addons — a unit of measurement, the prefix of a protocol, a search icon through the slots;
  • the length is limitedmaxlength with a character counter instead of silent truncation;
  • background work is going on in the field — a busy indicator that does not block typing.

When to take something else

NeedTake
Text across several linesGrTextarea
A number with steps is enteredGrNumberInput
There are several values, as chipsGrInputTag
A choice among ready optionsGrSelect
A search with suggestionsGrAutocomplete
A date or a timeGrDatePicker

The trailing controls are reachable from the keyboard

The clear and show-password buttons stand in the ordinary tab order: Tab from the field leads to the cross, then to the eye. They used to carry tabindex="-1" — the widget was declared accessible (aria-label, aria-pressed), but there was no using it without a mouse.

Both buttons return the focus to the field after being pressed, so clearing does not drop the focus into nowhere: the button disappears together with the emptied value.

Addons: a segment or an ornament

#prefix and #suffix are drawn in two forms, and those are different entities rather than the styling of one.

segment (the default) is a compartment of its own, cut off by a border and aligned to the size step: a field with ”₽” and a field with “USD” stand in a column instead of dancing by width. That is what is expected of a money field and of a field with a unit.

addon="inline" is an ornament inside the border: no separator, no width of its own. The magnifier of a search bar, a currency symbol, a counter. In a segment the magnifier would read as a field with a button glued to it, so an icon inside the border could not be expressed with an addon at all — people simply gave it up.

<GrInput v-model="query" addon="inline">
  <template #prefix>
    <GrIcon size="sm"><span class="i-lucide-search" /></GrIcon>
  </template>
</GrInput>

The padding of the field is computed from the measured width of the addon in both modes: the text starts right after the ornament rather than in an empty compartment. prefixFixed/suffixFixed and *MinWidth/*MaxWidth work as before — they are about the segment.

The character counter

showCount draws 12 / 60 (or just the length, if maxlength is not set) and links the counter to the field through aria-describedby — on focus a screen reader reads the remainder together with the label.

Exhausting the limit is additionally announced by a role="status" live region (gr.input.limitReached). The region stays silent until the limit is reached: reading every character aloud is a guaranteed way to make the field unusable for a screen reader.

<GrInput v-model="bio" :maxlength="60" show-count clearable />

The events

EventWhen
update:modelValueon every keystroke
changethe value is committed: the native change (on blur or Enter) or the clear button
focus / blurthe focus arrived/left, the argument is a FocusEvent
clearthe value was erased with the clear button

clear exists separately from update:modelValue because by the value alone a programmatic clearing cannot be told from a manual erasure — and the reaction of a form to them is usually different.

Clearing with the button sends all three events in a row: update:modelValue, change, clear. It is as much a commitment of the value as the departure of the focus — a subscription for “the value has been set” would miss exactly it. The native equivalent behaves the same way: the cross of an <input type="search"> sends both input and change.

The imperative API

<GrInput ref="field" v-model="value" />

field.value gives away focus(), blur() and select(). The last one is the companion of focus() for the “the value has been filled in, let it be overwritten” scenario.

`loading`

A spinner in the trailing area plus aria-busy on the field: checking whether a login is taken, autosaving, loading a reference list. Typing is not blocked in the process — for a prohibition there are disabled and readonly. The spinner reserves room on the right on a par with the buttons, so the text does not move under it.

The states and the tokens

state (success / warning / danger) colours the border and the focus ring; invalid (its own prop or an error from GrFormField) always leads to the danger look.

A blocked field is dimmed with the --gr-muted background and --gr-muted-fg text rather than with transparency: opacity dilutes text tokens tuned to AA and lowers the contrast.

The classes of the sizes, the alignment and the states live in grInputStyles.ts and are declared in the safelist as a whole — together with the horizontal padding, which the same size also needs as a number (the addons set the padding with an inline style, and that overrides a class).

`type`

text, email, password, number, search, tel, url. The last two change the on-screen keyboard on mobile and switch on the browser’s validation of the format.

Playground 29

Loading…

Code
<GrInput />

Install

npm i @feugene/granularity

Import

import { GrInput } from '@feugene/granularity/components/GrInput'

API

Props

PropTypedefaultDescription
type"number" | "search" | "text" | "email" | "password" | "tel" | "url" | undefined"text"
modelValuestring | undefined""The value of the field. Optional: without a `v-model` the field is drawn empty. The default is an empty string rather than `undefined`: the length of the value is read directly (`showClear`), and an `undefined` would crash the render.
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead-only: the value is visible and selectable, but is not edited.
invalidboolean | undefinedfalse
requiredboolean | undefinedfalseA required field (`aria-required`). It adds to the `required` of `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`.
clearableboolean | undefinedundefinedShow the clear button when there is a value (and it is not disabled/readonly).
loadingboolean | undefinedfalseBackground work on the field (checking whether a login is taken, autosaving): a spinner in the trailing area plus `aria-busy`. Typing is not blocked — for that there are `disabled`/`readonly`.
clearLabelstring | undefinedundefinedThe i18n aria-label of the clear button.
namestring | undefinedundefined
prefixMinWidthstring | undefinedundefined
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalseA fixed width for the prefix/suffix: the addon gets a rigid width (from `*MaxWidth` → `*MinWidth` → the default), and the content is clipped at the edge (the prefix on the right, the suffix on the left). By default the addons "stretch" to the content (within min/max), and the excess is clipped by the shell.
suffixFixedboolean | undefinedfalse
idstring | undefinedundefined
state"default" | "success" | "warning" | "danger" | undefined"default"
autocompletestring | undefinedundefined
inputmode"search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefinedundefined
maxlengthnumber | undefinedundefinedA limit on the length plus the basis for the character counter.
showCountboolean | undefinedfalseShow the character counter (`len` or `len/maxlength`).
passwordToggleboolean | undefinedfalseA show/hide password button (only with `type="password"`).
passwordShowLabelstring | undefinedundefinedThe i18n aria-label of the show/hide password button.
passwordHideLabelstring | undefinedundefined
textAlignGrInputTextAlign | undefined"left"
addon"inline" | "segment" | undefined"segment"How the `#prefix`/`#suffix` addons look. `segment` (the default) is a compartment of its own, cut off by a border and aligned to the size step: that way a field with "₽" and a field with "USD" stand in a column. `inline` is an ornament inside the border: no separator, no width of its own. The difference is not cosmetic. A search bar with a magnifier in a segment reads as a composite element — a field with a button glued to it — rather than as one field; that is exactly why an icon inside the border could not be expressed with an addon, and consumers gave it up entirely.

Slots

SlotTypeDescription
prefixanyAn addon to the left of the field: an icon, a currency code, a label.
suffixanyAn addon to the right of the field: a unit of measurement, a hint.

Events

EventTypeDescription
update:modelValue[value: string]
change[value: string]
clear[]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void
blur() => void
select() => void

Examples 7

Icon inside the field

addon="segment" — режим по умолчанию

Inline Addon
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrIcon, GrInput } from '@feugene/granularity'

const query = ref('')
const price = ref('1 290')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <!--
      Украшение внутри рамки: у поисковой строки лупа обязана читаться как часть
      поля. Сегмент отрезал бы её рамкой, и строка выглядела бы полем с
      приклеенной кнопкой.
    -->
    <GrFormField label="Поиск">
      <GrInput v-model="query" addon="inline" placeholder="Название или артикул">
        <template #prefix>
          <GrIcon size="sm">
            <span class="i-lucide-search" />
          </GrIcon>
        </template>
      </GrInput>
    </GrFormField>

    <GrFormField label="Цена">
      <GrInput v-model="price" addon="inline" placeholder="0">
        <template #suffix></template>
      </GrInput>
    </GrFormField>

    <!-- Тот же слот в режиме по умолчанию — для сравнения. -->
    <GrFormField label="Цена сегментом" hint="addon=&quot;segment&quot; — режим по умолчанию">
      <GrInput v-model="price" placeholder="0">
        <template #suffix></template>
      </GrInput>
    </GrFormField>
  </div>
</template>

Events

Проверка занятости уходит по blur или Enter

0 / 24
Журнал событий пуст — поставьте фокус в поле.

Events
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrFormField, GrInput } from '@feugene/granularity'

type GrInputInstance = InstanceType<typeof GrInput>

const login = ref('')
const checking = ref(false)
const log = ref<string[]>([])

const field = ref<GrInputInstance>()

// Поколение проверки: очистка журнала обязана обесценить уже запущенный запрос.
// Без этого «Очистить» гасил журнал, а через 900 мс проверка дописывала в него
// свой результат — со стороны это выглядело как «журнал не очищается».
let checkRun = 0

function note(entry: string): void {
  log.value = [entry, ...log.value].slice(0, 4)
}

function clearLog(): void {
  checkRun += 1
  checking.value = false
  log.value = []
}

// `change` приходит по blur/Enter — момент, когда значение можно проверять.
async function onChange(value: string): Promise<void> {
  note(`change: ${value || ''}`)

  if (!value)
    return

  const run = ++checkRun
  checking.value = true
  await new Promise(resolve => setTimeout(resolve, 900))

  // Журнал успели очистить (или начали новую проверку) — результат устарел.
  if (run !== checkRun)
    return

  checking.value = false
  note(`проверен: ${value}`)
}

function prefill(): void {
  login.value = 'granularity'
  field.value?.focus()
  field.value?.select()
}
</script>

<template>
  <div class="grid gap-4">
    <GrFormField label="Логин" hint="Проверка занятости уходит по blur или Enter">
      <GrInput
        ref="field"
        v-model="login"
        :loading="checking"
        clearable
        :maxlength="24"
        show-count
        placeholder="ваш-логин"
        @change="onChange"
        @clear="note('clear: очищено кнопкой')"
        @focus="note('focus')"
        @blur="note('blur')"
      />
    </GrFormField>

    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" variant="outline" @click="prefill">
        Подставить и выделить
      </GrButton>
      <GrButton size="sm" variant="ghost" :disabled="!log.length" @click="clearLog">
        Очистить журнал
      </GrButton>
    </div>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      <div v-if="!log.length">
        Журнал событий пуст — поставьте фокус в поле.
      </div>
      <!-- Ключ по индексу: одинаковые записи (`focus`, `focus`) дают дубль ключа. -->
      <div v-for="(entry, index) in log" :key="index">
        {{ entry }}
      </div>
    </div>
  </div>
</template>

Validation states and native input types

Validation toggle
Search query: —

States
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrInput, GrSwitch } from '@feugene/granularity'

const displayName = ref('Ada Lovelace')
const email = ref('ops@granularity.dev')
const search = ref('')
const invalid = ref(false)
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
    <div class="grid gap-3">
      <GrFormField label="Display name">
        <GrInput v-model="displayName" placeholder="Ada Lovelace" />
      </GrFormField>

      <GrFormField label="Work email" :error="invalid ? 'Use a valid email address' : undefined">
        <GrInput
          v-model="email"
          type="email"
          placeholder="name@example.com"
          :invalid="invalid"
          :state="invalid ? 'danger' : 'success'"
        />
      </GrFormField>

      <GrFormField label="Search input">
        <GrInput
          v-model="search"
          type="search"
          placeholder="Search components"
        />
      </GrFormField>
    </div>

    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Validation toggle
      </div>
      <GrSwitch v-model="invalid" size="sm">
        Show invalid email state
      </GrSwitch>
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Search query: {{ search || '—' }}
      </div>
    </div>
  </div>
</template>

Prefix and suffix add-ons

Addons
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrInput } from '@feugene/granularity'

const amount = ref('12 540')
const weight = ref('68')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <GrFormField label="Currency input">
      <GrInput v-model="amount" placeholder="0.00">
        <template #prefix></template>
        <template #suffix>RUB</template>
      </GrInput>
    </GrFormField>

    <GrFormField label="Unit add-on">
      <GrInput v-model="weight" placeholder="0">
        <template #prefix>Weight</template>
        <template #suffix>kg</template>
      </GrInput>
    </GrFormField>
  </div>
</template>

Add-on slots: fixed (clip) vs stretch

Target field

Fixed: аддоны держат заданную ширину, лишний текст обрезается — prefix с правого края, suffix с левого. Контент никогда не вылезает за рамки поля.

Addon Slots
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrInput, GrSwitch } from '@feugene/granularity'

// Два поля управляют содержимым prefix и suffix целевого инпута — реактивно.
const prefixText = ref('International account')
const suffixText = ref('Primary settlement account')
const targetValue = ref('DE89 3704 0044 0532 0130 00')
const fixed = ref(true)
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid gap-3">
      <GrFormField label="Prefix content">
        <GrInput v-model="prefixText" placeholder="Prefix text" />
      </GrFormField>

      <GrFormField label="Suffix content">
        <GrInput v-model="suffixText" placeholder="Suffix text" />
      </GrFormField>

      <GrSwitch v-model="fixed" size="sm">
        Fixed width (clip content) — off = stretch to content
      </GrSwitch>
    </div>

    <div class="grid content-start gap-2">
      <div class="text-xs font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
        Target field
      </div>

      <GrInput
        v-model="targetValue"
        placeholder="IBAN"
        :prefix-fixed="fixed"
        :suffix-fixed="fixed"
        prefix-max-width="7rem"
        suffix-max-width="8rem"
      >
        <template #prefix>{{ prefixText }}</template>
        <template #suffix>{{ suffixText }}</template>
      </GrInput>

      <p class="text-sm text-[var(--gr-muted-fg)]">
        <template v-if="fixed">
          Fixed: аддоны держат заданную ширину, лишний текст обрезается — prefix с правого
          края, suffix с левого. Контент никогда не вылезает за рамки поля.
        </template>
        <template v-else>
          Stretch: аддоны растягиваются под контент (в пределах max-width), а всё лишнее
          аккуратно клипается оболочкой поля.
        </template>
      </p>
    </div>
  </div>
</template>

Clearable, password toggle, counter and readonly

22 / 60

Enhancements
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrInput } from '@feugene/granularity'

const search = ref('Granularity')
const bio = ref('Design-system engineer')
const password = ref('s3cr3t-pass')
const token = ref('sk-live-4f2a90e2f')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <GrFormField label="Clearable">
      <GrInput v-model="search" clearable placeholder="Type to search" />
    </GrFormField>

    <GrFormField label="Password with visibility toggle">
      <GrInput v-model="password" type="password" password-toggle />
    </GrFormField>

    <GrFormField label="Character counter (maxlength)">
      <GrInput v-model="bio" :maxlength="60" show-count clearable />
    </GrFormField>

    <GrFormField label="Read-only">
      <GrInput v-model="token" readonly />
    </GrFormField>
  </div>
</template>

Size scale and text alignment

Text alignment
xs
sm
md
lg

Sizing
<script setup lang="ts">
import { ref } from 'vue'

import { GrInput, GrRadioGroup } from '@feugene/granularity'

const alignment = ref<'left' | 'center' | 'right'>('left')
const alignmentOptions = [
  { label: 'Left', value: 'left' },
  { label: 'Center', value: 'center' },
  { label: 'Right', value: 'right' },
]

const sizeValues = {
  xs: ref('xs size'),
  sm: ref('sm size'),
  md: ref('md size'),
  lg: ref('lg size'),
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-2 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <div class="showcase-demo-title text-sm font-semibold">
        Text alignment
      </div>
      <GrRadioGroup
        v-model="alignment"
        :options="alignmentOptions"
        variant="button"
        size="sm"
      />
      <GrInput
        :model-value="`Aligned to ${alignment}`"
        :text-align="alignment"
        placeholder="Editable content"
      />
    </div>

    <div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
      <div class="grid gap-2">
        <div class="showcase-demo-caption text-xs">xs</div>
        <GrInput v-model="sizeValues.xs.value" size="xs" placeholder="Extra small" />
      </div>
      <div class="grid gap-2">
        <div class="showcase-demo-caption text-xs">sm</div>
        <GrInput v-model="sizeValues.sm.value" size="sm" placeholder="Small" />
      </div>
      <div class="grid gap-2">
        <div class="showcase-demo-caption text-xs">md</div>
        <GrInput v-model="sizeValues.md.value" size="md" placeholder="Medium" />
      </div>
      <div class="grid gap-2">
        <div class="showcase-demo-caption text-xs">lg</div>
        <GrInput v-model="sizeValues.lg.value" size="lg" placeholder="Large" />
      </div>
    </div>
  </div>
</template>

Accessibility

APG pattern

Full keyboard contract of the package

Component documentationAll components