GrDashboard

Пакет: @feugene/granularity-dashboardспутникГруппа: Прочее

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

Когда брать

  • раскладку определяет пользователь — аналитическая панель, рабочий стол, сводка под конкретную роль: набор и расположение виджетов принадлежат человеку, а не дизайнеру экрана;
  • раскладка обязана пережить перезагрузкуuseDashboardLayout кладёт её в localStorage или на сервер через интерфейс хранилища;
  • на ноутбуке и планшете нужно разное — раскладка хранится на каждый брейкпоинт, недостающая выводится из соседней сверху;
  • виджеты разного размераminW/minH/maxW/maxH объявляет сам виджет, потому что «этот график ниже трёх строк не читается» знает только он.

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

НужноБерите
Раскладка фиксирована дизайномCSS Grid и GrCard
Две области, границу двигает пользовательGrSplitter
Порядок элементов в одном спискеGrSortableList
Разделы, переключаемые вкладкамиGrTabs
Строки таблицы с изменяемым порядком колонокGrDataTable
Откуда виджет берёт данныеgranularity-datasource
Чем виджет рисует содержимоеgranularity-charts / granularity-chrono

Дашборд — не способ разложить статичный экран. Если пользователь ничего не двигает, сетка виджетов даёт лишний слой состояния, хранилище и режим редактирования там, где хватило бы CSS Grid.

Сдвинуть виджет в пустоту по оси уплотнения нельзя

compact знает четыре режима: 'vertical' (по умолчанию) тянет виджеты вверх, 'horizontal' — влево, 'both' — в обе стороны, 'none' оставляет их там, куда положили. Отпущенный в пустоту виджет уезжает обратно — вниз при 'vertical', вправо при 'horizontal', в обоих направлениях при 'both'. Это не дефект, а определение компактизации. Нужна свободная сетка с дырами — compact: 'none'.

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

Куда бросили — говорит сетка, кладёт приложение

Виджет, перетащенный из каталога, в раскладку сама сетка не пишет: она эмитит itemDrop с ячейкой, брейкпоинтом и своими опциями раскладки, а addItem вызывает потребитель — тем же способом, что и по кнопке каталога. Иначе в раскладке появился бы виджет, для которого приложение не рисовало разметки.

Пока указатель над сеткой, подложка показывает место по-настоящему: предпросмотр считается тем же addItem с теми же опциями, что уедут в событие. Поэтому опции и едут в событие: посчитай приложение раскладку своими, результат разошёлся бы с тем, что человек только что видел.

Столкновение со статикой отменяет перенос целиком

Подвинуть static-виджет нельзя по определению, а положить один поверх другого значило бы молча испортить раскладку. То же делает preventCollision для любого столкновения. Половинчатого исхода — «подвинулось насколько влезло» — здесь нет: пользователь не увидел бы, что именно не поместилось.

`itemAutoResize` — не то же, что `itemResize`

Виджет с auto-height подстраивается под содержимое сам, и его высота меняется без участия человека: приехали данные, раскрыли секцию, сменился язык. Такое изменение уезжает в update:layout наравне с остальными — раскладка остаётся единственной правдой, — но сопровождается отдельным событием.

СобытиеКогда
itemResizeпользователь потянул уголок или нажал стрелку
itemAutoResizeсодержимое виджета попросило другую высоту

Различать их приходится всякому, кто по правкам решает, грязна ли раскладка: без этого «сохранить изменения?» всплывало бы после загрузки данных в виджет. Подробности замера — ./GrDashboardItem.md.

`itemTransferOut` — виджет уехал к соседям

Два дашборда на странице обмениваются виджетами перетаскиванием: жест за ручку, ушедший за край своей сетки в чужую, становится переносом между ними.

СобытиеУ когоКогда
itemDropприёмникв сетку что-то положили — из каталога или из другого дашборда
itemTransferOutисточниквиджет уехал; из своей раскладки сетка убрала его сама

Отличать источники приёмник может по transfer.source ('palette' или 'dashboard') и transfer.from — идентификатору сетки, из которой виджет приехал. Разбор — ../model.md.

Отдавать и принимать разрешается по отдельности: transferable у источника, droppable у приёмника.

Нулевая ширина — это не самый узкий экран

Скрытая вкладка, свёрнутая панель, display: none дают нулевую ширину контейнера, и брейкпоинт от этого не переключается. Принять одно за другое значило бы перевести дашборд на две колонки и записать эту раскладку в модель — то есть потерять настройку пользователя из-за того, что он открыл соседнюю вкладку.

Границы

Чего в пакете нет и не будет — записано, чтобы вопрос не всплывал заново.

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

Установка

npm i @feugene/granularity-dashboard

Импорт

import { GrDashboard } from '@feugene/granularity-dashboard/components/GrDashboard'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 9

Auto Height

Auto Height
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDashboardItemLayout, GrDashboardResponsiveLayout } from '@feugene/granularity-dashboard'

/**
 * Авто-высота: содержимое решает, сколько строк занять.
 *
 * Демо намеренно ставит рядом виджет с `auto-height` и обычный, с той же
 * высотой в раскладке: на них видно, что подстраивается именно первый, а второй
 * честно обрезает своё содержимое полосой прокрутки.
 */
const mode = ref<'view' | 'edit'>('view')

const layout = ref<GrDashboardResponsiveLayout>({
  lg: [
    { id: 'log', x: 0, y: 0, w: 6, h: 2 },
    { id: 'fixed', x: 6, y: 0, w: 6, h: 2 },
    { id: 'total', x: 0, y: 2, w: 12, h: 1 },
  ],
})

const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }

const EVENTS = [
  'Сборка 2418 прошла',
  'Выкатили 0.4.0 на стенд',
  'Алерт: очередь писем выросла втрое',
  'Очередь разобрана',
  'Ночной бэкап завершён',
  'Индексация каталога переехала на реплику',
  'Сертификат обновлён автоматически',
]

const count = ref(2)
const events = computed(() => EVENTS.slice(0, count.value))

/** Последнее подстроившееся: показываем, что событие приходит отдельным. */
const lastAuto = ref<string | null>(null)

function onAutoResize(id: string, from: GrDashboardItemLayout, to: GrDashboardItemLayout): void {
  lastAuto.value = `${id}: было ${from.h}, стало ${to.h}`
}
</script>

<template>
  <div class="flex flex-col gap-4">
    <div class="flex flex-wrap items-center gap-2">
      <GrButton size="sm" variant="outline" :disabled="count >= EVENTS.length" @click="count += 1">
        Добавить событие
      </GrButton>
      <GrButton size="sm" variant="outline" :disabled="count <= 1" @click="count -= 1">
        Убрать событие
      </GrButton>
      <GrDashboardToolbar v-model:mode="mode" />
    </div>

    <GrDashboard
      v-model:layout="layout"
      :mode="mode"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="72"
      @item-auto-resize="onAutoResize"
    >
      <GrDashboardItem item-id="log" title="Лента событий" auto-height :min-h="1">
        <ul class="flex flex-col gap-1 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
          <li v-for="event in events" :key="event">{{ event }}</li>
        </ul>
      </GrDashboardItem>

      <GrDashboardItem item-id="fixed" title="То же, но без авто-высоты">
        <ul class="flex flex-col gap-1 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
          <li v-for="event in events" :key="event">{{ event }}</li>
        </ul>
      </GrDashboardItem>

      <GrDashboardItem item-id="total" title="Всего" auto-height>
        <p class="text-[var(--gr-muted-fg)]">
          {{ events.length }} из {{ EVENTS.length }}<span v-if="lastAuto"> · последняя подстройка — {{ lastAuto }}</span>
        </p>
      </GrDashboardItem>
    </GrDashboard>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Добавляйте и убирайте события: левый виджет <strong>растёт и ужимается обратно</strong>, расталкивая
      соседей, правый с той же лентой остаётся в своих двух строках и прячет остальное под прокрутку.
      Виджет «Всего» под ними поднимается и опускается уплотнением — авто-высота идёт через ту же
      арифметику раскладки, что и растягивание уголком.
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Работает и в просмотре, и в редактировании: содержимое меняется в рантайме, а не только когда
      включили ручки. Изменение уезжает в <code>update:layout</code>, как любое другое, но сетка
      дополнительно эмитит <code>itemAutoResize</code> — его видно в подписи справа. Без него
      приложение, которое считает раскладку грязной по правкам, спрашивало бы «сохранить изменения?»
      после обычной загрузки данных. Высота округляется <strong>вверх</strong> до целой строки:
      пустота в пару пикселей внизу безобидна, обрезанная строка — нет. У такого виджета уголок
      меняет только ширину.
    </p>
  </div>
</template>

Basic

Basic
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDashboardResponsiveLayout } from '@feugene/granularity-dashboard'

const mode = ref<'view' | 'edit'>('edit')

const layout = ref<GrDashboardResponsiveLayout>({
  lg: [
    { id: 'revenue', x: 0, y: 0, w: 8, h: 3 },
    { id: 'conversion', x: 8, y: 0, w: 4, h: 3, minW: 3 },
    { id: 'sources', x: 0, y: 3, w: 6, h: 2 },
    { id: 'errors', x: 6, y: 3, w: 6, h: 2 },
  ],
})

// Демо живёт в колонке витрины, а не во весь экран, поэтому пороги свои:
// брейкпоинты — свойство приложения, а не константа пакета.
const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }
</script>

<template>
  <div class="flex flex-col gap-4">
    <GrDashboardToolbar v-model:mode="mode" />

    <GrDashboard
      v-model:layout="layout"
      :mode="mode"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="72"
    >
      <GrDashboardItem item-id="revenue" title="Выручка">
        <p class="text-[var(--gr-muted-fg)]">₽ 12 480 000 за квартал</p>
      </GrDashboardItem>

      <GrDashboardItem item-id="conversion" title="Конверсия" :min-w="3">
        <p class="text-[var(--gr-muted-fg)]">4,8 % — на 0,3 пункта выше прошлого месяца</p>
      </GrDashboardItem>

      <GrDashboardItem item-id="sources" title="Источники">
        <p class="text-[var(--gr-muted-fg)]">Поиск, письма, партнёры</p>
      </GrDashboardItem>

      <GrDashboardItem item-id="errors" title="Ошибки">
        <p class="text-[var(--gr-muted-fg)]">14 пятисотых за сутки</p>
      </GrDashboardItem>
    </GrDashboard>
  </div>
</template>

Compaction

Compaction
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDashboardCompaction, GrDashboardResponsiveLayout } from '@feugene/granularity-dashboard'
import { compact } from '@feugene/granularity-dashboard'

/**
 * Одна и та же раскладка в четырёх режимах уплотнения.
 *
 * Раскладка нарочно с дырами по обеим осям: только на ней видно, чем режимы
 * отличаются друг от друга, а не от пустой сетки.
 */
const source = [
  { id: 'revenue', x: 3, y: 0, w: 3, h: 2 },
  { id: 'orders', x: 8, y: 1, w: 4, h: 1 },
  { id: 'refunds', x: 1, y: 4, w: 3, h: 1 },
  { id: 'nps', x: 6, y: 5, w: 2, h: 2 },
]

const mode = ref<GrDashboardCompaction>('vertical')

const layout = computed<GrDashboardResponsiveLayout>(() => ({ lg: compact(source, mode.value) }))

const titles: Record<string, string> = {
  revenue: 'Выручка',
  orders: 'Заказы',
  refunds: 'Возвраты',
  nps: 'NPS',
}

// Демо живёт в колонке витрины, а не во весь экран, поэтому пороги свои.
const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-baseline justify-between gap-4">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Одна раскладка, четыре режима
      </span>

      <GrSegmented
        v-model="mode"
        size="sm"
        :options="[
          { value: 'vertical', label: 'Вверх' },
          { value: 'horizontal', label: 'Влево' },
          { value: 'both', label: 'Обе' },
          { value: 'none', label: 'Свободно' },
        ]"
        aria-label="Режим уплотнения"
      />
    </div>

    <GrDashboard
      :layout="layout"
      :compact="mode"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="64"
      aria-label="Режимы уплотнения"
    >
      <GrDashboardItem
        v-for="item in layout.lg"
        :key="item.id"
        :item-id="item.id"
        :title="titles[item.id]"
        overflow="hidden"
      />
    </GrDashboard>
  </div>
</template>

Edge To Edge

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

import { GrBadge, GrButton, GrTable } from '@feugene/granularity'
import type { GrBadgeTone } from '@feugene/granularity'
import type { GrDashboardResponsiveLayout } from '@feugene/granularity-dashboard'

/**
 * Виджет без шапки: заголовка нет, значит и шапки нет — ни в просмотре, ни в
 * редактировании. Ручка переноса выезжает сверху по наведению и по фокусу, а
 * содержимое при переключении режима не сдвигается.
 */
const mode = ref<'view' | 'edit'>('edit')

const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }

const layout = ref<GrDashboardResponsiveLayout>({
  lg: [
    { id: 'rows', x: 0, y: 0, w: 7, h: 4 },
    { id: 'score', x: 7, y: 0, w: 5, h: 4 },
  ],
})

interface Row {
  region: string
  status: string
  tone: GrBadgeTone
  share: string
}

const regions = [
  'Москва',
  'Санкт-Петербург',
  'Новосибирск',
  'Екатеринбург',
  'Казань',
  'Нижний Новгород',
  'Челябинск',
  'Самара',
  'Омск',
  'Ростов-на-Дону',
  'Уфа',
  'Красноярск',
  'Воронеж',
  'Пермь',
  'Волгоград',
  'Краснодар',
  'Саратов',
  'Тюмень',
  'Тольятти',
  'Ижевск',
]

/** Двадцать строк: столько уже не влезает в виджет — и шапка обязана остаться на месте. */
const rows: Row[] = regions.map((region, index) => ({
  region,
  status: index % 7 === 3 ? 'Задержки' : 'Норма',
  tone: index % 7 === 3 ? 'warning' : 'success',
  share: `${Math.max(1, 38 - index * 2)}%`,
}))
</script>

<template>
  <div class="flex flex-col gap-4">
    <GrDashboardToolbar v-model:mode="mode" />

    <GrDashboard
      v-model:layout="layout"
      :mode="mode"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="72"
    >
      <!-- Таблица от края до края: `padding="none"` отдаёт виджет содержимому. -->
      <!--
        Скроллит сама таблица, а не тело виджета: `sticky` у шапки прилипает к
        ближайшему скролл-контейнеру, и со скроллом на теле она уехала бы вместе
        со строками. Отсюда `overflow="hidden"` у виджета — иначе скроллеров два.
      -->
      <GrDashboardItem item-id="rows" aria-label="Регионы" padding="none" overflow="hidden">
        <GrTable size="sm" sticky-header max-height="100%" aria-label="Доли по регионам">
          <template #header>
            <tr>
              <th class="px-3 py-2 text-left font-600">Регион</th>
              <th class="px-3 py-2 text-left font-600">Статус</th>
              <th class="px-3 py-2 text-right font-600">Доля</th>
            </tr>
          </template>

          <tr v-for="row in rows" :key="row.region" class="border-t border-[var(--gr-brd)]">
            <td class="px-3 py-2">{{ row.region }}</td>
            <td class="px-3 py-2">
              <GrBadge size="sm" :tone="row.tone">{{ row.status }}</GrBadge>
            </td>
            <td class="px-3 py-2 text-right [font-variant-numeric:tabular-nums]">{{ row.share }}</td>
          </tr>
        </GrTable>
      </GrDashboardItem>

      <!--
        Одна большая цифра: шапка тут только отняла бы место, прокрутка не нужна,
        а удаление виджета живёт в панели редактирования.
      -->
      <GrDashboardItem
        item-id="score"
        aria-label="Индекс качества"
        overflow="hidden"
        padding="lg"
      >
        <template #editActions>
          <GrButton size="xs" variant="ghost" tone="danger" aria-label="Убрать виджет">
            Убрать
          </GrButton>
        </template>

        <div class="flex h-full flex-col items-center justify-center gap-1 text-center">
          <strong class="text-[length:var(--gr-text-4xl)] leading-[var(--gr-leading-3xl)] [font-variant-numeric:tabular-nums]">
            94
          </strong>
          <span class="text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-sm)] text-[var(--gr-muted-fg)]">
            индекс качества
          </span>
        </div>
      </GrDashboardItem>
    </GrDashboard>
  </div>
</template>

Panel

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

import { GrAvatar, GrBadge, GrDivider, GrLink, GrStatistic, GrTable } from '@feugene/granularity'
import type { GrBadgeTone } from '@feugene/granularity'
import type { GrDashboardResponsiveLayout } from '@feugene/granularity-dashboard'

/**
 * Панель, а не витрина компонентов: виджет — это место под содержимое, и
 * содержимое здесь настоящее. Заголовок каждого виджета живёт в его шапке,
 * а `#actions` занимает то, что относится к заголовку, — бейдж периода.
 */
const mode = ref<'view' | 'edit'>('view')

// Демо живёт в колонке витрины, а не во весь экран, поэтому пороги свои.
const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }

const layout = ref<GrDashboardResponsiveLayout>({
  lg: [
    { id: 'traffic', x: 0, y: 0, w: 8, h: 4, minW: 4 },
    { id: 'signups', x: 8, y: 0, w: 4, h: 2, minW: 3 },
    { id: 'revenue', x: 8, y: 2, w: 4, h: 2, minW: 3 },
    { id: 'duty', x: 0, y: 4, w: 4, h: 4, minW: 3 },
    { id: 'campaigns', x: 4, y: 4, w: 8, h: 4, minW: 4 },
  ],
})

const traffic = Array.from({ length: 14 }, (_, day) => ({
  x: new Date(2026, 6, day + 1),
  y: Math.round(1800 + Math.sin(day / 2.2) * 420 + day * 55),
}))

const trafficSeries = [{
  id: 'sessions',
  label: 'Сессии',
  data: traffic,
  color: 'var(--gr-primary)',
  fillColor: 'var(--gr-primary)',
}]

const signups = [980, 1010, 995, 1042, 1078, 1065, 1120, 1156, 1190, 1215, 1246, 1284]

/** Число — последнее значение ряда, а не отдельная константа: разойтись им нельзя. */
const signupsNow = signups.at(-1)!.toLocaleString('ru-RU')
const signupsDelta = Math.round(((signups.at(-1)! - signups[0]!) / signups[0]!) * 100)

const avatarSvg = encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" fill="none">
    <rect width="96" height="96" fill="#dbeafe" />
    <circle cx="48" cy="36" r="16" fill="#2563eb" opacity="0.18" />
    <path d="M18 80c6-15 18-23 30-23s24 8 30 23" fill="#2563eb" opacity="0.26" />
    <circle cx="48" cy="36" r="13" fill="#2563eb" />
  </svg>
`)
const avatarSrc = `data:image/svg+xml;charset=UTF-8,${avatarSvg}`

interface Campaign {
  name: string
  owner: string
  status: string
  tone: GrBadgeTone
  reach: string
}

const names = [
  'Онбординг',
  'Миграция карт',
  'Напоминание о выплате',
  'Возврат ушедших',
  'Летняя рассрочка',
  'Кэшбэк на топливо',
  'Приведи друга',
  'Апгрейд тарифа',
  'Первый платёж',
  'Реактивация спящих',
  'Push о доставке',
  'Опрос удовлетворённости',
  'Скидка на подписку',
  'Бонус за отзыв',
  'Возврат корзины',
  'Годовая подписка',
  'Партнёрская программа',
  'Ранний доступ',
  'Пробный период',
  'Прощальное письмо',
]
const owners = ['Ольга', 'Максим', 'Анна', 'Пётр', 'Ирина']
const statuses: { status: string, tone: GrBadgeTone }[] = [
  { status: 'Идёт', tone: 'success' },
  { status: 'Ревью', tone: 'info' },
  { status: 'Пауза', tone: 'warning' },
  { status: 'Черновик', tone: 'neutral' },
]

/** Двадцать строк: столько уже не влезает в виджет, и шапка обязана остаться на месте. */
const campaigns: Campaign[] = names.map((name, index) => ({
  name,
  owner: owners[index % owners.length]!,
  ...statuses[index % statuses.length]!,
  reach: `${(18.2 - index * 0.8).toFixed(1).replace('.', ',')}k`,
}))
</script>

<template>
  <div class="flex flex-col gap-4">
    <GrDashboardToolbar v-model:mode="mode">
      <template #start>
        <span class="text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-sm)] text-[var(--gr-muted-fg)]">
          Продукт · июль
        </span>
      </template>
    </GrDashboardToolbar>

    <GrDashboard
      v-model:layout="layout"
      :mode="mode"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="72"
      aria-label="Панель продукта"
    >
      <!-- Площадь: важен не только уровень, но и объём — «сколько всего набежало». -->
      <GrDashboardItem item-id="traffic" title="Трафик" :min-w="4" overflow="hidden">
        <template #actions>
          <GrBadge tone="neutral" size="sm">2 недели</GrBadge>
        </template>

        <GrChartArea
          :series="trafficSeries"
          :height="232"
          curve="smooth"
          include-zero
          aria-label="Сессии за две недели"
        />
      </GrDashboardItem>

      <!--
        Карточка показателя целиком: число отвечает «сколько», спарклайн — «как
        менялось», бейдж — «на сколько за период». Осей у линии нет намеренно.
      -->
      <GrDashboardItem item-id="signups" title="Регистрации" :min-w="3" overflow="hidden">
        <template #actions>
          <GrBadge tone="success" size="sm">+{{ signupsDelta }}%</GrBadge>
        </template>

        <strong class="block text-[length:var(--gr-text-2xl)] leading-[var(--gr-leading-xl)] [font-variant-numeric:tabular-nums]">
          {{ signupsNow }}
        </strong>

        <div class="mt-2">
          <GrSparkline :data="signups" />
        </div>
      </GrDashboardItem>

      <GrDashboardItem item-id="revenue" title="Выручка" :min-w="3" overflow="hidden">
        <GrStatistic
          :value="12.48"
          :precision="2"
          suffix=" млн ₽"
          tone="success"
          trend="up"
          trend-text="+8,2% к прошлому кварталу"
        />
      </GrDashboardItem>

      <!-- Кто сейчас на связи: лицо, имя, роль — и его показатели за смену. -->
      <GrDashboardItem item-id="duty" title="Дежурный" :min-w="3" overflow="hidden">
        <div class="flex flex-col items-center gap-3 text-center">
          <GrAvatar :size="64" :src="avatarSrc" alt="Алексей Дорохов" status="online" />

          <div class="min-w-0">
            <strong class="block truncate">Алексей Дорохов</strong>
            <span class="block truncate text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-sm)] text-[var(--gr-muted-fg)]">
              Поддержка второй линии
            </span>
          </div>

          <GrBadge tone="success" size="sm">На связи до 18:00</GrBadge>

          <GrDivider class="w-full" />

          <dl class="grid w-full grid-cols-2 gap-2">
            <div>
              <dt class="text-[length:var(--gr-control-text-2xs)] text-[var(--gr-muted-fg)]">Заявок</dt>
              <dd class="text-[length:var(--gr-text-base)] [font-variant-numeric:tabular-nums]">12</dd>
            </div>
            <div>
              <dt class="text-[length:var(--gr-control-text-2xs)] text-[var(--gr-muted-fg)]">Ответ</dt>
              <dd class="text-[length:var(--gr-text-base)] [font-variant-numeric:tabular-nums]">4 мин</dd>
            </div>
          </dl>
        </div>
      </GrDashboardItem>

      <!--
        Скроллит сама таблица, а не тело виджета: `sticky` у шапки прилипает к
        ближайшему скролл-контейнеру, и со скроллом на теле она уехала бы вместе
        со строками. Шапка самого виджета при этом остаётся на месте.
      -->
      <GrDashboardItem item-id="campaigns" title="Кампании" :min-w="4" padding="none" overflow="hidden">
        <template #actions>
          <GrBadge tone="neutral" size="sm">{{ campaigns.length }}</GrBadge>
        </template>

        <GrTable size="sm" sticky-header max-height="100%" aria-label="Активные кампании">
          <template #header>
            <tr>
              <th class="px-3 py-2 text-left font-600">Название</th>
              <th class="px-3 py-2 text-left font-600">Владелец</th>
              <th class="px-3 py-2 text-left font-600">Статус</th>
              <th class="px-3 py-2 text-right font-600">Охват</th>
            </tr>
          </template>

          <tr v-for="row in campaigns" :key="row.name" class="border-t border-[var(--gr-brd)]">
            <td class="px-3 py-2">{{ row.name }}</td>
            <td class="px-3 py-2 text-[var(--gr-muted-fg)]">{{ row.owner }}</td>
            <td class="px-3 py-2">
              <GrBadge size="sm" :tone="row.tone">{{ row.status }}</GrBadge>
            </td>
            <td class="px-3 py-2 text-right [font-variant-numeric:tabular-nums]">{{ row.reach }}</td>
          </tr>
        </GrTable>

        <template #footer>
          <GrLink href="#" size="sm">Все кампании</GrLink>
        </template>
      </GrDashboardItem>
    </GrDashboard>
  </div>
</template>

Persistence

Persistence
<script setup lang="ts">
import { ref } from 'vue'
import { localStorageLayoutStorage, useDashboardLayout } from '@feugene/granularity-dashboard'

const mode = ref<'view' | 'edit'>('edit')

const { layout, reset } = useDashboardLayout({
  initial: {
    lg: [
      { id: 'today', x: 0, y: 0, w: 4, h: 2 },
      { id: 'week', x: 4, y: 0, w: 4, h: 2 },
      { id: 'month', x: 8, y: 0, w: 4, h: 2 },
    ],
  },
  storage: localStorageLayoutStorage(),
  key: 'showcase-dashboard-demo',
  version: 1,
})

// Демо живёт в колонке витрины, а не во весь экран, поэтому пороги свои:
// брейкпоинты — свойство приложения, а не константа пакета.
const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }
</script>

<template>
  <div class="flex flex-col gap-4">
    <GrDashboardToolbar v-model:mode="mode" resettable @reset="reset" />

    <GrDashboard
      v-model:layout="layout"
      :mode="mode"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="72"
    >
      <GrDashboardItem item-id="today" title="Сегодня">
        <p class="text-[var(--gr-muted-fg)]">Разложите виджеты и перезагрузите страницу.</p>
      </GrDashboardItem>

      <GrDashboardItem item-id="week" title="Неделя">
        <p class="text-[var(--gr-muted-fg)]">Раскладка вернётся такой, какой вы её оставили.</p>
      </GrDashboardItem>

      <GrDashboardItem item-id="month" title="Месяц">
        <p class="text-[var(--gr-muted-fg)]">«Сбросить» возвращает исходную.</p>
      </GrDashboardItem>
    </GrDashboard>
  </div>
</template>

Static

Static
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDashboardResponsiveLayout } from '@feugene/granularity-dashboard'

const layout = ref<GrDashboardResponsiveLayout>({
  lg: [
    { id: 'alert', x: 0, y: 0, w: 12, h: 1, static: true },
    { id: 'left', x: 0, y: 1, w: 6, h: 2 },
    { id: 'right', x: 6, y: 1, w: 6, h: 2 },
  ],
})

// Демо живёт в колонке витрины, а не во весь экран, поэтому пороги свои:
// брейкпоинты — свойство приложения, а не константа пакета.
const breakpoints = { lg: 680, md: 520, sm: 400, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }
</script>

<template>
  <GrDashboard
    v-model:layout="layout"
    mode="edit"
    :breakpoints="breakpoints"
    :cols="cols"
    :row-height="72"
    compact="none"
  >
    <GrDashboardItem item-id="alert" title="Плановые работы" static>
      <p class="text-[var(--gr-muted-fg)]">Закреплённый виджет не двигается ни сам, ни соседями.</p>
    </GrDashboardItem>

    <GrDashboardItem item-id="left" title="Заказы">
      <p class="text-[var(--gr-muted-fg)]">Свободная сетка: виджет остаётся там, куда его положили.</p>
    </GrDashboardItem>

    <GrDashboardItem item-id="right" title="Возвраты">
      <p class="text-[var(--gr-muted-fg)]">Дыра в раскладке при этом сохраняется.</p>
    </GrDashboardItem>
  </GrDashboard>
</template>

Transfer

Transfer
<script setup lang="ts">
import { computed, ref } from 'vue'
import type {
  GrDashboardDropEvent,
  GrDashboardPaletteItem,
  GrDashboardResponsiveLayout,
} from '@feugene/granularity-dashboard'
import { addItem, removeItem } from '@feugene/granularity-dashboard'

/**
 * Перетаскивание из каталога поверх кнопки, а не вместо неё.
 *
 * Сетка говорит, куда бросили, а кладёт приложение — тем же `addItem`, что и по
 * кнопке. Опции и брейкпоинт приходят в событии: посчитай их демо своими,
 * виджет встал бы не туда, где только что стояла подложка.
 */
const catalogue: GrDashboardPaletteItem[] = [
  { id: 'sessions', title: 'Сессии', description: 'За неделю', defaultSize: { w: 6, h: 2 } },
  { id: 'revenue', title: 'Выручка', description: 'За квартал', defaultSize: { w: 6, h: 2 } },
  { id: 'errors', title: 'Ошибки', description: 'По часам', defaultSize: { w: 8, h: 2 } },
]

const layout = ref<GrDashboardResponsiveLayout>({ lg: [{ id: 'sessions', x: 0, y: 0, w: 6, h: 2 }] })

const placed = computed(() => layout.value.lg ?? [])
const placedIds = computed(() => new Set(placed.value.map(item => item.id)))

/** Уже поставленный виджет остаётся в каталоге, но выключенным: список не прыгает. */
const items = computed<GrDashboardPaletteItem[]>(() => catalogue.map(item => ({
  ...item,
  disabled: placedIds.value.has(item.id),
})))

const titles = new Map(catalogue.map(item => [item.id, item.title]))

function drop(event: GrDashboardDropEvent): void {
  const { transfer, cell, breakpoint, options } = event
  if (placedIds.value.has(transfer.id))
    return

  const next = addItem(
    layout.value[breakpoint] ?? [],
    { id: transfer.id, x: 0, y: 0, w: transfer.size.w, h: transfer.size.h },
    options,
    cell,
  )

  layout.value = { ...layout.value, [breakpoint]: next }
}

function add(item: GrDashboardPaletteItem): void {
  const size = item.defaultSize ?? { w: 6, h: 2 }

  layout.value = {
    ...layout.value,
    lg: addItem(placed.value, { id: item.id, x: 0, y: 0, w: size.w, h: size.h }, { cols: 12 }),
  }
}

function remove(id: string): void {
  layout.value = { ...layout.value, lg: removeItem(placed.value, id, { cols: 12 }) }
}

const breakpoints = { lg: 520, md: 400, sm: 320, xs: 0 }
const cols = { lg: 12, md: 8, sm: 4, xs: 2 }
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)] lg:items-start">
    <GrDashboardPalette :items="items" aria-label="Что можно добавить" @add="add" />

    <GrDashboard
      v-model:layout="layout"
      mode="edit"
      :breakpoints="breakpoints"
      :cols="cols"
      :row-height="72"
      aria-label="Сборка дашборда перетаскиванием"
      @item-drop="drop"
    >
      <template #empty>
        <p class="text-[var(--gr-muted-fg)]">
          Перетащите виджет из каталога или нажмите «Добавить».
        </p>
      </template>

      <GrDashboardItem
        v-for="item in placed"
        :key="item.id"
        :item-id="item.id"
        :title="titles.get(item.id)"
        overflow="hidden"
      >
        <template #editActions>
          <GrButton
            size="xs"
            variant="ghost"
            tone="danger"
            :aria-label="`Убрать «${titles.get(item.id)}»`"
            @click="remove(item.id)"
          >
            Убрать
          </GrButton>
        </template>

        <p class="text-[var(--gr-muted-fg)]">Место под содержимое виджета.</p>
      </GrDashboardItem>
    </GrDashboard>
  </div>
</template>

Two Boards

Two Boards
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDashboardDropEvent, GrDashboardResponsiveLayout, GrDashboardTransfer } from '@feugene/granularity-dashboard'
import { addItem } from '@feugene/granularity-dashboard/layout'

/**
 * Обмен виджетами между двумя дашбордами.
 *
 * Правый намеренно `:transferable="false"` — принимает, но своих не отдаёт: на
 * нём видно, что принимать и отдавать это разные разрешения, а не одно.
 */
const mode = ref<'view' | 'edit'>('edit')

const active = ref<GrDashboardResponsiveLayout>({
  lg: [
    { id: 'revenue', x: 0, y: 0, w: 6, h: 2 },
    { id: 'conversion', x: 0, y: 2, w: 6, h: 2 },
  ],
})

const archive = ref<GrDashboardResponsiveLayout>({
  lg: [{ id: 'sources', x: 0, y: 0, w: 6, h: 2 }],
})

const TITLES: Record<string, string> = {
  revenue: 'Выручка',
  conversion: 'Конверсия',
  sources: 'Источники',
}

const breakpoints = { lg: 300, md: 240, sm: 180, xs: 0 }
const cols = { lg: 6, md: 6, sm: 4, xs: 2 }

const log = ref<string[]>([])

/** Кладёт приложение — сетка только сказала, что и куда бросили. */
function onDrop(target: 'active' | 'archive', event: GrDashboardDropEvent): void {
  const board = target === 'active' ? active : archive
  const current = board.value[event.breakpoint] ?? board.value.lg ?? []

  board.value = {
    ...board.value,
    [event.breakpoint]: addItem(
      current,
      { id: event.transfer.id, x: 0, y: 0, w: event.transfer.size.w, h: event.transfer.size.h },
      event.options,
      event.cell,
    ),
  }

  log.value = [`«${TITLES[event.transfer.id] ?? event.transfer.id}» приехал`, ...log.value].slice(0, 3)
}

function onTransferOut(id: string, payload: GrDashboardTransfer): void {
  log.value = [`«${TITLES[id] ?? id}» уехал из ${payload.from ? 'дашборда' : 'каталога'}`, ...log.value].slice(0, 3)
}

const activeIds = computed(() => (active.value.lg ?? []).map(item => item.id))
const archiveIds = computed(() => (archive.value.lg ?? []).map(item => item.id))
</script>

<template>
  <div class="flex flex-col gap-4">
    <GrDashboardToolbar v-model:mode="mode" />

    <div class="grid gap-4 md:grid-cols-2">
      <section class="flex flex-col gap-2">
        <h4 class="text-[length:var(--gr-control-text-sm)] font-600">Рабочий</h4>

        <GrDashboard
          v-model:layout="active"
          :mode="mode"
          :breakpoints="breakpoints"
          :cols="cols"
          :row-height="64"
          aria-label="Рабочий дашборд"
          class="min-h-40 rounded-[var(--gr-radius-lg)] border border-dashed border-[var(--gr-brd)] p-2"
          @item-drop="onDrop('active', $event)"
          @item-transfer-out="onTransferOut"
        >
          <GrDashboardItem v-for="id in activeIds" :key="id" :item-id="id" :title="TITLES[id]">
            <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">Данные за квартал</p>
          </GrDashboardItem>
        </GrDashboard>
      </section>

      <section class="flex flex-col gap-2">
        <h4 class="text-[length:var(--gr-control-text-sm)] font-600">Архив — принимает, но не отдаёт</h4>

        <GrDashboard
          v-model:layout="archive"
          :mode="mode"
          :breakpoints="breakpoints"
          :cols="cols"
          :row-height="64"
          :transferable="false"
          aria-label="Архивный дашборд"
          class="min-h-40 rounded-[var(--gr-radius-lg)] border border-dashed border-[var(--gr-brd)] p-2"
          @item-drop="onDrop('archive', $event)"
        >
          <GrDashboardItem v-for="id in archiveIds" :key="id" :item-id="id" :title="TITLES[id]">
            <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">Отложено</p>
          </GrDashboardItem>
        </GrDashboard>
      </section>
    </div>

    <p v-if="log.length > 0" class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ log.join(' · ') }}
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Включите редактирование и утащите виджет за ручку из рабочего дашборда в архивный. Жест
      начинается как обычный перенос и <strong>перерастает</strong> в межсеточный, когда указатель
      уходит за край своей сетки в чужую: просто выход за край переносом не считается, иначе на
      длинной странице виджет отрывался бы от любой прокрутки. Отпустите между дашбордами или
      нажмите <kbd>Esc</kbd> — виджет вернётся на место.
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Из архивного виджет не забрать: у него <code>:transferable="false"</code>. Принимать и отдавать —
      разные разрешения, и архив тем и архив, что складывать в него можно, а разбирать нельзя.
      Виджет из исходной раскладки убирает <strong>сама сетка</strong> и сообщает об этом
      <code>itemTransferOut</code>; кладёт по-прежнему приложение, по <code>itemDrop</code>. Удаление
      однозначно — разметки для него не нужно, в отличие от вставки, — и происходит только после
      успешного приземления.
    </p>
  </div>
</template>

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