Стандарты
Высокое качество UI/UX
Стандарт определяет обязательные требования к качеству интерфейса и пользовательского опыта.
Стандарт определяет обязательные требования к качеству интерфейса и пользовательского опыта.
Базовая вёрстка (UI)
Правило: Элементы интерфейса должны корректно заполнять доступное пространство и выравниваться.
Input в карточках
<!-- ✅ Хорошо: input растягивается на всю ширину -->
<UCard>
<UFormField label="Email">
<UInput v-model="email" class="w-full" />
</UFormField>
</UCard>
<!-- ❌ Плохо: input не растянут -->
<UCard>
<UFormField label="Email">
<UInput v-model="email" />
</UFormField>
</UCard>
Формы в карточках
<!-- ✅ Хорошо: форма занимает всю ширину карточки -->
<UCard>
<UForm :state="form" class="space-y-4">
<UFormField label="Название">
<UInput v-model="form.name" class="w-full" />
</UFormField>
<UFormField label="Описание">
<UTextarea v-model="form.description" class="w-full" />
</UFormField>
<div class="flex justify-end gap-2">
<UButton variant="ghost">Отмена</UButton>
<UButton type="submit">Сохранить</UButton>
</div>
</UForm>
</UCard>
Flex-контейнеры с input
<!-- ✅ Хорошо: input растягивается в flex -->
<div class="flex gap-2">
<UInput v-model="search" placeholder="Поиск..." class="flex-1" />
<UButton>Найти</UButton>
</div>
<!-- ❌ Плохо: input фиксированной ширины -->
<div class="flex gap-2">
<UInput v-model="search" placeholder="Поиск..." />
<UButton>Найти</UButton>
</div>
Grid-сетки
<!-- ✅ Хорошо: элементы в grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Имя">
<UInput v-model="form.firstName" class="w-full" />
</UFormField>
<UFormField label="Фамилия">
<UInput v-model="form.lastName" class="w-full" />
</UFormField>
</div>
Типичные ошибки вёрстки
| Проблема | Причина | Решение |
|---|---|---|
| Input не растянут | Нет w-full или flex-1 | Добавить class="w-full" |
| Кнопки не выровнены | Нет flex контейнера | <div class="flex justify-end gap-2"> |
| Контент выходит за карточку | overflow | class="overflow-hidden" на карточке |
| Элементы слиплись | Нет spacing | class="space-y-4" на контейнере |
| Текст не переносится | Длинное слово | class="break-words" |
Консистентные отступы
<!-- ✅ Хорошо: стандартные отступы -->
<UCard>
<template #header>
<h3 class="font-semibold">Заголовок</h3>
</template>
<div class="space-y-4">
<!-- контент -->
</div>
<template #footer>
<div class="flex justify-end gap-2">
<!-- кнопки -->
</div>
</template>
</UCard>
Чек-лист UI
- Все
UInput,UTextarea,USelectимеютclass="w-full"илиflex-1 - Формы используют
space-y-4для отступов между полями - Кнопки действий в
flex justify-end gap-2 - Grid-сетки адаптивны (
grid-cols-1 md:grid-cols-2) - Нет горизонтального скролла на мобильных
Темы: светлая и тёмная
Правило: Все компоненты обязаны корректно отображаться в обеих темах.
Использование
<!-- ✅ Хорошо: цвета из дизайн-системы, автоматически адаптируются -->
<div class="bg-default text-default border-default">
<span class="text-muted">Подсказка</span>
<span class="text-highlighted">Важный текст</span>
</div>
<!-- ❌ Плохо: захардкоженные цвета -->
<div class="bg-white text-black border-gray-200">
<span class="text-gray-500">Подсказка</span>
</div>
Семантические цвета Nuxt UI
| Класс | Назначение |
|---|---|
bg-default | Основной фон |
bg-muted | Приглушённый фон |
bg-elevated | Приподнятый фон (карточки) |
text-default | Основной текст |
text-muted | Второстепенный текст |
text-highlighted | Акцентный текст |
border-default | Стандартная граница |
Проверка
<!-- Переключатель темы для тестирования -->
<UColorModeButton />
Чек-лист темы:
- Текст читаем на обоих фонах
- Границы видны в обеих темах
- Иконки контрастны
- Нет "белых пятен" в тёмной теме
- Нет "чёрных пятен" в светлой теме
Состояния загрузки
Правило: Любое асинхронное действие должно показывать состояние загрузки.
Кнопки
<template>
<UButton :loading="isSubmitting" @click="submit">
Сохранить
</UButton>
</template>
<script setup>
const isSubmitting = ref(false)
async function submit() {
isSubmitting.value = true
try {
await api.post('/data', form)
} finally {
isSubmitting.value = false
}
}
</script>
Skeleton для контента
<template>
<!-- Загрузка -->
<div v-if="loading" class="space-y-4">
<USkeleton class="h-8 w-48" />
<USkeleton class="h-4 w-full" />
<USkeleton class="h-4 w-3/4" />
</div>
<!-- Контент -->
<div v-else>
<h1>{{ data.title }}</h1>
<p>{{ data.description }}</p>
</div>
</template>
Skeleton для таблиц
<template>
<UTable v-if="!loading" :rows="data" :columns="columns" />
<div v-else class="space-y-2">
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
</div>
</template>
Skeleton для карточек
<template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<template v-if="loading">
<UCard v-for="i in 6" :key="i">
<USkeleton class="h-32 w-full mb-4" />
<USkeleton class="h-4 w-3/4 mb-2" />
<USkeleton class="h-4 w-1/2" />
</UCard>
</template>
<template v-else>
<OrderCard v-for="order in orders" :key="order.id" :order="order" />
</template>
</div>
</template>
Полноэкранная загрузка
<template>
<!-- Для критических операций -->
<UModal v-model="isProcessing" :closeable="false">
<div class="p-8 text-center">
<UIcon name="i-lucide-loader-2" class="w-8 h-8 animate-spin mx-auto mb-4" />
<p class="text-muted">Обработка платежа...</p>
</div>
</UModal>
</template>
Переходы и анимации
Правило: Изменения состояния должны быть плавными, но не замедлять работу.
Появление/скрытие элементов
<template>
<Transition
enter-active-class="transition-opacity duration-200"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div v-if="visible">Контент</div>
</Transition>
</template>
Списки
<template>
<TransitionGroup
tag="div"
enter-active-class="transition-all duration-300"
leave-active-class="transition-all duration-200"
enter-from-class="opacity-0 translate-y-2"
leave-to-class="opacity-0 translate-y-2"
move-class="transition-transform duration-300"
>
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
</TransitionGroup>
</template>
Рекомендуемые длительности
| Тип | Длительность | Применение |
|---|---|---|
duration-150 | 150ms | Hover эффекты, микровзаимодействия |
duration-200 | 200ms | Появление элементов, fade |
duration-300 | 300ms | Модальные окна, slide |
duration-500 | 500ms | Сложные анимации (редко) |
Что анимировать
<!-- ✅ Хорошо: анимируем opacity и transform (GPU-ускорение) -->
<div class="transition-all duration-200 hover:opacity-80 hover:scale-105">
<!-- ❌ Плохо: анимируем width/height (вызывает reflow) -->
<div class="transition-all duration-200 hover:w-64">
Обработка ошибок с действиями
Правило: Каждое состояние ошибки должно предлагать действие для решения.
Пустой результат поиска
<template>
<div v-if="!results.length" class="text-center py-12">
<UIcon name="i-lucide-search-x" class="w-12 h-12 text-muted mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">Ничего не найдено</h3>
<p class="text-muted mb-4">Попробуйте изменить параметры поиска</p>
<UButton variant="soft" @click="resetFilters">
Сбросить фильтры
</UButton>
</div>
</template>
Ошибка загрузки
<template>
<div v-if="error" class="text-center py-12">
<UIcon name="i-lucide-alert-circle" class="w-12 h-12 text-error mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">Не удалось загрузить данные</h3>
<p class="text-muted mb-4">{{ error.message }}</p>
<UButton @click="retry">
Попробовать снова
</UButton>
</div>
</template>
Ошибка сети
<template>
<div v-if="isOffline" class="text-center py-12">
<UIcon name="i-lucide-wifi-off" class="w-12 h-12 text-warning mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">Нет подключения к интернету</h3>
<p class="text-muted mb-4">Проверьте соединение и попробуйте снова</p>
<UButton @click="checkConnection">
Проверить соединение
</UButton>
</div>
</template>
Нет доступа
<template>
<div v-if="forbidden" class="text-center py-12">
<UIcon name="i-lucide-lock" class="w-12 h-12 text-muted mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">Нет доступа</h3>
<p class="text-muted mb-4">У вас нет прав для просмотра этой страницы</p>
<UButton to="/">
На главную
</UButton>
</div>
</template>
Пустое состояние (первый запуск)
<template>
<div v-if="!hasData && !loading" class="text-center py-12">
<UIcon name="i-lucide-inbox" class="w-12 h-12 text-muted mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">Пока нет заказов</h3>
<p class="text-muted mb-4">Создайте первый заказ, чтобы начать работу</p>
<UButton @click="openCreateModal">
Создать заказ
</UButton>
</div>
</template>
Структура Empty State
┌─────────────────────────────────┐
│ [Иконка] │
│ │
│ Заголовок (что случилось) │
│ Описание (почему/как) │
│ │
│ [Кнопка действия] │
└─────────────────────────────────┘
Обязательные элементы:
- Иконка — визуальный индикатор состояния
- Заголовок — краткое описание ситуации
- Описание — пояснение или инструкция
- Кнопка — конкретное действие для решения
Обратная связь
Правило: Пользователь должен понимать результат своих действий.
Toast уведомления
const toast = useToast()
// Успех
toast.add({
title: 'Заказ создан',
description: 'Заказ #123 успешно создан',
icon: 'i-lucide-check-circle',
color: 'success',
})
// Ошибка
toast.add({
title: 'Ошибка сохранения',
description: 'Не удалось сохранить изменения. Попробуйте позже.',
icon: 'i-lucide-alert-circle',
color: 'error',
})
// С действием
toast.add({
title: 'Заказ удалён',
description: 'Заказ #123 перемещён в корзину',
icon: 'i-lucide-trash-2',
actions: [{
label: 'Отменить',
click: () => restoreOrder(123),
}],
})
Подтверждение опасных действий
<template>
<UModal v-model="showConfirm">
<UCard>
<template #header>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-alert-triangle" class="w-6 h-6 text-warning" />
<h3 class="font-semibold">Удалить заказ?</h3>
</div>
</template>
<p class="text-muted">
Заказ #{{ order.id }} будет удалён без возможности восстановления.
</p>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="showConfirm = false">
Отмена
</UButton>
<UButton color="error" :loading="deleting" @click="confirmDelete">
Удалить
</UButton>
</div>
</template>
</UCard>
</UModal>
</template>
Optimistic Updates
Правило: Все CRUD-операции должны использовать optimistic update для мгновенного отклика интерфейса.
Принцип
- Сначала UI — изменения применяются мгновенно
- Потом API — запрос отправляется в фоне
- Откат при ошибке — если API вернул ошибку, состояние восстанавливается
Пользователь нажал "Удалить"
│
▼
┌─────────────────────┐
│ UI: элемент удалён │ ← Мгновенно
│ API: DELETE /item │ ← В фоне
└─────────────────────┘
│
┌────┴────┐
▼ ▼
Успех Ошибка
│ │
▼ ▼
Готово Откат UI
+ Toast
Composable useOptimisticRecords
Используй useOptimisticRecords из helpers module для работы с коллекциями:
interface IOptimisticOps<T extends { id: number }> {
addApi: (data: T) => Promise<T>
removeApi: (id: number, data: Partial<T>) => Promise<number>
updateApi: (id: number, data: Partial<T>) => Promise<T>
}
// Использование
const { records, add, update, remove, setRecords } = useOptimisticRecords<Order>(
'orders',
{
addApi: (data) => api.post('/orders', data),
removeApi: (id) => api.delete(`/orders/${id}`),
updateApi: (id, data) => api.patch(`/orders/${id}`, data),
},
(a, b) => b.id - a.id // сортировка: новые сверху
)
Пример: Список с CRUD
<template>
<div class="space-y-4">
<!-- Форма добавления -->
<UForm :state="newItem" @submit="handleAdd">
<div class="flex gap-2">
<UInput v-model="newItem.title" placeholder="Новая задача" class="flex-1" />
<UButton type="submit" :loading="isAdding">Добавить</UButton>
</div>
</UForm>
<!-- Список -->
<TransitionGroup
tag="div"
class="space-y-2"
enter-active-class="transition-all duration-200"
leave-active-class="transition-all duration-150"
enter-from-class="opacity-0 -translate-x-2"
leave-to-class="opacity-0 translate-x-2"
>
<div
v-for="item in records"
:key="item.id"
class="flex items-center gap-2 p-3 bg-muted rounded-lg"
:class="{ 'opacity-50': item.id < 0 }"
>
<UCheckbox
:model-value="item.completed"
@update:model-value="handleToggle(item)"
/>
<span class="flex-1">{{ item.title }}</span>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
@click="handleRemove(item.id)"
/>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
interface Task {
id: number
title: string
completed: boolean
}
const toast = useToast()
const isAdding = ref(false)
const newItem = reactive({ title: '' })
const { records, add, update, remove, setRecords } = useOptimisticRecords<Task>(
'tasks',
{
addApi: (data) => api.post('/tasks', data),
removeApi: (id) => api.delete(`/tasks/${id}`),
updateApi: (id, data) => api.patch(`/tasks/${id}`, data),
},
(a, b) => a.id - b.id
)
// Загрузка начальных данных
onMounted(async () => {
const { data } = await api.get<Task[]>('/tasks')
setRecords(data)
})
async function handleAdd() {
if (!newItem.title.trim()) return
isAdding.value = true
try {
await add({ id: 0, title: newItem.title, completed: false })
newItem.title = ''
toast.add({ title: 'Задача добавлена', color: 'success' })
} catch {
toast.add({ title: 'Не удалось добавить', color: 'error' })
} finally {
isAdding.value = false
}
}
async function handleToggle(item: Task) {
try {
await update(item.id, { completed: !item.completed })
} catch {
toast.add({ title: 'Не удалось обновить', color: 'error' })
}
}
async function handleRemove(id: number) {
try {
await remove(id)
toast.add({
title: 'Задача удалена',
actions: [{
label: 'Отменить',
click: () => {/* восстановление */}
}]
})
} catch {
toast.add({ title: 'Не удалось удалить', color: 'error' })
}
}
</script>
Визуальные индикаторы
<!-- Временный элемент (id < 0) — показываем что синхронизируется -->
<div :class="{ 'opacity-50': item.id < 0 }">
<UIcon v-if="item.id < 0" name="i-lucide-loader-2" class="animate-spin" />
{{ item.title }}
</div>
Когда использовать
| Сценарий | Optimistic | Почему |
|---|---|---|
| Добавление в список | ✅ | Мгновенный отклик |
| Удаление из списка | ✅ | Мгновенный отклик |
| Toggle checkbox | ✅ | Мгновенный отклик |
| Редактирование inline | ✅ | Мгновенный отклик |
| Отправка формы | ❌ | Нужна валидация сервера |
| Оплата | ❌ | Критичная операция |
| Загрузка файла | ❌ | Нужен прогресс |
Обработка ошибок
async function handleRemove(id: number) {
try {
await remove(id)
} catch (error) {
// Состояние автоматически откатится
// Показываем пользователю что пошло не так
toast.add({
title: 'Не удалось удалить',
description: error.message,
color: 'error',
actions: [{
label: 'Повторить',
click: () => handleRemove(id)
}]
})
}
}
Чек-лист
Темы
- Все цвета через семантические классы Nuxt UI
- Проверено в светлой теме
- Проверено в тёмной теме
Загрузка
- Кнопки имеют
:loadingсостояние - Контент показывает Skeleton при загрузке
- Критические операции блокируют UI
Переходы
- Появление/скрытие элементов анимировано
- Списки используют
<TransitionGroup> - Длительность анимаций ≤ 300ms
Ошибки
- Пустые состояния имеют действие
- Ошибки загрузки имеют кнопку "Повторить"
- 404/403 страницы имеют навигацию
Обратная связь
- Успешные действия показывают toast
- Ошибки показывают toast с описанием
- Опасные действия требуют подтверждения
Optimistic Updates
- CRUD-операции используют optimistic update
- Временные элементы визуально отличаются (opacity)
- Ошибки откатывают состояние и показывают toast
Доступность (a11y)
- Интерактивные элементы доступны с клавиатуры (Tab, Enter, Escape)
- Фокус виден (focus-visible outline не скрыт)
- Изображения имеют
alt(содержательный или пустой для декоративных) - Формы имеют связанные
<label>(Nuxt UI UFormField делает это автоматически) - Цветовой контраст текста ≥ 4.5:1 (WCAG AA)
- Модальные окна трапят фокус (Nuxt UI UModal делает это автоматически)
- Aria-атрибуты для кастомных интерактивных элементов (
aria-expanded,aria-label) - Порядок Tab-навигации логичен (следует визуальному порядку)