mrzachnugent / react-native-reusables

Universal shadcn/ui for React Native featuring a focused collection of components - Crafted with NativeWind v4 and accessibility in mind.
https://rnr-docs.vercel.app
MIT License
3.04k stars 128 forks source link

I'm trying to use the combobox component but it's not working properly #208

Closed briskteq-faiz closed 4 days ago

briskteq-faiz commented 1 month ago

Combobox.tsx

import * as React from "react";
import {Text, View, type ListRenderItemInfo} from "react-native";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import {cn} from "../../lib/utils";
import {Check, ChevronsUpDown, Search} from "../Icons";
import {
    BottomSheet,
    BottomSheetContent,
    BottomSheetFlatList,
    BottomSheetHeader,
    BottomSheetOpenTrigger,
    BottomSheetTextInput,
    useBottomSheet,
} from "./bottom-sheet";
import {Button, buttonTextVariants, buttonVariants} from "./button";

const HEADER_HEIGHT = 130;

interface ComboboxOption {
  label?: string;
  value?: string;
}

const Combobox = React.forwardRef<
  React.ElementRef<typeof Button>,
  Omit<React.ComponentPropsWithoutRef<typeof Button>, "children"> & {
    items: ComboboxOption[];
    placeholder?: string;
    inputProps?: React.ComponentPropsWithoutRef<typeof BottomSheetTextInput>;
    emptyText?: string;
    defaultSelectedItem?: ComboboxOption | null;
    selectedItem?: ComboboxOption | null;
    onSelectedItemChange?: (option: ComboboxOption | null) => void;
  }
>(
  (
    {
      className,
      textClass,
      variant = "outline",
      size = "sm",
      inputProps,
      placeholder,
      items,
      emptyText = "Nothing found...",
      defaultSelectedItem = null,
      selectedItem: selectedItemProp,
      onSelectedItemChange,
      ...props
    },
    ref,
  ) => {
    const insets = useSafeAreaInsets();
    const [search, setSearch] = React.useState("");
    const [selectedItem, setSelectedItem] =
      React.useState<ComboboxOption | null>(defaultSelectedItem);
    const bottomSheet = useBottomSheet();
    const inputRef =
      React.useRef<React.ComponentRef<typeof BottomSheetTextInput>>(null);

    const listItems = React.useMemo(() => {
      return search
        ? items.filter((item) => {
            return item.label
              ?.toLocaleLowerCase()
              .includes(search.toLocaleLowerCase());
          })
        : items;
    }, [items, search]);

    function onItemChange(listItem: ComboboxOption) {
      if (selectedItemProp?.value === listItem.value) {
        return null;
      }
      setSearch("");
      bottomSheet.close();
      return listItem;
    }

    const renderItem = React.useCallback(
      ({ item }: ListRenderItemInfo<unknown>) => {
        const listItem = item as ComboboxOption;
        const isSelected = onSelectedItemChange
          ? selectedItemProp?.value === listItem.value
          : selectedItem?.value === listItem.value;
        return (
          <Button
            variant="ghost"
            className="flex-1 flex-row items-center justify-between px-3 py-4"
            style={{ minHeight: 70 }}
            onPress={() => {
              if (onSelectedItemChange) {
                onSelectedItemChange(onItemChange(listItem));
                return;
              }
              setSelectedItem(onItemChange(listItem));
            }}
          >
            <View className="flex-1 flex-row">
              <Text className={"text-foreground text-xl"}>
                {listItem.label}
              </Text>
            </View>
            {isSelected && (
              <Check size={24} className={"mt-1.5 px-6 text-foreground"} />
            )}
          </Button>
        );
      },
      [selectedItem, selectedItemProp],
    );

    function onSubmitEditing() {
      const firstItem = listItems[0];
      if (!firstItem) return;
      if (onSelectedItemChange) {
        onSelectedItemChange(firstItem);
      } else {
        setSelectedItem(firstItem);
      }
      bottomSheet.close();
    }

    function onSearchIconPress() {
      if (!inputRef.current) return;
      const input = inputRef.current;
      if (input && "focus" in input && typeof input.focus === "function") {
        input.focus();
      }
    }

    const itemSelected = onSelectedItemChange ? selectedItemProp : selectedItem;

    return (
      <BottomSheet>
        <BottomSheetOpenTrigger
          ref={ref}
          className={buttonVariants({
            variant,
            size,
            className: cn("w-full flex-row", className),
          })}
          role="combobox"
          {...props}
        >
          <View className="flex-1 flex-row justify-between ">
            <Text
              className={buttonTextVariants({
                variant,
                size,
                className: cn(!itemSelected && "opacity-50", textClass),
              })}
              numberOfLines={1}
            >
              {itemSelected ? itemSelected.label : placeholder ?? ""}
            </Text>
            <ChevronsUpDown className="ml-2 text-foreground opacity-50" />
          </View>
        </BottomSheetOpenTrigger>
        <BottomSheetContent
          ref={bottomSheet.ref}
          onDismiss={() => {
            setSearch("");
          }}
        >
          <BottomSheetHeader className="border-b-0">
            <Text className="px-0.5 text-center font-bold text-foreground text-xl">
              {placeholder}
            </Text>
          </BottomSheetHeader>
          <View className="relative border-border border-b px-4 pb-4">
            <BottomSheetTextInput
              role="searchbox"
              ref={inputRef}
              className="pl-12"
              value={search}
              onChangeText={setSearch}
              onSubmitEditing={onSubmitEditing}
              returnKeyType="next"
              clearButtonMode="while-editing"
              placeholder="Search..."
              {...inputProps}
            />
            <Button
              variant={"ghost"}
              size="sm"
              className="absolute top-2.5 left-4"
              onPress={onSearchIconPress}
            >
              <Search size={18} className="text-foreground opacity-50" />
            </Button>
          </View>
          <BottomSheetFlatList
            data={listItems}
            contentContainerStyle={{
              paddingBottom: insets.bottom + HEADER_HEIGHT,
            }}
            renderItem={renderItem}
            keyExtractor={(item, index) =>
              (item as ComboboxOption)?.value ?? index.toString()
            }
            className={"px-4"}
            keyboardShouldPersistTaps="handled"
            ListEmptyComponent={() => {
              return (
                <View
                  className="flex-1 flex-row items-center justify-center px-3 py-5"
                  style={{ minHeight: 70 }}
                >
                  <Text className={"text-center text-muted-foreground text-xl"}>
                    {emptyText}
                  </Text>
                </View>
              );
            }}
          />
        </BottomSheetContent>
      </BottomSheet>
    );
  },
);

Combobox.displayName = "Combobox";

export {Combobox, type ComboboxOption};

When clicking on the input it opens up the bottom sheet, it consists of a input and a list of options. When I click on the input, the bottom sheet closes automatically.

BottomSheet.tsx:

import type { BottomSheetFooterProps as GBottomSheetFooterProps } from "@gorhom/bottom-sheet";
import {
    BottomSheetFlatList as GBottomSheetFlatList,
    BottomSheetFooter as GBottomSheetFooter,
    BottomSheetTextInput as GBottomSheetTextInput,
    BottomSheetView as GBottomSheetView,
    useBottomSheetModal,
    type BottomSheetBackdrop,
    type BottomSheetModal,
} from "@gorhom/bottom-sheet";
import React, { useCallback } from "react";
import {
    Keyboard,
    Pressable,
    View,
    type GestureResponderEvent,
    type ViewStyle,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "../../components/Icons";
import { cn } from "../../lib/utils";
import * as Slot from "../primitives/slot";
import { Button } from "./button";

// !IMPORTANT: This file is only for web.

type BottomSheetRef = React.ElementRef<typeof View>;
type BottomSheetProps = React.ComponentPropsWithoutRef<typeof View>;

interface BottomSheetContext {
    sheetRef: React.RefObject<BottomSheetModal>;
}

const BottomSheetContext = React.createContext({} as BottomSheetContext);

const BottomSheet = React.forwardRef<BottomSheetRef, BottomSheetProps>(
    ({ ...props }, ref) => {
        return <View ref={ref} {...props} />;
    },
);

type BottomSheetContentRef = React.ElementRef<typeof BottomSheetModal>;

type BottomSheetContentProps = Omit<
    React.ComponentPropsWithoutRef<typeof BottomSheetModal>,
    "backdropComponent"
> & {
    backdropProps?: Partial<
        React.ComponentPropsWithoutRef<typeof BottomSheetBackdrop>
    >;
};

const BottomSheetContent = React.forwardRef<
    BottomSheetContentRef,
    BottomSheetContentProps
>(() => {
    return null;
});

const BottomSheetOpenTrigger = React.forwardRef<
    React.ElementRef<typeof Pressable>,
    React.ComponentPropsWithoutRef<typeof Pressable> & {
        asChild?: boolean;
    }
>(({ onPress, asChild = false, ...props }, ref) => {
    function handleOnPress() {
        window.alert(
            "Not implemented for web yet. Check `bottom-sheet.tsx` for more info.",
        );
    }
    const Trigger = asChild ? Slot.Pressable : Pressable;
    return <Trigger ref={ref} onPress={handleOnPress} {...props} />;
});

const BottomSheetCloseTrigger = React.forwardRef<
    React.ElementRef<typeof Pressable>,
    React.ComponentPropsWithoutRef<typeof Pressable> & {
        asChild?: boolean;
    }
>(({ onPress, asChild = false, ...props }, ref) => {
    const { dismiss } = useBottomSheetModal();
    function handleOnPress(ev: GestureResponderEvent) {
        dismiss();
        if (Keyboard.isVisible()) {
            Keyboard.dismiss();
        }
        onPress?.(ev);
    }
    const Trigger = asChild ? Slot.Pressable : Pressable;
    return <Trigger ref={ref} onPress={handleOnPress} {...props} />;
});

const BOTTOM_SHEET_HEADER_HEIGHT = 60; // BottomSheetHeader height

type BottomSheetViewProps = Omit<
    React.ComponentPropsWithoutRef<typeof GBottomSheetView>,
    "style"
> & {
    hadHeader?: boolean;
    style?: ViewStyle;
};

function BottomSheetView({
    className,
    children,
    hadHeader = true,
    style,
    ...props
}: BottomSheetViewProps) {
    const insets = useSafeAreaInsets();
    return (
        <GBottomSheetView
            style={[
                {
                    paddingBottom:
                        insets.bottom + (hadHeader ? BOTTOM_SHEET_HEADER_HEIGHT : 0),
                },
                style,
            ]}
            className={cn(`px-4`, className)}
            {...props}
        >
            {children}
        </GBottomSheetView>
    );
}

type BottomSheetTextInputRef = React.ElementRef<typeof GBottomSheetTextInput>;
type BottomSheetTextInputProps = React.ComponentPropsWithoutRef<
    typeof GBottomSheetTextInput
>;
const BottomSheetTextInput = React.forwardRef<
    BottomSheetTextInputRef,
    BottomSheetTextInputProps
>(({ className, placeholderClassName, ...props }, ref) => {
    return (
        <GBottomSheetTextInput
            ref={ref}
            className={cn(
                "h-14 items-center rounded-md border border-input bg-background px-3 text-foreground text-xl leading-[1.25] placeholder:text-muted-foreground disabled:opacity-50",
                className,
            )}
            placeholderClassName={cn("text-muted-foreground", placeholderClassName)}
            {...props}
        />
    );
});

type BottomSheetFlatListRef = React.ElementRef<typeof GBottomSheetFlatList>;
type BottomSheetFlatListProps = React.ComponentPropsWithoutRef<
    typeof GBottomSheetFlatList
>;
const BottomSheetFlatList = React.forwardRef<
    BottomSheetFlatListRef,
    BottomSheetFlatListProps
>(({ className, ...props }, ref) => {
    const insets = useSafeAreaInsets();
    return (
        <GBottomSheetFlatList
            ref={ref}
            contentContainerStyle={[{ paddingBottom: insets.bottom }]}
            className={cn("py-4", className)}
            keyboardShouldPersistTaps="handled"
            {...props}
        />
    );
});

type BottomSheetHeaderRef = React.ElementRef<typeof View>;
type BottomSheetHeaderProps = React.ComponentPropsWithoutRef<typeof View>;
const BottomSheetHeader = React.forwardRef<
    BottomSheetHeaderRef,
    BottomSheetHeaderProps
>(({ className, children, ...props }, ref) => {
    const { dismiss } = useBottomSheetModal();
    function close() {
        if (Keyboard.isVisible()) {
            Keyboard.dismiss();
        }
        dismiss();
    }
    return (
        <View
            ref={ref}
            className={cn(
                "flex-row items-center justify-between border-border border-b pl-4",
                className,
            )}
            {...props}
        >
            {children}
            <Button onPress={close} variant="ghost" className="pr-4">
                <X className="text-muted-foreground" size={24} />
            </Button>
        </View>
    );
});

type BottomSheetFooterRef = React.ElementRef<typeof View>;
type BottomSheetFooterProps = Omit<
    React.ComponentPropsWithoutRef<typeof View>,
    "style"
> & {
    bottomSheetFooterProps: GBottomSheetFooterProps;
    children?: React.ReactNode;
    style?: ViewStyle;
};

/**
 * To be used in a useCallback function as a props to BottomSheetContent
 */
const BottomSheetFooter = React.forwardRef<
    BottomSheetFooterRef,
    BottomSheetFooterProps
>(({ bottomSheetFooterProps, children, className, style, ...props }, ref) => {
    const insets = useSafeAreaInsets();
    return (
        <GBottomSheetFooter {...bottomSheetFooterProps}>
            <View
                ref={ref}
                style={[{ paddingBottom: insets.bottom + 6 }, style]}
                className={cn("px-4 pt-1.5", className)}
                {...props}
            >
                {children}
            </View>
        </GBottomSheetFooter>
    );
});

function useBottomSheet() {
    const ref = React.useRef<BottomSheetContentRef>(null);

    const open = useCallback(() => {
        ref.current?.present();
    }, []);

    const close = useCallback(() => {
        ref.current?.dismiss();
    }, []);

    return { ref, open, close };
}

export {
    BottomSheet,
    BottomSheetCloseTrigger,
    BottomSheetContent,
    BottomSheetFlatList,
    BottomSheetFooter,
    BottomSheetHeader,
    BottomSheetOpenTrigger,
    BottomSheetTextInput,
    BottomSheetView,
    useBottomSheet,
};

If someone could help me solve this I'm using this for address autocomplete

mrzachnugent commented 3 weeks ago

@briskteq-faiz What is the issue exactly? Is the following?

When clicking on the input it opens up the bottom sheet, it consists of a input and a list of options. When I click on the input, the bottom sheet closes automatically.

If you want to prevent that, you can remove bottomSheet.close(); from the onItemChange function. Please use note that that is a deprecated component. I also noticed, that you are missing at least one class for android devices so you can copy the latest version on the component.

If that is not the issue, please provide more details and a minimal reproduction repo.

mrzachnugent commented 4 days ago

Closed due to issue being out of newly defined scope.