GrDashboard

Package: @feugene/granularity-dashboardcompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the layout is determined by the user — an analytics panel, a workspace, a summary for a particular role: the set and the arrangement of the widgets belong to a person rather than to the designer of the screen;
  • the layout has to survive a reloaduseDashboardLayout puts it into localStorage or onto the server through the storage interface;
  • a laptop and a tablet need different things — the layout is stored per breakpoint, and a missing one is derived from the neighbouring one above;
  • the widgets are of different sizesminW/minH/maxW/maxH are declared by the widget itself, because only it knows that “this chart is unreadable below three rows”.

When to take something else

NeedTake
The layout is fixed by the designCSS Grid and GrCard
Two areas whose boundary is moved by the userGrSplitter
The order of the elements in a single listGrSortableList
Sections switched with tabsGrTabs
Table rows with a changeable order of columnsGrDataTable
Where a widget takes its data fromgranularity-datasource
What a widget draws its content withgranularity-charts / granularity-chrono

A dashboard is not a way to lay out a static screen. If the user moves nothing, a grid of widgets gives an extra layer of state, a storage and an edit mode where CSS Grid would have been enough.

A widget cannot be moved into emptiness along the axis of compaction

compact knows four modes: 'vertical' (the default) pulls the widgets up, 'horizontal' to the left, 'both' in both directions, and 'none' leaves them where they were put. A widget released into emptiness moves back — down with 'vertical', to the right with 'horizontal', in both directions with 'both'. That is not a defect but the definition of compaction. If a free grid with holes is needed — compact: 'none'.

A push on a collision propagates along a chain and always downwards, in any mode: there are infinitely many rows, while to the right the right edge of the grid gets in the way. In the horizontal modes that gives a combination worth seeing once: a neighbour pushed downwards immediately moves to the left.

Where it was dropped is said by the grid, and the putting is done by the application

The grid does not write a widget dragged in from the catalogue into the layout itself: it emits itemDrop with the cell, the breakpoint and its own layout options, and addItem is called by the consumer — the same way as with a button in the catalogue. Otherwise a widget would appear in the layout for which the application drew no markup.

While the pointer is over the grid, the underlay shows the place for real: the preview is computed with the same addItem and the same options that will travel into the event. That is why the options travel into the event: were the application to compute the layout with its own, the result would diverge from what the person has just seen.

A collision with a static widget cancels the whole move

Moving a static widget is impossible by definition, and putting one on top of another would mean silently spoiling the layout. preventCollision does the same for any collision. There is no half-way outcome — “it moved as far as it fit” — here: the user would not see what exactly did not fit.

`itemAutoResize` is not the same as `itemResize`

A widget with auto-height adjusts to its content itself, and its height changes with no participation from a person: the data arrived, a section was expanded, the language changed. Such a change travels into update:layout on a par with the rest — the layout remains the only truth — but is accompanied by a separate event.

EventWhen
itemResizethe user pulled a corner or pressed an arrow
itemAutoResizethe content of the widget asked for a different height

Telling them apart is necessary for anyone who decides from the edits whether the layout is dirty: without that, “save the changes?” would pop up after data has loaded into a widget. The details of the measurement — ./GrDashboardItem.md.

`itemTransferOut` — the widget went to the neighbours

Two dashboards on a page exchange widgets by dragging: a gesture by the handle that left the edge of its own grid into another becomes a transfer between them.

EventWhoseWhen
itemDropthe receiversomething was put into the grid — from the catalogue or from another dashboard
itemTransferOutthe sourcethe widget left; the grid removed it from its own layout itself

The receiver can tell the sources apart by transfer.source ('palette' or 'dashboard') and transfer.from — the identifier of the grid the widget came from. The details — ../model.md.

Giving away and receiving are allowed separately: transferable on the source, droppable on the receiver.

A zero width is not the narrowest screen

A hidden tab, a collapsed panel, a display: none give a zero width of the container, and the breakpoint does not switch because of that. Taking one for the other would mean moving the dashboard to two columns and writing that layout into the model — that is, losing the user’s setting because they opened a neighbouring tab.

Limits

What is not in the package and will not be — written down so that the question does not come up again.

  • virtualisation of the grid — there are dozens of widgets on a dashboard rather than thousands, and the price of the complexity does not pay off;
  • nested dashboards — a grid inside a grid poses an unanswerable question about whose gesture to handle;
  • a card of its own instead of GrCard — a copy would diverge from the cards of the rest of the application;
  • a report builder with fields and aggregates — that is a product rather than a component.

Install

npm i @feugene/granularity-dashboard

Import

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

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 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>

Component documentationAll components