Select

PreviousNext

A select component for React Native applications.

Docs
nativeuiui

Preview

Loading preview…
registry/select/select.tsx
import * as React from "react";
import { View, Text, Pressable, ScrollView, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { cn } from "@/lib/utils";
import { Drawer, useDrawer } from "@/components/ui/drawer";

interface SelectProps {
  value?: string;
  onValueChange?: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  className?: string;
  triggerClassName?: string;
  contentClassName?: string;
  size?: "small" | "medium" | "large" | "full" | number[];
  initialSnapIndex?: number;
  avoidKeyboard?: boolean;
  children: React.ReactNode;
}

interface SelectItemProps {
  value: string;
  children: React.ReactNode;
  disabled?: boolean;
  className?: string;
  selectedValue?: string;
}

interface SelectLabelProps {
  children: React.ReactNode;
  className?: string;
}

interface SelectGroupProps {
  children: React.ReactNode;
  className?: string;
}

interface SelectSeparatorProps {
  className?: string;
}

interface SelectContextValue {
  selectedValue?: string;
  onSelect: (value: string, label: React.ReactNode) => void;
}

const SelectContext = React.createContext<SelectContextValue>({
  selectedValue: undefined,
  onSelect: () => { },
});

const Select = React.forwardRef<View, SelectProps>(
  (
    {
      value,
      onValueChange,
      placeholder,
      disabled = false,
      className,
      triggerClassName,
      contentClassName,
      size = "medium",
      initialSnapIndex = 0,
      avoidKeyboard = true,
      children,
    },
    ref
  ) => {
    const [open, setOpen] = React.useState(false);
    const [selectedValue, setSelectedValue] = React.useState(value);
    const [selectedLabel, setSelectedLabel] =
      React.useState<React.ReactNode>("");

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

      let found = false;

      const findLabel = (child: React.ReactNode) => {
        if (!React.isValidElement(child)) return;

        const childElement = child as React.ReactElement<any>;

        if (
          childElement.type === SelectItem &&
          childElement.props.value === selectedValue
        ) {
          setSelectedLabel(childElement.props.children);
          found = true;
          return;
        }

        if (childElement.type === SelectGroup) {
          React.Children.forEach(childElement.props.children, findLabel);
        }
      };

      React.Children.forEach(children, findLabel);

      if (!found) {
        setSelectedLabel("");
      }
    }, [selectedValue, children]);

    const handleSelect = React.useCallback(
      (value: string, label: React.ReactNode) => {
        setSelectedValue(value);
        setSelectedLabel(label);

        if (onValueChange) {
          onValueChange(value);
        }

        setOpen(false);
      },
      [onValueChange]
    );

    const contextValue = React.useMemo(
      () => ({
        selectedValue,
        onSelect: handleSelect,
      }),
      [selectedValue, handleSelect]
    );

    return (
      <View ref={ref} className={cn("w-full", className)}>
        <Pressable
          disabled={disabled}
          onPress={() => setOpen(true)}
          className={cn(
            "flex-row h-12 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2",
            "shadow-sm",
            "active:opacity-70",
            disabled && "opacity-50",
            Platform.OS === "ios"
              ? "ios:shadow-sm ios:shadow-foreground/10"
              : "android:elevation-1",
            triggerClassName
          )}
        >
          <Text
            className={cn(
              "text-base flex-1",
              !selectedValue && "text-muted-foreground",
              "text-foreground"
            )}
            numberOfLines={1}
          >
            {selectedValue && selectedLabel
              ? selectedLabel
              : placeholder || "Select an option"}
          </Text>

          <Ionicons
            name="chevron-down"
            size={16}
            color="#9CA3AF"
            style={{ marginLeft: 8, opacity: 0.7 }}
          />
        </Pressable>

        <SelectContext.Provider value={contextValue}>
          <Drawer
            open={open}
            onClose={() => setOpen(false)}
            title={placeholder || "Select an option"}
            size={size}
            initialSnapIndex={initialSnapIndex}
            contentClassName={contentClassName}
            avoidKeyboard={avoidKeyboard}
            closeOnBackdropPress={true}
          >
            <ScrollView
              className="px-1 pt-2 pb-6"
              keyboardShouldPersistTaps="handled"
              nestedScrollEnabled={true}
            >
              {children}
            </ScrollView>
          </Drawer>
        </SelectContext.Provider>
      </View>
    );
  }
);

Select.displayName = "Select";

const SelectGroup = React.forwardRef<View, SelectGroupProps>(
  ({ className, children, ...props }, ref) => {
    return (
      <View ref={ref} className={cn("", className)} {...props}>
        {children}
      </View>
    );
  }
);

SelectGroup.displayName = "SelectGroup";

const SelectItem = React.forwardRef<typeof Pressable, SelectItemProps>(
  ({ className, children, value, disabled, ...props }, ref) => {
    const { selectedValue, onSelect } = React.useContext(SelectContext);
    const isSelected = selectedValue === value;
    const drawer = useDrawer();

    const handlePress = React.useCallback(() => {
      if (disabled) return;

      onSelect(value, children);

      setTimeout(() => {
        if (drawer && typeof drawer.close === "function") {
          drawer.close();
        }
      }, 50);
    }, [onSelect, value, children, disabled, drawer]);

    return (
      <Pressable
        ref={ref as any}
        disabled={disabled}
        onPress={handlePress}
        className={cn(
          "flex-row h-14 items-center justify-between px-4 py-2 active:bg-accent/50",
          isSelected ? "bg-accent" : "",
          disabled && "opacity-50",
          className
        )}
        {...props}
      >
        <Text
          className={cn(
            "text-base",
            isSelected
              ? "text-accent-foreground font-medium"
              : "text-foreground"
          )}
        >
          {children}
        </Text>

        {isSelected && <Ionicons name="checkmark" size={20} color="#4F46E5" />}
      </Pressable>
    );
  }
);

SelectItem.displayName = "SelectItem";

const SelectLabel = React.forwardRef<Text, SelectLabelProps>(
  ({ className, children, ...props }, ref) => {
    return (
      <Text
        ref={ref}
        className={cn(
          "px-3 py-2 text-sm font-semibold text-foreground",
          className
        )}
        {...props}
      >
        {children}
      </Text>
    );
  }
);

SelectLabel.displayName = "SelectLabel";

const SelectSeparator = React.forwardRef<View, SelectSeparatorProps>(
  ({ className, ...props }, ref) => {
    return (
      <View
        ref={ref}
        className={cn("h-px bg-muted mx-2 my-1", className)}
        {...props}
      />
    );
  }
);

SelectSeparator.displayName = "SelectSeparator";

export { Select, SelectGroup, SelectItem, SelectLabel, SelectSeparator };

Installation

npx shadcn@latest add @nativeui/select

Usage

import { Select } from "@/components/ui/select"
<Select />