GrCollapse

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда содержимого много, а нужно не всё сразу.

Когда брать

  • содержимого много, а нужно не всё сразу — вопросы и ответы, настройки, детали записи;
  • открыта одна секция за раз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

PropTypeпо умолчаниюОписание
modelValueGrCollapseValue | GrCollapseValue[] | undefinedundefined
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер секций. Не задан — берётся из `GrConfigProvider`, иначе `md`.
headingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedУровень заголовков секций (`h2`…`h6`) — под структуру страницы.
accordionboolean | undefinedfalse
dividedboolean | undefinedundefined
borderlessboolean | undefinedundefined
expandIconPosition"end" | "start" | undefinedundefinedСторона шеврона относительно заголовка.
beforeChangeGrCollapseBeforeChange | undefinedundefined
emptyboolean | undefinedundefinedПусто ли. Не задан — считается по содержимому: аккордеон без секций показывает заглушку вместо пустой рамки. `false` подавляет автоопределение — например, когда секции приезжают асинхронно и мигать текстом не надо.
emptyTextstring | undefinedundefinedТекст пустого состояния. Слот `#empty` сильнее.

Slots

SlotTypeОписание
defaultanyСекции аккордеона (`GrCollapseItem`).
emptyanyСодержимое пустого состояния вместо текста по умолчанию.

Events

EventTypeОписание
update:modelValue[value: GrCollapseModelValue]
change[value: GrCollapseModelValue]

Примеры 6

Пустой аккордеон говорит сам за себя

Аккордеон без секций показывает текст из локали вместо пустой рамки: пустота считается по содержимому слота, а перебить её можно пропом emptyText или слотом empty.

Invoices, payment method and tax details.

Roles, invitations and seat limits.
Own markup instead of the default text — the `empty` slot.

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 можно вывести рядом.

Open panel:Profile setup

Keep onboarding steps in a single accordion so only one block stays expanded at a time.

Group less-frequent preferences into a secondary panel without overwhelming the main settings form.

Reserve the last section for sensitive actions or audit details.

Accordion
<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 сценариев.

Multi-expand mode works well for dense dashboards where several sections should stay visible together.

Key financial highlights, ownership notes and recent approvals can stay open side by side.

Use a custom title slot when you need counters, badges or richer inline status markers.

Keep audit notes collapsed by default until the operator explicitly opens them.

Multi Section
<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.

Switch the whole collapse to a read-only state during background sync or permission checks.

Some items can remain unavailable even when the rest of the group is interactive.

Disabled styling is inherited from the parent and still preserves the overall layout.

Disabled State
<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

Статус секции живёт в самом заголовке: подсветка строки накрывает его целиком, и правый край держат отступы карточки, а не отдельная колонка.

Без рамки и разделителей строку структурирует только hover — поэтому он обязан доходить до правого края, а не обрываться на середине.

Шеврон слева читается как дерево в сайдбаре, справа — как классический аккордеон.

Borderless
<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 думает, повторный клик по заголовку игнорируется.

This section opens and closes freely — the guard only protects the draft above.
Last guard decision:

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>

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