TabPane
박스형 탭 + 통합 콘텐츠 패널
목차
박스형 탭 스트립과 각 탭이 소유한 콘텐츠 패널을 하나로 묶은 자체 완결형 탭 카드입니다. 기본 variant (boxed) 는 테두리 있는 카드 안에 밑줄 탭 바와 패널을 담은 카드형 탭이고,
editorStrip 은 IDE 에디터 크롬용 스트립 전용 API 입니다 — 콘텐츠 패널을 렌더하지 않습니다. 탭 바만 제공하는
Tabs 와 달리, boxed 에서는 CoreTabItem.content 슬롯의 위젯을 아래 패널에 함께 렌더합니다.
기본 사용법 (Basic Usage)#
TabPane(
tabs: [
CoreTabItem(label: 'Account', content: accountPanel),
CoreTabItem(label: 'Password', content: passwordPanel),
],
selectedIndex: index,
onChanged: (i) => setState(() => index = i),
)
빠른 오버라이드 (Chain)#
이미 만든 TabPane 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius24처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius24 ==
CoreRadius.radius24) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class TabPaneChainExample extends StatefulWidget {
const TabPaneChainExample({super.key});
@override
State<TabPaneChainExample> createState() => _TabPaneChainExampleState();
}
class _TabPaneChainExampleState extends State<TabPaneChainExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space20).toValue(geometry: Theme.of(context).geometry),
child: Text(
'Manage your account settings.',
).bodyMedium.onSurfaceVariant,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space20).toValue(geometry: Theme.of(context).geometry),
child: Text('Change your password here.').bodyMedium.onSurfaceVariant,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space20).toValue(geometry: Theme.of(context).geometry),
child: Text(
'Manage your account settings.',
).bodyMedium.onSurfaceVariant,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space20).toValue(geometry: Theme.of(context).geometry),
child: Text('Change your password here.').bodyMedium.onSurfaceVariant,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
).withStyle(
const CoreTabPaneStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
);
}
}
class TabPaneChainExample extends StatefulComponent {
const TabPaneChainExample({super.key});
@override
State<TabPaneChainExample> createState() => _TabPaneChainExampleState();
}
class _TabPaneChainExampleState extends State<TabPaneChainExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurfaceVariant],
classes: 'p-${CoreSpace.scale.space20}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurfaceVariant],
classes: 'p-${CoreSpace.scale.space20}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurfaceVariant],
classes: 'p-${CoreSpace.scale.space20}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurfaceVariant],
classes: 'p-${CoreSpace.scale.space20}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
).withStyle(
const CoreTabPaneStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
classes: 'flex flex-col items-start',
);
}
}
Live Preview#
코드 보기
class TabPaneExample extends StatefulComponent {
const TabPaneExample({super.key});
@override
State<TabPaneExample> createState() => _TabPaneExampleState();
}
class _TabPaneExampleState extends State<TabPaneExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurfaceVariant],
classes: 'p-${CoreSpace.scale.space20}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurfaceVariant],
classes: 'p-${CoreSpace.scale.space20}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
);
}
}
class TabPaneExample extends StatefulWidget {
const TabPaneExample({super.key});
@override
State<TabPaneExample> createState() => _TabPaneExampleState();
}
class _TabPaneExampleState extends State<TabPaneExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
return TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space20).toValue(geometry: Theme.of(context).geometry),
child: Text('Manage your account settings.').bodyMedium.onSurfaceVariant,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space20).toValue(geometry: Theme.of(context).geometry),
child: Text('Change your password here.').bodyMedium.onSurfaceVariant,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 탭과 그 콘텐츠 패널을 한 덩어리(카드)로 함께 보여줄 때 (
variant: .boxed, 기본값) - IDE·코드 에디터처럼 탭 스트립만 필요하고 본문 에디터는 호출자가 배치할 때 (
variant: .editorStrip) - 각 탭이 자체 콘텐츠를 소유하고 드래그로 순서를 바꿀 수 있어야 할 때
대신 다른 컴포넌트를 사용하세요:
-
Tabs: 탭 바만 필요하고 콘텐츠 영역을 직접 배치할 때 (pill / underline) — 단, IDE 파일 탭(닫기 × · 파일 글리프 · flush 스트립)은TabPane.editorStrip이 정답입니다.Button나열로 스트립을 합성하지 마세요 Accordion: 모든 섹션을 한 페이지에서 펼치고 접을 때
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 콘텐츠 타입입니다. selectedIndex는 0-based이며 선택 값과 재정렬 후 목록은 호출자가 갱신합니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
tabs |
List<CoreTabItem<W>> |
필수 | 탭 목록. boxed는 content 패널을 표시하고 editorStrip은 스트립만 표시. 항목 슬롯은 Tabs 표 참고 |
selectedIndex | int | 필수 | 현재 포커스된 탭 인덱스 |
variant |
CoreTabPaneVariant |
boxed |
boxed = 카드형 탭+패널 · editorStrip = IDE 스트립 전용 |
onChanged |
CoreValueChanged<int>? |
null |
탭 변경 콜백 |
reorderable |
bool |
false |
드래그로 탭 순서 변경 |
onReorder |
void Function(List<CoreTabItem>)? |
null |
탭 순서 변경 콜백 |
leading |
List<W> |
[] |
탭 바 앞 콘텐츠. 항목 내부 leading과 별개 |
trailing |
List<W> |
[] |
탭 바 뒤 콘텐츠. 항목 내부 trailing과 별개 |
tabPaneStyle |
CoreTabPaneStyle? |
null |
인스턴스 스타일 (defaultsByVariant 가 variant 별 프리셋을 제공) |
스타일 시스템 (Style System)#
모든 chrome / dimensional 오버라이드는 CoreTabPaneStyle 단일 슬롯으로 흐릅니다. behaviour (tabs
/ selectedIndex / onChanged / reorderable) 는 위젯/컴포넌트 파라미터로 직접 전달합니다.
Resolve chain#
CoreTabPaneStyle.defaultX / defaultsByVariant // 디자인 시스템 기본값
→ CoreTabPaneTheme.style // 프로젝트 공통
→ CoreTabPaneTheme.variantStyles[variant] // 프로젝트 variant 별
→ widget.tabPaneStyle // 인스턴스별
CoreTabPaneStyle 필드#
CoreTabPaneStyle 은 backgroundColor / stripBackgroundColor / borderColor
/ tabBorderColor / borderRadius / borderWidth / barHeight
/ tabPadding / tabSpacing / tabSeparatorColor / tabSeparatorWidth
/ dragOpacity / contentTextStyle / activeContentColor / inactiveContentColor
/ clickableStyle(합성된 boxed-tab Clickable 로 raw-forward) / labelBadgeGapStyle(탭 내부 한 걸음 — 라벨과 그 옆에 걸린 것 사이 간격, 기본
CoreSpace.space6 — 합성된 Gap 으로 raw-forward. CoreTabItem 의 badge
· leading · trailing 이 같은 값을 씁니다) / indicatorColor · indicatorWidth
· indicatorBorderRadius(선택 탭의 표시줄 — 색 · 두께 · 모서리. boxed 는 탭 아래쪽, editorStrip
은 위쪽) / barPadding(탭 바 안쪽 여백 — boxed 는 좌우 space16) / closeButtonStyle(닫기 × 의 상자 — 합성된 ghost
Button 으로 raw-forward) / closeIconStyle(닫기 글리프 — 합성된 Icon 으로 raw-forward) /
slotIconStyle(앞·뒤 슬롯의 글리프 — 기본 크기 16. 두 슬롯을 앰비언트 아이콘 크기로 감싸므로 크기를 적지 않은 Icon
이 이 값을 따릅니다) 를 가집니다.
indicatorColor 가 transparent 이면 그리지 않음을 뜻합니다(indicatorWidth
기본 stroke2, 색이 있을 때만 읽습니다). boxed 프리셋은 primary 를 진술해 선택 탭의 아래쪽 가장자리에 탭 너비 전체의 막대를 그리고(위쪽 두 모서리
radius2), 막대는 바 아래 선 위에 얹힙니다. editorStrip 은 위쪽 가장자리에 모서리 없는 막대를 그립니다. 그림자는 쓰지 않습니다 — CoUI 에서 그림자는 떠 있는 판에만 쓰고 화면 안 카드는 테두리만 씁니다.
closeButtonStyle · closeIconStyle 기본값은 빈 partial 이라 boxed 의 닫기 ×는
Button ghost sm 행(높이 32, 너비는 글리프에 좌우 패딩을 더한 값)과 글리프가 버튼 색을 따르는 모습 그대로입니다.
CoreTabPaneStyle.boxed 프리셋(및 defaultsByVariant[.boxed])은 barHeight: 44, 좌우
space16 의 barPadding, 좌우 space2 의 tabPadding, tabSpacing: space20,
labelLarge 탭 라벨, 카드 borderRadius: radius12 를 진술합니다. 카드 자체가 하나의 borderColor(outlineVariant) 테두리를 가지고, 바와 패널은 그 안에 놓입니다. 선택 탭은 면도 외곽선도 없습니다(tabBorderColor
투명 — 진술하면 그때만 면과 외곽선을 그립니다). 바 아래에는 카드 테두리와 같은 색의 선이 한 줄 있습니다.
CoreTabPaneStyle.editorStrip 프리셋(및 defaultsByVariant[.editorStrip])은 새 토큰 스케일 없이
아래 값을 진술합니다: barHeight: 36, tabSpacing: 0, 탭 사이 outlineVariant
1px 선(tabSeparatorColor), 페이지보다 한 단계 아래의 stripBackgroundColor(surfaceContainerLow), 시작 12 · 끝 8 의
tabPadding, codeSm 탭 라벨, flush borderRadius: 0. borderWidth
/ borderColor(stroke1 / outlineVariant)는 외곽 카드가 아니라 스트립 아래 선입니다 — 스트립 높이 안쪽 맨 아래에 그려지고, 선택된 탭의 면이 그 선을 덮으며 선택되지 않은 탭 아래에서만 보입니다.
clickableStyle 은 탭의 키보드 포커스 링을 탭 상자 안쪽으로 옮깁니다.
같은 프리셋이 다음 세 가지를 더 진술합니다.
-
선택 표시줄 —
indicatorColor: primary·indicatorWidth: stroke2. 선택된 탭 위쪽 가장자리 안쪽에 탭 너비 전체의 2px 막대를 칠합니다. 레이아웃 높이를 쓰지 않으므로 선택/비선택 탭의 라벨 y 위치가 같습니다. -
닫기 상자 —
closeButtonStyle: 20×20 · 패딩 0 · 모서리radius4. 36px 탭 칩 안에서 시작 12 + 라벨 + 6 + 20 + 끝 8 로 맞춰집니다. -
닫기 글리프 —
closeIconStyle: 크기 12 · 색onSurfaceSubtle. 색을 진술한 글리프는 버튼 상태를 따르지 않고 고정입니다 — hover 에서는 버튼의 hover 면만 바뀌고 글리프 색은 그대로입니다.
세 값은 모두 editorStrip 행에 있습니다. boxed 의 닫기 ×는 이 행을 읽지 않으므로 Button
ghost sm 행(높이 32) · 버튼 색 추종 그대로입니다.
동작 스펙 (Behavior)#
탭 전환#
- 탭 클릭 시 해당 콘텐츠 패널이 즉시 표시
onChanged콜백으로 탭 전환 이벤트 감지-
선택 탭은 아래쪽 가장자리의 막대로 구분됩니다 — 탭에는 면도 외곽선도 없고, 스트립은 카드 윗변에 붙어 놓입니다. 스트립 양끝 여백은
barPadding이 진술한 값이며 카드 모서리 반경에서 파생되지 않습니다 -
콘텐츠 패널은 여백과 글자 스타일을 진술하지 않습니다 —
CoreTabItem.content로 넘긴 위젯이 그대로 놓입니다.contentTextStyle은 탭 라벨에만 닿습니다. 위 예제처럼 설명 한 줄을 넣을 때는space20여백 안에Text(...).bodyMedium.onSurfaceVariant를 둡니다(Figma 세트의 대체 콘텐츠도 같은 모양입니다)
드래그 정렬#
reorderable: true일 때 탭을 드래그하여 순서 변경onReorder콜백이 재정렬된CoreTabItem목록을 전달- 탭이 많아지면 탭 바가 가로 스크롤되며 가장자리에 페이드 효과 표시
사용 가이드라인 (Usage Guidelines)#
탭 스트립과 boxed 콘텐츠 패널을 함께 소유하거나 editorStrip의 닫기·재정렬 슬롯이 필요하면 TabPane을 사용합니다. 탭 선택 바만 필요하면 Tabs, 라벨 없이 뷰 전환만 필요하면 Switcher를 선택합니다.
IDE 에디터 크롬 (editorStrip)#
제품(ADE/IDE) 파일 탭 스트립은 TabPane 의 공식 strip-only API 를 쓰세요. 빈 content 로 박스 카드를 비우는 해킹은 금지입니다 — 그건 외곽선·빈 패널을 남깁니다.
TabPane(
variant: .editorStrip, // 또는 TabPane(...).editorStrip
tabs: [
CoreTabItem(
label: 'main.dart',
leading: Icon(LucideIcons.fileCode),
onClose: () => closeTab(0),
),
CoreTabItem(
label: 'pubspec.yaml',
onClose: () => closeTab(1),
),
],
selectedIndex: index,
onChanged: (i) => setState(() => index = i),
)
- 콘텐츠 패널을 렌더하지 않습니다 (
CoreTabItem.content무시). 에디터 본문은 호출자가 둡니다. -
스타일 프리셋:
barHeight: 36·tabSpacing: 0· 탭 사이outlineVariant1px 선 · flat strip band · flush (radius0) · 스트립 아래 선(borderWidth·borderColor) · 선택 탭 위primary2px 표시줄(indicatorColor·indicatorWidth) · 20×20 닫기 상자(closeButtonStyle) · 12onSurfaceSubtle닫기 글리프(closeIconStyle). -
체인:
.editorStrip은variant만 바꿉니다 — chrome 은defaultsByVariant가 채웁니다.
Button 합성 스트립에서 이전#
// ❌ 예전 — Button 나열로 IDE 톤을 합성 (공식 API 없음)
Row(children: [
for (final f in files)
Button(onPressed: () => select(f), child: Text(f.name)),
])
// ❌ 예전의 빈 content 해킹 — 빈 카드 남음
TabPane(
tabs: [CoreTabItem(label: 'main.dart' /* content 없음 */)],
selectedIndex: 0,
onChanged: ...,
)
// ✅ 지금 — 공식 editorStrip
TabPane(variant: .editorStrip, tabs: [...], selectedIndex: index, onChanged: ...)
예전 문서에 있던 “TabPane 으로는 IDE 스트립을 못 만든다” 는 더 이상 맞지 않습니다.
editorStrip이 그 자리입니다.
닫기 × 와 hit-test#
CoreTabItem.onClose 가 있으면 ghost Button + LucideIcons.x 를 탭 선택 Clickable 밖에 합성합니다 — × 클릭은
onChanged 를 호출하지 않습니다. 커스텀 닫기는 trailing 에 넣어도 같은 분리(select 밖)가 적용됩니다.
닫기 × 의 상자와 글리프는 closeButtonStyle · closeIconStyle 로 바꿉니다. editorStrip
에서 글리프 색 onSurfaceSubtle 은 hover 에서도 바뀌지 않으며, 비활성 탭(CoreTabItem.enabled: false)에서도 흐려지지 않습니다 — ×는 탭의 선택
Clickable 밖에 있어 탭의 비활성 상태가 닿지 않고, 활성 버튼으로 남아 같은 색을 유지합니다. 색을 진술하지 않은 boxed 의 글리프는 반대로 버튼의 hover · disabled 를 따릅니다.
선택 영역 밖이어도 × 와 trailing 은 같은 탭 칩 안에 있습니다. tabPadding 은 칩 전체(leading
· 라벨 · badge · trailing · ×)를 감싸므로, × 는 라벨이 칩 시작 가장자리에서 떨어진 만큼 칩 끝 가장자리에서 떨어집니다 — Flutter · Web · Figma 탭 프레임이 같은 상자를 감쌉니다.
✅ Do#
boxed 에서는 각 CoreTabItem 의 content 슬롯을 채우기
TabPane(
tabs: [
CoreTabItem(label: 'Account', content: accountPanel),
CoreTabItem(label: 'Password', content: passwordPanel),
],
selectedIndex: index,
onChanged: (i) => setState(() => index = i),
)
content 슬롯은 boxed TabPane 이 콘텐츠 패널에 실제로 렌더하는 값입니다. IDE 스트립만 필요하면 variant: .editorStrip 을 쓰세요 — content 를 비운 boxed 는 빈 카드가 됩니다.
❌ Don't#
빈 content 로 IDE 스트립을 흉내 내지 않기 / Button 합성 스트립 쓰지 않기
// ❌ 빈 content 해킹
TabPane(tabs: [CoreTabItem(label: 'a.dart')], ...)
// ❌ Button 나열
Row(children: [for (final t in tabs) Button(...)])
공식 경로: TabPane(variant: .editorStrip, ...) 또는 .editorStrip.
reorderable: true 만 켜고 onReorder 콜백을 생략하지 않기 — 둘 다 있어야 드래그가 활성화됩니다.
접근성 (Accessibility)#
역할#
-
Flutter: 탭 라벨마다
Semantics(button: true, selected: <선택 여부>, enabled: <활성 여부>)가 붙습니다. 합성된Clickable도 자체적으로Semantics(button: true, enabled: …)를 emit 합니다. 탭 바와 콘텐츠 패널에는Semantics가 없습니다 — tablist / tabpanel 에 해당하는 구조는 Flutter 쪽에 존재하지 않습니다. -
Web: 탭 바
<div>에role="tablist", 탭마다role="tab"+aria-selected, boxed 콘텐츠 패널<div>에role="tabpanel"이 붙습니다.editorStrip은 패널이 없습니다.role="tab"은Clickable의 기본 버튼 역할을 덮어씁니다. -
루트
<div>에는 역할이 없습니다 —attributespassthrough 만 통과하므로 필요하면 호출부가 직접 실을 수 있습니다. -
탭과 패널을 잇는
aria-controls/aria-labelledby/id배선은 양 플랫폼 어디에도 없습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab | 탭마다 개별 tab stop | 스트립 전체가 tab stop 하나(선택된 탭). 닫기 버튼은 별도 stop |
← / → | 프레임워크 방향성 포커스 이동 | 이전/다음 탭으로 포커스 이동, 끝에서 순환 |
Home / End | 없음 | 첫/마지막 탭으로 포커스 이동 |
Enter / Space | 포커스된 탭 선택 | 포커스된 탭 선택 |
Web 은 Tabs 와 같은 수동 활성화입니다 — 화살표는 포커스만 옮기고 선택은
Enter / Space 로 합니다. Flutter 의 방향성 순회는 탭 전용 탐색이 아닙니다.
선택된 boxed 탭은 아래쪽 가장자리의 막대로 구분됩니다 (Flutter · Web · Figma
동일). 카드가 하나의 외곽선을 소유하고, 스트립은 barPadding 만큼 안쪽에서
시작합니다.
브라우저 검증 — Web 키보드 프로브(
tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제 포커스 이동 결과를 확인합니다.
스크린 리더#
- 각 탭의 접근 가능한 이름은
CoreTabItem.label문자열에서 옵니다. - 선택 상태는 Flutter
Semantics(selected:)/ Webaria-selected로 양 플랫폼 모두 통보됩니다. -
비활성 탭(
CoreTabItem.enabled가false이거나onChanged가null)은 FlutterSemantics(enabled: false)/ Webaria-disabled="true"로 전달되며 Web 에서는tabindex가 빠져 포커스도 받지 않습니다. -
닫기 × 는 아이콘뿐인 버튼이라 렌더 시점 로케일의 닫기 문구가 이름입니다 — Flutter
Semantics(label:), Webaria-label. Tabs 의 × 도 같습니다. - 탭을 바꿔 콘텐츠 패널이 교체되는 것은 통보되지 않습니다 — 어느 쪽에도 live region 이 없습니다.
포커스 관리#
- 진입: 오버레이가 아니므로 포커스 이동이 없습니다. 마운트 시 자동 포커스도 없습니다.
- 이탈: 해당 없음 — 닫히는 표면이 아닙니다.
- 트랩: 없습니다.
Tab은 탭 바를 지나 그대로 다음 요소로 빠져나갑니다. - 탭을 선택해도 포커스는 그 탭에 남고 콘텐츠 패널로 옮겨가지 않습니다(양 플랫폼).
알려진 제약#
-
Flutter 는 탭마다 tab stop 이 생깁니다. Web 의 roving tabindex 와 달리
프레임워크 순회를 쓰므로 탭이 많으면
Tab이동 횟수도 늘어납니다. -
탭과 콘텐츠 패널이 프로그램적으로 연결되어 있지 않습니다 — Web 의
role="tab"에aria-controls가,role="tabpanel"에aria-labelledby가 없어 스크린 리더가 "이 패널이 저 탭의 것"이라고 안내하지 못합니다. 두 노드의 attributes 모두 내부에서 생성되므로 호출부가 채울 수 없습니다. -
reorderable: true의 순서 변경은 포인터 전용입니다 — Web 은 HTML5 drag-and-drop(dragstart/dragover/drop), Flutter 는Draggable/DragTarget으로만 동작하고, 키보드로onReorder를 일으킬 표면이 양쪽 모두 없습니다(WCAG 2.1.1). — 대안 없음. -
Flutter 에는 탭 목록·패널 구조가 없습니다 — Web 이
tablist/tabpanel로 알리는 구조를 Flutter 쪽 스크린 리더는 받지 못하고, 각 탭이 "선택된 버튼"으로만 읽힙니다. 호출부가Semantics로 감싸 컨테이너 라벨 정도는 얹을 수 있으나 탭↔패널 관계는 재현할 수 없습니다.
크로스 플랫폼 차이점 (Platform Differences)#
TabPane 은 Flutter / Web 에서 동일한 named properties API 를 사용합니다. 파라미터 이름·구조·동작이 양쪽 동일합니다 (variant
· editorStrip · onClose 포함).
선택 표시줄과 색 강제 모드#
선택 표시줄은 Flutter 가 탭 칩 위에 쌓은 막대(Stacks + Position)로, Web 이 칩 안의 절대 배치 <span aria-hidden>(배경색 · 모서리 반경)으로 그립니다. 두 플랫폼 모두 레이아웃을 쓰지 않고 칠하기만 하며, 모서리 반경(indicatorBorderRadius)도 같은 값을 읽습니다.
브라우저의 색 강제 모드(forced-colors: active)는 배경색을 시스템 색으로 치환하므로, 막대의 배경만으로는 그 모드에서 선택 탭을 구분하는 것이 남지 않습니다(선택 탭의 면도 Canvas 로 접힙니다). 그래서 Web 은 선택 탭 칩에 두께로 정해지는 표시 클래스(co-tab-pane-indicator-<두께>)를 달고, 같은 두께로 쓴
@media (forced-colors:active) 규칙이 그 칩의 높이 0 의사요소에 실제 border-top(editorStrip) 또는 아래쪽 가장자리의 테두리(boxed, 색은 시스템 색
Highlight)를 그립니다. 테두리는 이 모드가 남기고, 높이 0 의 절대 배치 상자라 레이아웃 높이를 쓰지 않으므로 선택/비선택 탭의 라벨 y 위치는 그대로입니다. 일반 모드의 출력은 칩 클래스 하나와
<style> 블록 하나가 더해질 뿐 그리는 것은 바뀌지 않습니다. 표시줄을 그리지 않는 경우(indicatorColor: transparent
· 두께 0)에는 클래스도 <style> 도 없습니다.
남는 비대칭은 스트립 아래 선입니다. Web 은 box-shadow 라 색 강제 모드에서 사라지는데(Flutter 의 Border
는 계속 그립니다), 아래 선은 border-bottom 으로 옮기면 레이아웃 높이를 쓰고 선택 탭 면이 그 선을 덮지 못해 같은 방식으로 풀리지 않습니다. 이 칸은 아직 폴백이 없습니다.
남은 갭 (Figma)#
buildTabPane 은 COMPONENT_SET 을 만들고, STRUCTURAL_MIGRATIONS 의 TabPane
(oldType: 'COMPONENT') 이 옛 단일 COMPONENT 를 한 번 제거합니다. Figma 는 COMPONENT 를 COMPONENT_SET 으로 그 자리에서 바꾸지 못하므로, 그 항목이 없으면 같은 이름의 단일 컴포넌트가 페이지에 남습니다. 그 빌더와 마이그레이션은 저장소에 있으며, 아래 Generate + Publish 를 하기 전에는 퍼블리시된 라이브러리에 반영되지 않습니다.
-
축 이름:
Variant(Variant=boxed|Variant=editorStrip). 계약 enumCoreTabPaneVariant와 같고, 다른 내비게이션 빌더가 계약 enum 에 쓰는 라벨과 같습니다.chrome/theme/dark축은 없습니다. 다크 모습은 TabPane 프로퍼티가 아니라 제품 캔버스의 CoUI mode / variables 가 정합니다. -
editorStrip포즈: 콘텐츠 패널 없음 · 카드 없음 (이 variant 의borderWidth는 외곽 카드가 아니라 스트립 아래 선의 두께) · strip band fill · 탭 사이outlineVariant선 · flush corners (borderRadius0) · 스트립 높이 36. -
boxed포즈: 루트가 카드(테두리 · 콘텐츠 재질 면 · 모서리radius12) · 바 높이 44 · 바 안쪽 좌우 16 · 탭이 바 높이를 채움 · 바 아래 선 · 선택 탭의 막대(탭 너비 전체, 위쪽 두 모서리 2). 겹침(itemSpacing음수)도 위/아래 순서 뒤집기도 쓰지 않습니다. 크롬 값은CoreTabPaneStyle.editorStrip/defaultsByVariant.editorStrip필드만 사용합니다. -
선택 표시줄 · 닫기 20×20 · 글리프 12:
TabPaneTab세트가 소유합니다. 선택 탭의 표시줄은 탭 가장자리에 고정된 절대 배치 막대(indicator)이고(boxed는 아래,editorStrip은 위),Show close속성으로 켜는 닫기 상자는editorStrip멤버에만 있습니다. 남는 차이는 닫기를Button인스턴스가 아니라 같은 크기의 그린 상자로 그린다는 점(hover 면 없음)뿐입니다.
라이브 Figma 파일은 이 PR 이 바꾸지 않습니다. 코드를 머지해도 퍼블리시된 라이브러리는 그대로입니다. 디자이너가 CoUI 파일 9FgsLhiWz0aLsVqcoI6nYJ
에서 직접:
- Generate Components (
TabPane) - Publish library
- ADE 에서 Libraries Update
- 인스턴스의
Variant를editorStrip으로 설정