Table | CoUI
LogoCoUI

Table

행·열·셀 구조의 데이터 테이블. rowSpan/columnSpan, hover/selection, flex/fixed/intrinsic 사이징, frozen cells, 드래그 리사이즈를 지원한다.

Table#

Table은 Flutter와 Web 양쪽에서 동일한 API로 렌더링되는 통일 테이블 컴포넌트다. CoreTableRowData<W> / CoreTableCellData<W> 구조를 받아 셀 단위 호버/선택, 행/열 병합(rowSpan/columnSpan), 유연한 사이즈 전략(CoreFlexTableSize/CoreFixedTableSize/CoreIntrinsicTableSize), frozen cells, 스크롤 오프셋을 지원한다. 드래그로 열/행 크기를 바꿔야 할 때는 ResizableTable + ResizableTableController를 양쪽 플랫폼에서 동일하게 사용한다.

Live Preview#

사용 시기 (When to Use)#

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

  • 데이터를 행과 열로 비교/분석해야 할 때
  • 헤더/바디/푸터 구조가 필요한 목록일 때
  • 셀 병합, frozen 열/행이 필요한 스프레드시트형 UI일 때

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

  • Card 리스트: 각 항목이 복잡한 레이아웃을 가질 때
  • Accordion: 행별 상세 정보를 펼쳐서 보여줄 때

기본 사용법 (Basic Usage)#

Table(
  rows: [
    CoreTableRowData<Widget>.header(cells: [
      CoreTableCellData<Widget>(child: Text('Name')),
      CoreTableCellData<Widget>(child: Text('Status')),
      CoreTableCellData<Widget>(child: Text('Role')),
    ]),
    CoreTableRowData<Widget>(cells: [
      CoreTableCellData<Widget>(child: Text('Alice')),
      CoreTableCellData<Widget>(
        child: Badge(
          variant: CoreBadgeVariant.primary,
          child: Text('Active'),
        ),
      ),
      CoreTableCellData<Widget>(child: Text('Admin')),
    ]),
  ],
)
Table(
  rows: [
    CoreTableRowData<Component>.header(cells: [
      CoreTableCellData<Component>(child: Text('Name')),
      CoreTableCellData<Component>(child: Text('Status')),
      CoreTableCellData<Component>(child: Text('Role')),
    ]),
    CoreTableRowData<Component>(cells: [
      CoreTableCellData<Component>(child: Text('Alice')),
      CoreTableCellData<Component>(
        child: Badge(
          variant: CoreBadgeVariant.primary,
          child: Text('Active'),
        ),
      ),
      CoreTableCellData<Component>(child: Text('Admin')),
    ]),
  ],
)

빠른 오버라이드 (Chain)#

이미 만든 Table 인스턴스에 tableStyle을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 tableStyle: CoreTableStyle(...) 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Table(
          rows: [
            CoreTableRowData<Widget>.header(
              cells: const [
                CoreTableCellData<Widget>(child: Text('Name')),
                CoreTableCellData<Widget>(child: Text('Status')),
                CoreTableCellData<Widget>(child: Text('Role')),
              ],
            ),
            CoreTableRowData<Widget>(
              cells: [
                const CoreTableCellData<Widget>(child: Text('Alice')),
                CoreTableCellData<Widget>(
                  child: Badge(
                    variant: CoreBadgeVariant.primary,
                    child: const Text('Active'),
                  ),
                ),
                const CoreTableCellData<Widget>(child: Text('Admin')),
              ],
            ),
            CoreTableRowData<Widget>(
              cells: [
                const CoreTableCellData<Widget>(child: Text('Bob')),
                CoreTableCellData<Widget>(
                  child: Badge(
                    variant: CoreBadgeVariant.secondary,
                    child: const Text('Inactive'),
                  ),
                ),
                const CoreTableCellData<Widget>(child: Text('User')),
              ],
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
        Table(
          rows: [
            CoreTableRowData<Widget>.header(
              cells: const [
                CoreTableCellData<Widget>(child: Text('Name')),
                CoreTableCellData<Widget>(child: Text('Status')),
                CoreTableCellData<Widget>(child: Text('Role')),
              ],
            ),
            CoreTableRowData<Widget>(
              cells: [
                const CoreTableCellData<Widget>(child: Text('Alice')),
                CoreTableCellData<Widget>(
                  child: Badge(
                    variant: CoreBadgeVariant.primary,
                    child: const Text('Active'),
                  ),
                ),
                const CoreTableCellData<Widget>(child: Text('Admin')),
              ],
            ),
            CoreTableRowData<Widget>(
              cells: [
                const CoreTableCellData<Widget>(child: Text('Bob')),
                CoreTableCellData<Widget>(
                  child: Badge(
                    variant: CoreBadgeVariant.secondary,
                    child: const Text('Inactive'),
                  ),
                ),
                const CoreTableCellData<Widget>(child: Text('User')),
              ],
            ),
          ],
        ).withStyle(
          const CoreTableStyle(
            headerBackgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
    );
  }
}
class TableChainExample extends StatelessComponent {
  const TableChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Table(
          rows: [
            CoreTableRowData<Component>.header(
              cells: [
                CoreTableCellData<Component>(child: Text('Name')),
                CoreTableCellData<Component>(child: Text('Status')),
                CoreTableCellData<Component>(child: Text('Role')),
              ],
            ),
            CoreTableRowData<Component>(
              cells: [
                CoreTableCellData<Component>(child: Text('Alice')),
                CoreTableCellData<Component>(
                  child: Badge(
                    variant: CoreBadgeVariant.primary,
                    child: Text('Active'),
                  ),
                ),
                CoreTableCellData<Component>(child: Text('Admin')),
              ],
            ),
            CoreTableRowData<Component>(
              cells: [
                CoreTableCellData<Component>(child: Text('Bob')),
                CoreTableCellData<Component>(
                  child: Badge(
                    variant: CoreBadgeVariant.secondary,
                    child: Text('Inactive'),
                  ),
                ),
                CoreTableCellData<Component>(child: Text('User')),
              ],
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
        Table(
          rows: [
            CoreTableRowData<Component>.header(
              cells: [
                CoreTableCellData<Component>(child: Text('Name')),
                CoreTableCellData<Component>(child: Text('Status')),
                CoreTableCellData<Component>(child: Text('Role')),
              ],
            ),
            CoreTableRowData<Component>(
              cells: [
                CoreTableCellData<Component>(child: Text('Alice')),
                CoreTableCellData<Component>(
                  child: Badge(
                    variant: CoreBadgeVariant.primary,
                    child: Text('Active'),
                  ),
                ),
                CoreTableCellData<Component>(child: Text('Admin')),
              ],
            ),
            CoreTableRowData<Component>(
              cells: [
                CoreTableCellData<Component>(child: Text('Bob')),
                CoreTableCellData<Component>(
                  child: Badge(
                    variant: CoreBadgeVariant.secondary,
                    child: Text('Inactive'),
                  ),
                ),
                CoreTableCellData<Component>(child: Text('User')),
              ],
            ),
          ],
        ).withStyle(
          const CoreTableStyle(
            headerBackgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Props#

Table / ResizableTable#

속성타입기본값설명
rows List<CoreTableRowData<W>> 필수 렌더링할 행 목록 (body/header/footer)
defaultColumnWidth CoreTableSize CoreFlexTableSize() 지정되지 않은 열 기본 사이즈
defaultRowHeight CoreTableSize CoreIntrinsicTableSize() 지정되지 않은 행 기본 사이즈
columnWidths Map<int, CoreTableSize>? null 열별 사이즈 오버라이드
rowHeights Map<int, CoreTableSize>? null 행별 사이즈 오버라이드
frozenCells CoreFrozenTableData? null 고정 행/열 지정
horizontalOffset double? null 수평 스크롤 오프셋 (px)
verticalOffset double? null 수직 스크롤 오프셋 (px)
clipBehavior CoreClipMode hardEdge 컨테이너 모서리 클리핑 방식
themeCoreTableTheme?null위젯 레벨 테마 오버라이드
tableStyle CoreTableStyle? null 인스턴스별 chrome 단일 진입점 (Style 시스템 참조)

ResizableTablerows · frozenCells · horizontalOffset · verticalOffset · clipBehavior · theme · tableStyle을 그대로 받고, 여기에 controller: ResizableTableController(필수)와 columnResizeMode (reallocate 기본) / rowResizeMode (expand 기본)가 추가된다. 열/행 사이즈는 컨트롤러가 소유하므로 columnWidths / rowHeights / defaultColumnWidth / defaultRowHeight는 받지 않는다.

스타일 시스템 (Style System)#

Table 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreTableStyle 단일 슬롯으로 흐릅니다. 시맨틱/구조/스크롤 상태 (rows, columnWidths, rowHeights, frozenCells, horizontalOffset, verticalOffset, clipBehavior) 는 위젯 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • 시맨틱 / behaviour / 구조: 위젯 파라미터로 직접 (rows, columnWidths, rowHeights, frozenCells, horizontalOffset, verticalOffset, clipBehavior, controller, columnResizeMode, rowResizeMode)
  • chrome / dimensional / 슬롯 스타일: CoreTableStyle 한 곳으로 (viewportWidth / viewportHeight + header/footer/row/cell/border/resizer chrome + headerTextStyle / cellTextStyle)

Resolve chain#

design system default for table
  → CoreTableTheme.style                       // 프로젝트 공통
  → parent component slot override
  → widget.tableStyle                          // 인스턴스별

각 nested 슬롯 스타일 (headerTextStyle / cellTextStyle) 은 CoreTextStyle 자체 resolve chain 으로 다시 한 번 머지됩니다.

CoreTableStyle 필드#

필드타입설명
backgroundColor CoreColor? Table container background colour. Deliberately without a default* , and it must not gain one: an unfilled container is what lets a table read on whatever surface it lands on. A non-null default — transparent included — makes the Web resolver's containerBgOverride non-null, and the frozen-cell branch reads that as "the caller chose a fill" and emits it inline over the opaque [defaultFrozenCellBackgroundColor] class, so sticky cells would stop covering the rows scrolling under them. Flutter reaches the same fill as the tail of the row-base chain ( rowAlternate ?? frozen ?? row ?? container ?? transparent ).
borderRadius CoreBorderRadius? Table container border radius.
viewportWidth double? Viewport width (logical px). When set together with the widget's horizontalOffset , the table enables horizontal virtual scroll inside this width. Deliberately without a default* : the null test is the switch. Both resolvers read "is a viewport extent set" to decide whether the table sits inside a scroll container — Web lays out at the natural grid size ( w-max ) instead of filling its parent ( w-full ), Flutter builds a viewportSize at all — so any default would put every table into virtual-scroll layout.
viewportHeight double? Viewport height (logical px). When set together with the widget's verticalOffset , the table enables vertical virtual scroll inside this height. Deliberately without a default* — same switch as [viewportWidth], which names it.
headerBackgroundColor CoreColor? Header row background colour.
headerHeight double? Header row height (logical px). Deliberately without a default* : unset means the grid track sizing ( CoreTableSize ) drives the natural header height. A default would floor every table's header — a min-height rule on each header cell (Web) and a BoxConstraints minimum (Flutter) — including the ones sized by their content today.
headerPadding CoreEdgeInsets? Header cell padding.
footerBackgroundColor CoreColor? Footer row background colour.
rowBackgroundColor CoreColor? Body row background colour. Deliberately without a default* : it is one link in the row-base chain ( rowAlternate ?? frozen ?? row ?? container ?? transparent ) and unset means "this row states no fill of its own". A default would fill every body row, and in Table (the caller that opts into the row fallback) it would also stand in for the container fill — the same frozen-cell path written down on [backgroundColor].
rowAlternateBackgroundColor CoreColor? Alternate (zebra-stripe) body row background colour. Deliberately without a default* : both resolvers gate the stripe on this being set — usesZebra on Web, the bodyRowEven ? … : null head of the Flutter row-base chain — so a default would stripe every table rather than the ones that ask for it.
rowHoverColor CoreColor? Body row background colour on hover.
rowSelectedColor CoreColor? Body row background colour when selected.
rowHeight double? Body row height (logical px). Deliberately without a default* for the reason written on [headerHeight] — unset lets the grid track sizing drive the natural row height, and a default would floor every body row on both platforms.
cellPaddingCoreEdgeInsets?Body cell padding.
borderColor CoreColor? Cell / row separator border colour.
borderWidth double? Cell / row separator border width (logical px).
resizerHoverColor CoreColor? Column/row resizer handle colour on hover.
resizerDragColor CoreColor? Column/row resizer handle colour while dragging.
resizerThickness double? Resizer handle thickness (logical px).
headerTextStyle CoreTextStyle? Header cell text style override.
cellTextStyle CoreTextStyle? Body cell text style override.
frozenCellBackgroundColor CoreColor? Frozen (pinned) cell background colour override. null → [defaultFrozenCellBackgroundColor].
resizerColor CoreColor? Column-resizer handle idle colour override. null → [defaultResizerColor].
foregroundColor CoreColor? Cell foreground (text) colour override. null → [defaultForegroundColor].
disabledForegroundColor CoreColor? Disabled-state cell foreground colour override. null → [defaultDisabledForegroundColor].

리사이저 4 필드는 ResizableTable 에서만 그려집니다.

테마 (CoreTableTheme)#

CoreTableThemestyle: CoreTableStyle? 슬롯 하나만 가집니다 — 프로젝트 공통 chrome 은 그 슬롯으로 넣고, 인스턴스별 오버라이드는 위젯의 tableStyle 로 넣습니다. 위젯 theme 파라미터는 그 프로젝트 기본값을 이 인스턴스에서만 바꿔 끼우는 용도입니다.

CoreTableTheme(
  style: CoreTableStyle(
    headerBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
    borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
  ),
)

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

✅ Do#

드래그 리사이즈가 필요하면 ResizableTable + ResizableTableController 를 쓰고, 사이즈는 컨트롤러가 소유하게 하기

final controller = ResizableTableController(
  columnCount: 3,
  rowCount: rows.length,
);

ResizableTable(
  controller: controller,
  rows: rows,
)

ResizableTablecolumnWidths / rowHeights / defaultColumnWidth / defaultRowHeight 를 받지 않습니다 — 열/행 사이즈의 단일 출처는 컨트롤러이므로, Table 에서 쓰던 사이즈 파라미터를 그대로 옮기려 하면 컴파일조차 되지 않습니다.


❌ Don't#

선택 / 비활성 상태를 rowSelectedColor / cell.enabled 색상만으로 접근 가능하다고 가정하지 않기

// ❌ 시각만 칠하고 끝 — 스크린 리더는 이 행이 선택됐는지 알 수 없음
Table(
  rows: rows,
  tableStyle: CoreTableStyle(
    rowSelectedColor: CoreColor.token(CoreColors.primaryContainer),
  ),
)

rowSelectedColorcell.enabled 는 배경/전경 색만 바꿀 뿐 aria-selected / Semantics(selected:), aria-disabled / Semantics(enabled:) 를 내보내지 않습니다 — 선택·비활성 정보가 스크린 리더 사용자에게 중요하다면 소비자가 직접 얹어야 합니다.

접근성 (Accessibility)#

역할 / Semantics#

양 플랫폼 모두 tablerowcolumnheader/cell 역할 트리를 냅니다. Web Table/ResizableTable 은 루트에 role="table", 각 행을 role="row"<div>(display: contents — CSS Grid track 배치에는 관여하지 않고 접근성 트리 그룹핑만 만듦)로 감싸고, 개별 셀에 role="columnheader"(헤더 행) / role="cell"(그 외), 그리고 columnSpan/rowSpan 이 1 초과면 aria-colspan/aria-rowspan 을 붙입니다. Flutter 쌍둥이는 RenderTableLayoutBox.assembleSemanticsNode 가 같은 구조를 만듭니다 — 플랫한 셀 render 트리를 TableLayoutParentData.row 기준으로 묶어 SemanticsRole.row 노드를 합성하고, 각 셀은 Semantics(container: true, role: .columnHeader/.cell) 로 감쌉니다.

헤더-셀 연결은 이 트리 구조 자체로 전달됩니다 — <table>/role="table" 계열 접근성 트리는 명시적 headers/aria-describedby 없이도 행·열 위치로 헤더를 셀에 연결하는 것이 표준 동작입니다(네이티브 <table> 과 동일한 브라우저/스크린리더 규약).

키보드#

처리하는 키가 없습니다. cell.onPressed 는 포인터 전용이고(Flutter 는 Clickable 이 아니라 raw GestureDetector 라 Enter/Space 를 상속하지 않고, Web 은 포커스 불가능한 <div>click 핸들러만 붙습니다), 열·행 리사이즈도 드래그 전용입니다. 키보드만 쓰는 사용자는 눌리는 셀을 활성화할 수도, 크기를 조절할 수도 없습니다.

포커스#

표·행·셀·리사이즈 핸들 중 어느 것도 포커스를 받지 않습니다. roving tabindex 도, 방향키 그리드 탐색도, 포커스 트랩이나 복원도 없습니다. 구현된 것은 포인터 어포던스뿐입니다 — hover 추적과 커서 변경(SystemMouseCursors.click / resizeColumn / resizeRow).

스크린 리더#

표 구조(표 → 행 → 헤더/셀, 헤더-셀 연결)는 전달됩니다 — 위 "역할 / Semantics" 참조. 행·열 인덱스·총 개수 안내(aria-rowindex/aria-colindex/aria-rowcount/aria-colcount)는 아직 없습니다. 눌리는 셀도 아직 버튼 역할이 아니라 cell/columnheader 역할의 일반 텍스트로 읽힙니다.

알려진 제약#

  • 행·열 인덱스와 총 개수 안내 (aria-rowindex/aria-colindex/aria-rowcount/aria-colcount — 아직 없음, 표 구조 자체는 위에서 전달됨)
  • cell.onPressed 의 키보드 도달 경로 (양 플랫폼 포인터 전용)
  • 열·행 리사이즈의 키보드 경로
  • 포커스 관리와 방향키 그리드 탐색
  • 선택 상태 안내 — rowSelectedColor 로 칠하기만 하고 aria-selected / Semantics(selected:) 를 내보내지 않습니다
  • 비활성 상태 안내 — cell.enabled 는 hover/tap 억제와 전경색 변경까지만 하고 aria-disabled / Semantics(enabled:) 가 없습니다

정렬 상태와 고정 셀(frozenCells)도 시각 전용입니다. 위 항목이 필요한 데이터 표로 쓸 계획이라면 소비자가 직접 얹어야 합니다.

감속 모션 · 고대비 · 강제 색상 · 최소 터치 타겟처럼 전 컴포넌트에 공통으로 걸리는 축은 전역 접근성 축에서 다룹니다.

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

항목FlutterWeb
레이아웃 TableLayoutWidget (custom RenderBox, RenderTableLayoutBox ) — flex/fixed/intrinsic + span + frozen + scroll CSS Grid (grid-template-columns/rows, span N)
rowSpan/columnSpan 엔진이 오프셋 계산 grid-column: span N / grid-row: span N
frozen cells RenderTableLayoutBox.performLayout에서 viewport 기반 adjustment 동일 — horizontalOffset + verticalOffset + frozenCells로 구현
드래그 리사이즈 ResizableTable + ResizableTableController 동일 — 각 셀 우측/하단에 drag handle overlay
스크롤 ScrollableClient(builder) — 2D TwoDimensionalScrollable 기반 동일 API — overflow: auto + onscroll로 offset 추적해 builder 재호출

관련 컴포넌트#