mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* feat(app): modernize compact headers and sidebar affordances Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings. * test(app): match darker light-theme workspace selection * test(app): align plugin journeys with compact header
1806 lines
52 KiB
TypeScript
1806 lines
52 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useReducer,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { ReactElement, ReactNode } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
Modal,
|
|
ScrollView,
|
|
Platform,
|
|
StatusBar,
|
|
useWindowDimensions,
|
|
type LayoutChangeEvent,
|
|
type PressableStateCallbackType,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
import { Check, File, Folder, 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";
|
|
import {
|
|
buildVisibleComboboxOptions,
|
|
getComboboxFallbackIndex,
|
|
orderVisibleComboboxOptions,
|
|
shouldShowCustomComboboxOption,
|
|
} from "./combobox-options";
|
|
import type { ComboboxOptionModel } from "./combobox-options";
|
|
import { isWeb } from "@/constants/platform";
|
|
import {
|
|
IsolatedBottomSheetModal,
|
|
SheetVisibleFrame,
|
|
useIsolatedBottomSheetVisibility,
|
|
} from "./isolated-bottom-sheet-modal";
|
|
import {
|
|
AdaptiveTextInput,
|
|
InlineHeaderView,
|
|
SheetHeaderView,
|
|
type SheetHeader,
|
|
} from "@/components/adaptive-modal-sheet";
|
|
import { FloatingSurface } from "@/components/ui/floating";
|
|
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
|
import {
|
|
getOverlayRoot,
|
|
OverlayLayerProvider,
|
|
useOverlayLayer,
|
|
useWebOverlayRegistration,
|
|
} from "@/lib/overlay-root";
|
|
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
|
|
|
export { buildDesktopFrameStyle } from "./combobox-frame-style";
|
|
|
|
const IS_WEB = isWeb;
|
|
|
|
export type ComboboxOption = ComboboxOptionModel;
|
|
export type ComboboxDesktopPlacement = "top-start" | "bottom-start";
|
|
|
|
export interface ComboboxProps {
|
|
options: ComboboxOption[];
|
|
value: string;
|
|
onSelect: (id: string) => void;
|
|
renderOption?: (input: {
|
|
option: ComboboxOption;
|
|
selected: boolean;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
}) => ReactElement;
|
|
onSearchQueryChange?: (query: string) => void;
|
|
searchable?: boolean;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
allowCustomValue?: boolean;
|
|
customValuePrefix?: string;
|
|
customValueDescription?: string;
|
|
customValueKind?: "directory" | "file";
|
|
optionsPosition?: "below-search" | "above-search";
|
|
title?: string;
|
|
/**
|
|
* Structured header. When provided, replaces `title` + `stickyHeader` and
|
|
* is rendered via the shared SheetHeaderView (mobile) / InlineHeaderView
|
|
* (desktop). Built-in search (when `searchable=true` and no `header.search`)
|
|
* is folded into the header so its magnifying glass aligns with the title
|
|
* and any leading icon at the sheet's shared indent.
|
|
*/
|
|
header?: SheetHeader;
|
|
mobileChildrenScrollEnabled?: boolean;
|
|
/** Let custom desktop children own scrolling, for example a virtualized list. */
|
|
desktopChildrenScrollEnabled?: boolean;
|
|
/** Overrides the mobile scroll container spacing for custom child content. */
|
|
mobileChildrenContentContainerStyle?: StyleProp<ViewStyle>;
|
|
presentation?: "push" | "replace";
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
desktopPlacement?: ComboboxDesktopPlacement;
|
|
/**
|
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
|
* coordinates resolve. This intentionally disables fade enter/exit animation
|
|
* for that combobox instance to avoid animation overriding hidden opacity.
|
|
*/
|
|
desktopPreventInitialFlash?: boolean;
|
|
/** Minimum width for the desktop popover (overrides trigger-based width). */
|
|
desktopMinWidth?: number;
|
|
/** Keep the desktop popover at its opening width as its content changes. */
|
|
desktopLockWidth?: boolean;
|
|
/** Fixed height for the desktop popover (overrides default 400px max). */
|
|
desktopFixedHeight?: number;
|
|
/** Content rendered above the scroll area on desktop (sticky header). */
|
|
stickyHeader?: ReactNode;
|
|
/** Content rendered below the scroll area. */
|
|
footer?: ReactNode;
|
|
/** When true, selecting an option does not close the picker (multi-select mode). */
|
|
keepOpenOnSelect?: boolean;
|
|
anchorRef: React.RefObject<View | null>;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
function resolveControlledOpen(
|
|
open: boolean | undefined,
|
|
internalOpen: boolean,
|
|
): { isControlled: boolean; isOpen: boolean } {
|
|
const isControlled = typeof open === "boolean";
|
|
return { isControlled, isOpen: isControlled ? open : internalOpen };
|
|
}
|
|
|
|
function toNumericStyleValue(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
const parsed = Number.parseFloat(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
|
const { theme } = useUnistyles();
|
|
|
|
const combinedStyle = useMemo(
|
|
() => [
|
|
style,
|
|
{
|
|
backgroundColor: theme.colors.surface0,
|
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
|
borderTopRightRadius: theme.borderRadius["2xl"],
|
|
},
|
|
],
|
|
[style, theme.colors.surface0, theme.borderRadius],
|
|
);
|
|
|
|
return <Animated.View pointerEvents="none" style={combinedStyle} />;
|
|
}
|
|
|
|
export interface SearchInputProps {
|
|
placeholder: string;
|
|
onChangeText: (text: string) => void;
|
|
onSubmitEditing?: () => void;
|
|
autoFocus?: boolean;
|
|
useBottomSheetInput?: boolean;
|
|
resetKey?: string | number;
|
|
}
|
|
|
|
export function SearchInput({
|
|
placeholder,
|
|
onChangeText,
|
|
onSubmitEditing,
|
|
autoFocus = false,
|
|
useBottomSheetInput: _useBottomSheetInput = false,
|
|
resetKey,
|
|
}: SearchInputProps): ReactElement {
|
|
const { theme } = useUnistyles();
|
|
const inputRef = useRef<EditingTextInputHandle>(null);
|
|
|
|
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} />
|
|
<AdaptiveTextInput
|
|
ref={inputRef}
|
|
// @ts-expect-error - outlineStyle is web-only
|
|
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
|
placeholder={placeholder}
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
resetKey={resetKey}
|
|
onChangeText={onChangeText}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
onSubmitEditing={onSubmitEditing}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export interface ComboboxItemProps {
|
|
label: string;
|
|
description?: string;
|
|
kind?: "directory" | "file";
|
|
leadingSlot?: ReactNode;
|
|
trailingSlot?: ReactNode;
|
|
selected?: boolean;
|
|
active?: boolean;
|
|
disabled?: boolean;
|
|
accessibilityLabel?: string;
|
|
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
|
|
elevated?: boolean;
|
|
onPress: () => void;
|
|
testID?: string;
|
|
}
|
|
|
|
export function ComboboxItem({
|
|
label,
|
|
description,
|
|
kind,
|
|
leadingSlot,
|
|
trailingSlot,
|
|
selected,
|
|
active,
|
|
disabled,
|
|
accessibilityLabel,
|
|
elevated,
|
|
onPress,
|
|
testID,
|
|
}: ComboboxItemProps): ReactElement {
|
|
const { theme } = useUnistyles();
|
|
|
|
let leadingContent: ReactElement | null = null;
|
|
if (leadingSlot) {
|
|
leadingContent = <View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>;
|
|
} else if (kind === "directory") {
|
|
leadingContent = (
|
|
<View style={styles.comboboxItemLeadingSlot}>
|
|
<Folder size={16} color={theme.colors.foregroundMuted} />
|
|
</View>
|
|
);
|
|
} else if (kind === "file") {
|
|
leadingContent = (
|
|
<View style={styles.comboboxItemLeadingSlot}>
|
|
<File size={16} color={theme.colors.foregroundMuted} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const itemPressableStyle = useCallback(
|
|
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.comboboxItem,
|
|
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
|
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
|
active && styles.comboboxItemActive,
|
|
disabled && styles.comboboxItemDisabled,
|
|
],
|
|
[elevated, active, disabled],
|
|
);
|
|
|
|
const itemContentStyle = useMemo(
|
|
() => [styles.comboboxItemContent, description && styles.comboboxItemContentInline],
|
|
[description],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
testID={testID}
|
|
disabled={disabled}
|
|
onPress={onPress}
|
|
style={itemPressableStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={accessibilityLabel ?? label}
|
|
>
|
|
{leadingContent}
|
|
<View style={itemContentStyle}>
|
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
|
{label}
|
|
</Text>
|
|
{description ? (
|
|
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
|
{description}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{selected || trailingSlot ? (
|
|
<View style={styles.comboboxItemTrailingContainer}>
|
|
<View style={styles.comboboxItemTrailingSlot}>
|
|
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
|
|
</View>
|
|
{trailingSlot}
|
|
</View>
|
|
) : null}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
|
return (
|
|
<Text testID="combobox-empty-text" style={styles.emptyText}>
|
|
{children}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
type RenderOptionFn = NonNullable<ComboboxProps["renderOption"]>;
|
|
|
|
interface OptionRowProps {
|
|
option: ComboboxOption;
|
|
selected: boolean;
|
|
active: boolean;
|
|
onSelect: (id: string) => void;
|
|
renderOption: RenderOptionFn | undefined;
|
|
}
|
|
|
|
function OptionRow({ option, selected, active, onSelect, renderOption }: OptionRowProps) {
|
|
const handlePress = useCallback(() => onSelect(option.id), [onSelect, option.id]);
|
|
if (renderOption) {
|
|
return <View>{renderOption({ option, selected, active, onPress: handlePress })}</View>;
|
|
}
|
|
return (
|
|
<ComboboxItem
|
|
label={option.label}
|
|
description={option.description}
|
|
kind={option.kind}
|
|
selected={selected}
|
|
active={active}
|
|
onPress={handlePress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface OptionsListProps {
|
|
options: ComboboxOption[];
|
|
value: string;
|
|
activeIndex: number;
|
|
emptyText: string;
|
|
onSelect: (id: string) => void;
|
|
renderOption: RenderOptionFn | undefined;
|
|
}
|
|
|
|
function OptionsList({
|
|
options,
|
|
value,
|
|
activeIndex,
|
|
emptyText,
|
|
onSelect,
|
|
renderOption,
|
|
}: OptionsListProps): ReactElement {
|
|
if (options.length === 0) {
|
|
return <ComboboxEmpty>{emptyText}</ComboboxEmpty>;
|
|
}
|
|
return (
|
|
<>
|
|
{options.map((opt, index) => (
|
|
<OptionRow
|
|
key={opt.id}
|
|
option={opt}
|
|
selected={opt.id === value}
|
|
active={index === activeIndex}
|
|
onSelect={onSelect}
|
|
renderOption={renderOption}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface DesktopPositionInput {
|
|
isDesktopAboveSearch: boolean;
|
|
isMobile: boolean;
|
|
desktopPlacement: ComboboxDesktopPlacement;
|
|
referenceTop: number | null;
|
|
referenceLeft: number | null;
|
|
referenceAtOrigin: boolean;
|
|
desktopContentWidth: number | null;
|
|
windowWidth: number;
|
|
windowHeight: number;
|
|
collisionPadding: number;
|
|
floatingTop: number | null;
|
|
floatingLeft: number | null;
|
|
floatingStyles: ReturnType<typeof useFloating>["floatingStyles"];
|
|
desktopPreventInitialFlash: boolean;
|
|
referenceWidth: number | null;
|
|
}
|
|
|
|
interface DesktopPositionResult {
|
|
desktopPositionStyle:
|
|
| { left: number; bottom: number }
|
|
| ReturnType<typeof useFloating>["floatingStyles"];
|
|
hasResolvedDesktopPosition: boolean;
|
|
shouldHideDesktopContent: boolean;
|
|
shouldUseDesktopFade: boolean;
|
|
useMeasuredTopStartPosition: boolean;
|
|
}
|
|
|
|
function shouldUseMeasuredTopStart(input: DesktopPositionInput): boolean {
|
|
return (
|
|
!input.isDesktopAboveSearch &&
|
|
IS_WEB &&
|
|
!input.isMobile &&
|
|
input.desktopPlacement === "top-start" &&
|
|
input.referenceTop !== null &&
|
|
input.referenceLeft !== null &&
|
|
input.desktopContentWidth !== null
|
|
);
|
|
}
|
|
|
|
function resolvePositionReady(
|
|
input: DesktopPositionInput,
|
|
useMeasured: boolean,
|
|
measuredLeft: number | null,
|
|
measuredBottom: number | null,
|
|
aboveSearchBottom: number | null,
|
|
): boolean {
|
|
const { isDesktopAboveSearch, floatingLeft, floatingTop, referenceAtOrigin } = input;
|
|
const hasNonZeroFloating = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
|
|
if (isDesktopAboveSearch) {
|
|
return floatingLeft !== null && aboveSearchBottom !== null;
|
|
}
|
|
if (useMeasured) {
|
|
return measuredLeft !== null && measuredBottom !== null;
|
|
}
|
|
return floatingLeft !== null && floatingTop !== null && (hasNonZeroFloating || referenceAtOrigin);
|
|
}
|
|
|
|
function computeDesktopPosition(input: DesktopPositionInput): DesktopPositionResult {
|
|
const {
|
|
isDesktopAboveSearch,
|
|
referenceTop,
|
|
referenceLeft,
|
|
desktopContentWidth,
|
|
windowWidth,
|
|
windowHeight,
|
|
collisionPadding,
|
|
floatingLeft,
|
|
floatingStyles,
|
|
desktopPreventInitialFlash,
|
|
referenceWidth,
|
|
} = input;
|
|
|
|
const desktopAboveSearchBottom =
|
|
isDesktopAboveSearch && referenceTop !== null
|
|
? Math.max(windowHeight - referenceTop, collisionPadding)
|
|
: null;
|
|
const useMeasuredTopStartPosition = shouldUseMeasuredTopStart(input);
|
|
const clampedMeasuredTopStartLeft =
|
|
useMeasuredTopStartPosition && referenceLeft !== null && desktopContentWidth !== null
|
|
? Math.max(
|
|
collisionPadding,
|
|
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
|
|
)
|
|
: null;
|
|
const measuredTopStartBottom =
|
|
useMeasuredTopStartPosition && referenceTop !== null
|
|
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
|
|
: null;
|
|
|
|
const resolvedPositionReady = resolvePositionReady(
|
|
input,
|
|
useMeasuredTopStartPosition,
|
|
clampedMeasuredTopStartLeft,
|
|
measuredTopStartBottom,
|
|
desktopAboveSearchBottom,
|
|
);
|
|
const hasResolvedDesktopPosition =
|
|
referenceWidth !== null && referenceWidth > 0 && resolvedPositionReady;
|
|
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
|
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
|
|
|
let desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"];
|
|
if (isDesktopAboveSearch) {
|
|
desktopPositionStyle = {
|
|
left: floatingLeft ?? 0,
|
|
bottom: desktopAboveSearchBottom ?? 0,
|
|
};
|
|
} else if (useMeasuredTopStartPosition) {
|
|
desktopPositionStyle = {
|
|
left: clampedMeasuredTopStartLeft ?? 0,
|
|
bottom: measuredTopStartBottom ?? 0,
|
|
};
|
|
} else {
|
|
desktopPositionStyle = floatingStyles;
|
|
}
|
|
|
|
return {
|
|
desktopPositionStyle,
|
|
hasResolvedDesktopPosition,
|
|
shouldHideDesktopContent,
|
|
shouldUseDesktopFade,
|
|
useMeasuredTopStartPosition,
|
|
};
|
|
}
|
|
|
|
function advanceActiveIndex(itemCount: number, key: "ArrowDown" | "ArrowUp") {
|
|
return (currentIndex: number) => getNextActiveIndex({ currentIndex, itemCount, key });
|
|
}
|
|
|
|
type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape";
|
|
|
|
interface DesktopKeyHandlerInput {
|
|
isOpen: boolean;
|
|
isMobile: boolean;
|
|
orderedVisibleOptions: ComboboxOption[];
|
|
activeIndex: number;
|
|
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
|
|
handleSelect: (id: string) => void;
|
|
handleClose: () => void;
|
|
}
|
|
|
|
function handleDesktopArrowKey(input: DesktopKeyHandlerInput, key: "ArrowDown" | "ArrowUp") {
|
|
input.setActiveIndex(advanceActiveIndex(input.orderedVisibleOptions.length, key));
|
|
}
|
|
|
|
function handleDesktopEnterKey(input: DesktopKeyHandlerInput) {
|
|
if (input.orderedVisibleOptions.length === 0) return;
|
|
const { activeIndex, orderedVisibleOptions } = input;
|
|
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
|
input.handleSelect(orderedVisibleOptions[index].id);
|
|
}
|
|
|
|
interface FloatingSizeSetters {
|
|
setAvailableSize: React.Dispatch<
|
|
React.SetStateAction<{ width?: number; height?: number } | null>
|
|
>;
|
|
setReferenceWidth: React.Dispatch<React.SetStateAction<number | null>>;
|
|
}
|
|
|
|
function updateAvailableSize(
|
|
setAvailableSize: FloatingSizeSetters["setAvailableSize"],
|
|
availableWidth: number,
|
|
availableHeight: number,
|
|
) {
|
|
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;
|
|
});
|
|
}
|
|
|
|
function updateReferenceWidth(
|
|
setReferenceWidth: FloatingSizeSetters["setReferenceWidth"],
|
|
width: number,
|
|
) {
|
|
setReferenceWidth((prev) => {
|
|
if (!(width > 0)) return prev;
|
|
if (prev === width) return prev;
|
|
return width;
|
|
});
|
|
}
|
|
|
|
interface MeasuredAnchorSetters {
|
|
setReferenceLeft: React.Dispatch<React.SetStateAction<number | null>>;
|
|
setReferenceTop: React.Dispatch<React.SetStateAction<number | null>>;
|
|
setReferenceWidth: React.Dispatch<React.SetStateAction<number | null>>;
|
|
setReferenceAtOrigin: React.Dispatch<React.SetStateAction<boolean>>;
|
|
}
|
|
|
|
function applyMeasuredAnchor(setters: MeasuredAnchorSetters, x: number, y: number, width: number) {
|
|
setters.setReferenceLeft((prev) => (prev === x ? prev : x));
|
|
setters.setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
|
setters.setReferenceTop((prev) => (prev === y ? prev : y));
|
|
updateReferenceWidth(setters.setReferenceWidth, width);
|
|
}
|
|
|
|
function useActiveIndexSync(
|
|
isOpen: boolean,
|
|
isMobile: boolean,
|
|
orderedVisibleOptions: ComboboxOption[],
|
|
effectiveOptionsPosition: "below-search" | "above-search",
|
|
normalizedSearch: string,
|
|
value: string,
|
|
setActiveIndex: React.Dispatch<React.SetStateAction<number>>,
|
|
) {
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
if (!IS_WEB && isMobile) return;
|
|
setActiveIndex(
|
|
resolveInitialActiveIndex(
|
|
orderedVisibleOptions,
|
|
effectiveOptionsPosition,
|
|
normalizedSearch,
|
|
value,
|
|
),
|
|
);
|
|
}, [
|
|
effectiveOptionsPosition,
|
|
isMobile,
|
|
isOpen,
|
|
normalizedSearch,
|
|
value,
|
|
orderedVisibleOptions,
|
|
setActiveIndex,
|
|
]);
|
|
}
|
|
|
|
function useDesktopOptionsPinToBottom(
|
|
isOpen: boolean,
|
|
orderedVisibleOptions: ComboboxOption[],
|
|
pinDesktopOptionsToBottom: () => void,
|
|
) {
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
pinDesktopOptionsToBottom();
|
|
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
|
|
}
|
|
|
|
function useDesktopFloatingUpdate(
|
|
isOpen: boolean,
|
|
isMobile: boolean,
|
|
orderedVisibleOptionsLength: number,
|
|
searchQuery: string,
|
|
update: () => unknown,
|
|
) {
|
|
useLayoutEffect(() => {
|
|
if (!isOpen || isMobile) return;
|
|
void update();
|
|
}, [isOpen, isMobile, orderedVisibleOptionsLength, searchQuery, update]);
|
|
}
|
|
|
|
function useResetSearchOnOpen(
|
|
isOpen: boolean,
|
|
setSearchQueryWithCallback: (query: string) => void,
|
|
bumpSearchResetKey: () => void,
|
|
) {
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setSearchQueryWithCallback("");
|
|
bumpSearchResetKey();
|
|
}
|
|
}, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]);
|
|
}
|
|
|
|
interface DesktopResetSetters {
|
|
setAvailableSize: React.Dispatch<
|
|
React.SetStateAction<{ width?: number; height?: number } | null>
|
|
>;
|
|
setDesktopContentWidth: React.Dispatch<React.SetStateAction<number | null>>;
|
|
setReferenceLeft: React.Dispatch<React.SetStateAction<number | null>>;
|
|
setReferenceWidth: React.Dispatch<React.SetStateAction<number | null>>;
|
|
}
|
|
|
|
function useDesktopPositionReset(
|
|
isOpen: boolean,
|
|
isMobile: boolean,
|
|
desktopPlacement: ComboboxDesktopPlacement,
|
|
update: () => unknown,
|
|
setters: DesktopResetSetters,
|
|
) {
|
|
const { setAvailableSize, setDesktopContentWidth, setReferenceLeft, setReferenceWidth } = setters;
|
|
useEffect(() => {
|
|
if (!isOpen || isMobile) {
|
|
setAvailableSize(null);
|
|
setDesktopContentWidth(null);
|
|
setReferenceLeft(null);
|
|
setReferenceWidth(null);
|
|
return;
|
|
}
|
|
const raf = requestAnimationFrame(() => void update());
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [
|
|
desktopPlacement,
|
|
isMobile,
|
|
isOpen,
|
|
update,
|
|
setAvailableSize,
|
|
setDesktopContentWidth,
|
|
setReferenceLeft,
|
|
setReferenceWidth,
|
|
]);
|
|
}
|
|
|
|
function useAnchorMeasure(
|
|
isOpen: boolean,
|
|
isMobile: boolean,
|
|
anchorRef: React.RefObject<View | null>,
|
|
searchQuery: string,
|
|
windowHeight: number,
|
|
setters: MeasuredAnchorSetters,
|
|
) {
|
|
const { setReferenceLeft, setReferenceTop, setReferenceWidth, setReferenceAtOrigin } = setters;
|
|
useEffect(() => {
|
|
if (!isOpen || isMobile) {
|
|
setReferenceLeft(null);
|
|
setReferenceAtOrigin(false);
|
|
setReferenceTop(null);
|
|
return;
|
|
}
|
|
|
|
const referenceEl = anchorRef.current;
|
|
if (!referenceEl) {
|
|
setReferenceAtOrigin(false);
|
|
setReferenceTop(null);
|
|
return;
|
|
}
|
|
|
|
const measure = () => {
|
|
referenceEl.measureInWindow((x, y, width) => {
|
|
applyMeasuredAnchor(
|
|
{ setReferenceLeft, setReferenceTop, setReferenceWidth, setReferenceAtOrigin },
|
|
x,
|
|
y,
|
|
width,
|
|
);
|
|
});
|
|
};
|
|
|
|
measure();
|
|
const raf = requestAnimationFrame(measure);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [
|
|
anchorRef,
|
|
isMobile,
|
|
isOpen,
|
|
searchQuery,
|
|
windowHeight,
|
|
setReferenceLeft,
|
|
setReferenceTop,
|
|
setReferenceWidth,
|
|
setReferenceAtOrigin,
|
|
]);
|
|
}
|
|
|
|
function applySetOpen(
|
|
isControlled: boolean,
|
|
setInternalOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
|
onOpenChange: ((open: boolean) => void) | undefined,
|
|
nextOpen: boolean,
|
|
) {
|
|
if (!isControlled) {
|
|
setInternalOpen(nextOpen);
|
|
}
|
|
onOpenChange?.(nextOpen);
|
|
}
|
|
|
|
function applySetSearchQuery(
|
|
setSearchQuery: React.Dispatch<React.SetStateAction<string>>,
|
|
onSearchQueryChange: ((query: string) => void) | undefined,
|
|
nextQuery: string,
|
|
) {
|
|
setSearchQuery(nextQuery);
|
|
onSearchQueryChange?.(nextQuery);
|
|
}
|
|
|
|
function applyDesktopContentLayout(
|
|
event: LayoutChangeEvent,
|
|
setDesktopContentWidth: React.Dispatch<React.SetStateAction<number | null>>,
|
|
useMeasuredTopStartPosition: boolean,
|
|
hasResolvedDesktopPosition: boolean,
|
|
update: () => unknown,
|
|
) {
|
|
const { width } = event.nativeEvent.layout;
|
|
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
|
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
|
void update();
|
|
}
|
|
}
|
|
|
|
function runIfSelected(keepOpenOnSelect: boolean, handleClose: () => void) {
|
|
if (!keepOpenOnSelect) {
|
|
handleClose();
|
|
}
|
|
}
|
|
|
|
function runIfPinOpen(isOpen: boolean, pin: () => void) {
|
|
if (!isOpen) return;
|
|
pin();
|
|
}
|
|
|
|
function runIfSubmitSearch(
|
|
showCustomOption: boolean,
|
|
handleSelect: (id: string) => void,
|
|
sanitizedSearchValue: string,
|
|
) {
|
|
if (showCustomOption) {
|
|
handleSelect(sanitizedSearchValue);
|
|
}
|
|
}
|
|
|
|
function computeCollisionPadding(): number {
|
|
const basePadding = 16;
|
|
if (Platform.OS !== "android") return basePadding;
|
|
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
|
return Math.max(basePadding, statusBarHeight + basePadding);
|
|
}
|
|
|
|
function scrollDesktopOptionsToEnd(scrollRef: React.RefObject<ScrollView | null>) {
|
|
scrollRef.current?.scrollToEnd({ animated: false });
|
|
requestAnimationFrame(() => {
|
|
scrollRef.current?.scrollToEnd({ animated: false });
|
|
});
|
|
}
|
|
|
|
function resolveEffectiveOptionsPosition(
|
|
isMobile: boolean,
|
|
optionsPosition: "below-search" | "above-search",
|
|
): "below-search" | "above-search" {
|
|
return isMobile ? "below-search" : optionsPosition;
|
|
}
|
|
|
|
function resolveIsDesktopAboveSearch(
|
|
isMobile: boolean,
|
|
effectiveOptionsPosition: "below-search" | "above-search",
|
|
): boolean {
|
|
return !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
|
}
|
|
|
|
function maybePinDesktopOptionsToBottom(
|
|
isMobile: boolean,
|
|
effectiveOptionsPosition: "below-search" | "above-search",
|
|
scrollRef: React.RefObject<ScrollView | null>,
|
|
) {
|
|
if (isMobile || effectiveOptionsPosition !== "above-search") return;
|
|
scrollDesktopOptionsToEnd(scrollRef);
|
|
}
|
|
|
|
interface FloatingMiddlewareInput {
|
|
collisionPadding: number;
|
|
isDesktopAboveSearch: boolean;
|
|
setAvailableSize: FloatingSizeSetters["setAvailableSize"];
|
|
setReferenceWidth: FloatingSizeSetters["setReferenceWidth"];
|
|
}
|
|
|
|
function buildFloatingMiddleware(input: FloatingMiddlewareInput) {
|
|
const { collisionPadding, isDesktopAboveSearch, setAvailableSize, setReferenceWidth } = input;
|
|
return [
|
|
floatingOffset(isWeb ? 5 : 4),
|
|
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
|
|
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
|
floatingSize({
|
|
padding: collisionPadding,
|
|
apply({ availableWidth, availableHeight, rects }) {
|
|
updateAvailableSize(setAvailableSize, availableWidth, availableHeight);
|
|
updateReferenceWidth(setReferenceWidth, rects.reference.width);
|
|
},
|
|
}),
|
|
];
|
|
}
|
|
|
|
function isDesktopKey(key: string): key is DesktopKey {
|
|
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
|
|
}
|
|
|
|
function dispatchDesktopKey(
|
|
input: DesktopKeyHandlerInput,
|
|
key: DesktopKey,
|
|
event?: KeyboardEvent,
|
|
): boolean {
|
|
if (!input.isOpen) return false;
|
|
if (!IS_WEB && input.isMobile) return false;
|
|
|
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
|
event?.preventDefault();
|
|
handleDesktopArrowKey(input, key);
|
|
return true;
|
|
}
|
|
if (key === "Enter") {
|
|
if (input.orderedVisibleOptions.length === 0) return false;
|
|
event?.preventDefault();
|
|
handleDesktopEnterKey(input);
|
|
return true;
|
|
}
|
|
if (key === "Escape") {
|
|
event?.preventDefault();
|
|
input.handleClose();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveInitialActiveIndex(
|
|
orderedVisibleOptions: ComboboxOption[],
|
|
effectiveOptionsPosition: "below-search" | "above-search",
|
|
normalizedSearch: string,
|
|
value: string,
|
|
): number {
|
|
if (orderedVisibleOptions.length === 0) return -1;
|
|
const fallbackIndex = getComboboxFallbackIndex(
|
|
orderedVisibleOptions.length,
|
|
effectiveOptionsPosition,
|
|
);
|
|
if (normalizedSearch) return fallbackIndex;
|
|
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
|
|
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
|
}
|
|
|
|
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
|
|
|
|
interface MobileBodyProps {
|
|
bottomSheetRef: BottomSheetVisibility["sheetRef"];
|
|
snapPoints: string[];
|
|
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
|
|
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
|
|
handleIndicatorStyle: { backgroundColor: string };
|
|
titleColor: string;
|
|
title: string;
|
|
header: SheetHeader | undefined;
|
|
onClose: () => void;
|
|
stickyHeader: ReactNode;
|
|
footer: ReactNode;
|
|
searchable: boolean;
|
|
hasChildren: boolean;
|
|
mobileChildrenScrollEnabled: boolean;
|
|
mobileChildrenContentContainerStyle: StyleProp<ViewStyle>;
|
|
presentation?: "push" | "replace";
|
|
searchResetKey: number;
|
|
searchPlaceholder: string;
|
|
searchQuery: string;
|
|
setSearchQueryWithCallback: (query: string) => void;
|
|
handleSubmitSearch: () => void;
|
|
orderedVisibleOptions: ComboboxOption[];
|
|
value: string;
|
|
activeIndex: number;
|
|
emptyText: string;
|
|
handleSelect: (id: string) => void;
|
|
renderOption: RenderOptionFn | undefined;
|
|
children: ReactNode;
|
|
safeAreaBottom: number;
|
|
}
|
|
|
|
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|
const comboboxTitleStyle = useMemo(
|
|
() => [styles.comboboxTitle, { color: props.titleColor }],
|
|
[props.titleColor],
|
|
);
|
|
const { footer, safeAreaBottom } = props;
|
|
const frameStyle = useMemo(
|
|
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
|
[footer, safeAreaBottom],
|
|
);
|
|
const footerStyle = useMemo(
|
|
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
|
[safeAreaBottom],
|
|
);
|
|
|
|
const footerView = useMemo(
|
|
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
|
[footer, footerStyle],
|
|
);
|
|
|
|
const body = props.hasChildren ? (
|
|
props.children
|
|
) : (
|
|
<OptionsList
|
|
options={props.orderedVisibleOptions}
|
|
value={props.value}
|
|
activeIndex={props.activeIndex}
|
|
emptyText={props.emptyText}
|
|
onSelect={props.handleSelect}
|
|
renderOption={props.renderOption}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<IsolatedBottomSheetModal
|
|
ref={props.bottomSheetRef}
|
|
contextBridge={null}
|
|
snapPoints={props.snapPoints}
|
|
index={0}
|
|
enableDynamicSizing={false}
|
|
onChange={props.handleSheetChange}
|
|
onDismiss={props.handleSheetDismiss}
|
|
backdropOpacity={0.45}
|
|
enablePanDownToClose
|
|
backgroundComponent={ComboboxSheetBackground}
|
|
handleIndicatorStyle={props.handleIndicatorStyle}
|
|
keyboardBehavior="extend"
|
|
keyboardBlurBehavior="none"
|
|
presentation={props.presentation}
|
|
>
|
|
<SheetVisibleFrame footer={footerView}>
|
|
<View style={frameStyle}>
|
|
{props.header ? (
|
|
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
|
) : (
|
|
<>
|
|
<View style={styles.bottomSheetHeader}>
|
|
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
|
{props.title}
|
|
</Text>
|
|
</View>
|
|
{props.stickyHeader}
|
|
{!props.hasChildren && props.searchable ? (
|
|
<SearchInput
|
|
placeholder={props.searchPlaceholder}
|
|
onChangeText={props.setSearchQueryWithCallback}
|
|
onSubmitEditing={props.handleSubmitSearch}
|
|
autoFocus={false}
|
|
useBottomSheetInput
|
|
resetKey={props.searchResetKey}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
|
body
|
|
) : (
|
|
<BottomSheetScrollView
|
|
style={styles.mobileSheetBody}
|
|
contentContainerStyle={[
|
|
styles.comboboxScrollContent,
|
|
props.mobileChildrenContentContainerStyle,
|
|
]}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{body}
|
|
</BottomSheetScrollView>
|
|
)}
|
|
</View>
|
|
</SheetVisibleFrame>
|
|
</IsolatedBottomSheetModal>
|
|
);
|
|
}
|
|
|
|
interface DesktopBodyProps {
|
|
overlayLayer: number;
|
|
isOpen: boolean;
|
|
handleClose: () => void;
|
|
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
|
|
refs: ReturnType<typeof useFloating>["refs"];
|
|
shouldUseDesktopFade: boolean;
|
|
desktopFrameStyle: StyleProp<ViewStyle>;
|
|
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
|
|
header: SheetHeader | undefined;
|
|
stickyHeader: ReactNode;
|
|
footer: ReactNode;
|
|
searchable: boolean;
|
|
searchPlaceholder: string;
|
|
searchQuery: string;
|
|
setSearchQueryWithCallback: (query: string) => void;
|
|
handleSubmitSearch: () => void;
|
|
effectiveOptionsPosition: "below-search" | "above-search";
|
|
desktopOptionsScrollRef: React.RefObject<ScrollView | null>;
|
|
desktopAboveSearchContentContainerStyle: unknown;
|
|
handleDesktopOptionsContentSizeChange: () => void;
|
|
orderedVisibleOptions: ComboboxOption[];
|
|
value: string;
|
|
activeIndex: number;
|
|
emptyText: string;
|
|
handleSelect: (id: string) => void;
|
|
renderOption: RenderOptionFn | undefined;
|
|
hasChildren: boolean;
|
|
childrenScrollEnabled: boolean;
|
|
children: ReactNode;
|
|
}
|
|
|
|
function DesktopComboboxChildrenBody(props: {
|
|
header: SheetHeader | undefined;
|
|
stickyHeader: ReactNode;
|
|
scrollEnabled: boolean;
|
|
children: ReactNode;
|
|
}): ReactElement {
|
|
const header = props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader;
|
|
if (!props.scrollEnabled) {
|
|
return (
|
|
<>
|
|
{header}
|
|
<View style={styles.desktopChildrenBody}>{props.children}</View>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{header}
|
|
<ScrollView
|
|
contentContainerStyle={styles.desktopChildrenScrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
style={styles.desktopScroll}
|
|
>
|
|
{props.children}
|
|
</ScrollView>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DesktopComboboxOptionsBody(props: {
|
|
header: SheetHeader | undefined;
|
|
stickyHeader: ReactNode;
|
|
searchable: boolean;
|
|
searchPlaceholder: string;
|
|
searchQuery: string;
|
|
setSearchQueryWithCallback: (query: string) => void;
|
|
handleSubmitSearch: () => void;
|
|
effectiveOptionsPosition: "below-search" | "above-search";
|
|
desktopOptionsScrollRef: React.RefObject<ScrollView | null>;
|
|
desktopAboveSearchContentContainerStyle: unknown;
|
|
handleDesktopOptionsContentSizeChange: () => void;
|
|
orderedVisibleOptions: ComboboxOption[];
|
|
value: string;
|
|
activeIndex: number;
|
|
emptyText: string;
|
|
handleSelect: (id: string) => void;
|
|
renderOption: RenderOptionFn | undefined;
|
|
}): ReactElement {
|
|
const list = (
|
|
<OptionsList
|
|
options={props.orderedVisibleOptions}
|
|
value={props.value}
|
|
activeIndex={props.activeIndex}
|
|
emptyText={props.emptyText}
|
|
onSelect={props.handleSelect}
|
|
renderOption={props.renderOption}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
|
|
{props.header || !props.searchable ? null : (
|
|
<SearchInput
|
|
placeholder={props.searchPlaceholder}
|
|
onChangeText={props.setSearchQueryWithCallback}
|
|
onSubmitEditing={props.handleSubmitSearch}
|
|
autoFocus
|
|
useBottomSheetInput={false}
|
|
/>
|
|
)}
|
|
{props.effectiveOptionsPosition === "above-search" ? (
|
|
<ScrollView
|
|
ref={props.desktopOptionsScrollRef}
|
|
contentContainerStyle={props.desktopAboveSearchContentContainerStyle as never}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
style={styles.desktopScroll}
|
|
onContentSizeChange={props.handleDesktopOptionsContentSizeChange}
|
|
>
|
|
{list}
|
|
</ScrollView>
|
|
) : (
|
|
<ScrollView
|
|
contentContainerStyle={styles.desktopScrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
style={styles.desktopScroll}
|
|
>
|
|
{list}
|
|
</ScrollView>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
|
const handleDesktopKey = props.handleDesktopKey;
|
|
const handleWebOverlayKeyDown = useCallback(
|
|
(event: KeyboardEvent) => {
|
|
if (!isDesktopKey(event.key)) return false;
|
|
return handleDesktopKey(event.key, event);
|
|
},
|
|
[handleDesktopKey],
|
|
);
|
|
const setWebOverlayScope = useWebOverlayRegistration({
|
|
active: isWeb && props.isOpen,
|
|
layer: props.overlayLayer,
|
|
onKeyDown: handleWebOverlayKeyDown,
|
|
});
|
|
const setFloatingRef = useCallback(
|
|
(node: View | null) => {
|
|
props.refs.setFloating(node);
|
|
setWebOverlayScope(node);
|
|
},
|
|
[props.refs, setWebOverlayScope],
|
|
);
|
|
|
|
const overlay = (
|
|
<OverlayLayerProvider layer={props.overlayLayer}>
|
|
<View
|
|
ref={props.refs.setOffsetParent}
|
|
collapsable={false}
|
|
style={[
|
|
styles.desktopOverlay,
|
|
isWeb ? styles.desktopOverlayWeb : null,
|
|
isWeb ? { zIndex: props.overlayLayer } : null,
|
|
]}
|
|
>
|
|
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
|
|
<FloatingSurface
|
|
testID="combobox-desktop-container"
|
|
entering={props.shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
|
|
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
|
style={styles.desktopContainer}
|
|
frameStyle={props.desktopFrameStyle}
|
|
ref={setFloatingRef}
|
|
collapsable={false}
|
|
role="dialog"
|
|
aria-modal
|
|
tabIndex={-1}
|
|
onLayout={props.handleDesktopContentLayout}
|
|
>
|
|
{props.hasChildren ? (
|
|
<DesktopComboboxChildrenBody
|
|
header={props.header}
|
|
stickyHeader={props.stickyHeader}
|
|
scrollEnabled={props.childrenScrollEnabled}
|
|
>
|
|
{props.children}
|
|
</DesktopComboboxChildrenBody>
|
|
) : (
|
|
<DesktopComboboxOptionsBody
|
|
header={props.header}
|
|
stickyHeader={props.stickyHeader}
|
|
searchable={props.searchable}
|
|
searchPlaceholder={props.searchPlaceholder}
|
|
searchQuery={props.searchQuery}
|
|
setSearchQueryWithCallback={props.setSearchQueryWithCallback}
|
|
handleSubmitSearch={props.handleSubmitSearch}
|
|
effectiveOptionsPosition={props.effectiveOptionsPosition}
|
|
desktopOptionsScrollRef={props.desktopOptionsScrollRef}
|
|
desktopAboveSearchContentContainerStyle={
|
|
props.desktopAboveSearchContentContainerStyle
|
|
}
|
|
handleDesktopOptionsContentSizeChange={props.handleDesktopOptionsContentSizeChange}
|
|
orderedVisibleOptions={props.orderedVisibleOptions}
|
|
value={props.value}
|
|
activeIndex={props.activeIndex}
|
|
emptyText={props.emptyText}
|
|
handleSelect={props.handleSelect}
|
|
renderOption={props.renderOption}
|
|
/>
|
|
)}
|
|
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
|
</FloatingSurface>
|
|
</View>
|
|
</OverlayLayerProvider>
|
|
);
|
|
|
|
if (isWeb && typeof document !== "undefined") {
|
|
return createPortal(overlay, getOverlayRoot());
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
transparent
|
|
animationType="none"
|
|
visible={props.isOpen}
|
|
onRequestClose={props.handleClose}
|
|
>
|
|
{/* Android Modal opens a separate window outside the app's gesture root. */}
|
|
<GestureHandlerRootView style={styles.desktopOverlay}>{overlay}</GestureHandlerRootView>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function Combobox({
|
|
options,
|
|
value,
|
|
onSelect,
|
|
renderOption,
|
|
onSearchQueryChange,
|
|
searchable = true,
|
|
placeholder,
|
|
searchPlaceholder,
|
|
emptyText,
|
|
allowCustomValue = false,
|
|
customValuePrefix = "Use",
|
|
customValueDescription,
|
|
customValueKind,
|
|
optionsPosition = "below-search",
|
|
title,
|
|
header,
|
|
mobileChildrenScrollEnabled = true,
|
|
desktopChildrenScrollEnabled = true,
|
|
mobileChildrenContentContainerStyle,
|
|
presentation,
|
|
open,
|
|
onOpenChange,
|
|
desktopPlacement = "bottom-start",
|
|
desktopPreventInitialFlash = true,
|
|
desktopMinWidth,
|
|
desktopLockWidth,
|
|
desktopFixedHeight,
|
|
stickyHeader,
|
|
footer,
|
|
keepOpenOnSelect = false,
|
|
anchorRef,
|
|
children,
|
|
}: ComboboxProps): ReactElement | null {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const resolvedPlaceholder = placeholder ?? t("common.placeholders.search");
|
|
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
|
const resolvedTitle = title ?? t("common.actions.select");
|
|
const isMobile = useIsCompactFormFactor();
|
|
const floatingLayer = useOverlayLayer("floating");
|
|
const safeAreaInsets = useSafeAreaInsets();
|
|
const titleColor = theme.colors.foreground;
|
|
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
|
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
|
|
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
|
null,
|
|
);
|
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
|
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
|
|
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
|
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
|
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
|
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const { isControlled, isOpen } = resolveControlledOpen(open, internalOpen);
|
|
|
|
const setOpen = useCallback(
|
|
(nextOpen: boolean) => applySetOpen(isControlled, setInternalOpen, onOpenChange, nextOpen),
|
|
[isControlled, onOpenChange],
|
|
);
|
|
|
|
const setSearchQueryWithCallback = useCallback(
|
|
(nextQuery: string) => applySetSearchQuery(setSearchQuery, onSearchQueryChange, nextQuery),
|
|
[onSearchQueryChange],
|
|
);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setOpen(false);
|
|
setSearchQueryWithCallback("");
|
|
bumpSearchResetKey();
|
|
}, [setOpen, setSearchQueryWithCallback]);
|
|
|
|
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
|
|
|
const collisionPadding = useMemo(computeCollisionPadding, []);
|
|
|
|
const middleware = useMemo(
|
|
() =>
|
|
buildFloatingMiddleware({
|
|
collisionPadding,
|
|
isDesktopAboveSearch,
|
|
setAvailableSize,
|
|
setReferenceWidth,
|
|
}),
|
|
[collisionPadding, isDesktopAboveSearch],
|
|
);
|
|
|
|
const { refs, floatingStyles, update } = useFloating({
|
|
placement: isWeb ? desktopPlacement : "bottom-start",
|
|
middleware,
|
|
sameScrollView: false,
|
|
elements: {
|
|
reference: anchorRef.current ?? undefined,
|
|
},
|
|
});
|
|
|
|
useDesktopPositionReset(isOpen, isMobile, desktopPlacement, update, {
|
|
setAvailableSize,
|
|
setDesktopContentWidth,
|
|
setReferenceLeft,
|
|
setReferenceWidth,
|
|
});
|
|
|
|
useAnchorMeasure(isOpen, isMobile, anchorRef, searchQuery, windowHeight, {
|
|
setReferenceLeft,
|
|
setReferenceTop,
|
|
setReferenceWidth,
|
|
setReferenceAtOrigin,
|
|
});
|
|
|
|
const floatingTop = toNumericStyleValue(floatingStyles.top);
|
|
const floatingLeft = toNumericStyleValue(floatingStyles.left);
|
|
|
|
const {
|
|
desktopPositionStyle,
|
|
hasResolvedDesktopPosition,
|
|
shouldHideDesktopContent,
|
|
shouldUseDesktopFade,
|
|
useMeasuredTopStartPosition,
|
|
} = computeDesktopPosition({
|
|
isDesktopAboveSearch,
|
|
isMobile,
|
|
desktopPlacement,
|
|
referenceTop,
|
|
referenceLeft,
|
|
referenceAtOrigin,
|
|
desktopContentWidth,
|
|
windowWidth,
|
|
windowHeight,
|
|
collisionPadding,
|
|
floatingTop,
|
|
floatingLeft,
|
|
floatingStyles,
|
|
desktopPreventInitialFlash,
|
|
referenceWidth,
|
|
});
|
|
|
|
const {
|
|
sheetRef: bottomSheetRef,
|
|
handleSheetChange,
|
|
handleSheetDismiss,
|
|
} = useIsolatedBottomSheetVisibility({
|
|
visible: isOpen,
|
|
isEnabled: isMobile,
|
|
onClose: handleClose,
|
|
});
|
|
|
|
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
|
const sanitizedSearchValue = searchQuery.trim();
|
|
const showCustomOption = useMemo(
|
|
() =>
|
|
shouldShowCustomComboboxOption({
|
|
options,
|
|
searchQuery,
|
|
searchable,
|
|
allowCustomValue,
|
|
}),
|
|
[allowCustomValue, options, searchQuery, searchable],
|
|
);
|
|
|
|
const visibleOptions = useMemo(
|
|
() =>
|
|
buildVisibleComboboxOptions({
|
|
options,
|
|
searchQuery,
|
|
searchable,
|
|
allowCustomValue,
|
|
customValuePrefix,
|
|
customValueDescription,
|
|
customValueKind,
|
|
}),
|
|
[
|
|
allowCustomValue,
|
|
customValueDescription,
|
|
customValueKind,
|
|
customValuePrefix,
|
|
options,
|
|
searchQuery,
|
|
searchable,
|
|
],
|
|
);
|
|
|
|
const orderedVisibleOptions = useMemo(
|
|
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
|
[effectiveOptionsPosition, visibleOptions],
|
|
);
|
|
|
|
const handleDesktopContentLayout = useCallback(
|
|
(event: LayoutChangeEvent) =>
|
|
applyDesktopContentLayout(
|
|
event,
|
|
setDesktopContentWidth,
|
|
useMeasuredTopStartPosition,
|
|
hasResolvedDesktopPosition,
|
|
update,
|
|
),
|
|
[useMeasuredTopStartPosition, hasResolvedDesktopPosition, update],
|
|
);
|
|
|
|
const pinDesktopOptionsToBottom = useCallback(
|
|
() =>
|
|
maybePinDesktopOptionsToBottom(isMobile, effectiveOptionsPosition, desktopOptionsScrollRef),
|
|
[effectiveOptionsPosition, isMobile],
|
|
);
|
|
|
|
const handleDesktopOptionsContentSizeChange = useCallback(
|
|
() => runIfPinOpen(isOpen, pinDesktopOptionsToBottom),
|
|
[isOpen, pinDesktopOptionsToBottom],
|
|
);
|
|
|
|
useDesktopOptionsPinToBottom(isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom);
|
|
|
|
useDesktopFloatingUpdate(isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update);
|
|
|
|
useActiveIndexSync(
|
|
isOpen,
|
|
isMobile,
|
|
orderedVisibleOptions,
|
|
effectiveOptionsPosition,
|
|
normalizedSearch,
|
|
value,
|
|
setActiveIndex,
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
onSelect(id);
|
|
runIfSelected(keepOpenOnSelect, handleClose);
|
|
},
|
|
[handleClose, keepOpenOnSelect, onSelect],
|
|
);
|
|
|
|
const handleSubmitSearch = useCallback(
|
|
() => runIfSubmitSearch(showCustomOption, handleSelect, sanitizedSearchValue),
|
|
[handleSelect, sanitizedSearchValue, showCustomOption],
|
|
);
|
|
|
|
const handleDesktopKey = useCallback(
|
|
(key: DesktopKey, event?: KeyboardEvent) => {
|
|
return dispatchDesktopKey(
|
|
{
|
|
isOpen,
|
|
isMobile,
|
|
orderedVisibleOptions,
|
|
activeIndex,
|
|
setActiveIndex,
|
|
handleSelect,
|
|
handleClose,
|
|
},
|
|
key,
|
|
event,
|
|
);
|
|
},
|
|
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
|
|
);
|
|
|
|
useDismissKeyboardOnOpen(isOpen, isMobile);
|
|
|
|
const handleIndicatorStyle = useMemo(
|
|
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
|
[theme.colors.palette.zinc],
|
|
);
|
|
|
|
const desktopFrameStyle = useMemo(
|
|
() =>
|
|
buildDesktopFrameStyle({
|
|
desktopMinWidth,
|
|
desktopLockWidth: Boolean(desktopLockWidth),
|
|
referenceWidth,
|
|
desktopFixedHeight,
|
|
desktopPositionStyle,
|
|
shouldHideDesktopContent,
|
|
availableHeight: availableSize?.height,
|
|
}),
|
|
[
|
|
desktopMinWidth,
|
|
desktopLockWidth,
|
|
referenceWidth,
|
|
desktopFixedHeight,
|
|
desktopPositionStyle,
|
|
shouldHideDesktopContent,
|
|
availableSize?.height,
|
|
],
|
|
);
|
|
|
|
const desktopAboveSearchContentContainerStyle = useMemo(
|
|
() => [styles.desktopScrollContent, styles.desktopScrollContentAboveSearch],
|
|
[],
|
|
);
|
|
|
|
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
|
|
const hasChildren = Boolean(children);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<MobileComboboxBody
|
|
bottomSheetRef={bottomSheetRef}
|
|
snapPoints={snapPoints}
|
|
handleSheetChange={handleSheetChange}
|
|
handleSheetDismiss={handleSheetDismiss}
|
|
handleIndicatorStyle={handleIndicatorStyle}
|
|
titleColor={titleColor}
|
|
title={resolvedTitle}
|
|
header={header}
|
|
onClose={handleClose}
|
|
stickyHeader={stickyHeader}
|
|
footer={footer}
|
|
searchable={searchable}
|
|
hasChildren={hasChildren}
|
|
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
|
mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle}
|
|
presentation={presentation}
|
|
searchResetKey={searchResetKey}
|
|
searchPlaceholder={effectiveSearchPlaceholder}
|
|
searchQuery={searchQuery}
|
|
setSearchQueryWithCallback={setSearchQueryWithCallback}
|
|
handleSubmitSearch={handleSubmitSearch}
|
|
orderedVisibleOptions={orderedVisibleOptions}
|
|
value={value}
|
|
activeIndex={activeIndex}
|
|
emptyText={resolvedEmptyText}
|
|
handleSelect={handleSelect}
|
|
renderOption={renderOption}
|
|
safeAreaBottom={safeAreaInsets.bottom}
|
|
>
|
|
{children}
|
|
</MobileComboboxBody>
|
|
);
|
|
}
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<DesktopComboboxBody
|
|
overlayLayer={floatingLayer}
|
|
isOpen={isOpen}
|
|
handleClose={handleClose}
|
|
handleDesktopKey={handleDesktopKey}
|
|
refs={refs}
|
|
shouldUseDesktopFade={shouldUseDesktopFade}
|
|
desktopFrameStyle={desktopFrameStyle}
|
|
handleDesktopContentLayout={handleDesktopContentLayout}
|
|
header={header}
|
|
stickyHeader={stickyHeader}
|
|
footer={footer}
|
|
searchable={searchable}
|
|
searchPlaceholder={effectiveSearchPlaceholder}
|
|
searchQuery={searchQuery}
|
|
setSearchQueryWithCallback={setSearchQueryWithCallback}
|
|
handleSubmitSearch={handleSubmitSearch}
|
|
effectiveOptionsPosition={effectiveOptionsPosition}
|
|
desktopOptionsScrollRef={desktopOptionsScrollRef}
|
|
desktopAboveSearchContentContainerStyle={desktopAboveSearchContentContainerStyle}
|
|
handleDesktopOptionsContentSizeChange={handleDesktopOptionsContentSizeChange}
|
|
orderedVisibleOptions={orderedVisibleOptions}
|
|
value={value}
|
|
activeIndex={activeIndex}
|
|
emptyText={resolvedEmptyText}
|
|
handleSelect={handleSelect}
|
|
renderOption={renderOption}
|
|
hasChildren={hasChildren}
|
|
childrenScrollEnabled={desktopChildrenScrollEnabled}
|
|
>
|
|
{children}
|
|
</DesktopComboboxBody>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
mobileSheetFrame: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
mobileSheetBody: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
searchInputContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: theme.spacing[3],
|
|
gap: theme.spacing[2],
|
|
backgroundColor: theme.colors.surface1,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
paddingVertical: theme.spacing[3],
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
},
|
|
comboboxItem: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
minHeight: 36,
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
borderRadius: 0,
|
|
...(IS_WEB
|
|
? {}
|
|
: {
|
|
marginHorizontal: theme.spacing[1],
|
|
marginBottom: theme.spacing[1],
|
|
}),
|
|
},
|
|
comboboxItemHovered: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
comboboxItemHoveredElevated: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
comboboxItemPressed: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
comboboxItemPressedElevated: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
comboboxItemActive: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
comboboxItemDisabled: {
|
|
opacity: 0.55,
|
|
},
|
|
comboboxItemTrailingSlot: {
|
|
width: 16,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
comboboxItemTrailingContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
marginLeft: "auto",
|
|
},
|
|
comboboxItemContent: {
|
|
flex: 1,
|
|
flexShrink: 1,
|
|
},
|
|
comboboxItemContentInline: {
|
|
flexDirection: "row",
|
|
alignItems: "baseline",
|
|
gap: theme.spacing[2],
|
|
},
|
|
comboboxItemLeadingSlot: {
|
|
width: 16,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
comboboxItemLabel: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foreground,
|
|
flexShrink: 0,
|
|
},
|
|
comboboxItemDescription: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
flexShrink: 1,
|
|
},
|
|
emptyText: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
},
|
|
footer: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.colors.border,
|
|
},
|
|
bottomSheetHeader: {
|
|
paddingHorizontal: theme.spacing[6],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
comboboxTitle: {
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.medium,
|
|
textAlign: "left",
|
|
},
|
|
comboboxScrollContent: {
|
|
paddingBottom: theme.spacing[8],
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingTop: theme.spacing[1],
|
|
},
|
|
desktopOverlay: {
|
|
flex: 1,
|
|
},
|
|
desktopOverlayWeb: {
|
|
...StyleSheet.absoluteFillObject,
|
|
pointerEvents: "auto" as const,
|
|
},
|
|
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.border,
|
|
...theme.shadow.md,
|
|
maxHeight: 400,
|
|
overflow: "hidden",
|
|
},
|
|
desktopScroll: {
|
|
flexShrink: 1,
|
|
minHeight: 0,
|
|
},
|
|
desktopScrollContent: {
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
desktopChildrenScrollContent: {
|
|
// No padding — custom children (e.g. model selector) control their own spacing
|
|
},
|
|
desktopChildrenBody: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
desktopScrollContentAboveSearch: {
|
|
flexGrow: 1,
|
|
justifyContent: "flex-end",
|
|
},
|
|
}));
|