GrProgressBar

Пакет: @feugene/granularityядроГруппа: Обратная связь

Берут, когда доля выполненного известна.

Когда брать

  • доля выполненного известна — импорт, загрузка файла, шаг мастера: полоса отвечает «сколько осталось»;
  • конец неизвестенindeterminate показывает, что процесс идёт, не обещая срока;
  • загружено больше, чем проиграноbuffer рисует второй слой: буферизация видео, предзагрузка;
  • значение надо показать словамиshowValue с formatValue вместо отдельной подписи.

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

НужноБерите
Места по ширине нетGrProgressCircle
Доля неизвестна и контент уже естьGrLoading
Контента ещё нетGrSkeleton
Прогресс загрузки файловGrFileUpload
Показать долю от целого как данныеGrChartPie

Когда прогресс неизвестен

<GrProgressBar :value="percent" :indeterminate="!sizeKnown" aria-label="Импорт" />

indeterminate побеждает value: полоса бежит, aria-valuenow и aria-valuetext не выставляются вовсе — по спецификации это и есть признак неопределённости, отдельного aria-busy не нужно. Подпись значения в этом режиме не рендерится: показывать нечего.

Это самый частый сценарий прогресса — запрос ушёл, а размер ответа сервер не сообщил. GrFileUpload включает режим сам, когда у XHR нет lengthComputable.

Под prefers-reduced-motion: reduce полоса не замирает, а становится нейтральной заливкой на всю ширину (--gr-progress-indeterminate-bg). Замерший кадр бегущей полосы лёг бы у левого края и читался как «прогресс 40%», то есть врал бы о состоянии; заливка на всю ширину говорит «работа идёт, значение неизвестно» и не притворяется ни нулём, ни завершением. Период пробега — --gr-progress-indeterminate-duration. Общий контракт движения — motion.md.

Подпись значения

<GrProgressBar :value="percent" show-value aria-label="Загрузка" />

<GrProgressBar :value="percent" :format-value="v => `${gb(v)} из 32 ГБ`" show-value />

showValue печатает целые проценты справа от трека. Подпись фиксированной ширины и с табличными цифрами: переход 9%10% не должен дёргать трек.

formatValue управляет и подписью, и aria-valuetext — «184 из 512 МБ» вместо голого «36». Без своего формата aria-valuetext не выставляется: «36» при aria-valuemax="100" диктор уже читает как проценты, дублировать это текстом незачем. Формат работает и без showValue — тогда он остаётся только для скринридера.

Буфер

<GrProgressBar :value="played" :buffer="buffered" aria-label="Воспроизведение" />

Второй слой позади заливки: воспроизведено против загруженного в буфер, залито против подтверждённого сервером. Клампится по тем же правилам, что value (0..100, NaN0), и не обязан быть больше значения — слой просто окажется короче заливки.

Буфер не наследует tone и красится одним --gr-progress-buffer-bg: -light-роли есть не у всех восьми тонов, а нейтральный слой между треком и заливкой читается у любого из них.

Значение вне диапазона

value и buffer клампятся в 0..100, любое нечисловое значение превращается в 0. Полоса, которой отдали -5 или 140, не ломает раскладку и не отдаёт наружу невалидный aria-valuenow — это осознанная граница, а не побочный эффект.

Сюда же попадает не доехавший обязательный value: полоса рисуется пустой, aria-valuenow="0", а в dev-режиме компонент печатает предупреждение. Молчать здесь нельзя — aria-valuenow="NaN" диктор читает, и это нарушение axe.

Оформление

sizexslg, читается из GrConfigProvider; для линейной полосы это толщина трека и кегль подписи, ширину задаёт контейнер. Тон заливки — tone из палитры пакета.

borderless снимает рамку трека и тоже читается из GrConfigProvider. Рамка нужна на голом фоне, где трек --gr-muted почти не отличается от страницы; внутри карточки она становится второй рамкой рядом с её собственной.

Точечная кастомизация — переменные --gr-progress-bg (и по одной на каждый тон), --gr-progress-buffer-bg, --gr-progress-indeterminate-bg, --gr-progress-indeterminate-duration. Полный список — tokens.md.

Доступное имя

role="progressbar" стоит на треке, ariaLabel — единственный способ дать полосе имя: соседний заголовок именем не считается. Внутри GrFileUpload метку подставляет сам загрузчик.

Playground 8

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

Код
<GrProgressBar />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"Цветовая тональность заливки.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedТолщина трека.
ariaLabelstring | undefinedundefinedМетка для скринридера (обязательна, если рядом нет видимого заголовка).
indeterminateboolean | undefinedfalseПрогресс неизвестен: полоса бежит, значение наружу не объявляется.
borderlessboolean | undefinedundefinedУбрать рамку трека: внутри карточки вторая рамка только шумит.
buffernumber | undefinedundefinedЗагружено с запасом: слой позади заливки, `0..100`. Не задан — слоя нет.
showValueboolean | undefinedfalseПоказать значение подписью справа от трека.
formatValue((value: number) => string) | undefinedundefinedСвой формат значения. Управляет и подписью, и `aria-valuetext`.
valueобязательныйnumberТекущее значение `0..100`; выходящие за границы клампятся, нечисловое → `0`.

Примеры 7

Интерактивный определённый прогресс

Базовый сценарий: меняем value, переключаем tone и рядом выводим фактический процент выполнения.

Verification progress32% · primary

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

import { GR_TONES, GrButton, GrProgressBar, type GrTone } from '@feugene/granularity'

const progress = ref(32)
const tone = ref<GrTone>('primary')

const tones = GR_TONES
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" variant="outline" @click="progress = Math.max(0, progress - 16)">
        -16%
      </GrButton>
      <GrButton size="sm" @click="progress = Math.min(100, progress + 16)">
        +16%
      </GrButton>
    </div>

    <div class="flex flex-wrap gap-2">
      <GrButton
        v-for="item in tones"
        :key="item"
        size="sm"
        variant="outline"
        :tone="item"
        @click="tone = item"
      >
        {{ item }}
      </GrButton>
    </div>

    <div class="grid gap-2">
      <div class="flex items-center justify-between text-sm">
        <span>Verification progress</span>
        <span class="text-[var(--gr-muted-fg)]">{{ progress }}% · {{ tone }}</span>
      </div>
      <GrProgressBar :value="progress" :tone="tone" aria-label="Verification progress" />
    </div>
  </div>
</template>

Без рамки

Внутри карточки рамка трека превращается во вторую рамку рядом с её собственной. borderless снимает её — и настраивается глобально через GrConfigProvider.

С рамкой
64%
Дефолт: трек обведён `--gr-brd` и виден на любом фоне.
borderless
64%
Внутри карточки рамка трека становится второй рамкой рядом с её собственной.

Borderless
<script setup lang="ts">
import { GrCard, GrProgressBar } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-2">
    <GrCard padding="md" body-class="grid gap-2">
      <div class="text-sm font-600">
        С рамкой
      </div>
      <GrProgressBar :value="64" show-value aria-label="Bordered progress" />
      <div class="text-xs text-[var(--gr-muted-fg)]">
        Дефолт: трек обведён `--gr-brd` и виден на любом фоне.
      </div>
    </GrCard>

    <GrCard padding="md" body-class="grid gap-2">
      <div class="text-sm font-600">
        borderless
      </div>
      <GrProgressBar :value="64" borderless show-value aria-label="Borderless progress" />
      <div class="text-xs text-[var(--gr-muted-fg)]">
        Внутри карточки рамка трека становится второй рамкой рядом с её собственной.
      </div>
    </GrCard>
  </div>
</template>

Значения вне диапазона зажимаются

Документируем важный edge-case: отрицательные и слишком большие значения безопасно ограничиваются диапазоном 0–100.

Imported from legacy jobinput: -18%
Actual processed recordsinput: 58%
Overreported upstream valueinput: 146%

Clamp
<script setup lang="ts">
import { GrBadge, GrProgressBar } from '@feugene/granularity'

const rows = [
  { label: 'Imported from legacy job', raw: -18, tone: 'danger' as const },
  { label: 'Actual processed records', raw: 58, tone: 'info' as const },
  { label: 'Overreported upstream value', raw: 146, tone: 'warning' as const },
]
</script>

<template>
  <div class="grid gap-3">
    <div
      v-for="row in rows"
      :key="row.label"
      class="grid gap-2 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-3"
    >
      <div class="flex items-center justify-between gap-3 text-sm">
        <span>{{ row.label }}</span>
        <GrBadge size="sm" :tone="row.tone">input: {{ row.raw }}%</GrBadge>
      </div>

      <GrProgressBar :value="row.raw" :tone="row.tone" :aria-label="row.label" />
    </div>
  </div>
</template>

Прогресс неизвестен

Запрос ушёл, а размер ответа сервер не сообщил: полоса бежит вместо того, чтобы врать про нуль. Как только процент известен, тот же компонент показывает значение.

Запрос отправлен, размер ответа неизвестен

Indeterminate
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'

import { GrButton, GrProgressBar } from '@feugene/granularity'

const known = ref(false)
const progress = ref(0)

let timer: ReturnType<typeof setInterval> | undefined

function start() {
  known.value = false
  progress.value = 0

  clearInterval(timer)
  timer = setInterval(() => {
    if (progress.value >= 100) {
      clearInterval(timer)
      return
    }

    // Ответ сервера пришёл — с этого момента размер известен, и полоса
    // перестаёт быть неопределённой.
    known.value = true
    progress.value = Math.min(100, progress.value + 7)
  }, 400)
}

onBeforeUnmount(() => clearInterval(timer))
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" @click="start">
        Запустить запрос
      </GrButton>
      <GrButton size="sm" variant="outline" @click="known = !known">
        {{ known ? 'Прогресс неизвестен' : 'Прогресс известен' }}
      </GrButton>
    </div>

    <div class="grid gap-2">
      <div class="text-sm">
        {{ known ? 'Загрузка идёт, размер известен' : 'Запрос отправлен, размер ответа неизвестен' }}
      </div>
      <GrProgressBar
        :value="progress"
        :indeterminate="!known"
        show-value
        aria-label="Import progress"
      />
    </div>
  </div>
</template>

Стопка этапов процесса

Data-display сценарий для pipelines/checklists: несколько progress bars в списке статусов одного workflow с семантическим tone у каждого этапа.

Validation100%
Fraud screening72%
Settlement41%

Pipeline
<script setup lang="ts">
import { GrBadge, GrProgressBar } from '@feugene/granularity'

const stages = [
  { label: 'Validation', value: 100, tone: 'success' as const },
  { label: 'Fraud screening', value: 72, tone: 'warning' as const },
  { label: 'Settlement', value: 41, tone: 'neutral' as const },
]
</script>

<template>
  <div class="grid gap-3">
    <div
      v-for="stage in stages"
      :key="stage.label"
      class="grid gap-2 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-3"
    >
      <div class="flex items-center justify-between gap-3 text-sm">
        <span>{{ stage.label }}</span>
        <GrBadge size="sm" :tone="stage.tone">{{ stage.value }}%</GrBadge>
      </div>

      <GrProgressBar :value="stage.value" :tone="stage.tone" :aria-label="stage.label" />
    </div>
  </div>
</template>

Шкала размеров

У линейного индикатора размер — это толщина трека и ничего больше: ширину задаёт контейнер.

size="xs"
size="sm"
size="md"
size="lg"

Sizes
<script setup lang="ts">
import { GrProgressBar } from '@feugene/granularity'

const sizes = ['xs', 'sm', 'md', 'lg'] as const
</script>

<template>
  <div class="grid gap-4">
    <div v-for="size in sizes" :key="size" class="grid gap-2">
      <div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
        size="{{ size }}"
      </div>

      <GrProgressBar :value="62" :size="size" aria-label="Upload progress" />
    </div>
  </div>
</template>

Подпись значения и буфер

Процент печатает сам компонент — считать и верстать подпись рядом больше не нужно. Буфер добавляет второй слой: воспроизведено против загружено, залито против подтверждено.

Плеер: заливка — воспроизведено, слой позади — загружено в буфер
28%
Своя подпись: `formatValue` управляет и текстом, и `aria-valuetext`
184 / 512 МБ

Value Buffer
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrButton, GrProgressBar } from '@feugene/granularity'

const played = ref(28)
const buffered = computed(() => Math.min(100, played.value + 24))

const uploadedMb = ref(184)
const totalMb = 512
const uploadPercent = computed(() => (uploadedMb.value / totalMb) * 100)

function formatMb(value: number) {
  return `${Math.round((value / 100) * totalMb)} / ${totalMb} МБ`
}
</script>

<template>
  <div class="grid gap-5">
    <div class="grid gap-2">
      <div class="text-sm">
        Плеер: заливка — воспроизведено, слой позади — загружено в буфер
      </div>
      <GrProgressBar
        :value="played"
        :buffer="buffered"
        show-value
        aria-label="Playback progress"
      />
      <div class="flex flex-wrap gap-2">
        <GrButton size="sm" variant="outline" @click="played = Math.max(0, played - 10)">
          -10%
        </GrButton>
        <GrButton size="sm" @click="played = Math.min(100, played + 10)">
          +10%
        </GrButton>
      </div>
    </div>

    <div class="grid gap-2">
      <div class="text-sm">
        Своя подпись: `formatValue` управляет и текстом, и `aria-valuetext`
      </div>
      <GrProgressBar
        :value="uploadPercent"
        :format-value="formatMb"
        show-value
        tone="success"
        aria-label="Upload progress"
      />
      <div class="flex flex-wrap gap-2">
        <GrButton size="sm" variant="outline" @click="uploadedMb = Math.max(0, uploadedMb - 64)">
          -64 МБ
        </GrButton>
        <GrButton size="sm" tone="success" @click="uploadedMb = Math.min(totalMb, uploadedMb + 64)">
          +64 МБ
        </GrButton>
      </div>
    </div>
  </div>
</template>

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