Select | CoUI
본문으로 건너뛰기
LogoCoUI

Select

드롭다운 선택 컴포넌트

목차

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

기본 사용법 (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: '채소'),
  ],
)

Live Preview#

구조 (Anatomy)#

코드가 정본입니다. Select 는 읽기 전용 TextField 트리거 + Popover 옵션 패널입니다. 부분 이름은 생성자/CoreSelectItem 파라미터와 Figma ❖ Select 레이어에서 가져왔습니다. 위젯 슬롯은 suffix 와 CoreSelectItem.child 이며, 슬롯이 없는 크롬은 — 입니다. suffix 를 생략하면 기존 트리거 쉐브론을 표시합니다.

부분역할슬롯
label트리거 위 필드 라벨label
trigger 닫힌 필드. 미선택 시 placeholder placeholder
suffix트리거 오른쪽의 비대화형 텍스트 또는 아이콘suffix
description트리거 아래 도움말description
errorText검증 메시지errorText
options옵션 패널items
empty목록이 비었을 때—
child 옵션 행 커스텀 내용 (label 대신) CoreSelectItem.child

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 미리 정해진 옵션 목록에서 하나를 선택할 때
  • 5개 이상의 옵션이 있어 라디오 버튼이 비효율적일 때
  • 폼에서 카테고리, 국가, 상태 등을 선택할 때

대신 다른 컴포넌트를 사용하세요:

  • TextField: 자유 텍스트 입력이 필요할 때
  • Autocomplete: 입력하면서 옵션을 필터링해 선택할 때 (Select 의 문자 키는 이동만)
  • RadioGroup: 옵션이 5개 이하이고 모두 한눈에 보여야 할 때
  • Menu: 네비게이션이나 액션 목록일 때 (폼 제출 아닌 경우)

트리거 보조 문구#

Flutter/Web 모두 같은 suffix 슬롯으로 기본 쉐브론을 대체할 수 있습니다. 선택 동작은 Select에 맡기고 버튼이나 다른 포커스 가능한 컨트롤은 넣지 않습니다.

Select<String>(
  value: 'KRW',
  items: const [
    CoreSelectItem(value: 'KRW', label: 'KRW'),
    CoreSelectItem(value: 'USD', label: 'USD'),
  ],
  suffix: Text('변경'),
  onChanged: handleCurrencyChange,
)

Props / Parameters#

T는 선택 값의 도메인 타입입니다. CoreSelectItem<T>의 합성 콘텐츠는 Flutter에서는 Widget, Web에서는 Component로 구성하며 단순한 슬롯 타입 차이를 별도 플랫폼 API로 보지 않습니다.

속성타입기본값설명
items List<CoreSelectItem<T>> 필수 선택 항목 목록
valueT?null현재 선택된 값
placeholder String? null 미선택 시 표시 텍스트
suffix Widget? / Component? null 기본 쉐브론을 대체하는 비대화형 콘텐츠. 버튼이나 포커스 가능한 입력을 넣지 않습니다. 선택 동작과 키보드 탐색은 Select가 담당합니다
variant CoreSelectVariant defaultVariant defaultVariant / error / filled (테두리 대신 채움 — TextField 의 filled 와 같음). errorText 가 있으면 자동으로 error
size CoreSelectSize md 트리거 높이 32 / 40 / 48 ( sm / md / lg ) — 값 글줄은 세로 가운데에 놓입니다. 옵션 행은 MenuItem 의 사다리(32 / 36 / 48)를 따라 md 가 36 입니다. 꺾쇠(chevron)는 14 / 16 / 18
label String? null 트리거 위 필드 라벨 (aria-labelledby 로 연결)
labelMaxLines int? null 라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다
labelOverflow CoreTextOverflow? null labelMaxLines 에 도달한 라벨의 넘침 처리 ( clip / fade / ellipsis / visible )
description String? null 트리거 아래 도움말. errorText 가 있으면 숨김
errorText String? null 검증 메시지. non-null 이면 error 변형 + aria-invalid
required bool false 라벨에 필수 표시
enabled bool true 활성화 여부. disabled > error > hover — 비활성이면 error 테두리도 억제
onChanged void Function(T value)? null 선택 변경 콜백
popoverPlacement CorePopoverPlacement CorePopoverStyle.defaultDropdownPlacement (bottomStart) 패널 배치 위치
popoverCollision Set<CorePopoverCollision> CorePopoverStyle.defaultCollision 뷰포트 충돌 정책
popoverWidthConstraint CorePopoverConstraint anchorFixedSize 옵션 패널과 트리거의 폭 관계
closeOnScroll bool CorePopoverStyle.defaultCloseOnScroll (true) 트리거가 속한 스크롤 영역이 움직이면 패널을 닫습니다. false이면 열린 상태를 유지합니다
selectStyle CoreSelectStyle? null 트리거 필드 / 패널 / 옵션 행 / 빈 상태 chrome 묶음

빠른 오버라이드 (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),
      ),
    );
  }
}

스타일 시스템 (Style System)#

selectStyle 슬롯#

Select 의 chrome 은 단일 selectStyle (CoreSelectStyle) 으로 흐릅니다. 시맨틱 (variant / size) 과 동작 정책 (popoverPlacement / popoverCollision / closeOnScroll) 은 위젯 파라미터 그대로.

Select 는 세 컴포넌트를 합성합니다 — 트리거는 TextField (triggerStyle), 패널은 Popover (popoverStyle), 옵션 행은 MenuItem (optionStyle). 각 nested 슬롯은 그 자식의 Style 을 그대로 받으므로 자식이 받는 모든 chrome 을 여기서 덮을 수 있습니다.

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 (재귀 머지) — 자식 컴포넌트의 Style 그대로
    triggerStyle: CoreTextFieldStyle(
      borderRadius: CoreBorderRadius.all(CoreRadius.radius4),
    ),
    popoverStyle: CorePopoverStyle(
      panelStyle: CorePopupStyle(
        padding: CoreEdgeInsets.all(CoreSpace.space8),
      ),
    ),
    optionStyle: CoreMenuItemStyle(
      padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space12),
    ),
    emptyStateStyle: CoreTextStyle.token(CoreTextStyles.bodySmall),
  ),
)

CoreSelectStyle 필드#

필드타입설명
triggerStyle CoreTextFieldStyle? Trigger field chrome — raw-forwarded to the composed TextField . null → [defaultTriggerStyle].
popoverStyle CorePopoverStyle? Popover style applied to the panel chrome and the trigger gap. null → [defaultPopoverStyle].
optionStyle CoreMenuItemStyle? Option-row chrome — raw-forwarded to each composed MenuItem . null → [defaultOptionStyle].
emptyStateStyle CoreTextStyle? Empty-state text style (rendered when the list is empty). 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). maxHeight stays here because it constrains the option list (Select's own scroll region), not the panel box.
optionSpacing double? Gap between consecutive option rows. null → [defaultOptionSpacing].
trailingIconStyle CoreIconStyle? Trigger chevron chrome. null → the size ladder ([defaultsBySize]). Colour follows [placeholderColor].
placeholderColor CoreColor? Trigger placeholder text colour override (shown when no value is selected). Defaults to [defaultPlaceholderColor]. Also the chevron colour.
selectedIconColor CoreColor? Selected-option mark colour override. Defaults to [defaultSelectedIconColor].
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: TextField(readOnly)) + 옵션마다 MenuItem 을 사용합니다. 옵션 행의 내용 자체를 바꾸려면 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.optionStyle (CoreMenuItemStyle) 은 행 chrome 미세 조정용 (padding / labelTextStyle / markIconStyle 등).

Resolve chain#

design system default
  → CoreSelectTheme.style                       // 프로젝트 공통
  → 부모 컴포넌트 슬롯 오버라이드
  → widget.selectStyle                          // 인스턴스별

내부 TextField / Popover / MenuItem 은 각자 자기 Style 체인 (CoreTextFieldTheme.style, CorePopoverTheme.style, CoreMenuItemTheme.style) 을 그대로 적용한 뒤 selectStyle 의 nested override (triggerStyle / popoverStyle / optionStyle) 가 마지막으로 머지됩니다. Select 가 트리거에 대해 스스로 정하는 것은 워시 하나뿐입니다 — pressed(12%) 만 칠합니다. 값 타이포는 TextField 의 값 글자 그대로이며 세 사이즈 모두 14px body-medium(역할 행간 150%)입니다. hover 는 테두리 outline → on-surface, focus 는 링과 primary 테두리이며 채움을 칠하지 않습니다.

변형 (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)#

상태 전환#

트리거는 TextField 계약을 그대로 상속합니다 (최소 높이 32 / 40 / 48, 가로 패딩 10 / 12 / 14, radius 8 / 10 / 12 — 높이 × 0.25, 꺾쇠 14 / 16 / 18, 쉬는 테두리 1). 포커스와 에러도 테두리 굵기는 1 그대로이고 색만 바뀌어, 트리거의 높이와 값 글자 위치는 움직이지 않습니다. 글자 색도 TextField 의 단계를 따릅니다 — 라벨 onSurfaceVariant, 도움말 · 비활성 라벨 onSurfaceSubtle (꺾쇠는 자기 색 placeholderColor 를 덮어 씁니다).

상태테두리채움 / 워시그 외
enabled outline surface, 워시 없음
hoveron-surface워시 없음
focus primary 워시 없음 링(+4, focus-ring), 사이 간격(+2) 투명
pressed on-surface on-surface 12%
disabled disabled-outline disabled-container 값 disabled-content
error (모든 상태) error 상태별 동일 focus 에서도 error 유지

우선순위는 disabled > error > hover 입니다 — 비활성 트리거는 errorText 가 있어도 비활성 3색을 그리고, error 트리거는 hover 해도 테두리가 바뀌지 않습니다. 그래서 error × disabled 조합은 별도 포즈가 아닙니다.

드롭다운 열기/닫기#

  • 양 플랫폼 모두 Popover 를 합성해 트리거 위치에 맞춰 패널을 배치합니다. 배치 위치는 popoverPlacement, 뷰포트 충돌 정책은 popoverCollision, 스크롤 시 자동 닫기는 closeOnScroll 로 조정합니다.
  • closeOnScroll 의 기본값 true는 트리거의 조상 스크롤에 반응합니다. Web에서는 문서·뷰포트 스크롤도 여기에 포함됩니다. 메뉴 안의 옵션 스크롤과 트리거와 무관한 다른 목록의 스크롤은 패널을 닫지 않습니다. false를 주면 조상이 스크롤해도 열려 있고 패널이 트리거를 따라갑니다. 이전 버전에서는 이 값이 전달되지 않아 기본값이어도 닫히지 않았으므로, 그 동작을 유지해야 하는 호출자는 closeOnScroll: false를 명시하세요.
  • 트리거는 부모의 가로 폭을 채웁니다 (triggerSizing: expand — Autocomplete · DatePicker 와 같은 필드 트리거 규약). 폭은 부모가 정하므로 두 플랫폼에서 같고, 내용 폭으로 줄이려면 popoverStyle: CorePopoverStyle(triggerSizing: .intrinsic) 을 줍니다. Flutter 에서 가로가 무한한 부모(Row)에는 채울 폭이 없으므로 트리거가 내용 폭이 되고, Expanded · SizedBox 로 폭을 주면 그 폭을 따릅니다.
  • 패널 폭은 기본적으로 트리거 폭과 같습니다 (anchorFixedSize). 좁은 트리거에서 옵션이 잘릴 때 popoverWidthConstraint: CorePopoverConstraint.anchorMinSize를 지정하면 트리거 폭을 최소로 사용하면서 내용에 맞게 확장합니다. 패널은 패딩 6, radius 12 (사이즈와 무관한 고정값 — 행 6 과 패딩 6 을 사이에 둔 동심), 행 간격 2, 트리거와의 간격 4, 테두리 1 outline-variant, 채움 surface 입니다.
  • 옵션 행은 MenuItem 이며 트리거와 같은 sm / md / lg 단을 씁니다(행 높이 32 / 36 / 48 — 트리거의 md 40 과 달리 md 행은 36). 모서리만 예외로, 패널이 담는 행이라 사이즈와 무관하게 6 입니다 (defaultOptionStyle) — MenuItem 의 사이즈 행 반경(혼자 선 lg 행은 12)을 따르면 lg 행이 6 안쪽에서 패널과 같은 12 가 되어, 동심인 6 보다 둥글어집니다. 선택된 행은 leading 자리에 체크 마크(on-surface, 액센트 아님)를 그리고, 마크 열은 모든 행에 예약되어 라벨 시작선이 흔들리지 않습니다. 선택 행의 배경 강조는 없습니다.

타입어헤드 (검색 아님)#

Select 는 목록을 필터링하지 않습니다. 문자 키를 누르면 그 글자로 시작하는 다음 항목으로 이동합니다 (aria-autocomplete="none"). 입력하면서 목록을 좁히는 것은 Autocomplete 의 일이며, Select 에는 검색 상자도 그것을 켜는 프로퍼티도 없습니다. 목록이 비어 있으면 빈 상태 문구가 표시됩니다 (emptyStateStyle / emptyStateColor / emptyStateInsets 로 조정).

단일 선택#

Select 는 단일 선택 전용입니다. 여러 값을 동시에 고르려면 ChipGroup (다중 선택 모드) 이나 Checkbox 목록을 사용하세요.

사용 가이드라인 (Usage Guidelines)#

항목 목록 안의 값만 선택하도록 해야 하면 Select를 사용합니다. 자유 텍스트를 입력하면서 제안도 받으려면 Autocomplete나 Datalist, 옵션을 모두 펼쳐 놓은 단일 선택은 RadioGroup, 값 선택이 아닌 액션 메뉴는 DropdownMenu를 선택합니다.

목록 값이 아닌 임의 입력·액션 패널은 Popover, 테마 이름과 팔레트 스와치를 함께 선택하는 UI는 ThemeController로 구성합니다.

✅ Do#

placeholder로 기대되는 선택을 안내하세요.

Select<String>(
  placeholder: '국가를 선택하세요',
  onChanged: handleCountryChange,
  items: countryItems,
)

사용자가 무엇을 선택해야 하는지 즉시 파악할 수 있습니다.


❌ Don't#

placeholder 없이 빈 Select를 배치하지 마세요.

Select<String>(
  onChanged: handleCountryChange,
  items: countryItems,
)

빈 드롭다운은 어떤 정보를 선택해야 하는지 알 수 없습니다.

✅ Do#

옵션이 많으면 Autocomplete 를 쓰세요.

Autocomplete(
  placeholder: '국가 검색',
  onChanged: handleChange,
  suggestions: allCountries,
)

입력하면서 목록을 좁히는 것은 Autocomplete 의 일입니다. Select 의 문자 키는 항목으로 이동만 합니다.


❌ Don't#

수십 개의 옵션을 Select 에 나열하지 마세요.

Select<String>(
  onChanged: handleChange,
  items: allCountries, // 200개 이상의 항목
)

스크롤이 과도하게 길어지고, 타입어헤드로는 원하는 항목에 닿기 어렵습니다.

✅ Do#

비활성화 항목은 이유를 설명하세요.

CoreSelectItem(
  value: 'premium',
  label: '프리미엄 플랜 (업그레이드 필요)',
  enabled: false,
)

왜 선택할 수 없는지 사용자에게 알려줍니다.


❌ Don't#

설명 없이 항목을 비활성화하지 마세요.

CoreSelectItem(value: 'premium', label: '프리미엄', enabled: false)

사용자가 왜 선택할 수 없는지 이해할 수 없습니다.

접근성 (Accessibility)#

역할#

  • Web — readonly 텍스트 필드 트리거가 role="combobox" 와 aria-haspopup="listbox" 를, 목록이 role="listbox", 행이 role="option" + aria-selected 를 갖습니다. 비활성 옵션은 aria-disabled 로 전달됩니다.
  • Flutter — 텍스트 필드 트리거와 MenuItem 옵션 행을 합성합니다. 트리거의 라벨·값·비활성 상태와 행의 option 역할·선택 상태가 전달됩니다. combobox / listbox 컨테이너 역할은 Flutter capability 에 없습니다.

키보드#

WAI-ARIA select-only combobox 패턴을 양 플랫폼 동일하게 구현합니다. 포커스는 항상 트리거에 머물고, 패널 안의 이동은 aria-activedescendant (Web) / 하이라이트 행 (Flutter) 으로 표현됩니다.

키닫힌 상태열린 상태
Enter / Space열기하이라이트 행 선택 후 닫기
Alt + ↓열기 (값 불변)—
↓ / ↑ 값을 다음 / 이전 항목으로 옮김 하이라이트 이동 (순환)
Home / End첫 / 마지막 값첫 / 마지막 행
문자 키그 글자로 시작하는 다음 항목으로 값 이동 (타입어헤드)하이라이트 이동
Escape—닫기. 값 불변, 포커스는 트리거
Tab / Shift+Tab다음 / 이전 요소로닫고 다음 / 이전 요소로

타입어헤드는 500ms 안에 이어 친 글자를 하나의 접두로 취급합니다. 문자 키는 목록을 필터링하지 않습니다.

브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 입력의 aria-activedescendant 가 기대한 옵션을 가리키는지(닫힌 뒤에는 아무것도 가리키지 않는지) 확인합니다. 핸들러가 달렸는지가 아니라 결과를 봅니다.

스크린 리더#

  • Web: 트리거 <input readonly> 가 role="combobox" · aria-expanded · aria-haspopup="listbox" · aria-controls · aria-activedescendant · aria-autocomplete="none" 을 내보내고, label 은 aria-labelledby, errorText 는 aria-invalid 로 연결됩니다. 패널은 role="listbox", 각 행은 role="option" + aria-selected 입니다.
  • Flutter: 트리거는 텍스트 필드 시맨틱 (라벨 / 값 / 비활성) 을, 각 행은 option 역할과 선택 상태를 Semantics 로 통보합니다. Flutter 에는 listbox / combobox 역할이 없어 컨테이너 역할은 통보되지 않습니다 — 플랫폼 capability 차이이며 미구현이 아닙니다.
  • 키보드 하이라이트: Web 은 트리거의 aria-activedescendant 가 하이라이트 행을 가리켜 리더가 그 행을 읽습니다. Flutter 에는 한 노드가 다른 노드를 가리키는 관계가 없고, 행의 selected 플래그는 이미 값(Web 의 aria-selected)을 뜻하므로 하이라이트에 다시 쓸 수 없습니다. 그래서 Flutter 는 패널이 열려 하이라이트가 놓이거나 옮겨갈 때마다 그 행의 라벨을 통보 (SemanticsService.sendAnnouncement) 합니다. 플랫폼이 통보를 지원하지 않으면 (MediaQuery.supportsAnnounceOf 가 거짓) 아무것도 쌓지 않습니다.

포커스 관리#

  • 진입 — 포커스는 트리거에 머물고 목록 안에서는 하이라이트만 이동합니다.
  • 이탈 — 선택·Escape 후에도 트리거가 포커스를 소유합니다. Tab 은 목록을 닫고 다음 요소로 나갑니다.
  • 트랩 — 없습니다. 비모달 선택 목록이고 배경을 격리하는 다이얼로그가 아닙니다.

알려진 제약#

  • 텍스트 입력으로 목록을 거르는 컨트롤이 아닙니다. 트리거는 readonly 이고 타입어헤드는 하이라이트·선택 이동입니다. 검색 필터가 필요하면 Autocomplete 를 쓰세요.
  • Flutter 하이라이트 통보는 플랫폼의 announce 지원에 의존합니다. 행의 선택 상태는 실제 값을 뜻하므로 하이라이트로 덮지 않으며, announce 를 지원하지 않는 플랫폼에서는 별도 발화를 쌓지 않습니다.
  • 트리거는 TextField, 옵션 행은 MenuItem 으로 합성되어 size 에 따라 트리거는 32 / 40 / 48 px, 옵션 행은 32 / 36 / 48 px 높이로 그려지며 WCAG 2.2 AA 최소치(24)를 넘습니다. TouchTarget 포인터-타겟 하한은 그보다 작게 그리는 컴포넌트(checkbox / radio / chip)에만 적용되고 select 는 대상이 아닙니다.

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

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

항목FlutterWeb
클래스명Select<T>Select<T>
드롭다운 구현 Popover + TextField 트리거 + MenuItem 행 Popover + TextField 트리거 + MenuItem 행
CoreSelectItem.child 타입 Widget Component
컨테이너 역할 통보 없음 (listbox / combobox 역할 부재 — 행의 option + 선택 상태만) role="combobox" / role="listbox"
키보드 하이라이트 통보 하이라이트 행 라벨을 통보 (sendAnnouncement) aria-activedescendant
hover / pressed 감지 WidgetState CSS :hover / :active (스코프 규칙)
이벤트 통과 없음 (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('제출'),
    ),
  ],
)