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>
);
};