ItemPicker | CoUI
LogoCoUI

ItemPicker

검색 가능한 단일 선택 아이템 리스트/그리드 피커

ItemPicker#

검색 입력과 함께 문자열 아이템을 리스트 또는 그리드로 나열하여 단일 선택하는 인라인 피커입니다. 검색어를 입력하면 아이템이 대소문자 무시 contains 로 필터링되고, 클릭하면 선택됩니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 미리 정의된 아이템 목록에서 검색하며 하나를 선택받는 경우
  • 색상·아이콘·템플릿처럼 그리드로 보여줘야 하는 선택지

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

  • Select: 드롭다운으로 공간을 절약해야 하는 경우
  • Autocomplete: 자유 입력 + 제안이 필요한 경우

기본 사용법 (Basic Usage)#

// 검색 가능한 리스트 피커
ItemPicker(
  items: const ['Apple', 'Banana', 'Cherry'],
  selectedValue: selected,
  onChanged: (value) => setState(() => selected = value),
)

// 그리드 레이아웃
ItemPicker(
  items: items,
  layout: CoreItemPickerLayout.grid,
  gridColumns: 3,
  title: 'Templates',
  onChanged: (value) => pick(value),
)
// 검색 가능한 리스트 피커
ItemPicker(
  items: const ['Apple', 'Banana', 'Cherry'],
  selectedValue: selected,
  onChanged: (value) => setState(() => selected = value),
)

// 그리드 레이아웃
ItemPicker(
  items: items,
  layout: CoreItemPickerLayout.grid,
  gridColumns: 3,
  title: 'Templates',
  onChanged: (value) => pick(value),
)

빠른 오버라이드 (Chain)#

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

class ItemPickerChainExample extends StatefulWidget {
  const ItemPickerChainExample({super.key});

  @override
  State<ItemPickerChainExample> createState() => _ItemPickerChainExampleState();
}

class _ItemPickerChainExampleState extends State<ItemPickerChainExample> {
  String _selected = 'Apple';

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        ItemPicker(
          items: const ['Apple', 'Banana', 'Cherry', 'Mango', 'Orange'],
          selectedValue: _selected,
          title: 'Fruits',
          onChanged: (value) => setState(() => _selected = value),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        ItemPicker(
          items: const ['Apple', 'Banana', 'Cherry', 'Mango', 'Orange'],
          selectedValue: _selected,
          title: 'Full control',
          onChanged: (value) => setState(() => _selected = value),
        ).withStyle(
          const CoreItemPickerStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
    );
  }
}
class ItemPickerChainExample extends StatefulComponent {
  const ItemPickerChainExample({super.key});

  @override
  State<ItemPickerChainExample> createState() => _ItemPickerChainExampleState();
}

class _ItemPickerChainExampleState extends State<ItemPickerChainExample> {
  String _selected = 'Apple';

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        ItemPicker(
          items: const ['Apple', 'Banana', 'Cherry', 'Mango', 'Orange'],
          selectedValue: _selected,
          title: 'Fruits',
          onChanged: (value) => setState(() => _selected = value),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        ItemPicker(
          items: const ['Apple', 'Banana', 'Cherry', 'Mango', 'Orange'],
          selectedValue: _selected,
          title: 'Full control',
          onChanged: (value) => setState(() => _selected = value),
        ).withStyle(
          const CoreItemPickerStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Props / Parameters#

속성타입기본값설명
items List<String> [] 선택 가능한 아이템 목록
selectedValue String? null 현재 선택된 아이템
searchPlaceholder String? null 검색 입력 placeholder
showSearch bool true 검색 입력 표시 여부
enabledbooltrue상호작용 가능 여부
layout CoreItemPickerLayout list 레이아웃 (list / grid)
gridColumns int 4 그리드 레이아웃의 열 개수
title String? null 아이템 위에 표시할 제목
emptyText String? null 검색 결과 없을 때 표시 텍스트
onChanged ValueChanged<String>? null 아이템 선택 콜백
itemPickerStyle CoreItemPickerStyle? null 컨테이너 / 옵션 색상·테두리 오버라이드

스타일 시스템 (Style System)#

CoreItemPickerStyle 필드#

필드타입설명
backgroundColor CoreColor? Picker container background colour override.
borderColor CoreColor? Picker container border colour override.
borderRadius CoreBorderRadius? Picker container border radius override.
borderWidth double? Picker container border width override (logical px).
maxHeight double? Picker items scroll-area maximum height override (logical px).
selectedColor CoreColor? Selected option background colour override.
optionTextStyle CoreTextStyle? Option label text style override applied in the resting (neither selected nor hovered) state. Layers onto the [defaultOptionTextStyle] token role; the resting colour is carried via [CoreTextStyle.color] inside this slot.
selectedTextStyle CoreTextStyle? Selected option text style override. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw selectedTextColor field removed).
optionHoverColor CoreColor? Option hover background colour override.
optionHoverTextStyle CoreTextStyle? Option text style override applied when the option is hovered. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw optionHoverTextColor field removed).
titleTextStyle CoreTextStyle? Picker title text style override. Layers onto the [defaultTitleTextStyle] token role; the heading's font weight / colour are carried via [CoreTextStyle.fontWeight] / [CoreTextStyle.color] inside this slot (sb-text-style-repackage — raw copyWith removed).
emptyTextStyle CoreTextStyle? Empty-state text style override. Layers onto the [defaultEmptyTextStyle] token role; the placeholder's colour is carried via [CoreTextStyle.color] inside this slot (sb-text-style-repackage — raw copyWith removed).
titlePadding CoreEdgeInsets? Picker title padding override.
itemsPadding CoreEdgeInsets? Picker items scroll-area padding override.
optionPadding CoreEdgeInsets? Option row padding override.
optionSpacing double? Inter-option spacing (logical px). Drives the grid layout mainAxisSpacing / crossAxisSpacing (Flutter GridView.count ) / gap inline CSS (Web grid container) and the per-option row icon ↔ label gap. null defers to [defaultOptionSpacing].
optionBorderRadius CoreBorderRadius? Option row corner radius override.
emptyPadding CoreEdgeInsets? Empty-state padding override.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed per-option Clickable (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.
checkIconSize double? Selected-option check-glyph icon size (logical/CSS px). null → [defaultCheckIconSize].
searchHeight double? Search <input> row height (logical/CSS px). null → [defaultSearchHeight].

동작 스펙 (Behavior)#

인터랙션#

  • 검색: 검색어 입력 시 대소문자 무시 contains 필터링
  • 클릭: 아이템 클릭 시 선택, 선택 아이템은 체크 아이콘 표시
  • 호버: 마우스 올린 아이템에 hover 색상 적용
  • enabledfalse이면 비활성

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

✅ Do#

항목이 적으면 검색 입력을 끄기

ItemPicker(
  items: const ['Small', 'Medium', 'Large'],
  showSearch: false,
  selectedValue: selectedSize,
  onChanged: (value) => setState(() => selectedSize = value),
)

이유: showSearch 의 기본값은 true 지만, 항목이 몇 개 안 되는 목록에서는 검색 입력이 불필요한 공간을 차지합니다. showSearch: false 로 끄면 리스트만 남아 더 간결해집니다.


❌ Don't#

목록에 없는 값을 입력받아야 하는 곳에 사용 금지

// ❌ 사전 정의된 items 목록에 없는 새 태그를 추가할 방법이 없음
ItemPicker(items: existingTags, onChanged: addTag)

이유: ItemPickeritems 로 주어진 고정 목록에서 대소문자 무시 contains 로 필터링하는 단일 선택 컴포넌트입니다. 사용자가 목록에 없는 값을 직접 입력해야 한다면 Autocomplete 를 사용해야 합니다.

접근성 (Accessibility)#

역할#

  • Web — 컨테이너 role="listbox"(비활성 시 aria-disabled="true"), 각 아이템 role="option" + aria-selected.
  • Flutter — role 없음. 계층은 위젯 구조로만 전달됩니다.

소유 관계가 끊겨 있습니다 — 어떤 optionlistbox 의 직계 자식이 아니라 role 없는 래퍼 <div> 안에 들어가며, aria-owns 로 보정하지도 않습니다. 리더가 목록 구조를 올바로 읽지 못할 수 있습니다.

키보드#

FlutterWeb
Enter / Space포커스된 옵션 선택포커스된 옵션 선택
Tab옵션마다 개별 tab stop옵션마다 개별 tab stop
ArrowUp/ArrowDown 옵션 간 포커스 이동 (아래 주의) 없음
Home/End없음없음

Flutter 의 화살표 이동은 이 컴포넌트가 아니라 프레임워크의 방향성 포커스 순회가 줍니다. 그래서 listbox 의미론이 없습니다 — 끝에서 순환하지 않고, picker 경계를 넘어 바깥 요소로도 넘어가며, 포커스가 선택을 따라가지도 않습니다.

스크린 리더#

  • Web — 컨테이너가 스스로 이름을 내보내지 않습니다. 호출자가 attributes: {'aria-label': ...} 로 줄 수 있습니다.
  • Flutterattributes 에 해당하는 파라미터도 Semantics 래퍼도 없어 호출자 우회로가 없습니다. 검색 입력에는 텍스트 유무와 무관하게 접근 가능한 이름이 없습니다(placeholder 는 라벨이 아니라 형제 텍스트 노드입니다).

포커스 관리#

  • 진입 / 이탈 — 인라인 컴포넌트라 열림·닫힘이 없습니다. 포커스를 옮기는 코드가 양쪽 다 없습니다.
  • 트랩 — 없음. Tab 은 네이티브 순서대로 통과합니다.

알려진 제약#

  • 옵션 수가 많으면 옵션마다 tab stop 이라 Tab 만으로 목록을 지나가야 합니다 (Web 은 화살표 대안도 없습니다).
  • Web 검색 입력은 type="search" 라 브라우저가 자체 clear affordance 를 그리고, Chrome 에서는 Escape 가 값을 지웁니다 — 컴포넌트가 구현한 동작이 아니라 브라우저 기본 동작이며 Flutter 에는 없습니다.
  • Flutter 는 옵션의 선택 상태를 리더에 알리지 않습니다.

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

항목FlutterWeb
클래스명ItemPickerItemPicker
검색 입력TextFieldInput(type: search)
체크 아이콘 Icon(LucideIcons.check) Icon(LucideIcons.check)