Listing | CoUI
LogoCoUI

Listing

행 기반 리스트 컨테이너 (ListRow + ListCol)

Listing#

데이터 행(row)을 세로로 쌓는 CoUI 리스트 컨테이너입니다. 각 행은 여러 [ListCol]을 담고, 지정이 없으면 두 번째 열이 자동으로 남은 공간을 채웁니다. itemBorder: true로 행 사이에 얇은 구분선을 넣을 수 있습니다.

Live Preview#

사용 시기 (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 자식들
itemBorderbool?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: 어떤 ListColgrow: true를 지정하지 않으면 두 번째 열이 자동으로 grow 됩니다.

ListCol#

속성타입기본값설명
childW필수열 내용
growboolfalse남은 수평 공간을 채움
wrapboolfalse다음 줄로 wrap 허용
shrinkboolfalseintrinsic 미만으로 축소되지 않음

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

✅ Do#

두 번째 열의 자동 grow 에 맡기고, 필요한 열에만 명시

ListRow(
  children: [
    ListCol(child: Avatar(...)),
    ListCol(child: Text('Alice')),  // grow 미지정 → 자동으로 남은 공간 채움
    ListCol(child: Text('Admin')),
  ],
)

이유: Listing 은 smart container 라 어떤 ListColgrow: true 를 지정하지 않으면 두 번째 열이 자동으로 남은 수평 공간을 채웁니다. 매 열마다 grow 를 직접 지정할 필요가 없습니다.


❌ Don't#

attributes 로 루트 role 을 덮어쓰려 하지 않기

// ❌ role="table" 로 덮어쓰려는 시도 — 조용히 무시됨
Listing(attributes: {'role': 'table'}, children: [...])

이유: Web 루트의 role 은 호출자가 넘긴 attributes 뒤에 병합되어, 호출자가 지정한 role 이 조용히 덮어써집니다. role="list" 가 아닌 구조가 필요하다면 Listing 대신 Table 같이 그 구조를 실제로 그리는 컴포넌트를 사용해야 합니다.

접근성 (Accessibility)#

Listing 은 레이아웃 컨테이너이고, 항목 자체의 시맨틱은 각 ListColchild 가 가진 것이 전부입니다. 리스트 역할 노출이 두 플랫폼에서 다르므로 아래를 확인하고 필요한 부분은 직접 채워야 합니다.

역할 / 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)#

항목FlutterWeb
클래스명Listing/ListRow/ListCol동일
컨테이너 Column <div role="list" class="flex flex-col w-full">
Row Padding(Row) <div class="flex flex-row ...">
Col growExpandedgrow class
DividerDivider 위젯 삽입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(...) — 파라미터는 그대로이므로 클래스명만 바꾸면 됩니다.

  • Table: 풍부한 테이블 구조
  • Accordion: 펼침/접힘 리스트