GrCollapse
Берут, когда содержимого много, а нужно не всё сразу.
Когда брать
- содержимого много, а нужно не всё сразу — вопросы и ответы, настройки, детали записи;
- открыта одна секция за раз —
accordionзакрывает предыдущую; - секции нужны незрячему —
headingLevelставит заголовок нужного уровня, и обход по заголовкам работает; - открытие надо перехватить —
beforeChangeоткладывает раскрытие до загрузки или подтверждения.
Когда взять другое
| Нужно | Берите |
|---|---|
| Разделы переключаются, а не раскрываются | GrTabs |
| Разделы формы идут подряд | GrFormSection |
| Данные вложены | GrTree |
| Содержимое открывается поверх страницы | GrDialog / GrDrawer |
Уровень заголовка
Заголовок секции рендерится тегом h3, а headingLevel подгоняет его под
структуру страницы: в разделе <h4> аккордеон обязан начинаться с h5, иначе
навигация по заголовкам получает разрыв уровней. Кнопка остаётся внутри
заголовка — этого требует APG для accordion.
Поверхность
borderless убирает обёртку в GrCard: аккордеон внутри карточки, сайдбара или
панели фильтров иначе получает вторую рамку и вторую тень. divided управляет
разделителями между секциями.
<GrCollapse v-model="open" borderless :heading-level="5" expand-icon-position="start">
<GrCollapseItem name="filters" title="Фильтры">
<template #extra>
<GrBadge size="sm">3</GrBadge>
</template>
…
</GrCollapseItem>
</GrCollapse>
Слот #icon заменяет шеврон, expandIconPosition переставляет его перед
заголовком. Слот #extra (счётчик, бейдж, кнопка) рендерится рядом с
триггером, а не внутри: <button> в <button> — невалидная разметка, и axe
ловит её как nested-interactive.
Guard на переключение
beforeChange(name, expanding) отменяет переключение, вернув false. Второй
аргумент — куда идёт секция, чтобы «сохранить изменения?» спрашивалось только на
сворачивании. Пока guard не ответил, повторный клик по тому же заголовку
игнорируется: иначе два подтверждения подряд вернули бы состояние к исходному.
async function beforeChange(name: GrCollapseValue, expanding: boolean): Promise<boolean> {
if (expanding)
return true
return confirmDiscardChanges(name)
}Клавиатура и вложенность
Стрелки ↑/↓ (по кругу) и Home/End ходят только по заголовкам своего
аккордеона: вложенный GrCollapse внутри раскрытой панели в обход не попадает.
Свёрнутая панель помечена inert — ни Tab, ни скринридер в неё не заходят,
при этом (в отличие от hidden) анимация раскрытия сохраняется.
Пустое состояние
<GrCollapse>
<GrCollapseItem v-for="item in filtered" :key="item.name" v-bind="item" />
</GrCollapse>
Фильтр ничего не нашёл — аккордеон сам покажет текст вместо пустой рамки: рамка без содержимого читается как поломка, а не как «пока пусто».
Пустоту считает содержимое слота, а не длина ваших данных, поэтому работает и
v-for по пустому массиву, и v-if, ничего не отрисовавший. Комментарии,
которые оставляет после себя v-if, и переносы строк из шаблона содержимым не
считаются — иначе заглушка не появилась бы никогда.
Текст берётся из локали (gr.collapse.empty). Перебить его можно двумя
способами, от простого к общему:
<GrCollapse empty-text="Нет разделов" />
<GrCollapse>
<template #empty>
<GrEmptyState title="Ничего не найдено" description="Смягчите фильтр" />
</template>
</GrCollapse>
:empty="false" подавляет автоопределение — это нужно, когда секции приезжают
асинхронно и мигать заглушкой в первом кадре не надо. :empty="true" показывает
её принудительно.
Playground 8
Загружается…
<GrCollapse />Установка
npm i @feugene/granularityИмпорт
import { GrCollapse } from '@feugene/granularity/components/GrCollapse'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelValue | GrCollapseValue | GrCollapseValue[] | undefined | undefined | — |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер секций. Не задан — берётся из `GrConfigProvider`, иначе `md`. |
headingLevel | 2 | 3 | 4 | 5 | 6 | undefined | undefined | Уровень заголовков секций (`h2`…`h6`) — под структуру страницы. |
accordion | boolean | undefined | false | — |
divided | boolean | undefined | undefined | — |
borderless | boolean | undefined | undefined | — |
expandIconPosition | "end" | "start" | undefined | undefined | Сторона шеврона относительно заголовка. |
beforeChange | GrCollapseBeforeChange | undefined | undefined | — |
empty | boolean | undefined | undefined | Пусто ли. Не задан — считается по содержимому: аккордеон без секций показывает заглушку вместо пустой рамки. `false` подавляет автоопределение — например, когда секции приезжают асинхронно и мигать текстом не надо. |
emptyText | string | undefined | undefined | Текст пустого состояния. Слот `#empty` сильнее. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Секции аккордеона (`GrCollapseItem`). |
empty | any | Содержимое пустого состояния вместо текста по умолчанию. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: GrCollapseModelValue] | — |
change | [value: GrCollapseModelValue] | — |
Примеры 6
Пустой аккордеон говорит сам за себя
Аккордеон без секций показывает текст из локали вместо пустой рамки: пустота считается по содержимому слота, а перебить её можно пропом emptyText или слотом empty.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrCollapse, GrCollapseItem, GrSwitch } from '@feugene/granularity'
const sections = [
{ name: 'billing', title: 'Billing', body: 'Invoices, payment method and tax details.' },
{ name: 'members', title: 'Members', body: 'Roles, invitations and seat limits.' },
]
const opened = ref<string[]>(['billing'])
const showSections = ref(true)
// Пустоту считает сам аккордеон: фильтр, не нашедший ничего, отдаёт пустой
// `v-for` — заглушка появляется без единой строчки на стороне экрана.
const visible = computed(() => (showSections.value ? sections : []))
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="showSections">
Show sections
</GrSwitch>
<GrCollapse v-model="opened">
<GrCollapseItem
v-for="section in visible"
:key="section.name"
:name="section.name"
:title="section.title"
>
{{ section.body }}
</GrCollapseItem>
</GrCollapse>
<GrCollapse borderless>
<template #empty>
<span class="text-[var(--gr-muted-fg)]">Own markup instead of the default text — the `empty` slot.</span>
</template>
</GrCollapse>
</div>
</template>Аккордеон с управляемой активной секцией
Базовый controlled-сценарий: в accordion режиме одновременно открыт только один раздел, а текущий state можно вывести рядом.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const active = ref<string | number | undefined>('profile')
const activeLabel = computed(() => {
if (active.value === 'profile')
return 'Profile setup'
if (active.value === 'notifications')
return 'Notifications'
if (active.value === 'security')
return 'Security review'
return 'Collapsed'
})
</script>
<template>
<div class="grid gap-3">
<div class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
<span>Open panel:</span>
<GrBadge tone="neutral">{{ activeLabel }}</GrBadge>
</div>
<GrCollapse v-model="active" accordion>
<GrCollapseItem name="profile" title="Profile setup">
Keep onboarding steps in a single accordion so only one block stays expanded at a time.
</GrCollapseItem>
<GrCollapseItem name="notifications" title="Notifications">
Group less-frequent preferences into a secondary panel without overwhelming the main settings form.
</GrCollapseItem>
<GrCollapseItem name="security" title="Security review">
Reserve the last section for sensitive actions or audit details.
</GrCollapseItem>
</GrCollapse>
</div>
</template>Несколько раскрытых секций и свой слот заголовка
Показываем accordion = false, массив в v-model и richer title slot для badge/counter сценариев.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const expanded = ref<Array<string | number>>(['summary', 'alerts'])
</script>
<template>
<div class="grid gap-3">
<div class="text-sm text-[var(--gr-muted-fg)]">
Multi-expand mode works well for dense dashboards where several sections should stay visible together.
</div>
<GrCollapse v-model="expanded" :divided="false">
<GrCollapseItem name="summary">
<template #title>
<div class="flex items-center gap-2 text-sm font-600">
Executive summary
<GrBadge size="sm" tone="success">Ready</GrBadge>
</div>
</template>
Key financial highlights, ownership notes and recent approvals can stay open side by side.
</GrCollapseItem>
<GrCollapseItem name="alerts">
<template #title>
<div class="flex items-center gap-2 text-sm font-600">
Risk alerts
<GrBadge size="sm" tone="warning">2 active</GrBadge>
</div>
</template>
Use a custom title slot when you need counters, badges or richer inline status markers.
</GrCollapseItem>
<GrCollapseItem name="history" title="Change history">
Keep audit notes collapsed by default until the operator explicitly opens them.
</GrCollapseItem>
</GrCollapse>
</div>
</template>Выключенный аккордеон и запрет на уровне секции
Отдельно проверяем whole-group disabled и disabled на уровне конкретного GrCollapseItem.
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const disabled = ref(false)
const expanded = ref<Array<string | number>>(['active'])
</script>
<template>
<div class="grid gap-3">
<GrButton class="justify-self-start" variant="outline" @click="disabled = !disabled">
{{ disabled ? 'Unlock' : 'Lock' }} all sections
</GrButton>
<GrCollapse v-model="expanded" :disabled="disabled">
<GrCollapseItem name="active" title="Available section">
Switch the whole collapse to a read-only state during background sync or permission checks.
</GrCollapseItem>
<GrCollapseItem name="blocked" title="Individually disabled item" disabled>
Some items can remain unavailable even when the rest of the group is interactive.
</GrCollapseItem>
<GrCollapseItem name="notes" title="Operational notes">
Disabled styling is inherited from the parent and still preserves the overall layout.
</GrCollapseItem>
</GrCollapse>
</div>
</template>Аккордеон без рамки внутри карточки
Аккордеон внутри чужой поверхности не должен рисовать вторую рамку: borderless снимает обёртку в GrCard, expandIconPosition и слот #extra доводят заголовок до вида настроек.
Report settings
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrCard, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const expanded = ref<Array<string | number>>(['filters'])
</script>
<template>
<!-- Аккордеон уже внутри карточки: borderless снимает вторую рамку и вторую тень. -->
<GrCard padding="md" body-class="grid gap-3">
<template #header>
<div class="flex items-center justify-between gap-3">
<h3 class="m-0 text-base font-600 text-[var(--gr-fg)]">
Report settings
</h3>
<GrButton size="xs" variant="ghost">
Reset all
</GrButton>
</div>
</template>
<GrCollapse
v-model="expanded"
borderless
size="sm"
:heading-level="4"
expand-icon-position="start"
:divided="false"
>
<GrCollapseItem name="filters">
<template #title>
<span class="flex items-center gap-2 font-600">
Filters
<GrBadge size="xs" tone="primary">3</GrBadge>
</span>
</template>
Статус секции живёт в самом заголовке: подсветка строки накрывает его целиком, и правый край
держат отступы карточки, а не отдельная колонка.
</GrCollapseItem>
<GrCollapseItem name="columns">
<template #title>
<span class="flex items-center gap-2 font-600">
Columns
<GrBadge size="xs" tone="neutral">12 of 18</GrBadge>
</span>
</template>
Без рамки и разделителей строку структурирует только hover — поэтому он обязан доходить до
правого края, а не обрываться на середине.
</GrCollapseItem>
<GrCollapseItem name="schedule" title="Delivery schedule">
Шеврон слева читается как дерево в сайдбаре, справа — как классический аккордеон.
</GrCollapseItem>
</GrCollapse>
</GrCard>
</template>Асинхронный guard перед сворачиванием
beforeChange успевает спросить «сохранить изменения?» и отменить переключение: пока guard думает, повторный клик по заголовку игнорируется.
<script setup lang="ts">
import { ref } from 'vue'
import { GrCollapse, GrCollapseItem, GrFormField, GrInput, GrSwitch } from '@feugene/granularity'
const expanded = ref<Array<string | number>>(['draft'])
const draft = ref('Quarterly report')
const dirty = ref(true)
const lastDecision = ref('—')
// Guard может быть async: пока он не ответил, повторный клик по заголовку
// игнорируется, поэтому диалог не откроется дважды.
async function beforeChange(name: string | number, expanding: boolean): Promise<boolean> {
if (name !== 'draft' || expanding || !dirty.value) {
lastDecision.value = `allowed: ${String(name)} ${expanding ? 'expanded' : 'collapsed'}`
return true
}
await new Promise(resolve => setTimeout(resolve, 400))
lastDecision.value = 'collapse of "draft" blocked: unsaved changes'
return false
}
</script>
<template>
<div class="grid gap-3">
<GrCollapse v-model="expanded" :before-change="beforeChange">
<GrCollapseItem name="draft" title="Draft with unsaved changes">
<div class="grid gap-3">
<GrFormField label="Draft title">
<GrInput v-model="draft" size="sm" />
</GrFormField>
<GrSwitch v-model="dirty" size="sm">
Treat the draft as unsaved
</GrSwitch>
</div>
</GrCollapseItem>
<GrCollapseItem name="history" title="Change history">
This section opens and closes freely — the guard only protects the draft above.
</GrCollapseItem>
</GrCollapse>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Last guard decision: <span class="font-semibold text-[var(--gr-fg)]">{{ lastDecision }}</span>
</div>
</div>
</template>