Listing#
데이터 행(row)을 세로로 쌓는 CoUI 리스트 컨테이너입니다. 각 행은 여러 [ListCol]을 담고, 지정이 없으면 두 번째 열이 자동으로 남은 공간을 채웁니다.
itemBorder: true로 행 사이에 얇은 구분선을 넣을 수 있습니다.
Live Preview#
class ListingDefaultExample extends StatelessComponent {
const ListingDefaultExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.theme.colorScheme;
Component label(String value) => div(
[Text(value)],
classes: 'text-${CoreTextStyles.bodyMedium.name} text-${cs.onSurface}',
);
return Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: label('Alice')),
ListCol(child: label('alice@example.com')),
ListCol(child: label('Admin')),
],
),
ListRow(
children: [
ListCol(child: label('Bob')),
ListCol(child: label('bob@example.com')),
ListCol(child: label('Member')),
],
),
ListRow(
children: [
ListCol(child: label('Cam')),
ListCol(child: label('cam@example.com')),
ListCol(child: label('Viewer')),
],
),
],
);
}
}
class ListingDefaultExample extends StatelessWidget {
const ListingDefaultExample({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textColor = theme.colorScheme.onSurface.toValue();
Widget label(String value) => Text(
value,
style: theme.typography.bodyMedium.toValue(theme: theme).copyWith(color: textColor),
);
return Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: label('Alice')),
ListCol(child: label('alice@example.com')),
ListCol(child: label('Admin')),
],
),
ListRow(
children: [
ListCol(child: label('Bob')),
ListCol(child: label('bob@example.com')),
ListCol(child: label('Member')),
],
),
ListRow(
children: [
ListCol(child: label('Cam')),
ListCol(child: label('cam@example.com')),
ListCol(child: label('Viewer')),
],
),
],
);
}
}
class ListingChainExample extends StatelessComponent {
const ListingChainExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.theme.colorScheme;
Component label(String value) => div(
[Text(value)],
classes: 'text-${CoreTextStyles.bodyMedium.name} text-${cs.onSurface}',
);
return Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: label('Alice')),
ListCol(child: label('alice@example.com')),
ListCol(child: label('Admin')),
],
),
ListRow(
children: [
ListCol(child: label('Bob')),
ListCol(child: label('bob@example.com')),
ListCol(child: label('Member')),
],
),
ListRow(
children: [
ListCol(child: label('Cam')),
ListCol(child: label('cam@example.com')),
ListCol(child: label('Viewer')),
],
),
],
).withStyle(
const CoreListingStyle(
padding: CoreEdgeInsets.all(CoreSpace.space16),
colSpacing: CoreSpace.space24,
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
),
);
}
}
class ListingChainExample extends StatelessWidget {
const ListingChainExample({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textColor = theme.colorScheme.onSurface.toValue();
Widget label(String value) => Text(
value,
style: theme.typography.bodyMedium.toValue(theme: theme).copyWith(color: textColor),
);
return Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: label('Alice')),
ListCol(child: label('alice@example.com')),
ListCol(child: label('Admin')),
],
),
ListRow(
children: [
ListCol(child: label('Bob')),
ListCol(child: label('bob@example.com')),
ListCol(child: label('Member')),
],
),
ListRow(
children: [
ListCol(child: label('Cam')),
ListCol(child: label('cam@example.com')),
ListCol(child: label('Viewer')),
],
),
],
).withStyle(
const CoreListingStyle(
padding: CoreEdgeInsets.all(CoreSpace.space16),
colSpacing: CoreSpace.space24,
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 간단한 행/열 구조의 리스트 (사용자·연락처·파일 등)
- Card/Table만큼 무겁지 않은 경량 리스트 뷰
대신 다른 컴포넌트를 사용하세요:
Table: 데이터 테이블 (정렬·고정 헤더·cell 기반)Accordion: 펼침/접힘 트리거가 필요한 경우
기본 사용법 (Basic Usage)#
Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: Avatar(...)),
ListCol(child: Text('Alice')),
ListCol(child: Text('Admin')),
],
),
ListRow(
children: [
ListCol(child: Avatar(...)),
ListCol(child: Text('Bob')),
ListCol(child: Text('Member')),
],
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 Listing 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ListingChainExample extends StatelessWidget {
const ListingChainExample({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textColor = theme.colorScheme.onSurface.toValue();
Widget label(String value) => Text(
value,
style: theme.typography.bodyMedium.toValue(theme: theme).copyWith(color: textColor),
);
return Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: label('Alice')),
ListCol(child: label('alice@example.com')),
ListCol(child: label('Admin')),
],
),
ListRow(
children: [
ListCol(child: label('Bob')),
ListCol(child: label('bob@example.com')),
ListCol(child: label('Member')),
],
),
ListRow(
children: [
ListCol(child: label('Cam')),
ListCol(child: label('cam@example.com')),
ListCol(child: label('Viewer')),
],
),
],
).withStyle(
const CoreListingStyle(
padding: CoreEdgeInsets.all(CoreSpace.space16),
colSpacing: CoreSpace.space24,
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
),
);
}
}
class ListingChainExample extends StatelessComponent {
const ListingChainExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.theme.colorScheme;
Component label(String value) => div(
[Text(value)],
classes: 'text-${CoreTextStyles.bodyMedium.name} text-${cs.onSurface}',
);
return Listing(
itemBorder: true,
children: [
ListRow(
children: [
ListCol(child: label('Alice')),
ListCol(child: label('alice@example.com')),
ListCol(child: label('Admin')),
],
),
ListRow(
children: [
ListCol(child: label('Bob')),
ListCol(child: label('bob@example.com')),
ListCol(child: label('Member')),
],
),
ListRow(
children: [
ListCol(child: label('Cam')),
ListCol(child: label('cam@example.com')),
ListCol(child: label('Viewer')),
],
),
],
).withStyle(
const CoreListingStyle(
padding: CoreEdgeInsets.all(CoreSpace.space16),
colSpacing: CoreSpace.space24,
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
),
);
}
}
Props / Parameters#
Listing#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
children |
List<W> |
필수 | ListRow 자식들 |
itemBorder | bool? | false | 행 사이에 얇은 구분선 |
semanticLabel |
String? |
null | 스크린리더 list landmark 라벨 |
listingStyle |
CoreListingStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 (아래 표 참고) |
CoreListingStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
itemBorder |
bool? |
Whether to render a thin divider between rows. |
padding |
CoreEdgeInsets? |
Per-row padding (logical px, pre-scaling). |
colSpacing |
double? |
Horizontal spacing between columns (logical px). |
dividerStyle |
CoreDividerStyle? |
Nested divider style for the inter-row rule — raw-forwarded to
Divider(dividerStyle: …)
, which resolves the colour / thickness / indent (rule 2.2 child-component nested slot).
null
falls back to the
Divider
design-system default (outlineVariant → outline,
stroke1
), so the listing carries no divider default of its own.
|
ListRow#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
children |
List<W> |
필수 | ListCol 자식들 |
padding |
CoreEdgeInsets? |
null | 이 row의 padding override |
Smart container: 어떤
ListCol도grow: true를 지정하지 않으면 두 번째 열이 자동으로 grow 됩니다.
ListCol#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | W | 필수 | 열 내용 |
grow | bool | false | 남은 수평 공간을 채움 |
wrap | bool | false | 다음 줄로 wrap 허용 |
shrink | bool | false | intrinsic 미만으로 축소되지 않음 |
사용 가이드라인 (Usage Guidelines)#
✅ Do#
두 번째 열의 자동 grow 에 맡기고, 필요한 열에만 명시
ListRow(
children: [
ListCol(child: Avatar(...)),
ListCol(child: Text('Alice')), // grow 미지정 → 자동으로 남은 공간 채움
ListCol(child: Text('Admin')),
],
)
이유: Listing 은 smart container 라 어떤 ListCol 도 grow: true 를 지정하지 않으면 두 번째 열이 자동으로 남은 수평 공간을 채웁니다. 매 열마다 grow 를 직접 지정할 필요가 없습니다.
❌ Don't#
attributes 로 루트 role 을 덮어쓰려 하지 않기
// ❌ role="table" 로 덮어쓰려는 시도 — 조용히 무시됨
Listing(attributes: {'role': 'table'}, children: [...])
이유: Web 루트의 role 은 호출자가 넘긴 attributes 뒤에 병합되어, 호출자가 지정한 role 이 조용히 덮어써집니다. role="list" 가 아닌 구조가 필요하다면 Listing 대신 Table 같이 그 구조를 실제로 그리는 컴포넌트를 사용해야 합니다.
접근성 (Accessibility)#
Listing 은 레이아웃 컨테이너이고, 항목 자체의 시맨틱은 각 ListCol 의 child 가 가진 것이 전부입니다. 리스트 역할 노출이 두 플랫폼에서 다르므로 아래를 확인하고 필요한 부분은 직접 채워야 합니다.
역할 / Semantics#
-
Web: 루트
<div>에role="list"를 항상 붙입니다.semanticLabel을 주면aria-label이 함께 나갑니다. -
Flutter: list 역할을 전혀 내보내지 않습니다.
semanticLabel을 준 경우에만Semantics(container: true, label: ...)로 묶이고, 기본값(null)에서는 아무 노드도 추가되지 않습니다. -
ListRow/ListCol/ 행 사이의Divider는 양쪽 모두 아무 역할도 내보내지 않습니다 —role="listitem"에 해당하는 것이 없습니다.
키보드#
없습니다. 화살표 키 · Home · End 로 항목을 오가는 roving 이 구현되어 있지 않습니다. Web 생성자의 일반 이벤트 맵에 keydown 을 넣는 것은 가능하지만, 컴포넌트 자신이 처리하는 키는 하나도 없습니다.
포커스#
없습니다. 리스트도 각 행도 포커스 대상이 아니며, roving tabindex 나 포커스 링이 없습니다.
스크린 리더#
-
Web: 목록으로 announce 됩니다(
semanticLabel을 주면 그 라벨과 함께). 다만 자식이role="listitem"없는 일반<div>라 항목 수와 "N / M 번째" 위치는 안내되지 않습니다. -
Flutter:
semanticLabel을 준 경우 라벨 붙은 컨테이너 노드 하나로 자식이 묶입니다. 기본값에서는 그룹화도 라벨도 추가되지 않습니다.
알려진 제약#
-
같은 코드가 Web 에서는 항상 "목록"으로, Flutter 에서는 결코 목록으로 읽히지 않습니다.
semanticLabel역시 Web 에서는aria-label이 되지만 Flutter 에서는 값을 주지 않으면 아무것도 남기지 않습니다. -
role="list"에listitem자식이 없어 ARIA 구조가 불완전합니다. 항목 수와 위치가 안내되어야 한다면 각ListRow의 내용 쪽에role="listitem"(Web) /Semantics(Flutter)를 호출자가 직접 넣어야 합니다. -
Web 루트의
role은 호출자가 넘긴 attributes 뒤에 병합되어, 호출자가 지정한role이 조용히 덮어써집니다. 다른 속성은 호출자 값이 이기는 것과 반대 방향이므로,role="list"가 아닌 구조(예:role="table")로 바꾸는 것은 이 컴포넌트로는 불가능합니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Listing/ListRow/ListCol | 동일 |
| 컨테이너 | Column |
<div role="list" class="flex flex-col w-full"> |
| Row | Padding(Row) |
<div class="flex flex-row ..."> |
| Col grow | Expanded | grow class |
| Divider | Divider 위젯 삽입 | Divider 컴포넌트 삽입 |
레거시 vs 통일 비교 (Migration Notes)#
이전 버전의 Web CoUI 를 참조하는 코드에는 이 컴포넌트가 List 라는 이름이었을 수 있습니다. Flutter 쪽 이름(Listing)에 맞춰 Web 도
Listing 으로 개명되었습니다 — Dart/Flutter 의 List 타입과 이름이 겹쳐 혼동을 주지 않기 위함이기도 합니다.
| 항목 | 레거시 (구 Web List) | 통일 Listing |
|---|---|---|
| API | 동일 named properties | 동일 named properties |
| 이름만 변경 | — | Flutter 와 동일 이름으로 통일 |
마이그레이션: List(...) → Listing(...) — 파라미터는 그대로이므로 클래스명만 바꾸면 됩니다.