PlSelect
여러 값 중 하나를 고릅니다. trigger는 chevron을 단 PlTextField의 껍데기 그대로라, 같은 form 안의 select와 field가 하나의 물건으로 읽힙니다.
import { PlSelect } from 'plass-ui';
<PlSelect
label="City"
placeholder="Pick a city"
items={[
{ value: 'seoul', label: 'Seoul' },
{ value: 'lisbon', label: 'Lisbon' }
]}
/>;import 'package:plass_ui/plass_ui.dart';
PlSelect<String>(
label: const Text('City'),
placeholder: const Text('Pick a city'),
value: city,
onChanged: (String? next) => setState(() => city = next),
options: const <PlSelectOption<String>>[
PlSelectOption<String>(value: 'seoul', label: Text('Seoul')),
PlSelectOption<String>(value: 'lisbon', label: Text('Lisbon')),
],
);목록은 자기를 트리 밖으로 들어 올리므로 select 위쪽에 Overlay가 필요합니다. navigator가 있는 WidgetsApp과 MaterialApp이 둘 다 제공합니다.
Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | 'solid' | 'glass' | 'ghost' | 'glass' | trigger의 재질. PlTextField와 같은 껍데기를 씁니다. solid는 시트에 파인 우물 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | trigger의 높이와 타입 스케일. PlTextField와 같은 사다리 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 0 | trigger의 그림자 깊이. 팝업은 3으로 고정입니다 — 팝업은 정말로 페이지 위에 떠 있습니다 |
| items * | readonly PlSelectOption[] | — | 옵션 목록. 데이터로 받습니다 — 팝업을 한 번도 열지 않은 trigger도 라벨을 알아야 하기 때문입니다 |
| value | string | number | null | — | 선택된 값. onValueChange와 함께 controlled로 씁니다 |
| defaultValue | string | number | null | — | uncontrolled일 때 처음 선택된 값 |
| onValueChange | (value: string | number | null) => void | — | 값이 바뀔 때 호출됩니다 |
| placeholder | ReactNode | — | 아무것도 고르지 않았을 때 trigger에 보이는 내용 |
| label | ReactNode | — | trigger 위 라벨. Base UI의 Field가 서로 엮어 줍니다 |
| description | ReactNode | — | trigger 아래 보조 설명 |
| error | ReactNode | — | 오류 메시지. 존재 자체가 invalid 상태를 만듭니다 |
| invalid | boolean | — | 메시지 없이 invalid로 만듭니다. 기본값은 error에 내용이 있는지 여부 |
| startIcon | ReactNode | — | 값 앞에 놓이는 내용. 1.2em으로 그려져 글자 크기를 따라갑니다 |
| fullWidth | boolean | false | 컨테이너 너비만큼 확장 |
| readOnly | boolean | false | 값은 보이지만 바꿀 수 없습니다 |
| disabled | boolean | false | 사용 불가. 시트 너머로 페이지가 비쳐 보이며, 포커스 순서에서 빠집니다 |
| required | boolean | false | form 제출 전에 값을 골라야 하는지 |
| name | string | — | form 제출 시 이 필드를 식별하는 이름 |
| hotKeys | Record<string, () => void> | — | 이 컨트롤이 답할 chord들. PlHotKeys가 그리는 것과 같은 철자입니다 — { 'Mod+Enter': save, Escape: cancel }. 맞는 chord는 **소비됩니다** |
| classNames | { label?, control?, description?, error?: string } | — | className이 닿지 않는 부분에 붙는 class. control은 실제로 조작하는 부분입니다 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| options * | List<PlSelectOption<T>> | — | 선택지들. children이 아니라 설명의 목록입니다 — 팝업을 한 번도 열지 않은 trigger도 라벨을 알아야 합니다 |
| value * | T? | — | 선택된 값. onValueChange와 함께 controlled로 씁니다 |
| onChanged | ValueChanged<T?>? | — | 값이 바뀔 때 호출됩니다 |
| placeholder | Widget? | — | 아무것도 고르지 않았을 때 trigger에 보이는 내용 |
| variant공통 | PlassVariant | PlassVariant.glass | trigger의 재질. PlTextField와 같은 껍데기를 씁니다. solid는 시트에 파인 우물 |
| size공통 | PlassSize | PlassSize.md | trigger의 높이와 타입 스케일. PlTextField와 같은 사다리 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | PlassDensity | PlassDensity.standard | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | int | 0 | trigger의 그림자 깊이. 목록은 사다리 꼭대기로 고정입니다 — 목록은 정말로 페이지 위에 떠 있습니다 |
| label | Widget? | — | trigger 위 라벨. Base UI의 Field가 서로 엮어 줍니다 |
| description | Widget? | — | trigger 아래 보조 설명 |
| error | Widget? | — | 오류 메시지. 존재 자체가 invalid 상태를 만듭니다 |
| invalid | bool? | — | 메시지 없이 invalid로 만듭니다. 기본값은 error에 내용이 있는지 여부 |
| startIcon | Widget? | — | 값 앞에 놓이는 내용. 값의 1.2배로 그려져 글자 크기를 따라갑니다 |
| fullWidth | bool | false | 컨테이너 너비만큼 확장 |
| readOnly | bool | false | 값은 보이지만 바꿀 수 없고, 목록도 열리지 않습니다 |
| disabled | bool | false | 사용 불가. 시트 너머로 페이지가 비쳐 보이며, 포커스 순서에서 빠집니다 |
| semanticLabel | String? | — | select를 스크린 리더가 부를 이름 |
| focusNode | FocusNode? | — | 바깥에서 focus를 몹니다 |
| autofocus | bool | false | 트리에 들어가는 순간 focus를 가져갑니다 |
| hotKeys | PlassHotKeys? | — | trigger가 답할 chord들. PlHotKeys가 그리는 것과 같은 철자입니다 — { 'Mod+Enter': save, 'Escape': cancel }. 맞는 chord는 **소비됩니다**. 맨 Enter를 묶으면 목록을 열고 확정하던 trigger에게서 그 키를 가져옵니다 |
네이티브 <div> 속성은 field wrapper로 그대로 전달됩니다. color는 위 표의 color와 충돌해서, defaultValue는 DOM 속성이 아니라 값으로 쓰기 때문에, children은 옵션이 items이기 때문에 제외됩니다.
className은 label과 control, 그 아래 두 줄을 함께 담는 stack에 붙습니다. 그 안쪽 네 부분에 닿는 것이 classNames입니다: label, control(트리거), description, error.
select는 값 타입에 대해 제네릭입니다(PlSelect<String>, PlSelect<Currency>). 그래서 value와 onChanged가 관습이 아니라 타입으로 지켜지며, 패키지의 다른 입력들과 마찬가지로 controlled입니다.
그 제네릭이 React 빌드의 조언과 갈리는 유일한 지점입니다. 거기서 값이 일부러 string이나 number인 이유는 그것이 form이 제출하는 것이기 때문인데, 여기서는 제출되는 것이 없으니 값이 그 대상 자체일 수 있고 타입 검사기가 그것을 지켜 줍니다.
PlSelectOption
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | string | number | — | 제출되는 값이자 value / onValueChange가 말하는 값 |
| label | ReactNode | — | 목록과 trigger에 보이는 내용. 생략하면 value가 그대로 쓰입니다 |
| disabled | boolean | false | 고를 수 없지만 목록에는 남습니다 — 존재하는 옵션인데 지금은 못 고르는 것 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | T | — | PlSelect.value가 담는 값이자 onChanged가 보고하는 값 |
| label | Widget? | — | 목록과 trigger에 보이는 내용. 생략하면 값의 toString이 쓰입니다 |
| disabled | bool | false | 고를 수 없지만 목록에는 남습니다 — 존재하는 옵션인데 지금은 못 고르는 것 |
라이브러리 전체에서 공유 축(variant size color density elevation)이 뜻하는 바는 prop 규칙에 있습니다.
Examples
variant
PlTextField가 입는 것과 똑같은 세 재질을, 똑같은 껍데기 위에서 씁니다. solid는 색 유리판이 아니라 우물(가장 불투명한 유리에 안쪽으로 떨어지는 그림자)입니다. 그러데이션 위에서 읽어야 하는 값은 결국 그러데이션 위에서 읽어야 하는 값이기 때문입니다.
size
다른 모든 컨트롤과 같은 높이 사다리를 씁니다. trigger를 field의 껍데기 위에 그리는 이유가 바로 이것입니다. select만 주변 field와 높이나 모서리, 재질이 다른 form은 설계된 것이 아니라 조립된 것처럼 보입니다.
readOnly · disabled · error
error는 select를 invalid로도 만들고, 그러면 색 계열 전체가 danger를 가리킵니다. 테두리, ring, 메시지가 함께 넘어갑니다. invalid는 메시지 없이 같은 일을 합니다. 외부 form 라이브러리가 유효성을 쥐고 있을 때 쓰세요.
readOnly인 select는 값과 focus를 유지하지만 열리지 않습니다. disabled인 것은 포커스 순서에서 빠집니다.
옵션 하나만 disabled로 둘 수도 있습니다. 그래도 목록에는 남습니다. 고를 수 없다고 사라지는 옵션은 읽는 사람이 계속 찾게 되는 옵션입니다.
Controlled
value와 onValueChange를 함께 넘기세요. 값은 언제나 string이나 number이고 객체가 아닙니다. select는 form 컨트롤이고, 그 값은 form이 제출하는 것입니다. 식별자만 여기 두고 객체는 반대쪽에서 찾으세요.
여기서는 이것이 유일한 방식입니다. value와 onChanged이고, 값은 T입니다. enum이든, id든, 객체 자체든. null은 아무것도 고르지 않은 select입니다.
startIcon
옆의 값보다 1.2배로 그려져 그 크기를 따라갑니다. endIcon은 없습니다. trigger의 끝자리는 chevron의 것입니다.
Accessibility
- Base UI가
role="combobox"trigger와 진짜option행이 붙은listbox팝업을 렌더링하고,aria-expanded와aria-activedescendant를 맞춰 주며, 목록이 열려 있는 동안 focus를 가둡니다. label,description,error는 Base UI의 Field가 trigger에 엮어 주므로htmlFor가 필요 없습니다.- 키보드는 primitive의 것입니다. ↑ ↓ Home End로 이동하고, 글자를 치면 prefix로 건너뛰며, Enter로 고르고 Esc로 닫습니다.
- 행은
:hover가 아니라data-highlighted에서 밝아집니다. 포인터와 방향키가 같은 행을 비춥니다. name을 주면 Base UI가 hidden input을 렌더링해서 값이 네이티브 form 제출에 포함됩니다.- trigger는 보여 줄 수 있는 가장 긴 라벨의 너비로 벌어져 있습니다. 짧은 옵션을 골랐다고 방금 고른 포인터 아래에서 필드가 줄어들지 않습니다. 이 샘플들은
aria-hidden이고 생성 콘텐츠로 그려지므로, 읽히지도 않고 페이지 내 검색에 걸리지도 않습니다. - 팝업은
<body>끝으로 portal되고, positioner에.plass-portal이 붙습니다. reset을 subtree에 한정해 둔 호스트가 같은 reset을 걸 수 있는 자리입니다.
- trigger는 무엇이 골라졌는지와 목록이 열려 있는지를 말하는 버튼으로 읽힙니다. 각 행은 서로 배타적인 묶음의 하나로, 골라졌는지 아닌지와 함께 읽힙니다.
- 키는 trigger에 남고, focus도 그렇습니다. ↑ ↓가 하이라이트를 옮기고, Home과 End가 양 끝으로 가며, Enter가 하이라이트된 행을 고르고 Escape는 고르지 않고 닫습니다. 목록은 trigger의 목록이지 따로 가 있어야 할 두 번째 장소가 아닙니다.
- 하이라이트는 행마다의 hover 상태가 아니라 숫자 하나입니다. 포인터와 방향키가 같은 행을 비추게 하는 것이 그것입니다.
- 고를 수 없는 행도 목록에 남고, 사용할 수 없다고 읽힙니다. 고를 수 없다고 사라지는 옵션은 읽는 사람이 계속 찾게 되는 옵션입니다.
- trigger는 말할 수 있는 가장 긴 라벨의 너비로 벌어져 있습니다. 그 샘플들은 배치되되 그려지지 않고 semantics에서도 제외되므로, 읽히는 것이 늘지 않습니다.
- 목록을 열면 focus가 trigger로 갑니다. 목록의 키가 거기 묶여 있기 때문입니다. 아무 데도 focus가 없는 열린 select는 방향키가 닿을 수 없는 목록입니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
items | options | 선택지 목록에 대해 패키지의 나머지가 쓰는 단어입니다. radio group의 것도 options입니다. |
string | number 값 | 제네릭 T | 여기서는 제출되는 것이 없으니 값이 그 대상 자체일 수 있고, 타입 검사기가 지켜 줍니다. |
value / defaultValue / onValueChange | value / onChanged | Flutter의 컨트롤은 controlled이고, 콜백 이름도 Flutter의 것입니다. |
| 글자를 쳐서 prefix로 건너뛰기 | — | typeahead에는 모든 라벨의 글자가 필요한데, 여기서 라벨은 위젯입니다. 긴 목록에는 추측 대신 위에 놓인 검색 field가 낫습니다. |
| focus가 팝업으로 이동 | focus는 trigger에 남음 | 목록은 trigger의 목록입니다. 시작한 자리에 focus를 두는 것이, 닫을 때 되돌릴 것이 없게 만드는 방법이기도 합니다. |
hidden input, name, required | — | 함께 제출될 네이티브 form이 없습니다. |
id | — | 여기서는 무엇도 id로 다른 것을 가리키지 않습니다. 라벨과 메시지는 컴포넌트의 일부입니다. |
role="combobox", aria-activedescendant | 펼쳐짐이 표시된 버튼과, 배타적 묶음의 행들 | Flutter는 상태를 노드 자체에 적습니다. 가리킬 id가 없습니다. |
className, style, 네이티브 속성 | — | 전달할 클래스 목록도 style 속성도 없습니다. |