Select | CoUI
LogoCoUI

Select

드롭다운 선택 컴포넌트

Select#

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

Live Preview#

사용 시기 (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#

속성타입기본값설명
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 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 하지 말고 CoreSelectItemchild 슬롯으로 위젯을 직접 주입합니다:

CoreSelectItem(
  value: 'apple',
  label: 'Apple',
  child: Row(children: [
    Icon(LucideIcons.apple),
    Gap(size: CoreSpace.space8),
    Text('Apple').bodyMedium.onSurface,
  ]),
)

childnull 이면 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)#

상태 전환#

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

드롭다운 열기/닫기#

  • 양 플랫폼 모두 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 이 role="listbox" 를 광고하지만 화살표 키 로빙 포커스는 아직 구현되지 않았습니다 (아래 키보드 절 참고) — 역할이 약속하는 조작을 다 주지는 못하는 상태입니다.

터치 타겟#

  • 트리거와 옵션 행은 Button 으로 합성되므로 size 에 따라 32~48 px 높이로 그려지며, WCAG 2.2 AA 최소치(24)를 넘습니다. TouchTarget 포인터-타겟 하한은 그보다 작게 그리는 컴포넌트(checkbox / radio / chip)에만 적용되고 select 는 대상이 아닙니다.

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

생성자 파라미터는 양 플랫폼 동일합니다. 아래는 플랫폼 고유 차이점만 나열합니다.

항목FlutterWeb
클래스명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
  • 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('제출'),
    ),
  ],
)