GrCard

  • ядро
  • Данные
  • API · 11 props

Берут, когда контенту нужна поверхность.

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

Обзор

Когда брать

  • контенту нужна поверхность — рамка, фон, радиус и тень одним компонентом вместо своих классов;
  • у блока есть заголовок — title даёт настоящий h2…h6, по которому отчёт из шести карточек обходится структурой;
  • карточка кликабельна — clickable/href дают роль и клавиатуру целиком, а не @click на div;
  • карточек много — общий вид держится variant, а не копией классов в каждом месте;
  • отступы задаются раскладкой — padding включая none для таблицы или картинки край в край.

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

НужноБерите
Нужна структура шапки, тела и подвала окнаGrDialog
Карточка — виджет на сетке дашбордаGrDashboardItem
Карточка объясняет пустотуGrEmptyState
Внутри список строкGrList
Нужен только разделительGrDivider

<GrCard> без пропов рендерит один <div> со слотом: ни обёрток, ни отступов. Это позволяет ставить его под любую раскладку, не борясь с чужими полями.

Варианты все тона и виды — настоящим компонентом

variant

  • elevated
  • outlined
  • ghost

Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.

Playground контролы выводятся из API, а не пишутся руками

Сцена

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

0 изменено· состояние — в адресе

Примеры 5 у каждого — живое демо и его код

  • Вся анатомия в одной карточке

    Заголовок и описание — пропы, а не разметка: карточка печатает настоящий заголовок над приглушённой строкой. actions ставит статус рядом с ним, не отнимая этот заголовок, body-class раскладывает тело, а footer держит то, что с карточкой делают: кнопки в теле спорили бы с содержимым за внимание.

    Team plan

    €48 per seat a month, billed yearly

    Active
    Seats9 of 12
    Storage182 GB of 500 GB
    Automation runs8,740 of 10,000
    Renews on 12 March 2027
  • Кнопки внутри, ссылка в заголовке

    Карточка со своими кнопками не может быть ссылкой: интерактив внутри интерактива ломает клавиатуру и скринридер. hoverable подсвечивает поверхность, не делая её интерактивной, слот header несёт заголовок со ссылкой, а as="article" помечает каждую карточку самостоятельной записью.

    GW

    Retry webhook deliveries with backoff

    #4182 · Grace Wu · 2 hours ago

    Failed deliveries are retried five times with exponential backoff instead of being dropped after the first 5xx.

    backendreliability
    +142−38 · 6 files
    LR

    Move invoice PDFs to the EU bucket

    #4179 · Linus Ray · yesterday

    New invoices are written to eu-central-1; a one-off job migrates the 18 400 existing files.

    compliance
    +61−12 · 3 files
  • Таблица край в край

    Без padding у тела нет полей, и таблица идёт от рамки до рамки, а шапка, которую карточка рисует сама, свои поля сохраняет. as="section" с region-label делает карточку именованной областью страницы, а heading-level вписывает заголовок в структуру страницы.

    Recent invoices

    Last 30 days · 128 invoices

    InvoiceCustomerIssuedAmountStatus
    INV-2041Northwind Traders2 Oct 2026€4,320.00Paid
    INV-2040Contoso Ltd29 Sep 2026€12,850.00Due
    INV-2039Fabrikam Inc24 Sep 2026€960.00Overdue
    INV-2038Tailspin Toys19 Sep 2026€2,475.50Paid
    Showing 4 of 128View all invoices
  • Вся карточка — ссылка или кнопка

    С href карточка — одна ссылка и одна остановка табуляции, с именем из заголовка; с clickable — кнопка, которая отдаёт click. Сетка карточек берёт variant и padding один раз из GrConfigProvider, а не повторяет их на каждой.

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
variantGrCardVariant | undefinedundefined`elevated` — рамка, фон и тень; `outlined` — без тени; `ghost` — без рамки.
titlestring | undefinedundefinedЗаголовок карточки — настоящий `h2`…`h6`, а не жирная строка: отчёт из шести карточек иначе не обойти по структуре, после `h1` страницы в нём нет ни одного заголовка.
asstring | Component | undefinedundefinedСвой корневой тег: компонент-ссылка (`RouterLink`, `Link` от Inertia) или семантический тег страницы (`section`, `article`, `aside`). Сильнее `href`. Неинтерактивный тег — только замена `div`: ни подсветки, ни кольца фокуса.
hrefstring | undefinedundefinedКарточка-ссылка.
paddingGrCardPadding | undefinedundefinedВнутренние отступы. По умолчанию `none`: карточка — поверхность, а её содержимое (`GrCollapse`, `GrList`) само знает про свои отступы.
regionLabelstring | undefinedundefinedИмя карточки как области страницы: `role="region"` плюс `aria-label`. Безымянная `<section>` для скринридера — обычный контейнер, и это норма: именованных областей на страницу нужно немного, иначе их обзор перестаёт помогать. Интерактивной карточке не даётся — `role="region"` на `<button>` невалиден.
clickableboolean | undefinedfalseКарточка-кнопка: интерактивна вся поверхность.
hoverableboolean | undefinedfalseПодсветка при наведении без интерактивности.
bodyClassstring | undefinedundefinedКлассы обёртки тела — она появляется вместе с секциями.
descriptionstring | undefinedundefinedПояснение под заголовком.
headingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedУровень заголовка под структуру страницы. Не задан — из `GrConfigProvider`, иначе `3`.

Слоты

SlotTypeОписание
defaultanyСодержимое карточки.
headeranyШапка вместо пропов `title` и `description`.
actionsanyДействия справа в собственной шапке карточки: «⋯», «Обновить», переключатель периода. Включают шапку так же, как `title`. Со слотом `#header` не сочетается: тот заменяет шапку целиком, и всё, что в ней есть, рисует потребитель.
footeranyПодвал: действия, сводка, пагинация.

События

EventСигнатураОписание
click(event: "click", event: MouseEvent): void—

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrCardProps, GrCardEmits, GrCardConfigurableProps } from '@feugene/granularity/components/GrCard'

Файлdist/types/components/GrCard/GrCard.vue.d.ts

GrCardPropsinterface
interface GrCardProps {
    /**
     * Внутренние отступы. По умолчанию `none`: карточка — поверхность, а её
     * содержимое (`GrCollapse`, `GrList`) само знает про свои отступы.
     */
    padding?: GrCardPadding;
    /** `elevated` — рамка, фон и тень; `outlined` — без тени; `ghost` — без рамки. */
    variant?: GrCardVariant;
    /**
     * Свой корневой тег: компонент-ссылка (`RouterLink`, `Link` от Inertia) или
     * семантический тег страницы (`section`, `article`, `aside`). Сильнее `href`.
     * Неинтерактивный тег — только замена `div`: ни подсветки, ни кольца фокуса.
     */
    as?: string | Component;
    /** Карточка-ссылка. */
    href?: string;
    /**
     * Имя карточки как области страницы: `role="region"` плюс `aria-label`.
     * Безымянная `<section>` для скринридера — обычный контейнер, и это норма:
     * именованных областей на страницу нужно немного, иначе их обзор перестаёт
     * помогать. Интерактивной карточке не даётся — `role="region"` на `<button>`
     * невалиден.
     */
    regionLabel?: string;
    /** Карточка-кнопка: интерактивна вся поверхность. */
    clickable?: boolean;
    /** Подсветка при наведении без интерактивности. */
    hoverable?: boolean;
    /** Классы обёртки тела — она появляется вместе с секциями. */
    bodyClass?: string;
    /**
     * Заголовок карточки — настоящий `h2`…`h6`, а не жирная строка: отчёт из
     * шести карточек иначе не обойти по структуре, после `h1` страницы в нём
     * нет ни одного заголовка.
     */
    title?: string;
    /** Пояснение под заголовком. */
    description?: string;
    /** Уровень заголовка под структуру страницы. Не задан — из `GrConfigProvider`, иначе `3`. */
    headingLevel?: GrCardHeadingLevel;
}
GrCardEmitsinterface
interface GrCardEmits {
    /** Клик по корню карточки. С клавиатуры приходит только у `clickable` и карточки-ссылки. */
    (e: 'click', event: MouseEvent): void;
}

Файлdist/types/components/GrCard/config.d.ts

grCardConfigconst
const grCardConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCard">;

Файлdist/types/components/GrCard/defaults.d.ts

GrCardConfigurablePropsinterface

Пропы GrCard, настраиваемые глобально через <GrConfigProvider :component-defaults="{ GrCard: { … } }">. Только оформление — см. GrButton/defaults.ts.

interface GrCardConfigurableProps {
    padding: GrCardPadding;
    variant: GrCardVariant;
    headingLevel: GrCardHeadingLevel;
}

Файлdist/types/components/GrCard/grCardStyles.d.ts

GrCardPaddingtype
type GrCardPadding = 'none' | 'sm' | 'md' | 'lg';
GrCardVarianttype
type GrCardVariant = 'elevated' | 'outlined' | 'ghost';
GrCardHeadingLeveltype
type GrCardHeadingLevel = typeof GR_CARD_HEADING_LEVELS[number];

Файлdist/types/components/GrCard/safelist.d.ts

grCardSafelistconst
const grCardSafelist: string[];

Использование из прозы библиотеки

Дефолт — одна поверхность и ничего сверх

<GrCard> без пропов рендерит один <div> со слотом внутри: ни обёрток, ни отступов. Это не осторожность ради осторожности — на GrCard стоят GrCollapse и GrList, и сдвинутый дефолт поехал бы у них. Отсюда padding="none" по умолчанию.

Отступы
vue
<GrCard padding="md">
…
</GrCard>

none (по умолчанию), sm (12px), md (16px), lg (24px). Пока секций нет, отступ живёт на самой поверхности — лишней обёртки не появляется.

Варианты
ЗначениеЧто даёт
elevated (по умолчанию)рамка + фон + тень
outlinedрамка + фон, без тени
ghostтолько фон — карточка внутри карточки не рисует вторую рамку

padding и variant читаются из GrConfigProvider:

vue
<GrConfigProvider :component-defaults="{ GrCard: { padding: 'md', variant: 'outlined' } }">
Секции
vue
<GrCard padding="md">
  <template #header>
    <h3>Релиз 0.15</h3>
  </template>

  Тело карточки.

  <template #footer>
    <GrButton size="sm">Опубликовать</GrButton>
  </template>
</GrCard>

Шапка отбивается border-b, подвал — border-t. Как только появляется хотя бы одна секция (или bodyClass), отступ переезжает с поверхности на секции — иначе он сложился бы вдвое.

Карточка отбивает то, что рисует сама. Шапка из title — её собственная, и отступ ей полагается независимо от padding, включая дефолтный none: иначе заголовок и разделитель под ним оказываются прижаты к рамке. Это и есть типовая карточка отчёта — заголовок с отступом, таблица край в край:

vue
<GrCard title="Документы" variant="outlined">
  <GrDataTable :rows="rows" :columns="columns" />
</GrCard>

Слоты #header и #footer наполняет потребитель — их отступы идут из padding и остаются его заботой. Правило то же, что и у приоритета: #header сильнее пропов и заменяет шапку целиком, а вместе с ней и ответственность за отступы. Иначе потребитель, у которого внутри слота свои отступы, получил бы двойные.

bodyClass кладёт классы на обёртку тела — там же, где обычно нужен grid gap-*.

Заголовок — настоящий, а не жирная строка
vue
<GrCard title="Выручка за месяц" description="Без учёта возвратов" padding="md">
  <RevenueChart />
</GrCard>

title печатается тегом h2…h6 (headingLevel, по умолчанию h3), а не <span class="font-semibold">. Разница не косметическая: страница из шести карточек, где после h1 нет ни одного заголовка, обходится скринридером только подряд — прыгать между блоками нечем.

#header сильнее пропов: слот занят — title и description не рендерятся вовсе. Нестандартная шапка не обязана объяснять, почему она не title.

Действие рядом с заголовком — слот #actions, и шапка при этом остаётся своей: заголовок печатается тем же h2…h6, отступы берутся из шкалы карточки.

vue
<GrCard title="Продажи за неделю" :heading-level="3">
  <template #actions>
    <GrButton size="xs" variant="ghost">Обновить</GrButton>
  </template>
</GrCard>

Слот появился не ради удобства: третьего случая не было — либо заголовок из пропов, либо #header целиком, — и любая кнопка в шапке заставляла забрать слот и переписать заголовок вручную. К этому обходу независимо пришли GrDashboardItem в кольце и обёртки потребителей, и все теряли одно и то же: настоящий уровень заголовка и шкалу отступов.

С #header слот не сочетается: тот заменяет шапку целиком, включая место, куда действия встали бы. GrDashboardItem по-прежнему пользуется #header — у него левее заголовка живёт ручка перетаскивания, а её #actions не выражает.

Карточка-ссылка берёт доступное имя из заголовка (aria-labelledby), а не из всего содержимого подряд.

У кликабельной карточки заголовка не будет. Корень там — <button>, а его контент-модель это phrasing content: <h3> внутри невалиден. title печатается <span>, и в dev компонент об этом предупреждает. Нужен и заголовок, и переход — hoverable плюс ссылка в самом заголовке; это тот же приём, что и с действиями внутри (ниже).

Карточка-ссылка и карточка-кнопка
vue
<GrCard href="/reports/42" padding="md">
…
</GrCard>

<GrCard clickable padding="md" @click="open">
…
</GrCard>

<GrCard :as="RouterLink" :to="{ name: 'report' }" padding="md">
…
</GrCard>

Порядок выбора тега — as → <a href> → <button clickable> → <div>, как у GrLink и GrListItem. Интерактивная карточка получает кольцо фокуса и подсветку при наведении.

Интерактивность даёт разрешённый тег, а не сам факт as. Кольцо фокуса достаётся тому, кто умеет фокус: <button>, <a> со ссылкой и компоненту роутера, про который до рендера известно только то, что он рендерит <a>. as="section" не делает карточку интерактивной — см. ниже.

Ограничение: внутрь кликабельной карточки нельзя класть кнопки и ссылки — интерактив внутри интерактива ломает и клавиатуру, и скринридер (axe: nested-interactive). Карточке с действиями внутри нужен hoverable плюс ссылка в заголовке — визуально это то же самое, семантически — правильно.

hoverable подсвечивает карточку, не делая её интерактивной.

Семантика страницы: as — это замена тега

Страница, у которой блок обязан быть <section> или <article> по разметке документа, берёт as со строковым тегом:

vue
<GrCard as="section" title="Продажи" padding="md">
…
</GrCard>

Меняется только тег. Классы, отступы, заголовок и слоты — те же, что у <div>: неинтерактивный тег не получает ни подсветки под курсором, ни кольца фокуса, ни aria-labelledby. Карточка остаётся поверхностью, а разметка страницы становится честной.

<div> остаётся дефолтом намеренно. HTML-спека не советует section там, где элемент нужен ради оформления, а карточка чаще всего именно поверхность — GrCollapse, GrList и GrSortableList стоят на ней как раз в этом качестве. Тег выбирает страница, которая знает свою структуру, а не компонент.

Обратная сторона правила: clickable или href вместе с тегом, который не попадает в таб-порядок, — ошибка вёрстки. Карточка будет кликаться мышью и останется недостижимой с клавиатуры, и в dev компонент об этом предупреждает.

Карточка как область страницы

Безымянная <section> для скринридера — обычный контейнер, и это норма. Имя превращает её в лендмарк, а именованных областей на страницу нужно немного: десяток карточек-регионов делает их обзор бесполезным. Поэтому имя даётся по явной просьбе — regionLabel:

vue
<GrCard as="section" region-label="Продажи за август" title="Продажи" padding="md">
…
</GrCard>

Проп ставит role="region" и aria-label — тот же приём, что у GrTable и GrFormSection. Интерактивной карточке он не даётся и в dev предупреждает: role="region" поверх <button> невалиден, а имя ссылке уже дал заголовок.