657 lines
17 KiB
TypeScript
657 lines
17 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { ReactElement, ReactNode } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
Modal,
|
|
TextInput,
|
|
ScrollView,
|
|
Platform,
|
|
StatusBar,
|
|
} from "react-native";
|
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
|
import {
|
|
BottomSheetModal,
|
|
BottomSheetScrollView,
|
|
BottomSheetBackdrop,
|
|
BottomSheetTextInput,
|
|
BottomSheetBackgroundProps,
|
|
} from "@gorhom/bottom-sheet";
|
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
|
import { Check, Search } from "lucide-react-native";
|
|
import {
|
|
flip,
|
|
offset as floatingOffset,
|
|
shift,
|
|
size as floatingSize,
|
|
useFloating,
|
|
} from "@floating-ui/react-native";
|
|
import { getNextActiveIndex } from "./combobox-keyboard";
|
|
|
|
const IS_WEB = Platform.OS === "web";
|
|
|
|
export interface ComboboxOption {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface ComboboxProps {
|
|
options: ComboboxOption[];
|
|
value: string;
|
|
onSelect: (id: string) => void;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
allowCustomValue?: boolean;
|
|
customValuePrefix?: string;
|
|
customValueDescription?: string;
|
|
title?: string;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
anchorRef: React.RefObject<View | null>;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
|
return (
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={[style, styles.bottomSheetBackground]}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SearchInputProps {
|
|
placeholder: string;
|
|
value: string;
|
|
onChangeText: (text: string) => void;
|
|
onSubmitEditing?: () => void;
|
|
autoFocus?: boolean;
|
|
}
|
|
|
|
function SearchInput({
|
|
placeholder,
|
|
value,
|
|
onChangeText,
|
|
onSubmitEditing,
|
|
autoFocus = false,
|
|
}: SearchInputProps): ReactElement {
|
|
const { theme } = useUnistyles();
|
|
const inputRef = useRef<TextInput>(null);
|
|
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && IS_WEB && inputRef.current) {
|
|
const timer = setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
return (
|
|
<View style={styles.searchInputContainer}>
|
|
<Search size={16} color={theme.colors.foregroundMuted} />
|
|
<InputComponent
|
|
ref={inputRef as any}
|
|
// @ts-expect-error - outlineStyle is web-only
|
|
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
|
placeholder={placeholder}
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
value={value}
|
|
onChangeText={onChangeText}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
onSubmitEditing={onSubmitEditing}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export interface ComboboxItemProps {
|
|
label: string;
|
|
description?: string;
|
|
selected?: boolean;
|
|
active?: boolean;
|
|
onPress: () => void;
|
|
testID?: string;
|
|
}
|
|
|
|
export function ComboboxItem({
|
|
label,
|
|
description,
|
|
selected,
|
|
active,
|
|
onPress,
|
|
testID,
|
|
}: ComboboxItemProps): ReactElement {
|
|
const { theme } = useUnistyles();
|
|
return (
|
|
<Pressable
|
|
testID={testID}
|
|
onPress={onPress}
|
|
style={({ pressed }) => [
|
|
styles.comboboxItem,
|
|
pressed && styles.comboboxItemPressed,
|
|
active && styles.comboboxItemActive,
|
|
]}
|
|
>
|
|
<View style={styles.comboboxItemCheckSlot}>
|
|
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
|
|
</View>
|
|
<View style={styles.comboboxItemContent}>
|
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
|
{description ? (
|
|
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
|
|
) : null}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
|
return <Text style={styles.emptyText}>{children}</Text>;
|
|
}
|
|
|
|
export function Combobox({
|
|
options,
|
|
value,
|
|
onSelect,
|
|
placeholder = "Search...",
|
|
searchPlaceholder,
|
|
emptyText = "No options match your search.",
|
|
allowCustomValue = false,
|
|
customValuePrefix = "Use",
|
|
customValueDescription,
|
|
title = "Select",
|
|
open,
|
|
onOpenChange,
|
|
anchorRef,
|
|
children,
|
|
}: ComboboxProps): ReactElement {
|
|
const isMobile =
|
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
|
|
const isControlled = typeof open === "boolean";
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const isOpen = isControlled ? open : internalOpen;
|
|
|
|
const setOpen = useCallback(
|
|
(nextOpen: boolean) => {
|
|
if (!isControlled) {
|
|
setInternalOpen(nextOpen);
|
|
}
|
|
onOpenChange?.(nextOpen);
|
|
},
|
|
[isControlled, onOpenChange]
|
|
);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setOpen(false);
|
|
setSearchQuery("");
|
|
}, [setOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setSearchQuery("");
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const collisionPadding = useMemo(() => {
|
|
const basePadding = 16;
|
|
if (Platform.OS !== "android") return basePadding;
|
|
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
|
return Math.max(basePadding, statusBarHeight + basePadding);
|
|
}, []);
|
|
|
|
const middleware = useMemo(
|
|
() => [
|
|
floatingOffset({ mainAxis: 4 }),
|
|
flip({ padding: collisionPadding }),
|
|
shift({ padding: collisionPadding }),
|
|
floatingSize({
|
|
padding: collisionPadding,
|
|
apply({ availableWidth, availableHeight, rects }) {
|
|
setAvailableSize((prev) => {
|
|
const next = { width: availableWidth, height: availableHeight };
|
|
if (!prev) return next;
|
|
if (prev.width === next.width && prev.height === next.height) return prev;
|
|
return next;
|
|
});
|
|
setReferenceWidth((prev) => {
|
|
const next = rects.reference.width;
|
|
if (prev === next) return prev;
|
|
return next;
|
|
});
|
|
},
|
|
}),
|
|
],
|
|
[collisionPadding]
|
|
);
|
|
|
|
const { refs, floatingStyles, update } = useFloating({
|
|
placement: "bottom-start",
|
|
middleware,
|
|
sameScrollView: false,
|
|
elements: {
|
|
reference: anchorRef.current ?? undefined,
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || isMobile) {
|
|
setAvailableSize(null);
|
|
setReferenceWidth(null);
|
|
return;
|
|
}
|
|
const raf = requestAnimationFrame(() => update());
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [isMobile, update, isOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
if (isOpen) {
|
|
bottomSheetRef.current?.present();
|
|
} else {
|
|
bottomSheetRef.current?.dismiss();
|
|
}
|
|
}, [isOpen, isMobile]);
|
|
|
|
const handleSheetChange = useCallback(
|
|
(index: number) => {
|
|
if (index === -1) {
|
|
handleClose();
|
|
}
|
|
},
|
|
[handleClose]
|
|
);
|
|
|
|
const renderBackdrop = useCallback(
|
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
|
<BottomSheetBackdrop
|
|
{...props}
|
|
disappearsOnIndex={-1}
|
|
appearsOnIndex={0}
|
|
opacity={0.45}
|
|
/>
|
|
),
|
|
[]
|
|
);
|
|
|
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
|
const filteredOptions = useMemo(() => {
|
|
if (!normalizedSearch) {
|
|
return options;
|
|
}
|
|
return options.filter(
|
|
(opt) =>
|
|
opt.label.toLowerCase().includes(normalizedSearch) ||
|
|
opt.id.toLowerCase().includes(normalizedSearch) ||
|
|
opt.description?.toLowerCase().includes(normalizedSearch)
|
|
);
|
|
}, [options, normalizedSearch]);
|
|
|
|
const sanitizedSearchValue = searchQuery.trim();
|
|
const showCustomOption =
|
|
allowCustomValue &&
|
|
sanitizedSearchValue.length > 0 &&
|
|
!options.some(
|
|
(opt) =>
|
|
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
|
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
|
);
|
|
|
|
const visibleOptions = useMemo(() => {
|
|
const next: Array<{
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
}> = [];
|
|
|
|
if (showCustomOption) {
|
|
next.push({
|
|
id: sanitizedSearchValue,
|
|
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
|
|
description: customValueDescription,
|
|
});
|
|
}
|
|
|
|
for (const opt of filteredOptions) {
|
|
next.push({
|
|
id: opt.id,
|
|
label: opt.label,
|
|
description: opt.description,
|
|
});
|
|
}
|
|
|
|
return next;
|
|
}, [
|
|
customValueDescription,
|
|
customValuePrefix,
|
|
filteredOptions,
|
|
sanitizedSearchValue,
|
|
showCustomOption,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
if (!IS_WEB && isMobile) return;
|
|
|
|
if (visibleOptions.length === 0) {
|
|
setActiveIndex(-1);
|
|
return;
|
|
}
|
|
|
|
if (normalizedSearch) {
|
|
setActiveIndex(0);
|
|
return;
|
|
}
|
|
|
|
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
|
|
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
|
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
|
|
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
onSelect(id);
|
|
handleClose();
|
|
},
|
|
[handleClose, onSelect]
|
|
);
|
|
|
|
const handleSubmitSearch = useCallback(() => {
|
|
if (showCustomOption) {
|
|
handleSelect(sanitizedSearchValue);
|
|
}
|
|
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
|
|
|
const handleDesktopKey = useCallback(
|
|
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
|
|
if (!isOpen) return;
|
|
if (!IS_WEB && isMobile) return;
|
|
|
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
|
event?.preventDefault();
|
|
setActiveIndex((currentIndex) =>
|
|
getNextActiveIndex({
|
|
currentIndex,
|
|
itemCount: visibleOptions.length,
|
|
key,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (key === "Enter") {
|
|
if (visibleOptions.length === 0) return;
|
|
event?.preventDefault();
|
|
const index =
|
|
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
|
|
handleSelect(visibleOptions[index]!.id);
|
|
return;
|
|
}
|
|
|
|
if (key === "Escape") {
|
|
event?.preventDefault();
|
|
handleClose();
|
|
}
|
|
},
|
|
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!IS_WEB || !isOpen) return;
|
|
|
|
const handler = (event: KeyboardEvent) => {
|
|
const key = event.key;
|
|
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
|
return;
|
|
}
|
|
handleDesktopKey(key, event);
|
|
};
|
|
|
|
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
|
|
window.addEventListener("keydown", handler, true);
|
|
return () => {
|
|
window.removeEventListener("keydown", handler, true);
|
|
};
|
|
}, [handleDesktopKey, isOpen]);
|
|
|
|
const searchInput = (
|
|
<SearchInput
|
|
placeholder={searchPlaceholder ?? placeholder}
|
|
value={searchQuery}
|
|
onChangeText={setSearchQuery}
|
|
onSubmitEditing={handleSubmitSearch}
|
|
autoFocus={!isMobile}
|
|
/>
|
|
);
|
|
|
|
const optionsList = (
|
|
<>
|
|
{visibleOptions.length > 0 ? (
|
|
visibleOptions.map((opt, index) => (
|
|
<ComboboxItem
|
|
key={opt.id}
|
|
label={opt.label}
|
|
description={opt.description}
|
|
selected={opt.id === value}
|
|
active={index === activeIndex}
|
|
onPress={() => handleSelect(opt.id)}
|
|
/>
|
|
))
|
|
) : (
|
|
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const content = children ?? (
|
|
<>
|
|
{searchInput}
|
|
{optionsList}
|
|
</>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<BottomSheetModal
|
|
ref={bottomSheetRef}
|
|
snapPoints={snapPoints}
|
|
index={0}
|
|
enableDynamicSizing={false}
|
|
onChange={handleSheetChange}
|
|
backdropComponent={renderBackdrop}
|
|
enablePanDownToClose
|
|
backgroundComponent={ComboboxSheetBackground}
|
|
handleIndicatorStyle={styles.bottomSheetHandle}
|
|
keyboardBehavior="extend"
|
|
keyboardBlurBehavior="restore"
|
|
>
|
|
<View style={styles.bottomSheetHeader}>
|
|
<Text style={styles.comboboxTitle}>{title}</Text>
|
|
</View>
|
|
<BottomSheetScrollView
|
|
contentContainerStyle={styles.comboboxScrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{content}
|
|
</BottomSheetScrollView>
|
|
</BottomSheetModal>
|
|
);
|
|
}
|
|
|
|
if (!isOpen) return <></>;
|
|
|
|
return (
|
|
<Modal
|
|
transparent
|
|
animationType="none"
|
|
visible={isOpen}
|
|
onRequestClose={handleClose}
|
|
>
|
|
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
|
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
|
<Animated.View
|
|
entering={FadeIn.duration(100)}
|
|
exiting={FadeOut.duration(100)}
|
|
style={[
|
|
styles.desktopContainer,
|
|
{
|
|
position: "absolute",
|
|
minWidth: referenceWidth ?? 200,
|
|
maxWidth: 400,
|
|
},
|
|
floatingStyles,
|
|
referenceWidth === null ? { opacity: 0 } : null,
|
|
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
|
]}
|
|
ref={refs.setFloating}
|
|
collapsable={false}
|
|
onLayout={() => update()}
|
|
>
|
|
{children ? (
|
|
<ScrollView
|
|
contentContainerStyle={styles.desktopScrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
style={styles.desktopScroll}
|
|
>
|
|
{content}
|
|
</ScrollView>
|
|
) : (
|
|
<>
|
|
{searchInput}
|
|
<ScrollView
|
|
contentContainerStyle={styles.desktopScrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
style={styles.desktopScroll}
|
|
>
|
|
{optionsList}
|
|
</ScrollView>
|
|
</>
|
|
)}
|
|
</Animated.View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
searchInputContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
paddingHorizontal: theme.spacing[3],
|
|
marginBottom: theme.spacing[1],
|
|
gap: theme.spacing[2],
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
paddingVertical: theme.spacing[3],
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
comboboxItem: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
minHeight: 36,
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
},
|
|
comboboxItemPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
comboboxItemActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
comboboxItemCheckSlot: {
|
|
width: 16,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
comboboxItemContent: {
|
|
flex: 1,
|
|
flexShrink: 1,
|
|
},
|
|
comboboxItemLabel: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foreground,
|
|
},
|
|
comboboxItemDescription: {
|
|
marginTop: 2,
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
emptyText: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
bottomSheetBackground: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
|
borderTopRightRadius: theme.borderRadius["2xl"],
|
|
},
|
|
bottomSheetHandle: {
|
|
backgroundColor: theme.colors.palette.zinc[600],
|
|
},
|
|
bottomSheetHeader: {
|
|
paddingHorizontal: theme.spacing[6],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
comboboxTitle: {
|
|
fontSize: theme.fontSize.lg,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
color: theme.colors.foreground,
|
|
textAlign: "center",
|
|
},
|
|
comboboxScrollContent: {
|
|
paddingBottom: theme.spacing[8],
|
|
paddingHorizontal: theme.spacing[1],
|
|
},
|
|
desktopOverlay: {
|
|
flex: 1,
|
|
},
|
|
desktopBackdrop: {
|
|
position: "absolute",
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
},
|
|
desktopContainer: {
|
|
backgroundColor: theme.colors.surface0,
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.borderAccent,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 8,
|
|
elevation: 8,
|
|
maxHeight: 400,
|
|
overflow: "hidden",
|
|
},
|
|
desktopScroll: {
|
|
maxHeight: 400,
|
|
},
|
|
desktopScrollContent: {
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
}));
|