TabPane | CoUI
본문으로 건너뛰기
LogoCoUI

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#

사용 시기 (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 표 참고
selectedIndexint필수현재 포커스된 탭 인덱스
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 · 탭 사이 outlineVariant 1px 선 · flat strip band · flush (radius0) · 스트립 아래 선(borderWidth · borderColor) · 선택 탭 위 primary 2px 표시줄(indicatorColor · indicatorWidth) · 20×20 닫기 상자(closeButtonStyle) · 12 onSurfaceSubtle 닫기 글리프(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> 에는 역할이 없습니다 — attributes passthrough 만 통과하므로 필요하면 호출부가 직접 실을 수 있습니다.
  • 탭과 패널을 잇는 aria-controls / aria-labelledby / id 배선은 양 플랫폼 어디에도 없습니다.

키보드#

키FlutterWeb
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:) / Web aria-selected 로 양 플랫폼 모두 통보됩니다.
  • 비활성 탭(CoreTabItem.enabled 가 false 이거나 onChanged 가 null)은 Flutter Semantics(enabled: false) / Web aria-disabled="true" 로 전달되며 Web 에서는 tabindex 가 빠져 포커스도 받지 않습니다.
  • 닫기 × 는 아이콘뿐인 버튼이라 렌더 시점 로케일의 닫기 문구가 이름입니다 — Flutter Semantics(label:), Web aria-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). 계약 enum CoreTabPaneVariant 와 같고, 다른 내비게이션 빌더가 계약 enum 에 쓰는 라벨과 같습니다. chrome / theme / dark 축은 없습니다. 다크 모습은 TabPane 프로퍼티가 아니라 제품 캔버스의 CoUI mode / variables 가 정합니다.
  • editorStrip 포즈: 콘텐츠 패널 없음 · 카드 없음 (이 variant 의 borderWidth 는 외곽 카드가 아니라 스트립 아래 선의 두께) · strip band fill · 탭 사이 outlineVariant 선 · flush corners (borderRadius 0) · 스트립 높이 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 에서 직접:

  1. Generate Components (TabPane)
  2. Publish library
  3. ADE 에서 Libraries Update
  4. 인스턴스의 Variant 를 editorStrip 으로 설정
  • Tabs — 콘텐츠 패널 없이 탭 바만 (pill / underline)
  • Accordion — 펼침/접힘 섹션