Disclosure | CoUI
LogoCoUI

Disclosure

브라우저의 <details> 를 그대로 쓰는, 자바스크립트 없이 열리는 접기/펼치기 영역

Disclosure#

제목을 누르면 내용이 열리고 닫히는 영역입니다. 자기 상태를 갖지 않습니다 — Web 에서는 <details>/<summary> 로 렌더되어 열고 닫기·키보드·스크린리더를 브라우저가 전부 담당합니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 정적 렌더(Jaspr mode: static) 페이지에서 접기/펼치기가 필요할 때 — 하이드레이션 전에도, 자바스크립트가 꺼져 있어도 열립니다
  • FAQ 처럼 각 항목이 서로 독립적으로 열려야 할 때
  • 첫 페인트에 이미 펼쳐져 있어야 하는 항목이 있을 때 (open: true 가 그대로 <details open> 이 됩니다)

대신 다른 컴포넌트를 사용하세요:

  • Collapsible: 열림 상태를 호출자가 소유해야 하거나, 펼침에 애니메이션이 필요할 때
  • Accordion: 하나를 열면 형제가 닫혀야 할 때 (상호배타)

Collapsible 로는 안 되는가#

Collapsible 은 펼침 애니메이션을 컴포넌트 상태에서 구동합니다. 그 상태가 바로 정적 렌더 페이지가 하이드레이션 전에 갖지 못하는 것이라, 패널은 닫힌 채로 배포되고 스크립트가 꺼져 있으면 영영 열리지 않습니다. Disclosure 는 그 애니메이션을 포기하는 대신 열고 닫기를 플랫폼에서 받아옵니다.

기본 사용법 (Basic Usage)#

// 가장 단순한 형태
Disclosure(
  title: '자주 묻는 질문 하나',
  content: Text('답변'),
)

// 처음부터 펼쳐진 상태 — 사전 렌더 마크업에 <details open> 으로 실린다
Disclosure(
  title: '약관 요약',
  open: true,
  content: TermsSummary(),
)

// 제목이 텍스트 이상이어야 할 때
Disclosure(
  trigger: Row(children: [Icon(LucideIcons.info), Text('상세')]),
  content: Text('...'),
)

// 목록 — 각 항목이 독립적으로 열린다
DisclosureGroup(
  items: [
    Disclosure(title: '첫 번째', open: true, content: Text('...')),
    Disclosure(title: '두 번째', content: Text('...')),
  ],
)

Props / Parameters#

Disclosure#

이름타입기본값설명
title String? null 평문 헤더. trigger둘 중 하나는 필수
trigger W? null 헤더 슬롯. 둘 다 주면 이쪽이 이깁니다
contentW필수펼쳤을 때 보이는 본문
open bool false 초기값이며 제어값이 아닙니다 — 이후에는 플랫폼이 뒤집습니다
onToggle void Function(bool)? null 토글된 새 상태를 알려주는 관찰용 콜백
disclosureStyle CoreDisclosureStyle? null 인스턴스별 chrome

DisclosureGroup#

이름타입기본값설명
items List<W> 필수 쌓을 Disclosure
disclosureStyle CoreDisclosureStyle? null itemSpacing 읽는다 — 스택에 대한 진술은 그것뿐이다. 자식에게 전달하지 않는다(아래 참조)

그룹은 자식의 스타일을 건드리지 않습니다. items 는 플랫폼 위젯 목록이라 어떤 항목이 Disclosure 인지 그룹이 알 수 없고, 확인하지 않은 것을 다시 칠하는 것은 추측입니다. 모든 섹션을 한 번에 스타일링하려면 CoreDisclosureTheme.style 을 쓰세요 — 각 자식의 resolver 를 통해 도달하며, 자식별 오버라이드가 여전히 이깁니다.

DisclosureItem 은 없습니다. AccordionItem 이 존재하는 이유는 아코디언이 "어느 항목이 열렸는가"를 소유하기 때문인데, Disclosure 는 아무것도 소유하지 않으므로 그룹이 가져갈 것이 없습니다. 항목이 곧 Disclosure 입니다.

CoreDisclosureStyle 필드#

필드타입설명
contentInset CoreEdgeInsets? Inner inset around the header row and the body (pre-scaling).
itemSpacing double? Vertical distance between siblings in a DisclosureGroup . Read by the group; a lone Disclosure has no sibling to space against.
durationDuration?Chevron rotation duration.
triggerTextStyle CoreTextStyle? Header text style override.
contentTextStyle CoreTextStyle? Body text style override.
iconLabelGapStyle CoreGapStyle? Horizontal gap slot between the header text and the chevron.
iconStyleCoreIconStyle?Chevron icon style.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the header's focus ring and press chrome. Flutter forwards it to the composed Clickable ; Web reads its focus-outline slot to paint <summary> 's ring, so the same override moves both platforms.

빠른 오버라이드 (Chain)#

이미 만든 Disclosure 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class DisclosureChainExample extends StatelessWidget {
  const DisclosureChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return const Disclosure(
      title: 'What is CoUI?',
      open: true,
      content: Text(
        'A design system that renders the same on Flutter and on the web.',
      ),
    ).withStyle(
      const CoreDisclosureStyle(
        contentInset: CoreEdgeInsets.all(CoreSpace.space24),
        triggerTextStyle: CoreTextStyle.token(
          CoreTextStyles.titleSmall,
          color: CoreColor.token(CoreColors.primary),
        ),
        iconLabelGapStyle: CoreGapStyle(size: CoreSpace.space12),
        iconStyle: CoreIconStyle(
          size: CoreSize.size20,
          color: CoreColor.token(CoreColors.primary),
        ),
      ),
    );
  }
}
class DisclosureChainExample extends StatelessComponent {
  const DisclosureChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return const Disclosure(
      title: 'What is CoUI?',
      open: true,
      content: Text(
        'A design system that renders the same on Flutter and on the web.',
      ),
    ).withStyle(
      const CoreDisclosureStyle(
        contentInset: CoreEdgeInsets.all(CoreSpace.space24),
        triggerTextStyle: CoreTextStyle.token(
          CoreTextStyles.titleSmall,
          color: CoreColor.token(CoreColors.primary),
        ),
        iconLabelGapStyle: CoreGapStyle(size: CoreSpace.space12),
        iconStyle: CoreIconStyle(
          size: CoreSize.size20,
          color: CoreColor.token(CoreColors.primary),
        ),
      ),
    );
  }
}

동작 스펙 (Behavior)#

열림 상태는 누구 것인가#

open씨앗입니다. 심고 나면 플랫폼이 키웁니다 — Web 에서는 브라우저가 open 속성을 직접 고쳐 쓰고, Flutter 에서는 위젯이 자기 상태로 들고 있습니다. onToggle 에서 아무것도 하지 않아도 섹션은 다시 닫히지 않습니다. 애초에 호출자가 그 상태를 소유한 적이 없기 때문입니다.

열림 상태를 호출자가 소유해야 한다면 그건 Collapsible 입니다.

상호배타 열림은 일부러 넣지 않았습니다#

HTML <details name="..."> 로 표현할 수는 있지만 브라우저 지원이 갈립니다. 하나를 열면 형제가 닫혀야 한다면 Accordion 이 그 일을 합니다.

애니메이션#

셰브론 회전만 애니메이션합니다. 본문이 열리고 닫히는 것은 브라우저가 하는 일이라, 여기서 duration 을 매기면 이 컴포넌트가 구동하지도 않는 전환을 구동한다고 주장하는 셈이 됩니다.

Web 의 회전은 group-open: 유틸리티로 CSS 가 처리합니다 — 상태 분기가 아니므로 스크립트가 꺼져 있어도 셰브론이 함께 돕니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

// 정적 페이지의 FAQ — 하이드레이션 없이 열린다
DisclosureGroup(
  items: [
    Disclosure(title: '배송은 얼마나 걸리나요?', content: Text('2~3일')),
    Disclosure(title: '교환이 되나요?', content: Text('7일 이내 가능합니다.')),
  ],
)

// 첫 화면에 답이 보여야 하는 항목은 open 으로 — 사전 렌더 마크업에 실린다
Disclosure(title: '가장 많이 묻는 것', open: true, content: Text('...'))

// 토글을 관찰만 한다 (분석 이벤트 등)
Disclosure(
  title: '상세',
  onToggle: (open) => analytics.log('faq_toggle', {'open': open}),
  content: Text('...'),
)

❌ Don't#

// ❌ 열림 상태를 이걸로 제어하려 하지 마세요 — open 은 씨앗이지 제어값이 아닙니다.
//    아래 코드는 닫히지 않습니다. 상태를 소유해야 하면 Collapsible 입니다.
Disclosure(
  title: '상세',
  open: myState,
  onToggle: (_) => setState(() => myState = false),
  content: Text('...'),
)

// ❌ 상호배타 열림을 흉내내지 마세요 — 그건 Accordion 의 일입니다.
DisclosureGroup(
  items: [
    Disclosure(title: 'A', onToggle: (_) => closeOthers(), content: Text('...')),
  ],
)

// ❌ 제목과 본문을 한 슬롯에 몰아넣지 마세요 — 헤더가 <summary> 라야
//    브라우저가 토글·키보드·시맨틱을 줍니다.
Disclosure(
  title: '',
  content: Column(children: [Text('제목'), Text('본문')]),
)

접근성 (Accessibility)#

시맨틱#

Web 은 <details>/<summary> 자체가 네이티브 디스클로저 위젯입니다. role 이나 aria-expanded 를 덧붙이지 않습니다 — 덧붙이면 중복일 뿐 아니라 거짓말이 됩니다. 그 속성을 갱신할 스크립트가 없으니, 브라우저가 섹션을 연 뒤에도 계속 "닫힘"이라고 알리게 됩니다.

Flutter 에는 그런 네이티브 요소가 없으므로 헤더가 Semantics(button: true, expanded: …) 로 같은 사실을 직접 말합니다.

키보드#

Web 은 <summary> 가 기본 포커스 대상이며 Enter / Space 가 토글합니다 — 전부 브라우저 기본 동작입니다. Flutter 는 Clickable 이 같은 키 조작을 제공합니다.

포커스 링#

기본값에서는 <summary>브라우저 자신의 포커스 아웃라인을 유지합니다. clickableStyle.focusOutlineStyle 로 링을 명시하면 그때 토큰 링으로 대체되고, 두 플랫폼이 같은 슬롯을 읽습니다.

크로스 플랫폼 차이점 (Platform Differences)#

WebFlutter
토글 주체브라우저 (<details>)위젯 내부 상태
시맨틱네이티브 요소Semantics(button, expanded)
닫힌 본문브라우저가 렌더하지 않음트리에서 제외
하이드레이션 필요없음해당 없음

메커니즘은 다르지만 API 와 기능은 같습니다 — 정적 렌더라는 문제 자체가 Flutter 에는 존재하지 않으므로, Flutter 는 같은 표면을 자기 방식으로 제공합니다.

  • Collapsible — 열림 상태를 호출자가 소유하고, 펼침이 애니메이션됩니다
  • Accordion — 하나를 열면 형제가 닫힙니다