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 Select<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 Select<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 SelectChainExample extends StatefulComponent {
const SelectChainExample({super.key});
@override
State<SelectChainExample> createState() => _SelectChainExampleState();
}
class _SelectChainExampleState extends State<SelectChainExample> {
String? _value = 'apple';
void handleChanged(String value) {
setState(() => _value = value);
}
@override
Component build(BuildContext context) {
return Select<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,
).withStyle(
const CoreSelectStyle(
optionSpacing: CoreSpace.space4,
trailingIconStyle: CoreIconStyle(
size: CoreSize.size20,
color: CoreColor.token(CoreColors.primary),
),
placeholderColor: CoreColor.token(CoreColors.onSurfaceVariant),
contentColor: CoreColor.token(CoreColors.primary),
),
);
}
}
class SelectChainExample extends StatefulWidget {
const SelectChainExample({super.key});
@override
State<SelectChainExample> createState() => _SelectChainExampleState();
}
class _SelectChainExampleState extends State<SelectChainExample> {
String? _value = 'apple';
void handleChanged(String value) {
setState(() => _value = value);
}
@override
Widget build(BuildContext context) {
return Select<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,
).withStyle(
const CoreSelectStyle(
optionSpacing: CoreSpace.space4,
trailingIconStyle: CoreIconStyle(
size: CoreSize.size20,
color: CoreColor.token(CoreColors.primary),
),
placeholderColor: CoreColor.token(CoreColors.onSurfaceVariant),
contentColor: CoreColor.token(CoreColors.primary),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 미리 정해진 옵션 목록에서 하나를 선택할 때
- 5개 이상의 옵션이 있어 라디오 버튼이 비효율적일 때
- 폼에서 카테고리, 국가, 상태 등을 선택할 때
대신 다른 컴포넌트를 사용하세요:
TextField: 자유 텍스트 입력이 필요할 때Autocomplete: 옵션을 검색하면서 선택할 때RadioGroup: 옵션이 5개 이하이고 모두 한눈에 보여야 할 때Menu: 네비게이션이나 액션 목록일 때 (폼 제출 아닌 경우)
기본 사용법 (Basic Usage)#
Select<String>(
onChanged: handleCategoryChange,
placeholder: '카테고리를 선택하세요',
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
CoreSelectItem(value: 'meat', label: '육류'),
],
)
// 초기값 설정
Select<String>(
value: 'fruit',
onChanged: handleCategoryChange,
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
],
)
// 기본 선택 — Flutter 와 동일한 생성자
Select<String>(
onChanged: handleCategoryChange,
placeholder: '카테고리를 선택하세요',
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
CoreSelectItem(value: 'meat', label: '육류'),
],
)
// 초기값 설정 — value로 기본 선택 항목 지정
Select<String>(
value: 'fruit',
onChanged: handleCategoryChange,
items: [
CoreSelectItem(value: 'fruit', label: '과일'),
CoreSelectItem(value: 'vegetable', label: '채소'),
],
)
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 |
CorePopoverStyle.defaultDropdownPlacement (bottomStart) |
패널 배치 위치 |
popoverCollision |
Set<CorePopoverCollision> |
CorePopoverStyle.defaultCollision |
뷰포트 충돌 정책 |
closeOnScroll |
bool |
CorePopoverStyle.defaultCloseOnScroll (true) |
스크롤 시 자동 닫기 |
searchable |
bool |
false |
검색 입력 표시 |
searchPlaceholder |
String? |
null (→ 활성 로케일 기본 문구) |
검색 입력 placeholder |
onSearchChanged |
void Function(String)? |
null |
검색 쿼리 변경 콜백 (서버 사이드 필터링용) |
selectStyle |
CoreSelectStyle? |
null |
panel chrome / nested 슬롯 묶음 |
빠른 오버라이드 (Chain)#
이미 만든 Select 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class SelectChainExample extends StatefulWidget {
const SelectChainExample({super.key});
@override
State<SelectChainExample> createState() => _SelectChainExampleState();
}
class _SelectChainExampleState extends State<SelectChainExample> {
String? _value = 'apple';
void handleChanged(String value) {
setState(() => _value = value);
}
@override
Widget build(BuildContext context) {
return Select<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,
).withStyle(
const CoreSelectStyle(
optionSpacing: CoreSpace.space4,
trailingIconStyle: CoreIconStyle(
size: CoreSize.size20,
color: CoreColor.token(CoreColors.primary),
),
placeholderColor: CoreColor.token(CoreColors.onSurfaceVariant),
contentColor: CoreColor.token(CoreColors.primary),
),
);
}
}
class SelectChainExample extends StatefulComponent {
const SelectChainExample({super.key});
@override
State<SelectChainExample> createState() => _SelectChainExampleState();
}
class _SelectChainExampleState extends State<SelectChainExample> {
String? _value = 'apple';
void handleChanged(String value) {
setState(() => _value = value);
}
@override
Component build(BuildContext context) {
return Select<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,
).withStyle(
const CoreSelectStyle(
optionSpacing: CoreSpace.space4,
trailingIconStyle: CoreIconStyle(
size: CoreSize.size20,
color: CoreColor.token(CoreColors.primary),
),
placeholderColor: CoreColor.token(CoreColors.onSurfaceVariant),
contentColor: CoreColor.token(CoreColors.primary),
),
);
}
}
스타일 시스템 — selectStyle#
Select 의 panel chrome / 슬롯 미세 조정은 단일 selectStyle
(CoreSelectStyle) 으로 흐릅니다. 시맨틱 (size) 과 동작 정책
(popoverPlacement / popoverCollision / closeOnScroll
/
searchable / searchPlaceholder / onSearchChanged) 은 위젯
파라미터 그대로.
Select<String>(
items: items,
value: _value,
onChanged: (v) => setState(() => _value = v),
selectStyle: CoreSelectStyle(
// 옵션 리스트 제약 / 간격
optionsPanelMaxHeight: 320,
optionSpacing: CoreSpace.space4,
// 트리거 / 아이콘 chrome
placeholderColor: CoreColor.token(CoreColors.onSurfaceVariant),
selectedIconColor: CoreColor.token(CoreColors.primary),
// Nested slots (재귀 머지)
popoverStyle: CorePopoverStyle(
panelStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
padding: CoreEdgeInsets.all(CoreSpace.space8),
),
),
optionButtonStyle: CoreButtonStyle(
padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space12),
),
searchInputStyle: CoreTextFieldStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
),
emptyStateStyle: CoreTextStyle.token(CoreTextStyles.bodySmall),
),
)
CoreSelectStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
popoverStyle |
CorePopoverStyle? |
Popover style applied to the trigger + dropdown panel chrome. Drives panel border / radius / shadow / open animation duration. |
optionButtonStyle |
CoreButtonStyle? |
Option-row button style. Each rendered option uses
Button(variant: ghost or menu)
internally; this slot lets the caller tweak option-row chrome (paddingH / labelStyle / leadingIconStyle) without touching the panel.
|
searchInputStyle |
CoreTextFieldStyle? |
Search input style applied when the widget's
searchable
parameter is
true
. Reuses [CoreTextFieldStyle] for chrome parity with regular form fields.
|
emptyStateStyle |
CoreTextStyle? |
Empty-state text style (rendered when filtering produces zero matches). Deliberately without a
default*
: this slot is an overlay, and what it overlays is the ambient text style of the panel it sits in — a
DefaultTextStyle.merge
on Flutter, plain CSS inheritance on Web. No
CoreTextStyle
constant can name "whatever the panel inherits", so a default would have to pin a role and would stop the empty-state copy from tracking the surrounding option rows. The one axis the design system does own here is the colour, and that has its own field and default ([emptyStateColor] / [defaultEmptyStateColor]); both resolvers seed this overlay on top of exactly that.
|
optionsPanelMaxHeight |
double? |
Options panel maximum height before internal scrolling kicks in (logical px). The panel
chrome
(background / border / radius / padding / shadow) is not declared here — the options panel is the composed
Popover
's panel box, so its chrome flows through [popoverStyle]
.panelStyle
(a
CorePopupStyle
, the single source of truth for the floating panel).
maxHeight
stays here because it constrains the option
list
(Select's own
ConstrainedBox
content), not the panel box.
|
optionSpacing |
double? |
Nested [CoreGapStyle] slot for the gap between consecutive option rows. Forwarded to the
Gap
siblings rendered between rows (Flutter) or used to drive the panel
row-gap
CSS (Web). Merged on top of [defaultOptionSpacing].
|
trailingIconStyle |
CoreIconStyle? |
Trailing icon style override for the trigger chevron and the selected-row check mark. Raw forwarded to
Icon(iconStyle: …)
—
Icon
's own resolver handles scaling. Defaults to [defaultTrailingIconStyle].
|
placeholderColor |
CoreColor? |
Trigger placeholder text colour override (shown when no value is selected). Defaults to [defaultPlaceholderColor]. Web also uses this as the chevron colour. |
selectedIconColor |
CoreColor? |
Selected-option check icon colour override. Defaults to [defaultSelectedIconColor]. |
searchInputPadding |
CoreEdgeInsets? |
Padding around the search input container (gap to the option list) override. Defaults to [defaultSearchInputPadding]. |
emptyStateInsets |
CoreEdgeInsets? |
Empty-state surface padding override (around the "no results" text). Defaults to [defaultEmptyStateInsets]. |
emptyStateColor |
CoreColor? |
Empty-state text colour override. Defaults to [defaultEmptyStateColor]. |
contentColor |
CoreColor? |
Trigger content (selected value) text colour override. null → [defaultContentColor]. |
asChild 패턴 — 옵션 행 위젯 주입#
Select 는 내부적으로 Popover(trigger: Button(outline)) +
옵션마다 Button(ghost) 를 사용합니다. 옵션 행의 내용 자체를 바꾸려면
selectStyle 에 packed 하지 말고 CoreSelectItem 의 child
슬롯으로
위젯을 직접 주입합니다:
CoreSelectItem(
value: 'apple',
label: 'Apple',
child: Row(children: [
Icon(LucideIcons.apple),
Gap(size: CoreSpace.space8),
Text('Apple').bodyMedium.onSurface,
]),
)
child 가 null 이면 label 이 그대로 텍스트로 렌더되고, 트리거에는
선택 여부와 무관하게 항상 label 이 표시됩니다.
selectStyle.optionButtonStyle 은 chrome 미세 조정용 (padding /
labelStyle 등).
Resolve chain#
design system default
→ CoreSelectTheme.style // 프로젝트 공통
→ 부모 컴포넌트 슬롯 오버라이드
→ widget.selectStyle // 인스턴스별
내부 Popover / Button / TextField 는 각자 자기 Style
체인 (CorePopoverTheme.style, CoreButtonTheme.variantStyles[ghost],
CoreTextFieldTheme.style) 을 그대로 적용한 뒤 selectStyle
의 nested
override 가 마지막으로 머지됩니다.
변형 (Variants)#
커스텀 옵션 행#
CoreSelectItem.child 로 옵션 행 위젯을 직접 넣습니다. 트리거에는 항상
label 이 표시되므로 행이 복잡해져도 트리거는 단순하게 유지됩니다.
Select<String>(
onChanged: handleChange,
items: [
CoreSelectItem(
value: 'apple',
label: '사과',
child: Row(children: [
Icon(LucideIcons.apple),
Gap(size: CoreSpace.space8),
Text('사과').bodyMedium.onSurface,
]),
),
CoreSelectItem(value: 'banana', label: '바나나'),
],
)
비활성화된 항목#
Select<String>(
onChanged: handleChange,
items: [
CoreSelectItem(value: 'a', label: '사용 가능'),
CoreSelectItem(value: 'b', label: '사용 불가', enabled: false),
],
)
동작 스펙 (Behavior)#
상태 전환#
-
default→hover(마우스 올림) →focused(클릭/Tab) →open(드롭다운 표시) open→ 항목 선택 →filled(선택된 값 표시) →defaultdisabled: 모든 인터랙션 비활성화, 흐리게 표시
드롭다운 열기/닫기#
-
양 플랫폼 모두
Popover를 합성해 트리거 위치에 맞춰 패널을 배치합니다. 배치 위치는popoverPlacement, 뷰포트 충돌 정책은popoverCollision, 스크롤 시 자동 닫기는closeOnScroll로 조정합니다. - 패널 폭은 트리거 폭을 하한으로 삼되, 긴 옵션 라벨이 있으면 그보다 넓어집니다.
검색 필터링#
Select<String>(
searchable: true,
searchPlaceholder: '검색...',
onChanged: handleChange,
items: items,
)
searchable: true 면 패널 상단에 검색 입력이 렌더되고 label 부분 일치로
항목이 필터링됩니다. onSearchChanged 를 넘기면 내부 필터링을 끄고 쿼리만
전달하므로 서버 사이드 필터링을 붙일 수 있습니다. 결과가 0건이면 빈 상태
문구가 표시됩니다 (emptyStateStyle / emptyStateColor /
emptyStateInsets
로 조정).
단일 선택#
Select 는 단일 선택 전용입니다. 여러 값을 동시에 고르려면 ChipGroup
(다중 선택 모드) 이나 Checkbox 목록을 사용하세요.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
placeholder로 기대되는 선택을 안내하세요.
Select<String>(
placeholder: '국가를 선택하세요',
onChanged: handleCountryChange,
items: countryItems,
)
사용자가 무엇을 선택해야 하는지 즉시 파악할 수 있습니다.
❌ Don't#
placeholder 없이 빈 Select를 배치하지 마세요.
Select<String>(
onChanged: handleCountryChange,
items: countryItems,
)
빈 드롭다운은 어떤 정보를 선택해야 하는지 알 수 없습니다.
✅ Do#
옵션이 많으면 검색을 켜세요.
Select<String>(
searchable: true,
searchPlaceholder: '국가 검색',
onChanged: handleChange,
items: allCountries,
)
검색 입력이 있으면 긴 목록에서도 사용자가 원하는 항목을 몇 글자로 찾습니다.
❌ Don't#
수십 개의 옵션을 검색 없이 나열하지 마세요.
Select<String>(
onChanged: handleChange,
items: allCountries, // 200개 이상의 항목
)
스크롤이 과도하게 길어져 사용자가 원하는 항목을 찾기 어렵습니다.
✅ Do#
비활성화 항목은 이유를 설명하세요.
CoreSelectItem(
value: 'premium',
label: '프리미엄 플랜 (업그레이드 필요)',
enabled: false,
)
왜 선택할 수 없는지 사용자에게 알려줍니다.
❌ Don't#
설명 없이 항목을 비활성화하지 마세요.
CoreSelectItem(value: 'premium', label: '프리미엄', enabled: false)
사용자가 왜 선택할 수 없는지 이해할 수 없습니다.
접근성 (Accessibility)#
키보드 인터랙션#
Select 는 자체 키 핸들러를 갖지 않습니다. 아래 동작은 트리거와 옵션 행이 모두
Button 이라는 사실에서 나오며, 항목 간 이동은 화살표가 아니라 Tab
입니다
(listbox 로빙 포커스는 구현되어 있지 않습니다).
| 키 | 동작 |
|---|---|
Enter / Space | 포커스된 트리거에서 드롭다운 열기/닫기, 포커스된 옵션 행 선택 |
Tab / Shift+Tab | 트리거 → 옵션 행 → 다음 요소로 포커스 이동 |
Escape |
드롭다운 닫기 — Web 만 (Popover.dismissOnEscape 기본 true) |
↑ / ↓ 로 항목을 옮기거나 Home / End 로 첫·마지막 항목으로 점프하는 동작은
양 플랫폼 모두 없습니다. Flutter 는 Escape 도 닫지 않습니다 — Flutter Popover
의
Escape 는 modal 인 패널에만 걸리고 Select 는 비모달로 띄웁니다. Flutter 에서
Escape 로 닫아야 하면 호출자가 트리거 주변에 자체 단축키를 배선하세요.
스크린 리더#
-
트리거는 양 플랫폼 모두
Button이 주는 버튼 시맨틱을 받고, 표시 텍스트는 선택된 항목의label(미선택 시placeholder) 입니다. -
두 플랫폼이 전달하는 정보량이 다릅니다.
- Web: 패널이
role="listbox", 각 행이role="option"+aria-selected/aria-disabled를 내보내므로 목록으로 읽히고 선택 상태가 통보됩니다. - Flutter: select 위젯에
Semantics가 없어 선택 상태가 체크 아이콘(시각)으로만 전달됩니다 — 스크린 리더 사용자는 어느 행이 선택됐는지 알 수 없습니다.
- Web: 패널이
-
Web 이
role="listbox"를 광고하지만 화살표 키 로빙 포커스는 아직 구현되지 않았습니다 (아래 키보드 절 참고) — 역할이 약속하는 조작을 다 주지는 못하는 상태입니다.
터치 타겟#
-
트리거와 옵션 행은
Button으로 합성되므로size에 따라 32~48 px 높이로 그려지며, WCAG 2.2 AA 최소치(24)를 넘습니다.TouchTarget포인터-타겟 하한은 그보다 작게 그리는 컴포넌트(checkbox / radio / chip)에만 적용되고 select 는 대상이 아닙니다.
크로스 플랫폼 차이점 (Platform Differences)#
생성자 파라미터는 양 플랫폼 동일합니다. 아래는 플랫폼 고유 차이점만 나열합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Select<T> | Select<T> |
| 드롭다운 구현 | Popover + Button(ghost) 옵션 행 |
Popover + Button(ghost) 옵션 행 |
CoreSelectItem.child 타입 |
Widget |
Component |
Escape 로 닫기 |
없음 (비모달 Popover — Escape 는 모달에만 걸림) |
Popover.dismissOnEscape 기본 true |
| 이벤트 통과 | 없음 (Flutter 콜백) | onClick / onKeyDown 등 DOM 이벤트 슬롯 |
| HTML 속성 통과 | 없음 | id / classes / css / attributes |
관련 컴포넌트 (Related Components)#
- TextField: 자유 텍스트 입력. Select와 달리 사전 정의된 옵션 없이 직접 입력
- Autocomplete: 입력하면서 옵션을 필터링. 옵션이 매우 많을 때 Select 대신 사용
- Menu: 액션 목록 표시. 폼 제출이 아닌 컨텍스트 액션에 적합
조합 예제#
// 주소 입력 폼 패턴
Form(
onSubmit: handleSubmit,
children: [
FormField(
label: '국가',
child: Select<String>(
placeholder: '국가를 선택하세요',
onChanged: handleCountryChange,
items: countryItems,
),
),
FormField(
label: '도시',
child: Select<String>(
placeholder: '도시를 선택하세요',
onChanged: handleCityChange,
items: cityItems,
enabled: selectedCountry != null,
),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: handleSubmit,
child: Text('제출'),
),
],
)