Стандарты

Высокое качество 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">
Контент выходит за карточкуoverflowclass="overflow-hidden" на карточке
Элементы слиплисьНет spacingclass="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-150150msHover эффекты, микровзаимодействия
duration-200200msПоявление элементов, fade
duration-300300msМодальные окна, slide
duration-500500msСложные анимации (редко)

Что анимировать

<!-- ✅ Хорошо: анимируем 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

┌─────────────────────────────────┐
│           [Иконка]              │
│                                 │
│      Заголовок (что случилось)  │
│      Описание (почему/как)      │
│                                 │
│         [Кнопка действия]       │
└─────────────────────────────────┘

Обязательные элементы:

  1. Иконка — визуальный индикатор состояния
  2. Заголовок — краткое описание ситуации
  3. Описание — пояснение или инструкция
  4. Кнопка — конкретное действие для решения

Обратная связь

Правило: Пользователь должен понимать результат своих действий.

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 для мгновенного отклика интерфейса.

Принцип

  1. Сначала UI — изменения применяются мгновенно
  2. Потом API — запрос отправляется в фоне
  3. Откат при ошибке — если 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-навигации логичен (следует визуальному порядку)

Связанные документы