Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
@@ -10,19 +10,11 @@ const OPTIONS = ["alpha", "beta", "gamma"];
describe("orderAutocompleteOptions", () => {
it("keeps first logical option closest to the input by default", () => {
expect(orderAutocompleteOptions(OPTIONS)).toEqual([
"gamma",
"beta",
"alpha",
]);
expect(orderAutocompleteOptions(OPTIONS)).toEqual(["gamma", "beta", "alpha"]);
});
it("keeps normal top-down order when below-input is selected", () => {
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([
"alpha",
"beta",
"gamma",
]);
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual(["alpha", "beta", "gamma"]);
});
});
@@ -45,7 +37,7 @@ describe("getAutocompleteScrollOffset", () => {
viewportHeight: 80,
itemTop: 90,
itemHeight: 20,
})
}),
).toBe(90);
});
@@ -56,7 +48,7 @@ describe("getAutocompleteScrollOffset", () => {
viewportHeight: 100,
itemTop: 150,
itemHeight: 24,
})
}),
).toBe(74);
});
});
@@ -4,7 +4,7 @@ export type AutocompleteOptionsPosition = "above-input" | "below-input";
export function orderAutocompleteOptions<T>(
options: readonly T[],
position: AutocompleteOptionsPosition = "above-input"
position: AutocompleteOptionsPosition = "above-input",
): T[] {
if (position === "below-input") {
return [...options];
@@ -14,7 +14,7 @@ export function orderAutocompleteOptions<T>(
export function getAutocompleteFallbackIndex(
itemCount: number,
position: AutocompleteOptionsPosition = "above-input"
position: AutocompleteOptionsPosition = "above-input",
): number {
if (itemCount <= 0) {
return -1;
+80 -80
View File
@@ -1,37 +1,37 @@
import { useCallback, useEffect, useRef } from 'react'
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { File, Folder } from 'lucide-react-native'
import { Theme } from '@/styles/theme'
import { getAutocompleteScrollOffset } from './autocomplete-utils'
import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { File, Folder } from "lucide-react-native";
import { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
export interface AutocompleteOption {
id: string
label: string
detail?: string
description?: string
kind?: 'command' | 'file' | 'directory'
id: string;
label: string;
detail?: string;
description?: string;
kind?: "command" | "file" | "directory";
}
interface AutocompleteProps {
options: readonly AutocompleteOption[]
selectedIndex: number
onSelect: (option: AutocompleteOption) => void
isLoading?: boolean
errorMessage?: string
loadingText?: string
emptyText?: string
maxHeight?: number
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
maxHeight?: number;
}
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g;
function removeBoltGlyphs(value?: string): string | undefined {
if (!value) {
return value
return value;
}
const cleaned = value.replace(BOLT_GLYPH_PATTERN, '').trim()
return cleaned.length > 0 ? cleaned : undefined
const cleaned = value.replace(BOLT_GLYPH_PATTERN, "").trim();
return cleaned.length > 0 ? cleaned : undefined;
}
export function Autocomplete({
@@ -40,24 +40,24 @@ export function Autocomplete({
onSelect,
isLoading = false,
errorMessage,
loadingText = 'Loading...',
emptyText = 'No results found',
loadingText = "Loading...",
emptyText = "No results found",
maxHeight = 220,
}: AutocompleteProps) {
const { theme } = useUnistyles()
const scrollRef = useRef<ScrollView>(null)
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
const viewportHeightRef = useRef(0)
const scrollOffsetRef = useRef(0)
const { theme } = useUnistyles();
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
const scrollOffsetRef = useRef(0);
const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) {
return
return;
}
const layout = rowLayoutsRef.current.get(selectedIndex)
const layout = rowLayoutsRef.current.get(selectedIndex);
if (!layout) {
return
return;
}
const nextOffset = getAutocompleteScrollOffset({
@@ -65,60 +65,60 @@ export function Autocomplete({
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
})
});
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return
return;
}
scrollOffsetRef.current = nextOffset
scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}, [selectedIndex])
scrollOffsetRef.current = nextOffset;
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}, [selectedIndex]);
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false })
scrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false })
})
}, [])
scrollRef.current?.scrollToEnd({ animated: false });
});
}, []);
useEffect(() => {
rowLayoutsRef.current.clear()
scrollOffsetRef.current = 0
}, [options])
rowLayoutsRef.current.clear();
scrollOffsetRef.current = 0;
}, [options]);
useEffect(() => {
if (options.length === 0) {
return
return;
}
pinToBottom()
}, [options, pinToBottom])
pinToBottom();
}, [options, pinToBottom]);
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible)
const raf = requestAnimationFrame(ensureActiveItemVisible);
return () => {
cancelAnimationFrame(raf)
}
}, [ensureActiveItemVisible, options.length])
cancelAnimationFrame(raf);
};
}, [ensureActiveItemVisible, options.length]);
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height
ensureActiveItemVisible()
viewportHeightRef.current = event.nativeEvent.layout.height;
ensureActiveItemVisible();
},
[ensureActiveItemVisible]
)
[ensureActiveItemVisible],
);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
})
ensureActiveItemVisible()
});
ensureActiveItemVisible();
},
[ensureActiveItemVisible]
)
[ensureActiveItemVisible],
);
if (isLoading) {
return (
@@ -127,7 +127,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{loadingText}</Text>
</View>
</View>
)
);
}
if (errorMessage) {
@@ -137,7 +137,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
)
);
}
if (options.length === 0) {
@@ -147,7 +147,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{emptyText}</Text>
</View>
</View>
)
);
}
return (
@@ -157,7 +157,7 @@ export function Autocomplete({
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
style={styles.scrollView}
@@ -165,10 +165,10 @@ export function Autocomplete({
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex
const optionLabel = removeBoltGlyphs(option.label) ?? option.label
const optionDetail = removeBoltGlyphs(option.detail)
const optionDescription = removeBoltGlyphs(option.description)
const isSelected = index === selectedIndex;
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDetail = removeBoltGlyphs(option.detail);
const optionDescription = removeBoltGlyphs(option.description);
return (
<Pressable
key={option.id}
@@ -179,9 +179,9 @@ export function Autocomplete({
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{option.kind === 'directory' || option.kind === 'file' ? (
{option.kind === "directory" || option.kind === "file" ? (
<View style={styles.itemLeading}>
{option.kind === 'directory' ? (
{option.kind === "directory" ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
@@ -200,11 +200,11 @@ export function Autocomplete({
) : null}
</View>
</Pressable>
)
);
})}
</ScrollView>
</View>
)
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
@@ -213,7 +213,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: 'hidden',
overflow: "hidden",
},
scrollView: {
flexGrow: 0,
@@ -223,16 +223,16 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
minHeight: 36,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemLeading: {
width: 18,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
marginRight: theme.spacing[1],
},
itemActive: {
@@ -243,8 +243,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
minWidth: 0,
},
itemHeader: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
itemLabel: {
@@ -269,4 +269,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>
})) as any) as Record<string, any>;
@@ -4,30 +4,19 @@ import { getNextActiveIndex } from "./combobox-keyboard";
describe("getNextActiveIndex", () => {
it("returns -1 when itemCount is 0", () => {
expect(
getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" })
).toBe(-1);
expect(getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" })).toBe(-1);
});
it("starts at 0 on ArrowDown when no active item", () => {
expect(
getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" })
).toBe(0);
expect(getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" })).toBe(0);
});
it("starts at last on ArrowUp when no active item", () => {
expect(
getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" })
).toBe(2);
expect(getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" })).toBe(2);
});
it("wraps around on ArrowDown and ArrowUp", () => {
expect(
getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" })
).toBe(0);
expect(
getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" })
).toBe(2);
expect(getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" })).toBe(0);
expect(getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" })).toBe(2);
});
});
@@ -16,4 +16,3 @@ export function getNextActiveIndex(args: {
? (normalizedCurrent + 1) % itemCount
: (normalizedCurrent - 1 + itemCount) % itemCount;
}
@@ -1,95 +1,97 @@
export type ComboboxOptionKind = 'directory' | 'file'
export type ComboboxOptionKind = "directory" | "file";
export interface ComboboxOptionModel {
id: string
label: string
description?: string
kind?: ComboboxOptionKind
id: string;
label: string;
description?: string;
kind?: ComboboxOptionKind;
}
export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
customValuePrefix: string
customValueDescription?: string
customValueKind?: ComboboxOptionKind
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
customValuePrefix: string;
customValueDescription?: string;
customValueKind?: ComboboxOptionKind;
}
export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
}): boolean {
const sanitizedSearchValue = input.searchQuery.trim()
const sanitizedSearchValue = input.searchQuery.trim();
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false
return false;
}
return !input.options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
)
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase(),
);
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput
input: BuildVisibleComboboxOptionsInput,
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
opt.description?.toLowerCase().includes(normalizedSearch),
)
: input.options
: input.options;
const sanitizedSearchValue = input.searchQuery.trim()
const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({
options: input.options,
searchQuery: input.searchQuery,
searchable: input.searchable,
allowCustomValue: input.allowCustomValue,
})
});
const visibleOptions: ComboboxOptionModel[] = []
const visibleOptions: ComboboxOptionModel[] = [];
if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim()
const trimmedPrefix = input.customValuePrefix.trim();
const customLabel =
trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
visibleOptions.push({
id: sanitizedSearchValue,
label: customLabel,
description: input.customValueDescription,
kind: input.customValueKind,
})
});
}
visibleOptions.push(...filteredOptions)
return visibleOptions
visibleOptions.push(...filteredOptions);
return visibleOptions;
}
export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[],
optionsPosition: 'below-search' | 'above-search'
optionsPosition: "below-search" | "above-search",
): ComboboxOptionModel[] {
if (optionsPosition !== 'above-search') {
return visibleOptions
if (optionsPosition !== "above-search") {
return visibleOptions;
}
return [...visibleOptions].reverse()
return [...visibleOptions].reverse();
}
export function getComboboxFallbackIndex(
itemCount: number,
optionsPosition: 'below-search' | 'above-search'
optionsPosition: "below-search" | "above-search",
): number {
if (itemCount <= 0) {
return -1
return -1;
}
return optionsPosition === 'above-search' ? itemCount - 1 : 0
return optionsPosition === "above-search" ? itemCount - 1 : 0;
}
+273 -273
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactElement, ReactNode } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
@@ -10,95 +10,95 @@ import {
Platform,
StatusBar,
useWindowDimensions,
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
} 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, File, Folder, Search } from 'lucide-react-native'
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
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'
} from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
shouldShowCustomComboboxOption,
} from './combobox-options'
import type { ComboboxOptionModel } from './combobox-options'
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
const IS_WEB = Platform.OS === 'web'
const IS_WEB = Platform.OS === "web";
export type ComboboxOption = ComboboxOptionModel
export type ComboboxOption = ComboboxOptionModel;
export interface ComboboxProps {
options: ComboboxOption[]
value: string
onSelect: (id: string) => void
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
open?: boolean
onOpenChange?: (open: boolean) => void
enableDismissOnClose?: boolean
desktopPlacement?: 'top-start' | 'bottom-start'
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;
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
desktopPlacement?: "top-start" | "bottom-start";
/**
* 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
anchorRef: React.RefObject<View | null>
children?: ReactNode
desktopPreventInitialFlash?: boolean;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
function toNumericStyleValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value)
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed
return parsed;
}
}
return null
return null;
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
}
export interface SearchInputProps {
placeholder: string
value: string
onChangeText: (text: string) => void
onSubmitEditing?: () => void
autoFocus?: boolean
placeholder: string;
value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
}
export function SearchInput({
@@ -108,18 +108,18 @@ export function SearchInput({
onSubmitEditing,
autoFocus = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles()
const inputRef = useRef<TextInput>(null)
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
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)
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus])
}, [autoFocus]);
return (
<View style={styles.searchInputContainer}>
@@ -127,7 +127,7 @@ export function SearchInput({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -137,18 +137,18 @@ export function SearchInput({
onSubmitEditing={onSubmitEditing}
/>
</View>
)
);
}
export interface ComboboxItemProps {
label: string
description?: string
kind?: 'directory' | 'file'
leadingSlot?: ReactNode
selected?: boolean
active?: boolean
onPress: () => void
testID?: string
label: string;
description?: string;
kind?: "directory" | "file";
leadingSlot?: ReactNode;
selected?: boolean;
active?: boolean;
onPress: () => void;
testID?: string;
}
export function ComboboxItem({
@@ -161,19 +161,19 @@ export function ComboboxItem({
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles()
const { theme } = useUnistyles();
const leadingContent = leadingSlot ? (
<View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>
) : kind === 'directory' || kind === 'file' ? (
) : kind === "directory" || kind === "file" ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
{kind === "directory" ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null
) : null;
return (
<Pressable
@@ -203,7 +203,7 @@ export function ComboboxItem({
</View>
) : null}
</Pressable>
)
);
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
@@ -211,7 +211,7 @@ export function ComboboxEmpty({ children }: { children: ReactNode }): ReactEleme
<Text testID="combobox-empty-text" style={styles.emptyText}>
{children}
</Text>
)
);
}
export function Combobox({
@@ -221,207 +221,207 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
placeholder = 'Search...',
placeholder = "Search...",
searchPlaceholder,
emptyText = 'No options match your search.',
emptyText = "No options match your search.",
allowCustomValue = false,
customValuePrefix = 'Use',
customValuePrefix = "Use",
customValueDescription,
customValueKind,
optionsPosition = 'below-search',
title = 'Select',
optionsPosition = "below-search",
title = "Select",
open,
onOpenChange,
enableDismissOnClose,
desktopPlacement = 'top-start',
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null)
const hasPresentedBottomSheetRef = useRef(false)
const snapPoints = useMemo(() => ['60%', '90%'], [])
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null
)
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
const [referenceTop, setReferenceTop] = useState<number | null>(null)
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState<number>(-1)
const desktopOptionsScrollRef = useRef<ScrollView>(null)
null,
);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const isControlled = typeof open === 'boolean'
const [internalOpen, setInternalOpen] = useState(false)
const isOpen = isControlled ? open : internalOpen
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = isControlled ? open : internalOpen;
const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen)
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen)
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange]
)
[isControlled, onOpenChange],
);
const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery)
setSearchQuery(nextQuery);
onSearchQueryChange?.(nextQuery);
},
[onSearchQueryChange]
)
[onSearchQueryChange],
);
const handleClose = useCallback(() => {
setOpen(false)
setSearchQueryWithCallback('')
}, [setOpen, setSearchQueryWithCallback])
setOpen(false);
setSearchQueryWithCallback("");
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (isOpen) {
setSearchQueryWithCallback('')
setSearchQueryWithCallback("");
}
}, [isOpen, setSearchQueryWithCallback])
}, [isOpen, setSearchQueryWithCallback]);
const collisionPadding = useMemo(() => {
const basePadding = 16
if (Platform.OS !== 'android') return basePadding
const statusBarHeight = StatusBar.currentHeight ?? 0
return Math.max(basePadding, statusBarHeight + basePadding)
}, [])
const basePadding = 16;
if (Platform.OS !== "android") return basePadding;
const statusBarHeight = StatusBar.currentHeight ?? 0;
return Math.max(basePadding, statusBarHeight + basePadding);
}, []);
const middleware = useMemo(
() => [
floatingOffset(Platform.OS === 'web' ? 0 : 4),
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [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
})
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
})
const next = rects.reference.width;
if (prev === next) return prev;
return next;
});
},
}),
],
[collisionPadding, isDesktopAboveSearch]
)
[collisionPadding, isDesktopAboveSearch],
);
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
middleware,
sameScrollView: false,
elements: {
reference: anchorRef.current ?? undefined,
},
})
});
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null)
setReferenceWidth(null)
return
setAvailableSize(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => void update())
return () => cancelAnimationFrame(raf)
}, [desktopPlacement, isMobile, update, isOpen])
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceAtOrigin(false)
setReferenceTop(null)
return
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const referenceEl = anchorRef.current
const referenceEl = anchorRef.current;
if (!referenceEl) {
setReferenceAtOrigin(false)
setReferenceTop(null)
return
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
setReferenceTop((prev) => (prev === y ? prev : y))
})
}
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
});
};
measure()
const raf = requestAnimationFrame(measure)
return () => cancelAnimationFrame(raf)
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
measure();
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
const floatingTop = toNumericStyleValue(floatingStyles.top)
const floatingLeft = toNumericStyleValue(floatingStyles.left)
const floatingTop = toNumericStyleValue(floatingStyles.top);
const floatingLeft = toNumericStyleValue(floatingStyles.left);
const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
const shouldUseDesktopFade = !desktopPreventInitialFlash
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles
: floatingStyles;
useEffect(() => {
if (!isMobile) return
if (!isMobile) return;
if (isOpen) {
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
bottomSheetRef.current?.snapToIndex(0)
bottomSheetRef.current?.snapToIndex(0);
} else {
hasPresentedBottomSheetRef.current = true
bottomSheetRef.current?.present()
hasPresentedBottomSheetRef.current = true;
bottomSheetRef.current?.present();
}
} else {
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close()
bottomSheetRef.current?.close();
} else {
bottomSheetRef.current?.dismiss()
bottomSheetRef.current?.dismiss();
}
}
}, [enableDismissOnClose, isOpen, isMobile])
}, [enableDismissOnClose, isOpen, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose()
handleClose();
}
},
[handleClose]
)
[handleClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
)
[],
);
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
const sanitizedSearchValue = searchQuery.trim()
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo(
() =>
shouldShowCustomComboboxOption({
@@ -430,8 +430,8 @@ export function Combobox({
searchable,
allowCustomValue,
}),
[allowCustomValue, options, searchQuery, searchable]
)
[allowCustomValue, options, searchQuery, searchable],
);
const visibleOptions = useMemo(
() =>
@@ -452,133 +452,133 @@ export function Combobox({
options,
searchQuery,
searchable,
]
)
],
);
const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions]
)
[effectiveOptionsPosition, visibleOptions],
);
const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== 'above-search') {
return
if (isMobile || effectiveOptionsPosition !== "above-search") {
return;
}
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
})
}, [effectiveOptionsPosition, isMobile])
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
});
}, [effectiveOptionsPosition, isMobile]);
const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) {
return
return;
}
pinDesktopOptionsToBottom()
}, [isOpen, pinDesktopOptionsToBottom])
pinDesktopOptionsToBottom();
}, [isOpen, pinDesktopOptionsToBottom]);
useEffect(() => {
if (!isOpen) {
return
return;
}
pinDesktopOptionsToBottom()
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
pinDesktopOptionsToBottom();
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
useLayoutEffect(() => {
if (!isOpen || isMobile) {
return
return;
}
void update()
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
void update();
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
useEffect(() => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1)
return
setActiveIndex(-1);
return;
}
const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length,
effectiveOptionsPosition
)
effectiveOptionsPosition,
);
if (normalizedSearch) {
setActiveIndex(fallbackIndex)
return
setActiveIndex(fallbackIndex);
return;
}
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]);
const handleSelect = useCallback(
(id: string) => {
onSelect(id)
handleClose()
onSelect(id);
handleClose();
},
[handleClose, onSelect]
)
[handleClose, onSelect],
);
const handleSubmitSearch = useCallback(() => {
if (showCustomOption) {
handleSelect(sanitizedSearchValue)
handleSelect(sanitizedSearchValue);
}
}, [handleSelect, sanitizedSearchValue, showCustomOption])
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
const handleDesktopKey = useCallback(
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (key === 'ArrowDown' || key === 'ArrowUp') {
event?.preventDefault()
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: orderedVisibleOptions.length,
key,
})
)
return
}),
);
return;
}
if (key === 'Enter') {
if (orderedVisibleOptions.length === 0) return
event?.preventDefault()
if (key === "Enter") {
if (orderedVisibleOptions.length === 0) return;
event?.preventDefault();
const index =
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
handleSelect(orderedVisibleOptions[index]!.id)
return
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
handleSelect(orderedVisibleOptions[index]!.id);
return;
}
if (key === 'Escape') {
event?.preventDefault()
handleClose()
if (key === "Escape") {
event?.preventDefault();
handleClose();
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
)
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
);
useEffect(() => {
if (!IS_WEB || !isOpen) return
if (!IS_WEB || !isOpen) return;
const handler = (event: KeyboardEvent) => {
const key = event.key
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
return
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
handleDesktopKey(key, event)
}
handleDesktopKey(key, event);
};
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener('keydown', handler, true)
window.addEventListener("keydown", handler, true);
return () => {
window.removeEventListener('keydown', handler, true)
}
}, [handleDesktopKey, isOpen])
window.removeEventListener("keydown", handler, true);
};
}, [handleDesktopKey, isOpen]);
const searchInput = (
<SearchInput
@@ -588,12 +588,12 @@ export function Combobox({
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>
)
);
const optionsList = (
<>
{orderedVisibleOptions.length > 0 ? (
orderedVisibleOptions.map((opt, index) => (
orderedVisibleOptions.map((opt, index) =>
renderOption ? (
<View key={opt.id}>
{renderOption({
@@ -613,23 +613,23 @@ export function Combobox({
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
/>
)
))
),
)
) : (
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
)}
</>
)
);
const defaultContent = (
<>
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
</>
)
);
const content = children ?? defaultContent
const content = children ?? defaultContent;
if (isMobile) {
return (
@@ -658,10 +658,10 @@ export function Combobox({
{content}
</BottomSheetScrollView>
</BottomSheetModal>
)
);
}
if (!isOpen) return <></>
if (!isOpen) return <></>;
return (
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
@@ -674,13 +674,13 @@ export function Combobox({
style={[
styles.desktopContainer,
{
position: 'absolute',
position: "absolute",
minWidth: referenceWidth ?? 200,
maxWidth: 400,
},
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === 'number'
typeof availableSize?.height === "number"
? { maxHeight: Math.min(availableSize.height, 400) }
: null,
]}
@@ -699,7 +699,7 @@ export function Combobox({
</ScrollView>
) : (
<>
{effectiveOptionsPosition === 'above-search' ? (
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={desktopOptionsScrollRef}
contentContainerStyle={[
@@ -715,7 +715,7 @@ export function Combobox({
</ScrollView>
) : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === 'below-search' ? (
{effectiveOptionsPosition === "below-search" ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
@@ -730,13 +730,13 @@ export function Combobox({
</Animated.View>
</View>
</Modal>
)
);
}
const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
@@ -754,8 +754,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
comboboxItem: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
@@ -779,9 +779,9 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
alignItems: "center",
justifyContent: "center",
marginLeft: "auto",
},
comboboxItemContent: {
flex: 1,
@@ -789,8 +789,8 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
@@ -809,8 +809,8 @@ const styles = StyleSheet.create((theme) => ({
},
bottomSheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
@@ -823,7 +823,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: 'left',
textAlign: "left",
},
comboboxScrollContent: {
paddingBottom: theme.spacing[8],
@@ -834,7 +834,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
desktopBackdrop: {
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
@@ -845,13 +845,13 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: '#000',
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: 'hidden',
overflow: "hidden",
},
desktopScroll: {
flexShrink: 1,
@@ -862,6 +862,6 @@ const styles = StyleSheet.create((theme) => ({
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: 'flex-end',
justifyContent: "flex-end",
},
}))
}));
+39 -29
View File
@@ -29,11 +29,7 @@ import {
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import {
BottomSheetBackdrop,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -88,7 +84,7 @@ function useControllableOpenState({
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
[isControlled, onOpenChange],
);
return [value, setValue];
}
@@ -120,8 +116,7 @@ function computePosition({
// Calculate available space
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
// Flip if needed
let actualPlacement = placement;
@@ -163,7 +158,7 @@ function computePosition({
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!)
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
);
return { x, y, actualPlacement };
@@ -226,7 +221,7 @@ export function ContextMenu({
anchorRect,
setAnchorRect,
}),
[anchorRect, isOpen, setAnchorRect, setIsOpen]
[anchorRect, isOpen, setAnchorRect, setIsOpen],
);
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
@@ -258,8 +253,7 @@ export function ContextMenuTrigger({
const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform =
enabled &&
(Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
@@ -279,7 +273,7 @@ export function ContextMenuTrigger({
});
ctx.setOpen(true);
},
[ctx, disabled, shouldEnableOnThisPlatform]
[ctx, disabled, shouldEnableOnThisPlatform],
);
const handleRef = useCallback(
@@ -287,7 +281,7 @@ export function ContextMenuTrigger({
assignRef(ctx.triggerRef, node);
assignRef(triggerRef, node);
},
[ctx.triggerRef, triggerRef]
[ctx.triggerRef, triggerRef],
);
return (
@@ -380,19 +374,14 @@ export function ContextMenuContent({
handleClose();
}
},
[handleClose]
[handleClose],
);
const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} opacity={0.45} />
),
[]
[],
);
// Measure trigger when opening (fallback) and capture point anchors.
@@ -468,7 +457,7 @@ export function ContextMenuContent({
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[]
[],
);
if (useMobileSheet) {
@@ -543,7 +532,11 @@ export function ContextMenuContent({
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
@@ -564,7 +557,13 @@ export function ContextMenuLabel({
);
}
export function ContextMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
export function ContextMenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
@@ -638,7 +637,10 @@ export function ContextMenuItem({
}
const trailingContent =
trailing ?? (!showSelectedCheck && selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null);
trailing ??
(!showSelectedCheck && selected ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
<Pressable
@@ -654,8 +656,14 @@ export function ContextMenuItem({
}}
style={({ pressed, hovered }) => [
styles.item,
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
@@ -684,7 +692,9 @@ export function ContextMenuItem({
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
selected && selectedVariant === "accent"
? styles.itemDescriptionSelectedAccent
: null,
]}
>
{description}
@@ -170,7 +170,6 @@ export function useDropdownFloating({
}, []);
const update = useCallback(async () => {
if (!referenceEl || !floatingElRef.current) {
return;
}
@@ -209,7 +208,6 @@ export function useDropdownFloating({
}, []);
useEffect(() => {
if (!open) {
setGeometry(null);
setFloatingReady(false);
@@ -74,7 +74,7 @@ function useControllableOpenState({
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
[isControlled, onOpenChange],
);
return [value, setValue];
}
@@ -146,7 +146,10 @@ function computePosition({
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
);
return { x, y, actualPlacement };
}
@@ -174,16 +177,14 @@ export function DropdownMenu({
setOpen: setIsOpen,
triggerRef,
}),
[isOpen, setIsOpen]
[isOpen, setIsOpen],
);
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
}
type TriggerState = { pressed: boolean; hovered: boolean; open: boolean };
type TriggerStyleProp =
| StyleProp<ViewStyle>
| ((state: TriggerState) => StyleProp<ViewStyle>);
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
style?: TriggerStyleProp;
@@ -315,10 +316,13 @@ export function DropdownMenuContent({
setPosition({ x, y: result.y });
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback((event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
}, []);
const handleContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[],
);
if (!open) return null;
@@ -363,7 +367,11 @@ export function DropdownMenuContent({
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
@@ -384,7 +392,13 @@ export function DropdownMenuLabel({
);
}
export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
export function DropdownMenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
@@ -464,7 +478,10 @@ export function DropdownMenuItem({
}
const trailingContent =
trailing ?? (!showSelectedCheck && selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null);
trailing ??
(!showSelectedCheck && selected ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
<Pressable
@@ -480,7 +497,11 @@ export function DropdownMenuItem({
}}
style={({ pressed, hovered }) => [
styles.item,
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
@@ -512,7 +533,9 @@ export function DropdownMenuItem({
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
selected && selectedVariant === "accent"
? styles.itemDescriptionSelectedAccent
: null,
]}
>
{description}
@@ -5,10 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
type SegmentedControlIconRenderer = (props: {
color: string;
size: number;
}) => ReactNode;
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
export type SegmentedControlOption<T extends string> = {
value: T;
+16 -28
View File
@@ -59,7 +59,7 @@ function useTooltipContext(componentName: string): TooltipContextValue {
function composeEventHandlers<E>(
original?: (event: E) => void,
injected?: (event: E) => void
injected?: (event: E) => void,
): (event: E) => void {
return (event: E) => {
original?.(event);
@@ -94,7 +94,7 @@ function useControllableOpenState({
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
[isControlled, onOpenChange],
);
return [value, setValue];
}
@@ -125,11 +125,9 @@ function computePosition({
const { width: contentWidth, height: contentHeight } = contentSize;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight =
displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
let actualSide = side;
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
@@ -184,13 +182,10 @@ function computePosition({
}
const padding = 8;
x = Math.max(
padding,
Math.min(displayArea.width - contentWidth - padding, x)
);
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y)
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y),
);
return { x, y, actualSide };
@@ -234,7 +229,7 @@ export function Tooltip({
enabled,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration]
[isOpen, setIsOpen, enabled, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -296,7 +291,7 @@ export function TooltipTrigger({
onHoverIn?.(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen]
[onHoverIn, scheduleOpen],
);
const handleHoverOut = useCallback(
@@ -304,7 +299,7 @@ export function TooltipTrigger({
onHoverOut?.(e);
close();
},
[onHoverOut, close]
[onHoverOut, close],
);
const handleFocus = useCallback(
@@ -314,7 +309,7 @@ export function TooltipTrigger({
clearOpenTimer();
ctx.setOpen(true);
},
[clearOpenTimer, ctx, disabled, onFocus]
[clearOpenTimer, ctx, disabled, onFocus],
);
const handleBlur = useCallback(
@@ -322,7 +317,7 @@ export function TooltipTrigger({
onBlur?.(e);
close();
},
[close, onBlur]
[close, onBlur],
);
const handlePress = useCallback(
@@ -330,7 +325,7 @@ export function TooltipTrigger({
onPress?.(e);
close();
},
[close, onPress]
[close, onPress],
);
const triggerProps = {
@@ -383,11 +378,7 @@ export function TooltipTrigger({
}
return (
<Pressable
{...triggerProps}
ref={ctx.triggerRef}
collapsable={false}
>
<Pressable {...triggerProps} ref={ctx.triggerRef} collapsable={false}>
{children}
</Pressable>
);
@@ -412,9 +403,7 @@ export function TooltipContent({
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(
null
);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
@@ -425,8 +414,7 @@ export function TooltipContent({
return;
}
const statusBarHeight =
Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(ctx.triggerRef.current).then((rect) => {
@@ -459,7 +447,7 @@ export function TooltipContent({
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[]
[],
);
if (!ctx.open || !ctx.enabled) return null;