Skip to content

Select Primitive

Primitive Select는 Popup과 FocusManager를 select 전용 value context와 조합합니다.

Usage

tsx
import {
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
  useSelect,
} from '@suis-ui/primitives';

권장 primitive 구조는 trigger, value, content, item을 compound component로 조합하는 형태입니다.

text
Select
├── SelectTrigger
│   └── SelectValue
└── SelectContent
    └── SelectItem
tsx
import { createSignal } from 'solid-js';
import { Select } from '@suis-ui/primitives';

const [value, setValue] = createSignal<string | null>(null);

<Select value={value()} onChangeValue={setValue} placement="bottom-start">
  <Select.Trigger>
    <Select.Value>
      {(value) => value ?? 'Choose an option'}
    </Select.Value>
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="small">Small</Select.Item>
    <Select.Item value="large">Large</Select.Item>
  </Select.Content>
</Select>

Props

Value

이름타입기본값간단한 설명
valuestring | nullnull현재 선택된 값입니다. required가 literal true이면 null을 허용하지 않습니다.
onChangeValue(value: string | null) => void-Context value가 바뀔 때 호출됩니다. required가 literal true이면 callback 타입에서 null이 제거됩니다.
requiredbooleanfalse선택된 item을 다시 눌러 해제하지 못하게 하고 trigger/listbox를 required로 표시합니다.
childrenJSX.Element필수Select composition입니다.

required를 literal true로 전달하면 root valueonChangeValue 타입에서 null이 제거됩니다. required={someBoolean}처럼 동적 boolean을 전달하면 안전하게 nullable 타입을 유지합니다. Select.ValueuseSelect context의 value는 uncontrolled 초기 상태를 표현할 수 있어 계속 string | null입니다.

Select는 Popup 위에 구성되므로 Popup의 state와 positioning props를 함께 받습니다.

이름타입기본값간단한 설명
openbooleanfalsePopup open request value입니다.
placementPlacementFloating UI default선호 placement입니다.
strategyStrategyFloating UI defaultCSS positioning strategy입니다.
offsetOffsetOptions-Offset middleware를 활성화합니다.
shiftShiftOptions | boolean-Shift middleware를 활성화합니다.
flipFlipOptions | boolean-Flip middleware를 활성화합니다.
autoUpdateAutoUpdateOptions | booleantrueLayout이 바뀔 때 position을 다시 계산합니다.
middlewareMiddleware[]-추가 Floating UI middleware입니다.

Component

Select.Trigger

기본값이 button인 polymorphic trigger를 렌더링하고 role="combobox"를 설정합니다. Popup trigger 동작을 사용하며, root Selectrequired이면 aria-required="true"를 설정합니다.

이름타입기본값간단한 설명
asTbuttonTrigger로 렌더링할 element 또는 component입니다.
childrenJSX.Element-Trigger 안에 렌더링할 내용입니다.
선택한 element propsOmit<ComponentProps<T>, 'children'>-Trigger element로 전달되는 props입니다.

Select.Value

Render function을 통해 현재 value를 받습니다.

이름타입기본값간단한 설명
children(value: string | null) => JSX.Element필수현재 value를 받아 trigger 안에 표시할 내용을 반환합니다.
tsx
<Select.Value>
  {(value) => value ?? 'Placeholder'}
</Select.Value>

Select.Content

Listbox를 popup portal에 렌더링합니다. 기본값은 ul이고, role="listbox"를 설정하며 popup이 열려 있는 동안 focus 동작을 설치합니다. Root Selectrequired이면 aria-required="true"를 설정합니다.

이름타입기본값간단한 설명
asTulContent로 렌더링할 element 또는 component입니다.
childrenJSX.Element-Option item을 배치합니다.
선택한 element propsOmit<ComponentProps<T>, 'children' | 'style'>-Content element로 전달되는 props입니다. 계산된 popup style과 함께 merge됩니다.

Select.Item

기본값이 li인 option을 렌더링합니다.

이름타입기본값간단한 설명
valuestring필수Click 시 Select context에 쓰이는 값입니다. 현재 값을 다시 선택하면 required가 true가 아닌 한 해제됩니다.
asTliItem으로 렌더링할 element 또는 component입니다.
childrenJSX.Element-Item label이나 custom content입니다.
선택한 element propsOmit<ComponentProps<T>, 'value' | 'children'>-Item element로 전달되는 props입니다.

각 item은 role="option", data-value, aria-selected, tabindex={-1}를 받습니다. 기본 item은 click 후 popup을 닫습니다.

Hooks

useSelect

useSelect는 Select provider 아래에서 선택 value와 popup 상태를 함께 읽거나, custom item에서 value/open state를 직접 갱신할 때 사용합니다.

Signature

ts
const [context, actions] = useSelect();
ts
const [context, actions]: readonly [
  {
    value: string | null;
    required: boolean;
    anchor: Element | null;
    element: HTMLElement | null;
    position: ComputePositionReturn | null;
    open: boolean;
    mount: boolean;
  },
  {
    setValue: (value: string | null) => void;
    requestOpen: (open: boolean) => void;
  },
] = useSelect();

Select provider 밖에서 호출하면 context를 찾을 수 없어 error가 발생합니다.

Context

context는 Select value context와 Popup context를 merge한 값입니다.

이름타입설명
context.valuestring | null현재 선택된 value입니다. value prop이 바뀌거나 setValue가 호출되면 갱신됩니다.
context.requiredboolean선택된 item을 다시 클릭했을 때 선택을 유지해야 하는지 나타냅니다.
context.anchorElement | nullSelect trigger가 등록한 popup anchor입니다.
context.elementHTMLElement | nullSelect.Content가 portal에 렌더링한 listbox element입니다.
context.positionComputePositionReturn | nullFloating UI가 계산한 popup position입니다.
context.openboolean가장 최근에 요청된 popup open state입니다.
context.mountbooleanContent가 실제로 portal에 렌더링되는지 나타냅니다.

Custom Select 구현에서는 위 표의 value와 popup 상태만 사용하세요.

Actions

이름타입설명
actions.setValue(value: string | null) => voidSelect value를 갱신합니다. Root SelectonChangeValue는 value 변경 effect를 통해 호출됩니다.
actions.requestOpen(open: boolean) => voidSelect popup의 open state를 요청합니다.

Behavior

setValue는 value만 바꾸며 popup을 자동으로 닫지 않습니다. 선택과 동시에 content를 닫아야 하면 setValue(value) 다음에 requestOpen(false)를 함께 호출하세요. 이 action은 low-level setter라서 required를 직접 강제하지 않습니다. Custom item에서 선택 해제를 제공한다면 context.required를 확인해 built-in Select.Item과 같은 정책을 적용하세요.

Root Selectvalue prop을 전달하면 effect가 context value를 prop 값으로 동기화합니다. Context value가 바뀌면 onChangeValue가 호출되므로 controlled usage에서는 외부 signal을 함께 갱신해야 합니다.

requestOpen은 Popup action을 그대로 사용합니다. 따라서 Select의 popup positioning, open, mount, async controller 동작은 Popup과 동일합니다.

Example

tsx
const CustomItem = (props: { value: string; children: JSX.Element }) => {
  const [, { setValue, requestOpen }] = useSelect();

  return (
    <button
      type="button"
      onClick={() => {
        setValue(props.value);
        requestOpen(false);
      }}
    >
      {props.children}
    </button>
  );
};

Selection Behavior

Built-in Select.Item은 click 후 항상 popup을 닫습니다. 선택 값 변경은 현재 값과 required에 따라 달라집니다.

상황결과
다른 item을 클릭해당 item value로 갱신하고 popup을 닫습니다.
선택된 item을 다시 클릭, required가 falsevalue를 null로 바꾸고 popup을 닫습니다.
선택된 item을 다시 클릭, required가 truevalue를 유지하고 popup만 닫습니다.

Root Selectrequired일 때 context value가 null로 바뀌더라도 onChangeValue에는 null을 전달하지 않습니다.

Examples

Basic Select

tsx
<Select placement="bottom-start">
  <Select.Trigger>
    <Select.Value>
      {(value) => value ?? 'Choose size'}
    </Select.Value>
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="small">Small</Select.Item>
    <Select.Item value="medium">Medium</Select.Item>
    <Select.Item value="large">Large</Select.Item>
  </Select.Content>
</Select>

Controlled Value

tsx
import { createSignal } from 'solid-js';

const [value, setValue] = createSignal<string | null>('medium');

<Select value={value()} onChangeValue={setValue}>
  <Select.Trigger>
    <Select.Value>
      {(value) => value ?? 'Choose size'}
    </Select.Value>
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="small">Small</Select.Item>
    <Select.Item value="medium">Medium</Select.Item>
    <Select.Item value="large">Large</Select.Item>
  </Select.Content>
</Select>

Required Select

tsx
import { createSignal } from 'solid-js';

const [value, setValue] = createSignal('medium');

<Select required value={value()} onChangeValue={setValue}>
  <Select.Trigger>
    <Select.Value>
      {(value) => value ?? 'Choose size'}
    </Select.Value>
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="small">Small</Select.Item>
    <Select.Item value="medium">Medium</Select.Item>
    <Select.Item value="large">Large</Select.Item>
  </Select.Content>
</Select>

Polymorphic Parts

tsx
<Select offset={6} shift flip>
  <Select.Trigger as="button" type="button">
    <Select.Value>
      {(value) => value ?? 'Choose status'}
    </Select.Value>
  </Select.Trigger>
  <Select.Content as="div">
    <Select.Item as="button" type="button" value="open">
      Open
    </Select.Item>
    <Select.Item as="button" type="button" value="closed">
      Closed
    </Select.Item>
  </Select.Content>
</Select>