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#
코드 보기
class ItemPickerDefaultExample extends StatefulComponent {
const ItemPickerDefaultExample({super.key});
@override
State<ItemPickerDefaultExample> createState() =>
_ItemPickerDefaultExampleState();
}
class _ItemPickerDefaultExampleState extends State<ItemPickerDefaultExample> {
String _selected = 'Apple';
@override
Component build(BuildContext context) {
return ItemPicker(
items: const ['Apple', 'Banana', 'Cherry', 'Mango', 'Orange'],
selectedValue: _selected,
title: 'Fruits',
onChanged: (value) => setState(() => _selected = value),
);
}
}
class ItemPickerDefaultExample extends StatefulWidget {
const ItemPickerDefaultExample({super.key});
@override
State<ItemPickerDefaultExample> createState() =>
_ItemPickerDefaultExampleState();
}
class _ItemPickerDefaultExampleState extends State<ItemPickerDefaultExample> {
String _selected = 'Apple';
@override
Widget build(BuildContext context) {
return ItemPicker(
items: const ['Apple', 'Banana', 'Cherry', 'Mango', 'Orange'],
selectedValue: _selected,
title: 'Fruits',
onChanged: (value) => setState(() => _selected = value),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 미리 정의된 아이템 목록에서 검색하며 하나를 선택받는 경우
- 색상·아이콘·템플릿처럼 그리드로 보여줘야 하는 선택지
대신 다른 컴포넌트를 사용하세요:
- Select: 목록을 인라인에 놓는 대신 트리거의 드롭다운에서 선택해야 할 때
- Autocomplete: 목록 안의 항목만 값으로 허용하지 않고 자유 문자열도 값이 될 때
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<String> |
[] |
선택 가능한 아이템 목록 |
selectedValue |
String? |
null |
현재 선택된 아이템 |
searchPlaceholder |
String? |
null |
검색 입력 placeholder |
showSearch |
bool |
true |
검색 입력 표시 여부 |
enabled | bool | true | 상호작용 가능 여부 |
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와 같은 값, 같은 파라미터입니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | ItemPicker | ItemPicker |
| 검색 입력 | TextField | Input(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 규칙 |
관련 컴포넌트 (Related Components)#
- Select: 드롭다운 단일 선택
- Autocomplete: 자유 입력 + 제안