Disclosure#
제목을 누르면 내용이 열리고 닫히는 영역입니다. 자기 상태를 갖지 않습니다 — Web 에서는 <details>/<summary>
로 렌더되어 열고 닫기·키보드·스크린리더를 브라우저가 전부 담당합니다.
Live Preview#
What is CoUI?
class DisclosureDefaultExample extends StatelessComponent {
const DisclosureDefaultExample({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.',
),
);
}
}
class DisclosureDefaultExample extends StatelessWidget {
const DisclosureDefaultExample({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.',
),
);
}
}
Does it work without JavaScript?
Does opening one close the others?
Can the header hold more than text?
class DisclosureGroupExample extends StatelessComponent {
const DisclosureGroupExample({super.key});
@override
Component build(BuildContext context) {
return const DisclosureGroup(
items: [
Disclosure(
title: 'Does it work without JavaScript?',
open: true,
content: Text(
'On the web it renders <details>, so the browser opens it.',
),
),
Disclosure(
title: 'Does opening one close the others?',
content: Text('No. Reach for Accordion when it should.'),
),
Disclosure(
title: 'Can the header hold more than text?',
content: Text('Yes — pass a trigger widget instead of a title.'),
),
],
);
}
}
class DisclosureGroupExample extends StatelessWidget {
const DisclosureGroupExample({super.key});
@override
Widget build(BuildContext context) {
return const DisclosureGroup(
items: [
Disclosure(
title: 'Does it work without JavaScript?',
open: true,
content: Text(
'On the web it renders <details>, so the browser opens it.',
),
),
Disclosure(
title: 'Does opening one close the others?',
content: Text('No. Reach for Accordion when it should.'),
),
Disclosure(
title: 'Can the header hold more than text?',
content: Text('Yes — pass a trigger widget instead of a title.'),
),
],
);
}
}
What is CoUI?
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),
),
),
);
}
}
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),
),
),
);
}
}
사용 시기 (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 |
헤더 슬롯. 둘 다 주면 이쪽이 이깁니다 |
content | W | 필수 | 펼쳤을 때 보이는 본문 |
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.
|
duration | Duration? | 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. |
iconStyle | CoreIconStyle? | 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)#
| 축 | Web | Flutter |
|---|---|---|
| 토글 주체 | 브라우저 (<details>) | 위젯 내부 상태 |
| 시맨틱 | 네이티브 요소 | Semantics(button, expanded) |
| 닫힌 본문 | 브라우저가 렌더하지 않음 | 트리에서 제외 |
| 하이드레이션 필요 | 없음 | 해당 없음 |
메커니즘은 다르지만 API 와 기능은 같습니다 — 정적 렌더라는 문제 자체가 Flutter 에는 존재하지 않으므로, Flutter 는 같은 표면을 자기 방식으로 제공합니다.
관련 컴포넌트 (Related Components)#
Collapsible— 열림 상태를 호출자가 소유하고, 펼침이 애니메이션됩니다Accordion— 하나를 열면 형제가 닫힙니다