GrInput
Берут, когда вводится строка.
Когда брать
- вводится строка — имя, адрес, поисковый запрос, пароль: базовый контрол формы;
- у поля есть аддоны — единица измерения, префикс протокола, иконка поиска через слоты;
- длина ограничена —
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
type | "number" | "search" | "text" | "email" | "password" | "tel" | "url" | undefined | "text" | — |
modelValue | string | undefined | "" | Значение поля. Необязательное: без `v-model` поле рисуется пустым. Дефолт — пустая строка, а не `undefined`: длина значения читается напрямую (`showClear`), и `undefined` уронил бы рендер. |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно и выделяемо, но не редактируется. |
invalid | boolean | undefined | false | — |
required | boolean | undefined | false | Обязательное поле (`aria-required`). Складывается с `required` у `GrFormField`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Доступное имя вне `GrFormField`. |
clearable | boolean | undefined | undefined | Показывать кнопку очистки, когда есть значение (и не disabled/readonly). |
loading | boolean | undefined | false | Фоновая работа по полю (проверка занятости логина, автосохранение): спиннер в trailing-области + `aria-busy`. Ввод не блокируется — для этого есть `disabled`/`readonly`. |
clearLabel | string | undefined | undefined | i18n aria-label кнопки очистки. |
name | string | undefined | undefined | — |
prefixMinWidth | string | undefined | undefined | — |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | Фиксированная ширина у prefix/suffix: аддон получает жёсткую ширину (из `*MaxWidth` → `*MinWidth` → дефолт), а контент обрезается по краю (prefix — справа, suffix — слева). По умолчанию аддоны «растягиваются» под контент (в пределах min/max), а излишек клипается оболочкой. |
suffixFixed | boolean | undefined | false | — |
id | string | undefined | undefined | — |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | — |
autocomplete | string | undefined | undefined | — |
inputmode | "search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefined | undefined | — |
maxlength | number | undefined | undefined | Ограничение длины + основа для счётчика символов. |
showCount | boolean | undefined | false | Показывать счётчик символов (`len` или `len/maxlength`). |
passwordToggle | boolean | undefined | false | Кнопка показать/скрыть пароль (только при `type="password"`). |
passwordShowLabel | string | undefined | undefined | i18n aria-label кнопки показать/скрыть пароль. |
passwordHideLabel | string | undefined | undefined | — |
textAlign | GrInputTextAlign | undefined | "left" | — |
addon | "inline" | "segment" | undefined | "segment" | Как выглядят аддоны `#prefix`/`#suffix`. `segment` (по умолчанию) — отдельный отсек, отрезанный рамкой и выровненный по ступени размера: так поле с «₽» и поле с «USD» стоят в колонку. `inline` — украшение внутри рамки: ни разделителя, ни своей ширины. Разница не косметическая. Поисковая строка с лупой в сегменте читается составным элементом — полем с приклеенной кнопкой, — а не одним полем; именно поэтому иконку внутри рамки нельзя было выразить аддоном, и потребители отказывались от неё вовсе. |
Slots
| Slot | Type | Описание |
|---|---|---|
prefix | any | Аддон слева от поля: иконка, код валюты, метка. |
suffix | any | Аддон справа от поля: единица измерения, подсказка. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: string] | — |
change | [value: string] | — |
clear | [] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
select | () => void | — |
Примеры 7
Иконка внутри поля
addon="inline" рисует #prefix/#suffix внутри рамки: без разделителя и без собственной ширины. Режим по умолчанию — segment: отдельный отсек, выровненный по ступени размера, каким его знают денежные поля.
addon="segment" — режим по умолчанию
<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="segment" — режим по умолчанию">
<GrInput v-model="price" placeholder="0">
<template #suffix>₽</template>
</GrInput>
</GrFormField>
</div>
</template>События поля и фоновая проверка
@change по blur/Enter, отдельный @clear для очистки кнопкой, loading под асинхронную проверку и focus()/select() через ref.
Проверка занятости уходит по blur или Enter
<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 оболочки.
<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>Аддоны слева и справа
Статичные add-on-слоты (валюта, единицы измерения) внутри поля — общий layout поля при этом не меняется.
<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 — растягивается под контент. Два поля слева реактивно управляют содержимым аддонов.
Fixed: аддоны держат заданную ширину, лишний текст обрезается — prefix с правого края, suffix с левого. Контент никогда не вылезает за рамки поля.
<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-состояние. Метки кнопок локализованы.
<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, а выравнивание текста настраивается отдельно от размера.
<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и возвращают фокус в поле)