ItemPicker | CoUI
본문으로 건너뛰기
LogoCoUI

ItemPicker

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

목차

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

기본 사용법 (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',
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

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

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

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

  • Select: 목록을 인라인에 놓는 대신 트리거의 드롭다운에서 선택해야 할 때
  • Autocomplete: 목록 안의 항목만 값으로 허용하지 않고 자유 문자열도 값이 될 때

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.
hoverBorderColor CoreColor? Picker container border colour override while the pointer is over the picker. null → [defaultHoverBorderColor]. Never applied while the picker is disabled.
borderRadius CoreBorderRadius? Picker container border radius override.
borderWidth double? Picker container border width override (logical px).
elevation CoreOutlinedContainerElevation? Picker container shadow rung override. null → [defaultElevation].
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 section label (title row) text style override. Layers onto the [defaultTitleTextStyle] token role; a weight or colour override rides [CoreTextStyle.fontWeight] / [CoreTextStyle.color] inside this slot.
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 section label (title row) 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].
checkColor CoreColor? Selected-option check glyph colour override. null → [defaultCheckColor].
disabledForegroundColor CoreColor? Foreground colour while the picker is disabled — the option text, the selected-option check, the title and the empty-state line. null → [defaultDisabledForegroundColor].
searchHeight double? Search <input> row height (logical/CSS px). null → [defaultSearchHeight]. A stated height fixes the row and stays as stated under every density — only [defaultSearchHeight], the control row, moves.
searchFieldStyle CoreTextFieldStyle? Nested chrome of the composed search TextField — raw-forwarded to TextField(textFieldStyle: …) on both platforms after merging onto [defaultSearchFieldStyle] (child-component-composition). The row height stays [searchHeight].
searchIconStyle CoreIconStyle? Nested chrome of the search glyph in front of the search field — merged onto [defaultSearchIconStyle] and raw-forwarded to the composed Icon . A disabled picker swaps the colour to [defaultDisabledSearchIconColor] unless this states one.
searchDividerStyle CoreDividerStyle? Nested chrome of the Divider that closes the search row — merged onto [defaultSearchDividerStyle] and raw-forwarded to Divider(dividerStyle: …) .

동작 스펙 (Behavior)#

인터랙션#

  • 검색: 검색어 입력 시 대소문자 무시 contains 필터링
  • 클릭: 아이템 클릭 시 선택, 선택 아이템은 primary 체크 아이콘만 표시합니다(채움 없음)
  • 호버: 마우스 올린 아이템(과 Web 의 키보드 하이라이트)에만 surfaceContainerHigh 채움이 깔립니다
  • 패널 모양: 패널은 떠 있는 면입니다 — 채움 surfaceBright, 테두리 outlineVariant, 모서리 12, 그림자 lg(elevation). 검색은 테두리 없는 TextField(plain)에 16 크기 검색 아이콘이 앞에 붙고, 그 아래를 Divider 가 닫습니다. 목록은 안쪽 여백 6, 행은 여백 8 · 모서리 8 · 행 사이 간격 0 이며 글자는 bodyMedium 입니다. title 은 패널 머리글이 아니라 목록 안 첫 줄의 구역 라벨입니다 — labelSmall · onSurfaceSubtle, 여백 위 6 · 아래 4 · 좌우 8(높이 27)이고 목록 안쪽 폭을 채웁니다. 목록 여백 6 + 좌우 8 = 14 라 검색 아이콘 · 구역 라벨 · 항목 글자의 왼쪽 끝이 한 줄에 맞습니다
  • 패널 호버: 포인터가 패널(제목·검색·옵션 위 포함)에 올라가면 패널 테두리가 outlineVariant 에서 outline 으로 한 단계 진해집니다(hoverBorderColor). 우선순위는 disabled > hover > rest — 패널 자체에는 포커스·오류 상태가 없고(검색 입력은 합성된 TextField 가 자기 상태를 가집니다), 비활성 picker 에는 호버 단계가 없습니다
  • enabled가 false이면 비활성 — 옵션 글자 · 체크 · 제목 · 빈 상태 문구가 모두 disabled-content 입니다(disabledForegroundColor)
  • 선택은 onChanged(item) 요청만 내고 자체 selectedValue를 바꾸지 않습니다. 부모가 selectedValue를 갱신해야 체크·선택 표시가 바뀝니다. 검색은 로컬 query이며 입력한 자유 문자열을 값으로 반환하지 않습니다.
  • 결과가 비면 emptyText를 표시합니다. gridColumns는 열 개수이며 양수로 제공해야 합니다. 키보드의 포커스 이동과 실제 선택은 별개입니다.

사용 가이드라인 (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)

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

접근성 (Accessibility)#

역할#

  • Web — 옵션을 직접 담는 목록 요소가 role="listbox"(비활성 시 aria-disabled="true", 목록 레이아웃이면 aria-orientation="vertical"), 각 아이템 role="option" + aria-selected. 검색 입력은 listbox 밖에 있고, 목록 안 제목은 role="presentation" 라벨이라 option 이 아닌 직계 자식은 그것 하나뿐입니다.
  • Flutter — listbox / option 역할은 Flutter 에 없습니다(플랫폼 capability 차이). 대신 각 옵션의 선택 여부가 Clickable.semanticsSelected 를 거쳐 selected 시맨틱 플래그로 실립니다 — Web 의 aria-selected 와 같은 값, 같은 파라미터입니다.

키보드#

키FlutterWeb
Enter / Space포커스된 옵션 선택포커스된 옵션 선택
Tab 옵션마다 개별 tab stop 목록 전체가 tab stop 하나(선택된 옵션, 없으면 첫 옵션)
ArrowUp/ArrowDown 옵션 간 포커스 이동 (아래 주의) 목록: 이전/다음 옵션 · 그리드: 한 행 위/아래 — 끝에서 멈춤
ArrowLeft/ArrowRight 프레임워크 방향성 포커스 이동 그리드: 이전/다음 옵션
Home/End없음첫 옵션 / 마지막 옵션
문자 입력없음그 글자로 시작하는 다음 옵션으로(타이핑 점프)

Web 은 WAI-ARIA listbox 패턴을 따르며, 포커스는 이동만 하고 선택은 Enter/Space 로 합니다.

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

브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 포커스가 기대한 항목으로 옮겨 갔는지 확인합니다. 핸들러가 달렸는지가 아니라 포커스가 어디에 있는지를 봅니다.

스크린 리더#

  • Web — title 이 있으면 그것이 listbox 의 이름이 됩니다. 호출자가 attributes: {'aria-label': ...} 로 주면 그 이름이 우선하며, role·aria 속성은 바깥 래퍼가 아니라 listbox 로 갑니다.
  • Flutter — attributes 에 해당하는 파라미터도 Semantics 래퍼도 없어 호출자 우회로가 없습니다. 검색 입력에는 텍스트 유무와 무관하게 접근 가능한 이름이 없습니다(placeholder 는 라벨이 아니라 형제 텍스트 노드입니다).

포커스 관리#

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

알려진 제약#

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

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

항목FlutterWeb
클래스명ItemPickerItemPicker
검색 입력TextFieldInput(type: search)
체크 아이콘 Icon(LucideIcons.check) Icon(LucideIcons.check)
옵션 탐색 항목별 Clickable·Tab roving tabindex · list/grid 화살표 · Home / End · typeahead, 끝에서는 순환 안 함
선택 의미Clickable의 selected 시맨틱listbox / option · aria-selected
패널 호버 테두리 MouseRegion 이 호버를 감지하고 resolver 가 만든 호버 컨테이너 스타일을 OutlinedContainer 에 넘김 토큰이면 hover:border-* 클래스, 토큰이 아닌 색은 패널 scope 클래스의 :hover 규칙