Select | CoUI

Select

드롭다운 선택 컴포넌트

Select#

옵션 목록에서 하나를 선택할 수 있는 드롭다운 컴포넌트입니다.

Live Preview#

Web
Flutter
Loading Flutter...
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
emptyStateStyleCoreTextStyle?검색 결과 없음 텍스트 스타일
optionsPanelBackgroundColor Color? / String? 옵션 패널 배경
optionsPanelBorderColor Color? / String? 옵션 패널 보더 색
optionsPanelBorderRadiusdouble?옵션 패널 보더 반경
optionsPanelPaddingdouble?옵션 패널 내부 패딩
optionsPanelMaxHeightdouble?옵션 패널 최대 높이 (px)
optionsGapdouble?옵션 행 간격 (px)

asChild 패턴 — 트리거 / 옵션 행 변경#

CoSelect 는 내부적으로 CoPopover(trigger: CoButton(outline)) + 옵션마다 CoButton(ghost) 를 사용합니다. 트리거 / 옵션의 시맨틱 (variant) 변경이 필요할 때는 selectStyle 안에 packed 하지 말고 후속 contract 확장 (asChild 슬롯) 또는 CoreSelectItemchild 슬롯을 통해 위젯 자체를 주입합니다 (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.buttonStyleCoreButtonStyle<CoreColor> 를 받지만 CoreSelectStyle.optionButtonStyle 은 contract 상 CoreButtonStyle<String> 입니다. Web 빌드에서는 optionButtonStyle 이 자동 전달되지 않으며, 옵션 행 chrome 변경은 프로젝트 CoreButtonTheme 또는 asChild 위젯 주입으로 수행합니다 — Web CoPopover.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#

속성타입기본값설명
valueT?null현재 선택된 값
onChanged void Function(T value)? null 선택 변경 콜백
items List<CoreSelectItem<T>> 필수 선택 항목 목록
placeholder String? null 미선택 시 표시 텍스트
enabledbooltrue활성화 여부
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)#

상태 전환#

  • defaulthover (마우스 올림) → focused (클릭/Tab) → open (드롭다운 표시)
  • open → 항목 선택 → filled (선택된 값 표시) → default
  • disabled: 모든 인터랙션 비활성화, 흐리게 표시

드롭다운 열기/닫기#

  • 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 등)가 통일되었습니다. 아래는 플랫폼 고유 차이점만 나열합니다.

항목FlutterWeb
클래스명ControlledSelect<T>Select
값 타입제네릭 T (모든 타입)String (문자열만)
드롭다운 구현Popover 기반 커스텀 UI네이티브 <select> 요소
검색 필터링searchable: true 지원없음
다중 선택MultiSelectControllermultiple: true (네이티브)
그룹화SelectGroup 위젯<optgroup> 매핑
프로그래밍 제어SelectController없음
커스텀 항목 렌더링itemBuilder 콜백없음 (텍스트만)
  • 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('제출')),
    ],
  ),
)