GrProgressBar
Берут, когда доля выполненного известна.
Когда брать
- доля выполненного известна — импорт, загрузка файла, шаг мастера: полоса отвечает «сколько осталось»;
- конец неизвестен —
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, NaN → 0), и не обязан быть больше значения — слой просто
окажется короче заливки.
Буфер не наследует tone и красится одним --gr-progress-buffer-bg:
-light-роли есть не у всех восьми тонов, а нейтральный слой между треком и
заливкой читается у любого из них.
Значение вне диапазона
value и buffer клампятся в 0..100, любое нечисловое значение превращается
в 0. Полоса, которой отдали -5 или 140, не ломает раскладку и не отдаёт
наружу невалидный aria-valuenow — это осознанная граница, а не побочный эффект.
Сюда же попадает не доехавший обязательный value: полоса рисуется пустой,
aria-valuenow="0", а в dev-режиме компонент печатает предупреждение. Молчать
здесь нельзя — aria-valuenow="NaN" диктор читает, и это нарушение axe.
Оформление
size — xs…lg, читается из 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "primary" | Цветовая тональность заливки. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Толщина трека. |
ariaLabel | string | undefined | undefined | Метка для скринридера (обязательна, если рядом нет видимого заголовка). |
indeterminate | boolean | undefined | false | Прогресс неизвестен: полоса бежит, значение наружу не объявляется. |
borderless | boolean | undefined | undefined | Убрать рамку трека: внутри карточки вторая рамка только шумит. |
buffer | number | undefined | undefined | Загружено с запасом: слой позади заливки, `0..100`. Не задан — слоя нет. |
showValue | boolean | undefined | false | Показать значение подписью справа от трека. |
formatValue | ((value: number) => string) | undefined | undefined | Свой формат значения. Управляет и подписью, и `aria-valuetext`. |
valueобязательный | number | — | Текущее значение `0..100`; выходящие за границы клампятся, нечисловое → `0`. |
Примеры 7
Интерактивный определённый прогресс
Базовый сценарий: меняем value, переключаем tone и рядом выводим фактический процент выполнения.
<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.
<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.
<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>Прогресс неизвестен
Запрос ушёл, а размер ответа сервер не сообщил: полоса бежит вместо того, чтобы врать про нуль. Как только процент известен, тот же компонент показывает значение.
<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 у каждого этапа.
<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>Шкала размеров
У линейного индикатора размер — это толщина трека и ничего больше: ширину задаёт контейнер.
<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>Подпись значения и буфер
Процент печатает сам компонент — считать и верстать подпись рядом больше не нужно. Буфер добавляет второй слой: воспроизведено против загружено, залито против подтверждено.
<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>