GrDashboard
Берут, когда раскладку определяет пользователь.
Когда брать
- раскладку определяет пользователь — аналитическая панель, рабочий стол, сводка под конкретную роль: набор и расположение виджетов принадлежат человеку, а не дизайнеру экрана;
- раскладка обязана пережить перезагрузку —
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
<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
<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
<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
<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
<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
<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
<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
<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
<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>