Перейти к содержимому
v3.0.0
English
Русский
Документация

Collapsible

Collapsible — составной компонент для одной раскрывающейся области с триггером и поверхностью содержимого.
VAT ID, invoice email and legal address.
collapsible.tsx · 18 строк
import {
  Button,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  Text,
} from '@vega-ui/react';

<div>
  <Collapsible defaultOpen>
    <CollapsibleTrigger asChild>
      <Button size='sm' variant='secondary' appearance='ghost'>Billing details</Button>
    </CollapsibleTrigger>
    <CollapsibleContent>
      <Text size={2}>VAT ID, invoice email and legal address.</Text>
    </CollapsibleContent>
  </Collapsible>
</div>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 113
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

Collapsible — составной компонент для одной раскрывающейся области с триггером и поверхностью содержимого. Это примитивный паттерн раскрытия, когда групповое поведение аккордеона не нужно.

Импорты

import {
  Collapsible,
  CollapsibleTrigger,
  CollapsibleContent,
  type CollapsibleProps,
} from '@vega-ui/react';

Экспортируемые типы

  • CollapsibleProps
  • CollapsibleTriggerProps
  • CollapsibleContentProps

Минимальная композиция

minimal-composition.tsx · 14 строк
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <div style={{ width: '100%' }}>
      <Collapsible>
        <CollapsibleTrigger>Advanced billing options</CollapsibleTrigger>
        <CollapsibleContent>
          VAT settings, invoice recipients, and legal entity data.
        </CollapsibleContent>
      </Collapsible>
    </div>
  );
}

Обязательные части

  • Collapsible: владелец состояния корня
  • CollapsibleTrigger: поверхность триггера раскрытия
  • CollapsibleContent: раскрывающаяся область содержимого

Необязательные части

  • Кроме модели «корень — триггер — содержимое», дополнительных публичных частей нет

Порядок композиции

  1. Collapsible
  2. CollapsibleTrigger
  3. CollapsibleContent

Варианты

  • Неконтролируемое или контролируемое состояние открытия
  • Стандартный триггер-кнопка или композиция триггера через asChild
  • Встроенная справка, фильтры и второстепенные детали как варианты на уровне продукта

Пограничные случаи

  • CollapsibleTrigger может рендериться через asChild, но пользовательская семантика всё равно должна сохранять контракт настоящего триггера.
  • onChangeHidden отражает жизненный цикл видимости, который во время переходов высоты может отличаться от состояния открытия.
  • Динамическую высоту содержимого нужно проверять на реальном содержимом, а не только на коротких заглушках.

Частые ошибки

  • Использовать Collapsible там, где на самом деле нужно групповое поведение Accordion.
  • Прятать важную информацию за расплывчатыми подписями триггера.
  • Считать open и hidden одним и тем же сигналом жизненного цикла.

Примеры

Базовый

Базовый: расшифровка цены под итоговой суммой

Неконтролируемое раскрытие: итог всегда виден, а строки счёта скрыты, пока их не попросят показать. Триггер — Button через asChild, поэтому он сохраняет стили кнопки и при этом получает aria-expanded и aria-controls.

price-breakdown.tsx · 34 строки
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Text } from '@vega-ui/react';

const lines = [
  { label: 'Team plan · 8 seats × $12', amount: '$96.00' },
  { label: 'Extra storage · 200 GB', amount: '$20.00' },
  { label: 'Annual discount −10%', amount: '−$11.60' },
  { label: 'VAT 19%', amount: '$19.84' },
];

export default function PriceBreakdown() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 360 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 'var(--spacing-8)' }}>
        <Text size={3} fontWeight={500}>Total due today</Text>
        <Text size={3} fontWeight={500} style={{ fontVariantNumeric: 'tabular-nums' }}>$124.24</Text>
      </div>
      <Collapsible>
        <CollapsibleTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }}>Price breakdown</Button>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <dl style={{ margin: 0, display: 'grid', gridTemplateColumns: '1fr auto', gap: 'var(--spacing-4) var(--spacing-8)' }}>
            {lines.map((line) => (
              <div key={line.label} style={{ display: 'contents' }}>
                <dt><Text size={2} style={{ color: 'var(--label-secondary)' }}>{line.label}</Text></dt>
                <dd style={{ margin: 0, textAlign: 'end' }}><Text size={2} style={{ fontVariantNumeric: 'tabular-nums' }}>{line.amount}</Text></dd>
              </div>
            ))}
          </dl>
        </CollapsibleContent>
      </Collapsible>
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: расширенные фильтры со сводкой

Родитель владеет open и использует его для подписи триггера. Значения фильтров хранятся рядом, поэтому сводка под блоком показывает число активных фильтров, даже когда блок свёрнут.

advanced-filters.tsx · 64 строки
import { useState } from 'react';
import {
  Button,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';

export default function AdvancedFilters() {
  const [open, setOpen] = useState(false);
  const [owner, setOwner] = useState('');
  const [tag, setTag] = useState('');

  const activeCount = [owner, tag].filter((value) => value.trim() !== '').length;

  let summary = 'No advanced filters applied';
  if (activeCount === 1) summary = '1 advanced filter applied';
  if (activeCount > 1) summary = `${activeCount} advanced filters applied`;

  const clear = () => {
    setOwner('');
    setTag('');
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 420 }}>
      <Collapsible open={open} onChangeOpen={setOpen}>
        <div style={{ display: 'flex', gap: 'var(--spacing-4)', alignItems: 'center' }}>
          <CollapsibleTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost'>
              {open ? 'Hide advanced filters' : 'Show advanced filters'}
            </Button>
          </CollapsibleTrigger>
          {activeCount > 0 && (
            <Button size='sm' variant='secondary' appearance='ghost' onClick={clear}>Clear</Button>
          )}
        </div>
        <CollapsibleContent>
          <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor='filter-owner'>Owner</Label>
              <TextField>
                <TextFieldInput id='filter-owner' value={owner} onChange={(event) => setOwner(event.target.value)} />
              </TextField>
            </div>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor='filter-tag'>Tag</Label>
              <TextField>
                <TextFieldInput id='filter-tag' value={tag} onChange={(event) => setTag(event.target.value)} />
              </TextField>
            </div>
          </div>
        </CollapsibleContent>
      </Collapsible>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        {summary}
      </Text>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: необязательные реквизиты компании

Скрытое содержимое не отключено: его поля всё равно отправятся с формой, а required всё равно заблокирует отправку. <fieldset disabled={!open}> вокруг необязательных полей убирает их из FormData и из валидации, пока блок закрыт.

invoice-details-form.tsx · 67 строк
import { FormEvent, useState } from 'react';
import {
  Button,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  Label,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';

export default function InvoiceDetailsForm() {
  const [open, setOpen] = useState(false);
  const [submitted, setSubmitted] = useState('');

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const values = Object.fromEntries(new FormData(event.currentTarget));
    setSubmitted(JSON.stringify(values, null, 2));
  };

  const reset = () => {
    setOpen(false);
    setSubmitted('');
  };

  return (
    <form onSubmit={submit} onReset={reset} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 420 }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor='invoice-email'>Receipt email</Label>
          <TextField>
            <TextFieldInput id='invoice-email' name='email' type='email' autoComplete='email' required />
          </TextField>
        </div>
        <Collapsible open={open} onChangeOpen={setOpen}>
          <CollapsibleTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }}>
              {open ? 'Remove company details' : 'Add company details for the invoice'}
            </Button>
          </CollapsibleTrigger>
          <CollapsibleContent>
            <fieldset disabled={!open} style={{ border: 0, margin: 0, padding: 0, display: 'grid', gap: 'var(--spacing-8)' }}>
              <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
                <Label htmlFor='invoice-company'>Company name</Label>
                <TextField>
                  <TextFieldInput id='invoice-company' name='company' autoComplete='organization' required />
                </TextField>
              </div>
              <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
                <Label htmlFor='invoice-vat'>VAT ID</Label>
                <TextField>
                  <TextFieldInput id='invoice-vat' name='vatId' required />
                </TextField>
              </div>
            </fieldset>
          </CollapsibleContent>
        </Collapsible>
      </div>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        <Button type='submit'>Pay $124.24</Button>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
      </div>
      {submitted && <pre style={{ margin: 0, fontSize: 'var(--font-size-2)', color: 'var(--label-secondary)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{submitted}</pre>}
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: технические подробности в диалоге

Диалог простыми словами объясняет сбой, а стек вызовов лежит в свёрнутом блоке со своей прокруткой, чтобы длинный стек не растягивал диалог.

error-details-dialog.tsx · 65 строк
import type { CSSProperties } from 'react';
import {
  Button,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const stackTrace = `ExportError: Request timed out after 30000 ms
    at fetchRows (exports/worker.ts:118:11)
    at async buildCsv (exports/csv.ts:42:20)
    at async runExport (exports/run.ts:77:5)
    at async Queue.process (queue/index.ts:203:9)
Request ID: 7f3c9a1e-52b4-4f0e-9d8a-0c61b2e4a913`;

export default function ErrorDetailsDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Show failed export</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby='export-error-title' aria-describedby='export-error-description' style={{ maxWidth: 480 }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id='export-error-title' as='h2' size={4} fontWeight={600}>Export failed</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <Text id='export-error-description' size={3} asChild>
          <p style={{ marginBlock: 'var(--spacing-4) var(--spacing-6)', color: 'var(--label-secondary)' }}>The CSV export of 48 210 rows did not finish. Try again with a shorter date range.</p>
        </Text>
        <Collapsible>
          <CollapsibleTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost'>Technical details</Button>
          </CollapsibleTrigger>
          <CollapsibleContent>
            <pre style={{ margin: 0, marginBlockStart: 'var(--spacing-4)', maxHeight: 160, overflow: 'auto', fontSize: 'var(--font-size-1)', lineHeight: 1.6, padding: 'var(--spacing-6)', borderRadius: 'var(--radius-3)', background: 'var(--fills-tertiary)', color: 'var(--label-secondary)' }}>
              {stackTrace}
            </pre>
          </CollapsibleContent>
        </Collapsible>
        <form method='dialog' style={{ display: 'flex', gap: 'var(--spacing-4)', justifyContent: 'flex-end', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit' variant='secondary' appearance='ghost'>Close</Button>
          <Button type='submit'>Try again</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

Ошибка

Ошибка: промокод со встроенной валидацией

Форма находится внутри содержимого. Неизвестный код показывает ошибку, связанную через aria-describedby, а ввод нового значения её снимает. Применённая скидка озвучивается через область role='status', которая отрендерена всегда.

promo-code.tsx · 68 строк
import { FormEvent, useState } from 'react';
import {
  Button,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  HelperText,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';

const codes: Record<string, number> = { WELCOME10: 10, TEAM25: 25 };

export default function PromoCode() {
  const [code, setCode] = useState('');
  const [error, setError] = useState('');
  const [discount, setDiscount] = useState(0);

  const apply = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const normalized = code.trim().toUpperCase();
    if (normalized === '') {
      setError('Enter a promo code.');
      return;
    }
    if (!(normalized in codes)) {
      setError(`“${normalized}” is not a valid or active promo code.`);
      return;
    }
    setError('');
    setDiscount(codes[normalized]);
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 360 }}>
      <Collapsible>
        <CollapsibleTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }}>Have a promo code?</Button>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <form onSubmit={apply} style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
            <Label htmlFor='promo-code'>Promo code</Label>
            <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
              <TextField error={error !== ''} style={{ flex: 1 }}>
                <TextFieldInput
                  id='promo-code'
                  placeholder='WELCOME10'
                  value={code}
                  onChange={(event) => {
                    setCode(event.target.value);
                    setError('');
                  }}
                  aria-invalid={error !== ''}
                  aria-describedby={error ? 'promo-code-error' : undefined}
                />
              </TextField>
              <Button type='submit' variant='secondary' appearance='ghost'>Apply</Button>
            </div>
            {error && <HelperText id='promo-code-error' error>{error}</HelperText>}
          </form>
        </CollapsibleContent>
      </Collapsible>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{discount > 0 ? `${discount}% discount applied` : ''}</Text>
    </div>
  );
}

Отключённый

Отключённый: триггер отключён родительским fieldset

CollapsibleTrigger рендерит нативную кнопку, поэтому <fieldset disabled> отключает её вместе с полями внутри. Причина связана с fieldset через aria-describedby. Если блок был открыт до блокировки, он остаётся открытым, но только для чтения.

locked-order.tsx · 44 строки
import { useState } from 'react';
import {
  Button,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';

export default function LockedOrder() {
  const [locked, setLocked] = useState(true);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 420 }}>
      <div style={{ display: 'flex', gap: 'var(--spacing-6)', alignItems: 'center', justifyContent: 'space-between' }}>
        <Text size={3} fontWeight={500}>Order #10482</Text>
        <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setLocked(!locked)}>
          {locked ? 'Unlock order' : 'Lock order'}
        </Button>
      </div>
      <fieldset disabled={locked} aria-describedby='order-lock-reason' style={{ border: 0, margin: 0, padding: 0, display: 'grid', gap: 'var(--spacing-6)' }}>
        <Text id='order-lock-reason' size={2} style={{ color: 'var(--label-secondary)' }}>
          {locked ? 'The order has shipped. Unlock it to change the delivery notes.' : 'Delivery notes can be edited.'}
        </Text>
        <Collapsible>
          <CollapsibleTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }}>Delivery notes</Button>
          </CollapsibleTrigger>
          <CollapsibleContent>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor='order-notes'>Notes for the courier</Label>
              <TextField>
                <TextFieldInput id='order-notes' defaultValue='Leave at the reception desk' />
              </TextField>
            </div>
          </CollapsibleContent>
        </Collapsible>
      </fieldset>
    </div>
  );
}

Пограничный случай

Пограничный случай: остановить фоновую работу, когда содержимое скрыто

onChangeHidden срабатывает после окончания анимации закрытия, а не в момент нажатия на триггер. Лог опрашивает сервер только пока содержимое показано и останавливается, когда оно полностью скрыто.

live-log.tsx · 41 строка
import { useEffect, useState } from 'react';
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Text } from '@vega-ui/react';

const routes = ['GET /api/health', 'POST /api/orders', 'GET /api/orders/10482', 'PATCH /api/users/42'];

export default function LiveLog() {
  const [hidden, setHidden] = useState(true);
  const [lines, setLines] = useState<string[]>([]);

  useEffect(() => {
    if (hidden) return;
    const timer = setInterval(() => {
      const time = new Date().toLocaleTimeString();
      setLines((current) => {
        const route = routes[current.length % routes.length];
        return [...current.slice(-19), `${time}  ${route}  200`];
      });
    }, 1000);
    return () => clearInterval(timer);
  }, [hidden]);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 480 }}>
      <Collapsible onChangeHidden={setHidden}>
        <div style={{ display: 'flex', gap: 'var(--spacing-6)', alignItems: 'center' }}>
          <CollapsibleTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost'>Request log</Button>
          </CollapsibleTrigger>
          <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
            {hidden ? `Paused · ${lines.length} lines` : `Streaming · ${lines.length} lines`}
          </Text>
        </div>
        <CollapsibleContent>
          <pre aria-label='Request log' tabIndex={0} style={{ margin: 0, height: 160, overflow: 'auto', fontSize: 'var(--font-size-1)', lineHeight: 1.6, fontVariantNumeric: 'tabular-nums', padding: 'var(--spacing-6)', borderRadius: 'var(--radius-3)', background: 'var(--fills-tertiary)', color: 'var(--label-secondary)' }}>
            {lines.join('\n')}
          </pre>
        </CollapsibleContent>
      </Collapsible>
    </div>
  );
}

Анатомия

Обзор

Collapsible владеет одной раскрывающейся областью и координирует её триггер, видимость содержимого и жизненный цикл скрытого состояния. Это составной компонент, потому что триггер и содержимое зависят от одного общего контекста корня.

Обязательные части

Collapsible

Обязательна. Владеет состоянием открытия, жизненным циклом скрытого состояния и координацией ID содержимого.

CollapsibleTrigger

Обязательна. Переключает область и несёт aria-expanded и aria-controls.

CollapsibleContent

Обязательна. Рендерит раскрывающуюся область и управляет переходом видимости по высоте.

Необязательные части

Кроме пары триггера и содержимого, Collapsible не предоставляет других публичных дочерних частей.

Порядок композиции

  1. Collapsible
  2. CollapsibleTrigger
  3. CollapsibleContent

Допустимые варианты композиции

price-breakdown.tsx · 34 строки
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Text } from '@vega-ui/react';

const lines = [
  { label: 'Team plan · 8 seats × $12', amount: '$96.00' },
  { label: 'Extra storage · 200 GB', amount: '$20.00' },
  { label: 'Annual discount −10%', amount: '−$11.60' },
  { label: 'VAT 19%', amount: '$19.84' },
];

export default function PriceBreakdown() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 360 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 'var(--spacing-8)' }}>
        <Text size={3} fontWeight={500}>Total due today</Text>
        <Text size={3} fontWeight={500} style={{ fontVariantNumeric: 'tabular-nums' }}>$124.24</Text>
      </div>
      <Collapsible>
        <CollapsibleTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }}>Price breakdown</Button>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <dl style={{ margin: 0, display: 'grid', gridTemplateColumns: '1fr auto', gap: 'var(--spacing-4) var(--spacing-8)' }}>
            {lines.map((line) => (
              <div key={line.label} style={{ display: 'contents' }}>
                <dt><Text size={2} style={{ color: 'var(--label-secondary)' }}>{line.label}</Text></dt>
                <dd style={{ margin: 0, textAlign: 'end' }}><Text size={2} style={{ fontVariantNumeric: 'tabular-nums' }}>{line.amount}</Text></dd>
              </div>
            ))}
          </dl>
        </CollapsibleContent>
      </Collapsible>
    </div>
  );
}

Недопустимые варианты композиции

Содержимое вне Collapsible

Это ломает общий ID, скрытое состояние и жизненный цикл перехода.

Семантика триггера заменена неинтерактивным дочерним элементом

Если используется asChild, дочерний элемент всё равно должен вести себя как настоящий триггер.

Если разделы должны координироваться, используйте Accordion вместо отдельных корней Collapsible.