GrProgressCircle

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

Берут, когда места по ширине нет.

Когда брать

  • места по ширине нет — плитка дашборда, ячейка, карточка показателя;
  • значение важнее хода — число в центре читается как показатель, а не как ожидание;
  • нужна шкала-«спидометр»shape разрывает кольцо, когда так понятнее;
  • состояние нужно значкомstatusIcon заменяет число на успех или отказ по завершении.

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

НужноБерите
Места по ширине хватаетGrProgressBar
Доля неизвестна, контент уже естьGrLoading
Число без шкалыGrStatistic
Доли нескольких частей целогоGrChartPie

Центр лежит рядом с ролью, а не внутри неё

Разметка нарочно двухслойная: role="progressbar" несёт только SVG, а содержимое центра — его сосед с абсолютным позиционированием.

Причина не косметическая. Роль-виджет объявляет своих потомков презентационными, и кнопка «Отменить» внутри кольца — первый же сценарий аплоада — оказалась бы nested-interactive: скринридер потерял бы и кнопку, и индикатор. Слот центра обязан оставаться местом, куда можно положить что угодно, включая интерактив.

Сам слой центра не ловит указатель (pointer-events: none) — иначе он накрыл бы кольцо; интерактив внутри включает события себе обратно.

Значение, иконка, слот

Приоритет содержимого центра: слот → иконка итога → значение.

  • showValue печатает проценты, formatValue задаёт свой текст и заодно aria-valuetext;
  • statusIcon меняет число на галочку при value >= 100 и на крест при tone="danger" — то есть только в терминальных состояниях;
  • слот по умолчанию получает уже клампнутое значение и перебивает оба варианта. Пустой слот (например, кнопка под v-if) центр не занимает — считается содержимое, а не факт передачи слота.

Значение в центре помещается начиная с sm; на xs его либо не показывают, либо уменьшают кегль токеном --gr-progress-circle-value-size.

Формы

circle — замкнутое кольцо, отсчёт с двенадцати часов по часовой. dashboard — три четверти окружности с вырезом строго снизу: под значением освобождается место для подписи, а шкала читается как спидометр. Значение в обеих формах означает одно и то же — доля пройденного, — меняется только длина дорожки.

Тона общие с полосой

Дуга красится теми же переменными, что и GrProgressBar (--gr-progress-bg, --gr-progress-success-bg и соседи): слой темизации у прогресса один, и потребитель, перекрасивший его, ждёт, что круг и полоса сойдутся. Каждая переменная берётся с фолбэком на роль темы — при гранулярном импорте одного круга покомпонентная тема полосы не подключается, и без фолбэка дуга осталась бы без цвета.

Своя у круга только геометрия: --gr-progress-circle-size, --gr-progress-circle-track, --gr-progress-circle-cap, --gr-progress-circle-value-size.

Неизвестный прогресс

indeterminate пускает дугу по кругу и не объявляет значения: aria-valuenow не выставляется, подпись не печатается.

Под prefers-reduced-motion компонент показывает замкнутое нейтральное кольцо, а не останавливает вращение. Остановленная четверть дуги читалась бы как «прогресс 25 %» — тот же довод, по которому у GrProgressBar своя ветка reduce, а не общий кламп анимаций.

Границы

  • buffer не переносится с полосы: слой «загружено с запасом» читается на шкале, которую видно слева направо целиком; на кольце две вложенные дуги выглядят одной толстой;
  • размер только по шкале пакета — пиксельный escape-hatch есть ровно у GrAvatar и GrIcon; точечная подгонка идёт через --gr-progress-circle-size, а геометрия дуги считается в единицах viewBox и от диаметра не зависит;
  • градиента по дуге нет — он требует <linearGradient> со своим id в каждом экземпляре, то есть ещё одного источника расхождений гидрации;
  • сегментов и рисок нет — кольцо с делениями это индикатор шагов, а не прогресса.

Playground 10

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

Код
<GrProgressCircle />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

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

Slots

SlotTypeОписание
default{ value: number; }Содержимое центра вместо значения: иконка, две строки, кнопка отмены.

Примеры 4

Размеры и тона

Диаметр — по шкале пакета, толщина обводки идёт следом. Тона берутся из той же темы, что и у линейного индикатора: перекрасив --gr-progress-bg, вы перекрасите оба.

xs
64%
sm
64%
md
64%
lg
72%
48%

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

const sizes = ['xs', 'sm', 'md', 'lg'] as const
const tones = [
  { tone: 'primary', value: 72 },
  { tone: 'success', value: 100 },
  { tone: 'warning', value: 48 },
  { tone: 'danger', value: 19 },
] as const
</script>

<template>
  <div class="grid gap-6">
    <div class="flex flex-wrap items-end gap-6">
      <div v-for="size in sizes" :key="size" class="grid justify-items-center gap-2">
        <GrProgressCircle :value="64" :size="size" :show-value="size !== 'xs'" aria-label="Заполнение диска" />
        <code class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">{{ size }}</code>
      </div>
    </div>

    <div class="flex flex-wrap items-center gap-6">
      <GrProgressCircle
        v-for="item in tones"
        :key="item.tone"
        :value="item.value"
        :tone="item.tone"
        show-value
        status-icon
        :aria-label="`Тон ${item.tone}`"
      />
    </div>
  </div>
</template>

Плитка метрики

shape="dashboard" оставляет вырез снизу — под значением освобождается место для подписи, а шкала читается как спидометр.

72%
CPU
91%
Память
34%
Диск
58%
Сеть · вживую

Dashboard
<!-- useTweenedValue.ts -->
import { onBeforeUnmount, ref } from 'vue'

/**
 * Значение, которое едет к новой точке за заданное время, а не прыгает.
 *
 * Собственный переход дуги (`--gr-duration-base`) сглаживает только сам скачок:
 * при шаге раз в пять секунд кольцо дёргалось бы за долю секунды и стояло всё
 * остальное время. Здесь движение растягивается на весь интервал — и число в
 * центре едет вместе с дугой.
 */
export function useTweenedValue(initial: number) {
  const value = ref(initial)

  let frame: number | undefined

  function stop(): void {
    if (frame !== undefined)
      cancelAnimationFrame(frame)
    frame = undefined
  }

  /** Уважать «уменьшить движение» обязан тот, кто двигает: CSS-кламп до JS не достаёт. */
  function prefersReducedMotion(): boolean {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
  }

  function tweenTo(target: number, duration: number): void {
    stop()

    const from = value.value
    if (from === target || duration <= 0 || prefersReducedMotion()) {
      value.value = target
      return
    }

    const start = performance.now()

    function step(now: number): void {
      const progress = Math.min(1, (now - start) / duration)
      value.value = from + (target - from) * progress

      if (progress < 1)
        frame = requestAnimationFrame(step)
      else frame = undefined
    }

    frame = requestAnimationFrame(step)
  }

  /** Мгновенно — для разрыва шкалы, где плавный переход выглядел бы перемоткой. */
  function jumpTo(target: number): void {
    stop()
    value.value = target
  }

  onBeforeUnmount(stop)

  return { value, tweenTo, jumpTo }
}

<!-- GrProgressCircleDashboardDemo.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { GrCard, GrProgressCircle } from '@feugene/granularity'

import { useTweenedValue } from './useTweenedValue'

const metrics = [
  { label: 'CPU', value: 72, tone: 'primary' as const },
  { label: 'Память', value: 91, tone: 'warning' as const },
  { label: 'Диск', value: 34, tone: 'success' as const },
]

/** Живая метрика: случайный шаг в пределах ±5 %, но не дальше ±10 % от базы. */
const LIVE_BASE = 58
const STEP = 5
const BAND = 10
const TICK = 1000

const { value: live, tweenTo } = useTweenedValue(LIVE_BASE)

let timer: ReturnType<typeof setInterval> | undefined

function nextValue(current: number): number {
  const delta = (Math.random() * 2 - 1) * STEP

  return Math.min(LIVE_BASE + BAND, Math.max(LIVE_BASE - BAND, current + delta))
}

onMounted(() => {
  // Дрожащая цифра — ровно то, чего не хочет «уменьшить движение».
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches)
    return

  timer = setInterval(() => tweenTo(nextValue(live.value), TICK), TICK)
})

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

<template>
  <div class="flex flex-wrap gap-4">
    <GrCard v-for="metric in metrics" :key="metric.label">
      <div class="grid justify-items-center gap-2 px-4 py-2">
        <GrProgressCircle
          :value="metric.value"
          :tone="metric.tone"
          shape="dashboard"
          size="lg"
          show-value
          :aria-label="metric.label"
        />
        <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">{{ metric.label }}</span>
      </div>
    </GrCard>

    <GrCard>
      <div class="grid justify-items-center gap-2 px-4 py-2">
        <GrProgressCircle
          :value="live"
          tone="info"
          shape="dashboard"
          size="lg"
          show-value
          aria-label="Сеть"
        />
        <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">Сеть · вживую</span>
      </div>
    </GrCard>
  </div>
</template>

Живое значение

Два кольца на одной шкале 0…100: левое прибавляет пять процентов раз в секунду, правое — раз в пять секунд. Дуга догоняет новое значение переходом, поэтому частый шаг читается как непрерывное движение, а редкий — как отдельные приращения.

35%
+5 % раз в секунду
35%
+5 % раз в пять секунд

Оба кольца прибавляют по 5 % на шаг, но левое делает это раз в секунду, а правое — раз в пять, и каждый шаг растянут на весь интервал до следующего: движение идёт от прежней точки к новой, а не рывком.

Ticking
<!-- useTweenedValue.ts -->
import { onBeforeUnmount, ref } from 'vue'

/**
 * Значение, которое едет к новой точке за заданное время, а не прыгает.
 *
 * Собственный переход дуги (`--gr-duration-base`) сглаживает только сам скачок:
 * при шаге раз в пять секунд кольцо дёргалось бы за долю секунды и стояло всё
 * остальное время. Здесь движение растягивается на весь интервал — и число в
 * центре едет вместе с дугой.
 */
export function useTweenedValue(initial: number) {
  const value = ref(initial)

  let frame: number | undefined

  function stop(): void {
    if (frame !== undefined)
      cancelAnimationFrame(frame)
    frame = undefined
  }

  /** Уважать «уменьшить движение» обязан тот, кто двигает: CSS-кламп до JS не достаёт. */
  function prefersReducedMotion(): boolean {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
  }

  function tweenTo(target: number, duration: number): void {
    stop()

    const from = value.value
    if (from === target || duration <= 0 || prefersReducedMotion()) {
      value.value = target
      return
    }

    const start = performance.now()

    function step(now: number): void {
      const progress = Math.min(1, (now - start) / duration)
      value.value = from + (target - from) * progress

      if (progress < 1)
        frame = requestAnimationFrame(step)
      else frame = undefined
    }

    frame = requestAnimationFrame(step)
  }

  /** Мгновенно — для разрыва шкалы, где плавный переход выглядел бы перемоткой. */
  function jumpTo(target: number): void {
    stop()
    value.value = target
  }

  onBeforeUnmount(stop)

  return { value, tweenTo, jumpTo }
}

<!-- GrProgressCircleTickingDemo.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { GrProgressCircle } from '@feugene/granularity'

import { useTweenedValue } from './useTweenedValue'

const STEP = 5
const START = 35

const { value: fast, tweenTo: tweenFast, jumpTo: jumpFast } = useTweenedValue(START)
const { value: slow, tweenTo: tweenSlow, jumpTo: jumpSlow } = useTweenedValue(START)

let fastTimer: ReturnType<typeof setInterval> | undefined
let slowTimer: ReturnType<typeof setInterval> | undefined

/**
 * Шаг занимает весь интервал до следующего — тогда движение читается как
 * непрерывное. На конце шкалы плавный переход был бы перемоткой назад через
 * всё кольцо, поэтому там значение возвращается мгновенно.
 */
function advance(
  current: number,
  tweenTo: (value: number, duration: number) => void,
  jumpTo: (value: number) => void,
  duration: number,
): void {
  const next = current + STEP

  if (next > 100)
    jumpTo(0)
  else tweenTo(next, duration)
}

/**
 * Само движение здесь и есть предмет демо, поэтому под «уменьшить движение»
 * таймеры не запускаются вовсе: кольца остаются на стартовых значениях.
 * `matchMedia` читается в `onMounted`, а не в теле `setup`.
 */
onMounted(() => {
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches)
    return

  fastTimer = setInterval(advance, 1000, fast.value, tweenFast, jumpFast, 1000)
  slowTimer = setInterval(advance, 5000, slow.value, tweenSlow, jumpSlow, 5000)
})

onBeforeUnmount(() => {
  clearInterval(fastTimer)
  clearInterval(slowTimer)
})
</script>

<template>
  <div class="flex flex-wrap items-start gap-10">
    <div class="grid justify-items-center gap-2">
      <GrProgressCircle :value="fast" size="lg" show-value aria-label="Обновление раз в секунду" />
      <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
        +{{ STEP }} % раз в секунду
      </span>
    </div>

    <div class="grid justify-items-center gap-2">
      <GrProgressCircle :value="slow" size="lg" tone="info" show-value aria-label="Обновление раз в пять секунд" />
      <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
        +{{ STEP }} % раз в пять секунд
      </span>
    </div>

    <p class="max-w-xs text-[length:var(--gr-text-sm)] text-[var(--gr-muted-fg)]">
      Оба кольца прибавляют по {{ STEP }} % на шаг, но левое делает это раз в секунду, а правое — раз в пять,
      и каждый шаг растянут на весь интервал до следующего: движение идёт от прежней точки к новой, а не рывком.
    </p>
  </div>
</template>

Аплоад: от «соединяемся» до галочки

Пока доли прогресса нет — indeterminate; дальше значение, а на завершении statusIcon меняет число на галочку. Кнопка отмены живёт в центре кольца и остаётся кликабельной: центр лежит рядом с role="progressbar", а не внутри него.

Uploadзависит от окружения витрины
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import IconX from '~icons/lucide/x'
import { GrButton, GrProgressCircle } from '@feugene/granularity'

type Stage = 'idle' | 'connecting' | 'uploading' | 'done'

const stage = ref<Stage>('idle')
const value = ref(0)

let timer: ReturnType<typeof setInterval> | undefined

function stop() {
  if (timer)
    clearInterval(timer)
  timer = undefined
}

function start() {
  stop()
  stage.value = 'connecting'
  value.value = 0

  // Пока сервер не ответил, доли прогресса нет — это и есть `indeterminate`.
  setTimeout(() => {
    stage.value = 'uploading'
    timer = setInterval(() => {
      value.value = Math.min(100, value.value + 7)
      if (value.value >= 100) {
        stop()
        stage.value = 'done'
      }
    }, 220)
  }, 900)
}

function cancel() {
  stop()
  stage.value = 'idle'
  value.value = 0
}

onBeforeUnmount(stop)
</script>

<template>
  <div class="flex flex-wrap items-center gap-6">
    <GrProgressCircle
      :value="value"
      :indeterminate="stage === 'connecting'"
      :tone="stage === 'done' ? 'success' : 'primary'"
      size="lg"
      status-icon
      show-value
      aria-label="Загрузка файла"
    >
      <GrButton
        v-if="stage === 'uploading'"
        variant="ghost"
        size="xs"
        aria-label="Отменить загрузку"
        @click="cancel"
      >
        <IconX class="h-3 w-3" />
      </GrButton>
    </GrProgressCircle>

    <div class="grid gap-2">
      <span class="text-[length:var(--gr-text-sm)] text-[var(--gr-muted-fg)]">
        {{ stage === 'idle' ? 'Готов к загрузке' : stage === 'connecting' ? 'Соединение…' : stage === 'uploading' ? 'Загружаем…' : 'Файл загружен' }}
      </span>
      <GrButton size="sm" :disabled="stage === 'connecting' || stage === 'uploading'" @click="start">
        Загрузить
      </GrButton>
    </div>
  </div>
</template>

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