GrInput

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда вводится строка.

Когда брать

  • вводится строка — имя, адрес, поисковый запрос, пароль: базовый контрол формы;
  • у поля есть аддоны — единица измерения, префикс протокола, иконка поиска через слоты;
  • длина ограниченаmaxlength со счётчиком символов вместо молчаливого обрезания;
  • в поле идёт фоновая работа — индикатор занятости, не блокируя ввод.

Когда взять другое

НужноБерите
Текст в несколько строкGrTextarea
Вводится число со ступенямиGrNumberInput
Значений несколько, они чипамиGrInputTag
Выбор из готовых вариантовGrSelect
Поиск с подсказкамиGrAutocomplete
Дата или времяGrDatePicker

Trailing-контролы достижимы с клавиатуры

Кнопки очистки и показа пароля стоят в обычном таб-порядке: Tab из поля ведёт на крестик, затем на глаз. Раньше на них висел tabindex="-1" — виджет был объявлен доступным (aria-label, aria-pressed), но воспользоваться им без мыши было нельзя.

Обе кнопки после нажатия возвращают фокус в поле, поэтому очистка не роняет фокус в никуда: кнопка исчезает вместе с опустевшим значением.

Аддоны: сегмент или украшение

#prefix и #suffix рисуются в двух видах, и это разные сущности, а не оформление одного.

segment (по умолчанию) — отдельный отсек, отрезанный рамкой и выровненный по ступени размера: поле с «₽» и поле с «USD» стоят в колонку, а не пляшут по ширине. Это то, чего ждут от денежного поля и поля с единицей измерения.

addon="inline" — украшение внутри рамки: ни разделителя, ни собственной ширины. Лупа поисковой строки, символ валюты, счётчик. В сегменте лупа читалась бы полем с приклеенной кнопкой, поэтому иконку внутри рамки нельзя было выразить аддоном вовсе — от неё просто отказывались.

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

Отступ поля считается измеренной шириной аддона в обоих режимах: текст начинается сразу за украшением, а не в пустом отсеке. prefixFixed/suffixFixed и *MinWidth/*MaxWidth работают как раньше — они про сегмент.

Счётчик символов

showCount рисует 12 / 60 (или просто длину, если maxlength не задан) и связывает счётчик с полем через aria-describedby — при фокусе диктор читает остаток вместе с подписью.

Исчерпание лимита дополнительно объявляется живым регионом role="status" (gr.input.limitReached). Регион молчит, пока лимит не выбран: читать вслух каждый символ — гарантированный способ сделать поле неюзабельным для SR.

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

События

СобытиеКогда
update:modelValueкаждый ввод
changeзначение зафиксировано: нативный change (по blur или Enter) или кнопка очистки
focus / blurфокус пришёл/ушёл, аргумент — FocusEvent
clearзначение стёрто кнопкой очистки

clear существует отдельно от update:modelValue потому, что по одному лишь значению программную очистку от ручного стирания не отличить — а реакция формы на них обычно разная.

Очистка кнопкой шлёт все три события подряд: update:modelValue, change, clear. Она такая же фиксация значения, как уход фокуса, — подписка ради «значение установилось» пропускала бы ровно её. Нативный аналог ведёт себя так же: крестик у <input type="search"> шлёт и input, и change.

Императивный API

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

field.value отдаёт focus(), blur() и select(). Последний — спутник focus() для сценария «подставили значение, дайте перезаписать».

`loading`

Спиннер в trailing-области плюс aria-busy на поле: проверка занятости логина, автосохранение, догрузка справочника. Ввод при этом не блокируется — для запрета есть disabled и readonly. Спиннер резервирует место справа наравне с кнопками, поэтому текст под него не уезжает.

Состояния и токены

state (success / warning / danger) красит рамку и кольцо фокуса; invalid (свой проп или ошибка из GrFormField) всегда приводит к danger-виду.

Заблокированное поле гасится фоном --gr-muted с текстом --gr-muted-fg, а не прозрачностью: opacity разбавляет выверенные на AA токены текста и роняет контраст.

Классы размеров, выравнивания и состояний живут в grInputStyles.ts и целиком объявлены в safelist — вместе с горизонтальным padding’ом, который тому же размеру нужен и числом (аддоны задают паддинги инлайн-стилем, а он перекрывает класс).

`type`

text, email, password, number, search, tel, url. Последние два меняют экранную клавиатуру на мобильных и включают браузерную валидацию формата.

Playground 29

Загружается…

Код
<GrInput />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
type"number" | "search" | "text" | "email" | "password" | "tel" | "url" | undefined"text"
modelValuestring | undefined""Значение поля. Необязательное: без `v-model` поле рисуется пустым. Дефолт — пустая строка, а не `undefined`: длина значения читается напрямую (`showClear`), и `undefined` уронил бы рендер.
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно и выделяемо, но не редактируется.
invalidboolean | undefinedfalse
requiredboolean | undefinedfalseОбязательное поле (`aria-required`). Складывается с `required` у `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedДоступное имя вне `GrFormField`.
clearableboolean | undefinedundefinedПоказывать кнопку очистки, когда есть значение (и не disabled/readonly).
loadingboolean | undefinedfalseФоновая работа по полю (проверка занятости логина, автосохранение): спиннер в trailing-области + `aria-busy`. Ввод не блокируется — для этого есть `disabled`/`readonly`.
clearLabelstring | undefinedundefinedi18n aria-label кнопки очистки.
namestring | undefinedundefined
prefixMinWidthstring | undefinedundefined
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalseФиксированная ширина у prefix/suffix: аддон получает жёсткую ширину (из `*MaxWidth` → `*MinWidth` → дефолт), а контент обрезается по краю (prefix — справа, suffix — слева). По умолчанию аддоны «растягиваются» под контент (в пределах min/max), а излишек клипается оболочкой.
suffixFixedboolean | undefinedfalse
idstring | undefinedundefined
state"default" | "success" | "warning" | "danger" | undefined"default"
autocompletestring | undefinedundefined
inputmode"search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefinedundefined
maxlengthnumber | undefinedundefinedОграничение длины + основа для счётчика символов.
showCountboolean | undefinedfalseПоказывать счётчик символов (`len` или `len/maxlength`).
passwordToggleboolean | undefinedfalseКнопка показать/скрыть пароль (только при `type="password"`).
passwordShowLabelstring | undefinedundefinedi18n aria-label кнопки показать/скрыть пароль.
passwordHideLabelstring | undefinedundefined
textAlignGrInputTextAlign | undefined"left"
addon"inline" | "segment" | undefined"segment"Как выглядят аддоны `#prefix`/`#suffix`. `segment` (по умолчанию) — отдельный отсек, отрезанный рамкой и выровненный по ступени размера: так поле с «₽» и поле с «USD» стоят в колонку. `inline` — украшение внутри рамки: ни разделителя, ни своей ширины. Разница не косметическая. Поисковая строка с лупой в сегменте читается составным элементом — полем с приклеенной кнопкой, — а не одним полем; именно поэтому иконку внутри рамки нельзя было выразить аддоном, и потребители отказывались от неё вовсе.

Slots

SlotTypeОписание
prefixanyАддон слева от поля: иконка, код валюты, метка.
suffixanyАддон справа от поля: единица измерения, подсказка.

Events

EventTypeОписание
update:modelValue[value: string]
change[value: string]
clear[]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeОписание
focus() => void
blur() => void
select() => void

Примеры 7

Иконка внутри поля

addon="inline" рисует #prefix/#suffix внутри рамки: без разделителя и без собственной ширины. Режим по умолчанию — segment: отдельный отсек, выровненный по ступени размера, каким его знают денежные поля.

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>

События поля и фоновая проверка

@change по blur/Enter, отдельный @clear для очистки кнопкой, loading под асинхронную проверку и focus()/select() через ref.

Проверка занятости уходит по 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>

Состояния проверки и нативные типы поля

Одна карточка показывает сразу базовый текстовый сценарий, email-валидацию и search-mode, чтобы было видно native-поведение без потери design-system оболочки.

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('[email protected]')
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="[email protected]"
          :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>

Аддоны слева и справа

Статичные add-on-слоты (валюта, единицы измерения) внутри поля — общий layout поля при этом не меняется.

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>

Слоты аддонов: фиксированный и растяжимый

Длинный контент в prefix/suffix больше не вылезает за рамки: в fixed-режиме аддон держит ширину и обрезает контент (prefix — справа, suffix — слева), в 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), переключатель видимости пароля (passwordToggle), счётчик символов с maxlength (showCount) и 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>

Шкала размеров и выравнивание текста

Показываем, что GrInput умеет жить и в компактных toolbars, и в крупных form-layout, а выравнивание текста настраивается отдельно от размера.

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>

Доступность

Паттерн APG
Клавиши
Tab из поля — на кнопку очистки, затем на переключатель пароля (обе активируются Enter/Space и возвращают фокус в поле)

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты