shadimage
tetra-ui
uinative-selectsource: ok · refreshed

Native Select

A native single-selection input built on Expo UI Picker. Always compose with Content; optionally add Trigger and Input for a form-styled field or custom trigger. Content opens a wheel bottom sheet on iOS and ExposedDropdownMenuBox on Android.

npx shadcn add "https://tetra-ui.com/r/native-select.json"
free

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@expo/uireact-native-reanimated

Registry dependencies

@tetra-ui/action-input@tetra-ui/bottom-sheet@tetra-ui/icons@tetra-ui/input@tetra-ui/slot@tetra-ui/slots

Files7 · source cached 10/8/2026, 3:32:53 AM

ui/native-select/index.ts
export * from "./native-select";
ui/native-select/native-select.tsx
import {
  type PickerAppearance,
  type PickerItemValue,
  Picker as PickerPrimitive,
} from "@expo/ui";
import {
  Children,
  createContext,
  isValidElement,
  useCallback,
  useContext,
  useEffect,
  useLayoutEffect,
  useMemo,
  useState,
} from "react";
import {
  type GestureResponderEvent,
  Platform,
  Pressable,
  type PressableProps,
  View,
} from "react-native";
import Animated, {
  Easing,
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated";
import { cn } from "@/lib/utils";
import { ActionInput } from "@/components/ui/action-input";
import {
  BottomSheet,
  BottomSheetBody,
  BottomSheetContent,
  BottomSheetFooter,
  BottomSheetHeader,
  BottomSheetTitle,
} from "@/components/ui/bottom-sheet";
import { ChevronDownIcon } from "@/components/ui/icons";
import {
  InputAddon,
  type InputAddonChild,
  type InputAddonChildren,
  InputAddonIcon,
  useInputAddons,
} from "@/components/ui/input";
import { Slot } from "@/components/ui/slot";
import {
  NativeSelectAndroidHost,
  NativeSelectContentMenu,
  NativeSelectTriggerAnchor,
} from "./native-select-input";
import { NativeSelectPicker } from "./native-select-picker";
import { nativeSelectSlots } from "./native-select-slots";

// Constants
const ANIMATION_DURATION = 280;
const ANIMATION_EASING = Easing.out(Easing.cubic);
const WHEEL_PICKER_HEIGHT = 216;
const DEFAULT_PLACEHOLDER = "Select...";

type NativeSelectVariant = PickerAppearance;

// Types
type NativeSelectItemData<T extends PickerItemValue> = {
  label: string;
  value: T;
};

type NativeSelectContextProps<T extends PickerItemValue> = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  value?: T;
  onValueChange: (value: T) => void;
  selectedValue?: T;
  setSelectedValue: (value: T) => void;
  onConfirm: (value?: T) => void;
  onCancel: () => void;
  disabled?: boolean;
  items: NativeSelectItemData<T>[];
  itemElements: React.ReactElement[];
  setItemElements: (elements: React.ReactElement[]) => void;
  placeholder: string;
  setPlaceholder: (placeholder: string) => void;
  variant: NativeSelectVariant;
  setVariant: (variant: NativeSelectVariant) => void;
  slotsReady: boolean;
  className?: string;
  testID?: string;
};

type NativeSelectProps<T extends PickerItemValue> = {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  value?: T;
  onValueChange?: (value: T) => void;
  disabled?: boolean;
  variant?: NativeSelectVariant;
  className?: string;
  testID?: string;
  children: React.ReactNode;
};

type NativeSelectInputProps = Partial<
  React.ComponentProps<typeof ActionInput>
> & {
  variant?: NativeSelectVariant;
  placeholder?: string;
  children?: React.ReactNode;
};

type NativeSelectTriggerProps = PressableProps & {
  asChild?: boolean;
};

type NativeSelectContentProps = {
  children?: React.ReactNode;
};

type NativeSelectSheetConfirmProps = PressableProps & {
  asChild?: boolean;
};

// Context
const NativeSelectContext =
  createContext<NativeSelectContextProps<PickerItemValue> | null>(null);

const useNativeSelect = () => {
  const context = useContext(NativeSelectContext);
  if (!context) {
    throw new Error("useNativeSelect must be used within a NativeSelect");
  }
  return context;
};

// Helpers
const partitionNativeSelectContent = (children: React.ReactNode) => {
  const itemElements: React.ReactElement[] = [];
  const slotChildren: React.ReactNode[] = [];

  for (const child of Children.toArray(children)) {
    if (isValidElement(child) && child.type === NativeSelectItem) {
      itemElements.push(child);
      continue;
    }

    slotChildren.push(child);
  }

  return { itemElements, slotChildren };
};

const extractNativeSelectItems = <T extends PickerItemValue>(
  children: React.ReactNode
): NativeSelectItemData<T>[] => {
  const items: NativeSelectItemData<T>[] = [];

  for (const child of Children.toArray(children)) {
    if (!isValidElement(child) || child.type !== NativeSelectItem) {
      continue;
    }

    const { label, value } = child.props as NativeSelectItemData<T>;
    items.push({ label, value });
  }

  return items;
};

// Components
export const NativeSelectItem = PickerPrimitive.Item;

/**
 * Native single-selection input built on Expo UI Picker.
 * Always compose with NativeSelectContent. Optionally add Trigger and Input.
 */
export const NativeSelect = <T extends PickerItemValue>(
  props: NativeSelectProps<T>
) => {
  return (
    <nativeSelectSlots.Provider>
      <NativeSelectRoot {...props} />
    </nativeSelectSlots.Provider>
  );
};

const NativeSelectRoot = <T extends PickerItemValue>({
  open: openProp,
  onOpenChange: onOpenChangeProp,
  value: valueProp,
  onValueChange: onValueChangeProp,
  disabled,
  variant: variantProp = "menu",
  className,
  testID,
  children,
}: NativeSelectProps<T>) => {
  const [internalOpen, setInternalOpen] = useState(openProp ?? false);
  const [internalValue, setInternalValue] = useState<T>();
  const [selectedValue, setSelectedValue] = useState<T>();
  const [itemElements, setItemElements] = useState<React.ReactElement[]>([]);
  const [placeholder, setPlaceholder] = useState(DEFAULT_PLACEHOLDER);
  const hasTrigger = nativeSelectSlots.useHasSlot("trigger");
  const hasInput = nativeSelectSlots.useHasSlot("input");
  const hasFormUi = hasTrigger || hasInput;
  const [inputVariant, setVariant] = useState<NativeSelectVariant>();
  const [slotsReady, setSlotsReady] = useState(false);

  const items = useMemo(
    () => extractNativeSelectItems<T>(itemElements),
    [itemElements]
  );

  const isOpenControlled = openProp !== undefined;
  const open = isOpenControlled ? openProp : internalOpen;

  const isValueControlled = valueProp !== undefined;
  const value = isValueControlled ? valueProp : internalValue;

  // Input/trigger Fills register after this render. Derive variant from slot
  // presence so a later root effect cannot overwrite Input's `wheel` with the
  // root default (`menu`). Trigger-only is always wheel.
  const variant: NativeSelectVariant = hasInput
    ? (inputVariant ?? "wheel")
    : hasTrigger
      ? "wheel"
      : variantProp;

  useLayoutEffect(() => {
    setSlotsReady(true);
  }, []);

  useEffect(() => {
    if (value !== undefined) {
      setSelectedValue(value);
    }
  }, [value]);

  const onOpenChange = useCallback(
    (nextOpen: boolean) => {
      if (!isOpenControlled) {
        setInternalOpen(nextOpen);
      }
      onOpenChangeProp?.(nextOpen);
    },
    [isOpenControlled, onOpenChangeProp]
  );

  const onValueChange = useCallback(
    (nextValue: T) => {
      if (!isValueControlled) {
        setInternalValue(nextValue);
      }
      onValueChangeProp?.(nextValue);
    },
    [isValueControlled, onValueChangeProp]
  );

  const onConfirm = useCallback(
    (nextValue?: T) => {
      const finalValue = nextValue ?? selectedValue;

      if (typeof finalValue !== "undefined") {
        onValueChange(finalValue);
      }

      onOpenChange(false);
    },
    [onOpenChange, onValueChange, selectedValue]
  );

  const onCancel = useCallback(() => {
    if (value !== undefined) {
      setSelectedValue(value);
    }
    onOpenChange(false);
  }, [onOpenChange, value]);

  const ctx = useMemo(
    () => ({
      className,
      disabled,
      itemElements,
      items,
      onCancel,
      onConfirm,
      onOpenChange,
      onValueChange,
      open,
      placeholder,
      selectedValue,
      setItemElements,
      setPlaceholder,
      setSelectedValue,
      setVariant,
      slotsReady,
      testID,
      value,
      variant,
    }),
    [
      className,
      disabled,
      itemElements,
      items,
      onCancel,
      onConfirm,
      onOpenChange,
      onValueChange,
      open,
      placeholder,
      selectedValue,
      slotsReady,
      testID,
      value,
      variant,
    ]
  );

  return (
    <NativeSelectContext.Provider
      value={ctx as NativeSelectContextProps<T | PickerItemValue>}
    >
      {Platform.OS === "android" && hasFormUi ? (
        <NativeSelectAndroidHost
          disabled={disabled}
          onOpenChange={onOpenChange}
          open={open}
        >
          {children}
        </NativeSelectAndroidHost>
      ) : (
        children
      )}
    </NativeSelectContext.Provider>
  );
};

export const NativeSelectTrigger = ({
  asChild,
  onPress: onPressProp,
  ...props
}: NativeSelectTriggerProps) => {
  const {
    disabled,
    open,
    onOpenChange,
    value,
    items,
    selectedValue,
    setSelectedValue,
  } = useNativeSelect();

  const handlePress = useCallback(
    (event: GestureResponderEvent) => {
      onPressProp?.(event);

      if (disabled) {
        return;
      }

      const committedValue = value ?? items.at(0)?.value;
      const seedValue = selectedValue ?? committedValue;
      if (seedValue !== undefined) {
        setSelectedValue(seedValue);
      }

      if (Platform.OS === "android") {
        onOpenChange(!open);
        return;
      }

      onOpenChange(true);
    },
    [
      disabled,
      items,
      onOpenChange,
      onPressProp,
      open,
      selectedValue,
      setSelectedValue,
      value,
    ]
  );

  const Comp = asChild ? Slot.Pressable : Pressable;

  return (
    <nativeSelectSlots.Fill name="trigger" passthrough>
      <NativeSelectTriggerAnchor disabled={disabled}>
        <Comp {...props} disabled={disabled} onPress={handlePress} />
      </NativeSelectTriggerAnchor>
    </nativeSelectSlots.Fill>
  );
};

NativeSelectTrigger.displayName = "NativeSelectTrigger";

/**
 * Form-styled native select input.
 * - Default / wheel: display-only ActionInput (open via NativeSelectTrigger)
 * - iOS `menu`: non-pressable input shell; only the native menu picker is interactive
 */
export const NativeSelectInput = (props: NativeSelectInputProps) => {
  return (
    <nativeSelectSlots.Fill name="input" passthrough>
      <NativeSelectInputView {...props} />
    </nativeSelectSlots.Fill>
  );
};

NativeSelectInput.displayName = "NativeSelectInput";

const NativeSelectInputView = ({
  variant = "wheel",
  placeholder = DEFAULT_PLACEHOLDER,
  className,
  testID,
  children,
  ...props
}: NativeSelectInputProps) => {
  const {
    open,
    value,
    onValueChange,
    items,
    itemElements,
    disabled,
    setPlaceholder,
    setVariant,
  } = useNativeSelect();

  const addonElements = useMemo(() => {
    const addons: InputAddonChild[] = [];
    for (const child of Children.toArray(children)) {
      if (isValidElement(child) && child.type === InputAddon) {
        addons.push(child as InputAddonChild);
      }
    }
    return addons;
  }, [children]);

  const { startAddons, endAddons, groupClassName } = useInputAddons(
    addonElements as InputAddonChildren
  );

  useLayoutEffect(() => {
    setPlaceholder(placeholder);
  }, [placeholder, setPlaceholder]);

  useLayoutEffect(() => {
    setVariant(variant);
  }, [variant, setVariant]);

  const committedValue = value ?? items.at(0)?.value;

  const valueLabel = useMemo(() => {
    if (committedValue === undefined) {
      return;
    }
    return items.find((item) => item.value === committedValue)?.label;
  }, [items, committedValue]);

  const openSharedValue = useSharedValue(open ? 1 : 0);

  useEffect(() => {
    openSharedValue.value = withTiming(open ? 1 : 0, {
      duration: ANIMATION_DURATION,
      easing: ANIMATION_EASING,
    });
  }, [open, openSharedValue]);

  const animatedStyle = useAnimatedStyle(() => {
    const rotate = interpolate(openSharedValue.value, [0, 1], [0, 180]);
    return {
      transform: [{ rotate: `${rotate}deg` }],
    };
  });

  if (Platform.OS === "ios" && variant === "menu") {
    if (committedValue === undefined) {
      return null;
    }

    return (
      <View
        className={cn(
          "flex min-h-12 w-full flex-row items-center gap-2 rounded-lg border border-input bg-background py-2 pr-0 pl-3",
          disabled && "opacity-50",
          groupClassName,
          className
        )}
        pointerEvents="box-none"
      >
        {startAddons}

        <View className="min-w-0 grow" />

        <InputAddon align="inline-end">
          <NativeSelectPicker
            appearance="menu"
            enabled={!disabled}
            onValueChange={onValueChange}
            selectedValue={committedValue}
            testID={testID}
          >
            {itemElements}
          </NativeSelectPicker>
        </InputAddon>

        {endAddons}
      </View>
    );
  }

  const sheetInputAddons = [
    ...startAddons,
    <InputAddon align="inline-end" key="native-select-chevron">
      <Animated.View pointerEvents="box-none" style={animatedStyle}>
        <InputAddonIcon>
          <ChevronDownIcon />
        </InputAddonIcon>
      </Animated.View>
    </InputAddon>,
    ...endAddons,
  ] as InputAddonChildren;

  return (
    <ActionInput
      {...props}
      className={cn(groupClassName, className)}
      disabled={disabled}
      focused={open}
      placeholder={placeholder}
      testID={testID}
      value={valueLabel}
    >
      {sheetInputAddons}
    </ActionInput>
  );
};

/**
 * Presentation surface for the native select. Always required.
 * - Content-only: inline Expo Picker (variant from root)
 * - iOS wheel + form UI: bottom sheet with wheel picker
 * - iOS menu + Input: registers items only (picker lives in NativeSelectInput)
 * - Android + form UI: ExposedDropdownMenu items
 */
export const NativeSelectContent = ({ children }: NativeSelectContentProps) => {
  const {
    open,
    onOpenChange,
    value,
    onValueChange,
    selectedValue,
    setSelectedValue,
    onCancel,
    disabled,
    variant,
    placeholder,
    setItemElements,
    slotsReady,
    className,
    testID,
  } = useNativeSelect();

  const hasTrigger = nativeSelectSlots.useHasSlot("trigger");
  const hasInput = nativeSelectSlots.useHasSlot("input");
  const hasFooter = nativeSelectSlots.useHasSlot("footer");
  const hasFormUi = hasTrigger || hasInput;

  const { itemElements, slotChildren } = useMemo(
    () => partitionNativeSelectContent(children),
    [children]
  );
  const items = useMemo(
    () => extractNativeSelectItems(itemElements),
    [itemElements]
  );

  useLayoutEffect(() => {
    setItemElements(itemElements);
  }, [itemElements, setItemElements]);

  const requiresConfirm =
    Platform.OS === "ios" && variant === "wheel" && hasFooter;
  const committedValue = value ?? items.at(0)?.value;
  const draftValue = selectedValue ?? committedValue;
  const pickerValue = requiresConfirm ? draftValue : committedValue;

  const handlePickerValueChange = useCallback(
    (nextValue: PickerItemValue) => {
      if (requiresConfirm) {
        setSelectedValue(nextValue);
        return;
      }
      onValueChange(nextValue);
    },
    [onValueChange, requiresConfirm, setSelectedValue]
  );

  if (!slotsReady) {
    return slotChildren;
  }

  if (!hasFormUi) {
    if (committedValue === undefined) {
      return slotChildren;
    }

    return (
      <>
        {slotChildren}
        <NativeSelectPicker
          appearance={variant}
          className={className}
          enabled={!disabled}
          onValueChange={onValueChange}
          selectedValue={committedValue}
          testID={testID}
        >
          {itemElements}
        </NativeSelectPicker>
      </>
    );
  }

  // Menu picker is embedded in Input; Content only registers items.
  if (Platform.OS === "ios" && variant === "menu" && hasInput) {
    return slotChildren;
  }

  if (Platform.OS === "android") {
    return (
      <>
        {slotChildren}
        <NativeSelectContentMenu
          disabled={disabled}
          items={items}
          onOpenChange={onOpenChange}
          onValueChange={onValueChange}
          open={open}
          selectedValue={committedValue}
        />
      </>
    );
  }

  if (pickerValue === undefined) {
    return slotChildren;
  }

  return (
    <>
      {slotChildren}
      <BottomSheet
        onOpenChange={requiresConfirm ? onCancel : onOpenChange}
        open={open}
      >
        <BottomSheetContent>
          <BottomSheetHeader>
            <BottomSheetTitle>{placeholder}</BottomSheetTitle>
          </BottomSheetHeader>
          <BottomSheetBody className={hasFooter ? undefined : "pb-4"}>
            <NativeSelectPicker
              appearance="wheel"
              enabled={!disabled}
              matchContents={false}
              onValueChange={handlePickerValueChange}
              selectedValue={pickerValue}
              style={{ height: WHEEL_PICKER_HEIGHT, width: "100%" }}
              testID={testID}
            >
              {itemElements}
            </NativeSelectPicker>
          </BottomSheetBody>
          <nativeSelectSlots.Outlet name="footer" />
        </BottomSheetContent>
      </BottomSheet>
    </>
  );
};

NativeSelectContent.displayName = "NativeSelectContent";

export const NativeSelectSheetFooter = (
  props: React.ComponentProps<typeof BottomSheetFooter>
) => {
  return (
    <nativeSelectSlots.Fill name="footer">
      <BottomSheetFooter {...props} />
    </nativeSelectSlots.Fill>
  );
};

NativeSelectSheetFooter.displayName = "NativeSelectSheetFooter";

export const NativeSelectSheetConfirm = ({
  asChild,
  onPress: onPressProp,
  ...props
}: NativeSelectSheetConfirmProps) => {
  const { onConfirm } = useNativeSelect();

  const onPress = useCallback(
    (event: GestureResponderEvent) => {
      onPressProp?.(event);
      onConfirm();
    },
    [onConfirm, onPressProp]
  );

  const Comp = asChild ? Slot.Pressable : Pressable;

  return <Comp {...props} onPress={onPress} />;
};
ui/native-select/native-select-slots.ts
import { createSlots } from "@/lib/slots";

export const nativeSelectSlots = createSlots<"footer" | "input" | "trigger">({
  errorMessage: "NativeSelect parts must be rendered inside NativeSelect",
});
ui/native-select/native-select-input.tsx
import type { PickerItemValue } from "@expo/ui";

type NativeSelectAndroidHostProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  disabled?: boolean;
  children: React.ReactNode;
};

type NativeSelectTriggerAnchorProps = {
  disabled?: boolean;
  children: React.ReactElement;
};

type NativeSelectContentMenuProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  disabled?: boolean;
  selectedValue?: PickerItemValue;
  onValueChange: (value: PickerItemValue) => void;
  items: { label: string; value: PickerItemValue }[];
};

/** Default stub — Metro resolves the `.android` file on Android. */
export const NativeSelectAndroidHost = ({
  children,
}: NativeSelectAndroidHostProps) => {
  return <>{children}</>;
};

/** Default stub — Metro resolves the `.android` file on Android. */
export const NativeSelectTriggerAnchor = ({
  children,
}: NativeSelectTriggerAnchorProps) => {
  return <>{children}</>;
};

/** Default stub — Metro resolves the `.android` file on Android. */
export const NativeSelectContentMenu = (
  _props: NativeSelectContentMenuProps
) => {
  return null;
};
ui/native-select/native-select-input.android.tsx
import type { PickerItemValue } from "@expo/ui";
import {
  DropdownMenuItem,
  ExposedDropdownMenu,
  ExposedDropdownMenuBox,
  Host,
  RNHostView,
  Text as TextPrimitive,
} from "@expo/ui/jetpack-compose";
import { menuAnchor } from "@expo/ui/jetpack-compose/modifiers";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";

// Must match InputGroup's focused/invalid outlineWidth. Outline paints
// outside layout; RNHostView/AndroidView clips to the hosted child's bounds.
const INPUT_OUTLINE_MAX_WIDTH = 2;

type NativeSelectAndroidHostProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  disabled?: boolean;
  children: React.ReactNode;
};

/**
 * Wraps Trigger + Content so ExposedDropdownMenuBox can own both the anchor and menu.
 *
 * @see https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/exposeddropdownmenubox/
 */
export const NativeSelectAndroidHost = ({
  open,
  onOpenChange,
  disabled,
  children,
}: NativeSelectAndroidHostProps) => {
  return (
    <Host
      matchContents
      style={{
        alignSelf: "stretch",
        margin: -INPUT_OUTLINE_MAX_WIDTH,
        width: "100%",
      }}
    >
      <ExposedDropdownMenuBox
        expanded={open}
        onExpandedChange={disabled ? undefined : onOpenChange}
      >
        {children}
      </ExposedDropdownMenuBox>
    </Host>
  );
};

type NativeSelectTriggerAnchorProps = {
  disabled?: boolean;
  children: React.ReactElement;
};

/** Anchors the Android ExposedDropdownMenu to the Trigger child. */
export const NativeSelectTriggerAnchor = ({
  disabled,
  children,
}: NativeSelectTriggerAnchorProps) => {
  return (
    <RNHostView
      matchContents
      modifiers={[menuAnchor("primaryNotEditable", !disabled)]}
    >
      <View
        style={{
          alignSelf: "stretch",
          padding: INPUT_OUTLINE_MAX_WIDTH,
          width: "100%",
        }}
      >
        {children}
      </View>
    </RNHostView>
  );
};

type NativeSelectContentMenuProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  disabled?: boolean;
  selectedValue?: PickerItemValue;
  onValueChange: (value: PickerItemValue) => void;
  items: { label: string; value: PickerItemValue }[];
};

/** Renders the Android ExposedDropdownMenu item list. */
export const NativeSelectContentMenu = ({
  open,
  onOpenChange,
  disabled,
  selectedValue,
  onValueChange,
  items,
}: NativeSelectContentMenuProps) => {
  const foregroundColor = useCSSVariable("--color-foreground") as string;
  const popoverColor = useCSSVariable("--color-popover") as string;

  return (
    <ExposedDropdownMenu
      containerColor={popoverColor}
      expanded={open}
      onDismissRequest={() => onOpenChange(false)}
    >
      {items.map((item) => {
        const isSelected = item.value === selectedValue;

        return (
          <DropdownMenuItem
            enabled={!disabled}
            key={String(item.value)}
            onClick={
              disabled
                ? undefined
                : () => {
                    onValueChange(item.value);
                    onOpenChange(false);
                  }
            }
          >
            <DropdownMenuItem.Text>
              <TextPrimitive color={foregroundColor}>
                {item.label}
              </TextPrimitive>
            </DropdownMenuItem.Text>
            {isSelected ? (
              <DropdownMenuItem.TrailingIcon>
                <TextPrimitive color={foregroundColor}>✓</TextPrimitive>
              </DropdownMenuItem.TrailingIcon>
            ) : null}
          </DropdownMenuItem>
        );
      })}
    </ExposedDropdownMenu>
  );
};
ui/native-select/native-select-picker.tsx
import {
  Host as HostPrimitive,
  type PickerAppearance,
  type PickerItemValue,
  Picker as PickerPrimitive,
} from "@expo/ui";
import { withUniwind } from "uniwind";
import { cn } from "@/lib/utils";

const StyledHost = withUniwind(HostPrimitive);

export type NativeSelectPickerProps<T extends PickerItemValue> = {
  appearance?: PickerAppearance;
  selectedValue: T;
  onValueChange: (value: T) => void;
  enabled?: boolean;
  className?: string;
  style?: React.ComponentProps<typeof HostPrimitive>["style"];
  testID?: string;
  matchContents?: boolean;
  children: React.ReactNode;
};

export const NativeSelectPicker = <T extends PickerItemValue>({
  appearance = "menu",
  selectedValue,
  onValueChange,
  enabled = true,
  className,
  style,
  testID,
  matchContents = true,
  children,
}: NativeSelectPickerProps<T>) => {
  return (
    <StyledHost
      className={cn(className)}
      matchContents={matchContents}
      style={style}
    >
      <PickerPrimitive
        appearance={appearance}
        enabled={enabled}
        onValueChange={onValueChange}
        selectedValue={selectedValue}
        testID={testID}
      >
        {children}
      </PickerPrimitive>
    </StyledHost>
  );
};
ui/native-select/native-select-picker.ios.tsx
import {
  Host as HostPrimitive,
  type PickerAppearance,
  type PickerItemValue,
} from "@expo/ui";
import { Picker as SwiftUIPicker, Text } from "@expo/ui/swift-ui";
import {
  disabled as disabledModifier,
  type ModifierConfig,
  pickerStyle,
  tag,
  tint,
} from "@expo/ui/swift-ui/modifiers";
import { Children, isValidElement } from "react";
import { useCSSVariable, withUniwind } from "uniwind";
import { cn } from "@/lib/utils";

const StyledHost = withUniwind(HostPrimitive);

export type NativeSelectPickerProps<T extends PickerItemValue> = {
  appearance?: PickerAppearance;
  selectedValue: T;
  onValueChange: (value: T) => void;
  enabled?: boolean;
  className?: string;
  style?: React.ComponentProps<typeof HostPrimitive>["style"];
  testID?: string;
  matchContents?: boolean;
  children: React.ReactNode;
};

type PickerItemProps<T extends PickerItemValue> = {
  label: string;
  value: T;
};

const extractItems = <T extends PickerItemValue>(children: React.ReactNode) => {
  const items: PickerItemProps<T>[] = [];

  for (const child of Children.toArray(children)) {
    if (!isValidElement(child)) {
      continue;
    }

    const { label, value } = child.props as PickerItemProps<T>;
    if (typeof label !== "string" || value === undefined) {
      continue;
    }

    items.push({ label, value });
  }

  return items;
};

export const NativeSelectPicker = <T extends PickerItemValue>({
  appearance = "menu",
  selectedValue,
  onValueChange,
  enabled = true,
  className,
  style,
  testID,
  matchContents = true,
  children,
}: NativeSelectPickerProps<T>) => {
  const primaryColor = useCSSVariable("--color-primary") as string;
  const items = extractItems<T>(children);

  const modifiers: ModifierConfig[] = [
    pickerStyle(appearance === "wheel" ? "wheel" : "menu"),
    tint(primaryColor),
  ];

  if (!enabled) {
    modifiers.push(disabledModifier(true));
  }

  return (
    <StyledHost
      className={cn(className)}
      matchContents={matchContents}
      style={style}
    >
      <SwiftUIPicker
        modifiers={modifiers}
        onSelectionChange={(value) => onValueChange(value as T)}
        selection={selectedValue}
        testID={testID}
      >
        {items.map((item) => (
          <Text key={String(item.value)} modifiers={[tag(item.value)]}>
            {item.label}
          </Text>
        ))}
      </SwiftUIPicker>
    </StyledHost>
  );
};

More from tetra-ui

Browse all components in this library →