Select#
옵션 목록에서 하나를 선택할 수 있는 드롭다운 컴포넌트입니다.
Live Preview#
class SelectDefaultExample extends StatefulComponent {
const SelectDefaultExample({super.key});
@override
State<SelectDefaultExample> createState() => _SelectDefaultExampleState();
}
class _SelectDefaultExampleState extends State<SelectDefaultExample> {
String? _value = 'apple';
void handleChanged(String value) {
setState(() => _value = value);
}
@override
Component build(BuildContext context) {
return CoSelect<String>(
items: const [
CoreSelectItem(value: 'apple', label: 'Apple'),
CoreSelectItem(value: 'banana', label: 'Banana'),
CoreSelectItem(value: 'orange', label: 'Orange'),
],
value: _value,
placeholder: 'Select a fruit',
onChanged: handleChanged,
);
}
}
class SelectDefaultExample extends StatefulWidget {
const SelectDefaultExample({super.key});
@override
State<SelectDefaultExample> createState() => _SelectDefaultExampleState();
}
class _SelectDefaultExampleState extends State<SelectDefaultExample> {
String? _value = 'apple';
void handleChanged(String value) {
setState(() => _value = value);
}
@override
Widget build(BuildContext context) {
return CoSelect<String>(
items: const [
CoreSelectItem(value: 'apple', label: 'Apple'),
CoreSelectItem(value: 'banana', label: 'Banana'),
CoreSelectItem(value: 'orange', label: 'Orange'),
],
value: _value,
placeholder: 'Select a fruit',
onChanged: handleChanged,
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 미리 정해진 옵션 목록에서 하나를 선택할 때
- 5개 이상의 옵션이 있어 라디오 버튼이 비효율적일 때
- 폼에서 카테고리, 국가, 상태 등을 선택할 때
대신 다른 컴포넌트를 사용하세요:
Input: 자유 텍스트 입력이 필요할 때Autocomplete: 옵션을 검색하면서 선택할 때RadioGroup: 옵션이 5개 이하이고 모두 한눈에 보여야 할 때Menu: 네비게이션이나 액션 목록일 때 (폼 제출 아닌 경우)
기본 사용법 (Basic Usage)#
CoSelect<String>(
onChanged: handleCategoryChange,
placeholder: '카테고리를 선택하세요',
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
CoreSelectItem(value: 'meat', label: '육류'),
],
)
// 초기값 설정
CoSelect<String>(
value: 'fruit',
onChanged: handleCategoryChange,
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
],
)
// 기본 선택 — 네이티브 <select> 요소 사용
CoSelect<String>(
onChanged: handleCategoryChange,
placeholder: '카테고리를 선택하세요',
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
CoreSelectItem(value: 'meat', label: '육류'),
],
)
// 초기값 설정 — value로 기본 선택 항목 지정
CoSelect<String>(
value: 'fruit',
onChanged: handleCategoryChange,
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
],
)
스타일 시스템 — selectStyle (Epic #1302)#
CoSelect 의 panel chrome / 슬롯 미세 조정은 단일 selectStyle
(CoreSelectStyle) 으로 흐릅니다. 시맨틱 (size) 과 동작 정책
(popoverPlacement / popoverCollision / closeOnScroll
/
searchable / searchPlaceholder / onSearchChanged) 은 위젯
파라미터 그대로 (Epic #1302 원칙 6/7).
CoSelect<String>(
items: items,
value: _value,
onChanged: (v) => setState(() => _value = v),
selectStyle: CoreSelectStyle(
// Options panel chrome (flat)
optionsPanelMaxHeight: 320,
optionsPanelBorderRadius: 12,
optionsPanelPadding: 8,
optionsGap: 4,
// Nested slots (재귀 머지)
popoverStyle: CorePopoverStyle(
gap: 8,
panelBorderRadius: 12,
),
optionButtonStyle: CoreButtonStyle(paddingH: 12),
searchInputStyle: CoreTextFieldStyle(borderRadius: 8),
emptyStateStyle: CoreTextStyle(
fontSize: 13,
fontWeight: CoreFontWeight.medium,
),
),
)
CoreSelectStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
popoverStyle |
CorePopoverStyle? |
트리거 + 드롭다운 chrome (재귀 머지) |
optionButtonStyle |
CoreButtonStyle? |
옵션 행 ghost CoButton chrome (Flutter 한정) |
searchInputStyle |
CoreTextFieldStyle? |
searchable: true 일 때 검색 입력 chrome |
emptyStateStyle | CoreTextStyle? | 검색 결과 없음 텍스트 스타일 |
optionsPanelBackgroundColor |
Color? / String? |
옵션 패널 배경 |
optionsPanelBorderColor |
Color? / String? |
옵션 패널 보더 색 |
optionsPanelBorderRadius | double? | 옵션 패널 보더 반경 |
optionsPanelPadding | double? | 옵션 패널 내부 패딩 |
optionsPanelMaxHeight | double? | 옵션 패널 최대 높이 (px) |
optionsGap | double? | 옵션 행 간격 (px) |
asChild 패턴 — 트리거 / 옵션 행 변경#
CoSelect 는 내부적으로 CoPopover(trigger: CoButton(outline)) +
옵션마다 CoButton(ghost) 를 사용합니다. 트리거 / 옵션의
시맨틱 (variant) 변경이 필요할 때는 selectStyle 안에
packed 하지 말고 후속 contract 확장 (asChild 슬롯) 또는 CoreSelectItem
의 child 슬롯을 통해 위젯 자체를 주입합니다 (Epic #1302 원칙 8):
CoreSelectItem(
value: 'apple',
label: 'Apple',
child: Row(children: [
CoIcon(CoLucideIcons.apple),
const SizedBox(width: 8),
Text('Apple'),
]),
)
selectStyle.optionButtonStyle 은 chrome 미세 조정용 (paddingH /
labelStyle 등).
주의 (Web): Web
CoButton.buttonStyle은CoreButtonStyle<CoreColor>를 받지만CoreSelectStyle.optionButtonStyle은 contract 상CoreButtonStyle<String>입니다. Web 빌드에서는optionButtonStyle이 자동 전달되지 않으며, 옵션 행 chrome 변경은 프로젝트CoreButtonTheme또는 asChild 위젯 주입으로 수행합니다 — WebCoPopover.popoverStyle.triggerStyle과 동일한 처리.
Resolve chain#
design system default
→ CoreSelectTheme.style // 프로젝트 공통
→ 부모 컴포넌트 슬롯 오버라이드
→ widget.selectStyle // 인스턴스별
내부 CoPopover / CoButton / CoTextField 는 각자 자기 Style
체인 (CorePopoverTheme.style, CoreButtonTheme.variantStyles[ghost],
CoreTextFieldTheme.style) 을 그대로 적용한 뒤 selectStyle
의 nested
override 가 마지막으로 머지됩니다.
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | T? | null | 현재 선택된 값 |
onChanged |
void Function(T value)? |
null |
선택 변경 콜백 |
items |
List<CoreSelectItem<T>> |
필수 | 선택 항목 목록 |
placeholder |
String? |
null |
미선택 시 표시 텍스트 |
enabled | bool | true | 활성화 여부 |
size |
CoreComponentSize? |
null (md) |
트리거 크기 (xs/sm/md/lg/xl) |
popoverPlacement |
CorePopoverPlacement |
dropdownPlacement |
패널 배치 위치 |
popoverCollision |
Set<CorePopoverCollision> |
collision |
뷰포트 충돌 정책 |
closeOnScroll |
bool |
true |
스크롤 시 자동 닫기 |
searchable |
bool |
false |
검색 입력 표시 |
searchPlaceholder |
String? |
'Search' |
검색 입력 placeholder |
onSearchChanged |
void Function(String)? |
null |
검색 쿼리 변경 콜백 (서버 사이드 필터링용) |
selectStyle |
CoreSelectStyle? |
null |
panel chrome / nested 슬롯 묶음 (Epic #1302) |
변형 (Variants)#
그룹화된 옵션#
CoSelect<String>(
onChanged: handleChange,
groups: [
SelectGroup(
label: '과일',
items: [
CoreSelectItem(value: 'apple', label: '사과'),
CoreSelectItem(value: 'banana', label: '바나나'),
],
),
SelectGroup(
label: '채소',
items: [
CoreSelectItem(value: 'carrot', label: '당근'),
CoreSelectItem(value: 'spinach', label: '시금치'),
],
),
],
)
비활성화된 항목#
CoSelect<String>(
onChanged: handleChange,
items: [
CoreSelectItem(value: 'a', label: '사용 가능'),
CoreSelectItem(value: 'b', label: '사용 불가', enabled: false),
],
)
동작 스펙 (Behavior)#
상태 전환#
-
default→hover(마우스 올림) →focused(클릭/Tab) →open(드롭다운 표시) open→ 항목 선택 →filled(선택된 값 표시) →defaultdisabled: 모든 인터랙션 비활성화, 흐리게 표시
드롭다운 열기/닫기#
-
Flutter: Popover 기반으로 트리거 위치에 맞춰 자동 배치.
SelectController로 프로그래밍 방식 제어 가능 - Web: 네이티브
<select>요소의 드롭다운 동작
검색 필터링#
CoSelect<String>(
searchable: true,
searchPlaceholder: '검색...',
onChanged: handleChange,
items: items,
)
Flutter의 ControlledSelect는 검색 입력을 지원하여 긴 목록에서 빠르게 항목을 찾을 수 있습니다.
다중 선택#
MultiSelect<String>(
controller: MultiSelectController(),
onChanged: handleMultiChange,
items: items,
)
Flutter에서는 MultiSelectController를 통해 다중 선택을 지원합니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
placeholder로 기대되는 선택을 안내하세요.
CoSelect<String>(
placeholder: '국가를 선택하세요',
onChanged: handleCountryChange,
items: countryItems,
)
사용자가 무엇을 선택해야 하는지 즉시 파악할 수 있습니다.
❌ Don't#
placeholder 없이 빈 Select를 배치하지 마세요.
CoSelect<String>(
onChanged: handleCountryChange,
items: countryItems,
)
빈 드롭다운은 어떤 정보를 선택해야 하는지 알 수 없습니다.
✅ Do#
옵션이 많으면 그룹으로 분류하세요.
CoSelect<String>(
onChanged: handleChange,
groups: [
SelectGroup(label: '아시아', items: asiaCountries),
SelectGroup(label: '유럽', items: europeCountries),
],
)
그룹화하면 사용자가 원하는 항목을 빠르게 찾을 수 있습니다.
❌ Don't#
수십 개의 옵션을 그룹 없이 나열하지 마세요.
CoSelect<String>(
onChanged: handleChange,
items: allCountries, // 200개 이상의 항목
)
스크롤이 과도하게 길어져 사용자가 원하는 항목을 찾기 어렵습니다.
✅ Do#
비활성화 항목은 이유를 설명하세요.
CoreSelectItem(
value: 'premium',
label: '프리미엄 플랜 (업그레이드 필요)',
enabled: false,
)
왜 선택할 수 없는지 사용자에게 알려줍니다.
❌ Don't#
설명 없이 항목을 비활성화하지 마세요.
CoreSelectItem(value: 'premium', label: '프리미엄', enabled: false)
사용자가 왜 선택할 수 없는지 이해할 수 없습니다.
접근성 (Accessibility)#
키보드 인터랙션#
| 키 | 동작 |
|---|---|
Enter / Space | 드롭다운 열기/닫기 |
↑ / ↓ | 항목 간 이동 |
Home / End | 첫/마지막 항목으로 이동 |
Escape | 드롭다운 닫기 |
Tab | 다음 포커스 가능 요소로 이동 |
스크린 리더#
- Flutter:
Semantics로 현재 선택된 값, 옵션 수, 열림/닫힘 상태 전달 - Web:
<select>요소의 네이티브 접근성.aria-label자동 연결
터치 타겟#
- 최소 터치 타겟 높이: 48dp
- 드롭다운 항목 높이: 최소 44dp로 터치 영역 확보
크로스 플랫폼 차이점 (Platform Differences)#
v3.0부터 기본 API (
enabled,onChanged,value등)가 통일되었습니다. 아래는 플랫폼 고유 차이점만 나열합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | ControlledSelect<T> | Select |
| 값 타입 | 제네릭 T (모든 타입) | String (문자열만) |
| 드롭다운 구현 | Popover 기반 커스텀 UI | 네이티브 <select> 요소 |
| 검색 필터링 | searchable: true 지원 | 없음 |
| 다중 선택 | MultiSelectController | multiple: true (네이티브) |
| 그룹화 | SelectGroup 위젯 | <optgroup> 매핑 |
| 프로그래밍 제어 | SelectController | 없음 |
| 커스텀 항목 렌더링 | itemBuilder 콜백 | 없음 (텍스트만) |
관련 컴포넌트 (Related Components)#
- Input: 자유 텍스트 입력. Select와 달리 사전 정의된 옵션 없이 직접 입력
- Autocomplete: 입력하면서 옵션을 필터링. 옵션이 매우 많을 때 Select 대신 사용
- Menu: 액션 목록 표시. 폼 제출이 아닌 컨텍스트 액션에 적합
조합 예제#
// 주소 입력 폼 패턴
Form(
onSubmit: handleSubmit,
child: Column(
children: [
FormField(
label: '국가',
child: CoSelect<String>(
placeholder: '국가를 선택하세요',
onChanged: handleCountryChange,
items: countryItems,
),
),
FormField(
label: '도시',
child: CoSelect<String>(
placeholder: '도시를 선택하세요',
onChanged: handleCityChange,
items: cityItems,
enabled: selectedCountry != null,
),
),
Button.primary(onPressed: handleSubmit, child: Text('제출')),
],
),
)