Table#
Table은 Flutter와 Web 양쪽에서 동일한 API로 렌더링되는 통일 테이블 컴포넌트다. CoreTableRowData<W>
/ CoreTableCellData<W> 구조를 받아 셀 단위 호버/선택, 행/열 병합(rowSpan/columnSpan), 유연한 사이즈 전략(CoreFlexTableSize/CoreFixedTableSize/CoreIntrinsicTableSize), frozen cells, 스크롤 오프셋을 지원한다. 드래그로 열/행 크기를 바꿔야 할 때는
ResizableTable + ResizableTableController를 양쪽 플랫폼에서 동일하게 사용한다.
Live Preview#
class TableDefaultExample extends StatelessComponent {
const TableDefaultExample({super.key});
@override
Component build(BuildContext context) {
return 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')),
],
),
],
);
}
}
class TableDefaultExample extends StatelessWidget {
const TableDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return 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')),
],
),
],
);
}
}
class TableResizableExample extends StatefulComponent {
const TableResizableExample({super.key});
@override
State<TableResizableExample> createState() => _TableResizableExampleState();
}
class _TableResizableExampleState extends State<TableResizableExample> {
late final ResizableTableController _controller;
@override
void initState() {
super.initState();
_controller = ResizableTableController(
defaultColumnWidth: 300,
defaultRowHeight: 56,
defaultWidthConstraint: const CoreConstrainedTableSize(min: 80),
defaultHeightConstraint: const CoreConstrainedTableSize(min: 44),
);
}
@override
Component build(BuildContext context) {
return ResizableTable(
controller: _controller,
rows: [/* ... */],
);
}
}
class TableResizableExample extends StatefulWidget {
const TableResizableExample({super.key});
@override
State<TableResizableExample> createState() => _TableResizableExampleState();
}
class _TableResizableExampleState extends State<TableResizableExample> {
late final ResizableTableController _controller;
@override
void initState() {
super.initState();
_controller = ResizableTableController(
defaultColumnWidth: 300,
defaultRowHeight: 56,
defaultWidthConstraint: const CoreConstrainedTableSize(min: 80),
defaultHeightConstraint: const CoreConstrainedTableSize(min: 44),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ResizableTable(
controller: _controller,
rows: [/* ... */],
);
}
}
class TableScrollableExample extends StatelessComponent {
const TableScrollableExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
ScrollableClient(
diagonalDragBehavior: CoreDiagonalDragBehavior.free,
builder: (context, offset, viewportSize, child) {
return Table(
horizontalOffset: offset.dx,
verticalOffset: offset.dy,
tableStyle: CoreTableStyle(
viewportWidth: viewportSize.width,
viewportHeight: viewportSize.height,
),
defaultColumnWidth: const CoreFixedTableSize(160),
defaultRowHeight: const CoreFixedTableSize(44),
frozenCells: const CoreFrozenTableData(
frozenRows: [CoreTableRef(0)],
frozenColumns: [CoreTableRef(0)],
),
rows: [/* ... */],
);
},
),
],
styles: Styles(raw: const {'height': '320px'}),
);
}
}
class TableScrollableExample extends StatelessWidget {
const TableScrollableExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 320,
child: ScrollableClient(
diagonalDragBehavior: CoreDiagonalDragBehavior.free,
builder: (context, offset, viewportSize, child) {
return Table(
horizontalOffset: offset.dx,
verticalOffset: offset.dy,
tableStyle: CoreTableStyle(
viewportWidth: viewportSize.width,
viewportHeight: viewportSize.height,
),
defaultColumnWidth: const CoreFixedTableSize(160),
defaultRowHeight: const CoreFixedTableSize(44),
frozenCells: const CoreFrozenTableData(
frozenRows: [CoreTableRef(0)],
frozenColumns: [CoreTableRef(0)],
),
rows: [/* ... */],
);
},
),
);
}
}
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',
);
}
}
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,
),
),
],
);
}
}
사용 시기 (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 |
컨테이너 모서리 클리핑 방식 |
theme | CoreTableTheme? | null | 위젯 레벨 테마 오버라이드 |
tableStyle |
CoreTableStyle? |
null | 인스턴스별 chrome 단일 진입점 (Style 시스템 참조) |
ResizableTable은 rows · 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.
|
cellPadding | CoreEdgeInsets? | 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)#
CoreTableTheme 은 style: 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,
)
ResizableTable 은 columnWidths / rowHeights / defaultColumnWidth / defaultRowHeight 를 받지 않습니다 — 열/행 사이즈의 단일 출처는 컨트롤러이므로, Table 에서 쓰던 사이즈 파라미터를 그대로 옮기려 하면 컴파일조차 되지 않습니다.
❌ Don't#
선택 / 비활성 상태를 rowSelectedColor / cell.enabled 색상만으로 접근 가능하다고 가정하지 않기
// ❌ 시각만 칠하고 끝 — 스크린 리더는 이 행이 선택됐는지 알 수 없음
Table(
rows: rows,
tableStyle: CoreTableStyle(
rowSelectedColor: CoreColor.token(CoreColors.primaryContainer),
),
)
rowSelectedColor 와 cell.enabled 는 배경/전경 색만 바꿀 뿐 aria-selected / Semantics(selected:), aria-disabled / Semantics(enabled:) 를 내보내지 않습니다 — 선택·비활성 정보가 스크린 리더 사용자에게 중요하다면 소비자가 직접 얹어야 합니다.
접근성 (Accessibility)#
역할 / Semantics#
양 플랫폼 모두 table → row → columnheader/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)#
| 항목 | Flutter | Web |
|---|---|---|
| 레이아웃 |
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 재호출 |
관련 컴포넌트#
- Card: 카드 리스트
- Pagination: 페이지 네비게이션
- Accordion: 접이식 행별 상세