-
-
Notifications
You must be signed in to change notification settings - Fork 211
Commit
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import React, { useCallback, useRef, useState, useEffect } from 'react' | ||
import type { SetStateAction, Dispatch, FC, ReactElement, MutableRefObject } from 'react' | ||
import type { LayoutChangeEvent, ViewProps } from 'react-native' | ||
import { StyleSheet, View } from 'react-native' | ||
import type { IAutocompleteDropdownRef } from './types' | ||
|
||
export interface IAutocompleteDropdownContext { | ||
content?: ReactElement | ||
setContent: Dispatch<SetStateAction<ReactElement | undefined>> | ||
direction?: 'up' | 'down' | ||
setDirection: Dispatch<SetStateAction<IAutocompleteDropdownContext['direction']>> | ||
activeInputContainerRef?: MutableRefObject<View | null> | ||
activeControllerRef?: MutableRefObject<IAutocompleteDropdownRef | null> | ||
controllerRefs?: MutableRefObject<IAutocompleteDropdownRef[]> | ||
} | ||
|
||
export interface IAutocompleteDropdownContextProviderProps { | ||
headerOffset?: number | ||
children: React.ReactNode | ||
} | ||
|
||
export const AutocompleteDropdownContext = React.createContext<IAutocompleteDropdownContext>({ | ||
content: undefined, | ||
setContent: () => null, | ||
direction: undefined, | ||
setDirection: () => null, | ||
activeInputContainerRef: undefined, | ||
activeControllerRef: undefined, | ||
controllerRefs: undefined, | ||
}) | ||
|
||
export const AutocompleteDropdownContextProvider: FC<IAutocompleteDropdownContextProviderProps> = ({ | ||
headerOffset = 0, | ||
children, | ||
}) => { | ||
const [content, setContent] = useState<IAutocompleteDropdownContext['content']>() | ||
const [direction, setDirection] = useState<IAutocompleteDropdownContext['direction']>(undefined) | ||
const [show, setShow] = useState(false) | ||
const [dropdownHeight, setDropdownHeight] = useState(0) | ||
const [inputMeasurements, setInputMeasurements] = useState< | ||
{ x: number; topY: number; bottomY: number; width: number; height: number } | undefined | ||
>() | ||
const [opacity, setOpacity] = useState(0) | ||
const [contentStyles, setContentStyles] = useState< | ||
{ top?: number; left: number; width?: number; bottom?: number } | undefined | ||
>(undefined) | ||
const activeInputContainerRef = useRef<View>(null) | ||
const wrapperRef = useRef<View>(null) | ||
const activeControllerRef = useRef<IAutocompleteDropdownRef | null>(null) | ||
const controllerRefs = useRef<IAutocompleteDropdownRef[]>([]) | ||
const positionTrackingIntervalRef = useRef<NodeJS.Timeout>() | ||
|
||
useEffect(() => { | ||
if (!inputMeasurements?.height) { | ||
setOpacity(0) | ||
return | ||
} | ||
|
||
if (dropdownHeight && direction === 'up') { | ||
setContentStyles({ | ||
bottom: inputMeasurements.bottomY + 5 + headerOffset, | ||
top: undefined, | ||
left: inputMeasurements.x, | ||
width: inputMeasurements.width, | ||
}) | ||
setOpacity(1) | ||
} else if (direction === 'down') { | ||
setContentStyles({ | ||
top: inputMeasurements.topY + inputMeasurements.height + 5 + headerOffset, | ||
bottom: undefined, | ||
left: inputMeasurements.x, | ||
width: inputMeasurements.width, | ||
}) | ||
setOpacity(1) | ||
} | ||
}, [direction, dropdownHeight, headerOffset, inputMeasurements]) | ||
|
||
const recalculatePosition = useCallback((showAfterCalculation = false) => { | ||
activeInputContainerRef?.current?.measure((x, y, width, height, inputPageX, inputPageY) => { | ||
wrapperRef.current?.measure((wrapperX, wrapperY, wrapperW, wrapperH, wrapperPageX, wrapperPageY) => { | ||
const currentMeasurement = { | ||
width, | ||
height, | ||
x: inputPageX, | ||
topY: inputPageY - wrapperPageY, | ||
bottomY: wrapperH - inputPageY + wrapperPageY, | ||
} | ||
setInputMeasurements(prev => | ||
Check failure on line 88 in mobile/components/AutocompleteDropdown-v4.3.1/AutocompleteDropdownContext.tsx GitHub Actions / build (lts/*)
Check failure on line 88 in mobile/components/AutocompleteDropdown-v4.3.1/AutocompleteDropdownContext.tsx GitHub Actions / build (lts/*)
Check failure on line 88 in mobile/components/AutocompleteDropdown-v4.3.1/AutocompleteDropdownContext.tsx GitHub Actions / build (lts/*)
|
||
JSON.stringify(prev) === JSON.stringify(currentMeasurement) ? prev : currentMeasurement, | ||
) | ||
Check failure on line 90 in mobile/components/AutocompleteDropdown-v4.3.1/AutocompleteDropdownContext.tsx GitHub Actions / build (lts/*)
|
||
showAfterCalculation && setShow(true) | ||
Check failure on line 91 in mobile/components/AutocompleteDropdown-v4.3.1/AutocompleteDropdownContext.tsx GitHub Actions / build (lts/*)
|
||
}) | ||
}) | ||
}, []) | ||
|
||
useEffect(() => { | ||
if (content) { | ||
recalculatePosition(true) | ||
} else { | ||
setInputMeasurements(undefined) | ||
setDropdownHeight(0) | ||
setOpacity(0) | ||
setContentStyles(undefined) | ||
setShow(false) | ||
} | ||
}, [content, recalculatePosition]) | ||
|
||
useEffect(() => { | ||
if (show && !!opacity) { | ||
positionTrackingIntervalRef.current = setInterval(() => { | ||
requestAnimationFrame(() => { | ||
recalculatePosition() | ||
}) | ||
}, 16) | ||
} else { | ||
clearInterval(positionTrackingIntervalRef.current) | ||
} | ||
|
||
return () => { | ||
clearInterval(positionTrackingIntervalRef.current) | ||
} | ||
}, [recalculatePosition, opacity, show]) | ||
|
||
const onLayout: ViewProps['onLayout'] = useCallback((e: LayoutChangeEvent) => { | ||
setDropdownHeight(e.nativeEvent.layout.height) | ||
}, []) | ||
|
||
return ( | ||
<AutocompleteDropdownContext.Provider | ||
value={{ | ||
content, | ||
setContent, | ||
activeInputContainerRef, | ||
direction, | ||
setDirection, | ||
activeControllerRef, | ||
controllerRefs, | ||
}}> | ||
<View | ||
ref={wrapperRef} | ||
style={styles.clickOutsideHandlerArea} | ||
onTouchEnd={() => { | ||
activeControllerRef.current?.close() | ||
activeControllerRef.current?.blur() | ||
}}> | ||
{children} | ||
</View> | ||
{!!content && show && ( | ||
<View | ||
onLayout={onLayout} | ||
style={{ | ||
...styles.wrapper, | ||
opacity, | ||
...contentStyles, | ||
}}> | ||
{content} | ||
</View> | ||
)} | ||
</AutocompleteDropdownContext.Provider> | ||
) | ||
} | ||
|
||
const styles = StyleSheet.create({ | ||
clickOutsideHandlerArea: { | ||
flex: 1, | ||
}, | ||
wrapper: { | ||
position: 'absolute', | ||
}, | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
/* eslint-disable react/display-name */ | ||
import React, { memo, useMemo } from 'react' | ||
import type { ListRenderItem } from 'react-native' | ||
import { StyleSheet, FlatList, View, useColorScheme } from 'react-native' | ||
import * as Animatable from 'react-native-animatable' | ||
import { fadeInDownShort, fadeInUpShort } from './helpers' | ||
import { theme } from './theme' | ||
import type { AutocompleteDropdownItem, IAutocompleteDropdownProps } from './types' | ||
|
||
interface DropdownProps extends Omit<IAutocompleteDropdownProps, 'renderItem' | 'ref'> { | ||
ListEmptyComponent: JSX.Element | ||
renderItem: ListRenderItem<AutocompleteDropdownItem> | ||
} | ||
|
||
export const Dropdown = memo((props: DropdownProps) => { | ||
const { | ||
dataSet, | ||
suggestionsListMaxHeight, | ||
renderItem, | ||
ListEmptyComponent, | ||
ItemSeparatorComponent, | ||
direction, | ||
...rest | ||
} = props | ||
const themeName = useColorScheme() | ||
const styles = useMemo(() => getStyles(themeName || 'light'), [themeName]) | ||
|
||
const defaultItemSeparator = useMemo(() => function () { | ||
return <View style={styles.itemSeparator} /> | ||
}, [styles.itemSeparator]) | ||
|
||
return ( | ||
<Animatable.View | ||
useNativeDriver | ||
animation={direction === 'up' ? fadeInUpShort : fadeInDownShort} | ||
easing="ease-out-quad" | ||
delay={direction === 'up' ? 150 : 0} | ||
duration={150} | ||
style={{ | ||
...styles.listContainer, | ||
...(rest.suggestionsListContainerStyle as object), | ||
}}> | ||
<FlatList | ||
keyboardDismissMode="on-drag" | ||
keyboardShouldPersistTaps="handled" | ||
nestedScrollEnabled={true} | ||
data={dataSet} | ||
style={{ maxHeight: suggestionsListMaxHeight }} | ||
renderItem={renderItem} | ||
keyExtractor={(item) => item.id} | ||
ListEmptyComponent={ListEmptyComponent} | ||
ItemSeparatorComponent={ItemSeparatorComponent ?? defaultItemSeparator} | ||
{...rest.flatListProps} | ||
/> | ||
</Animatable.View> | ||
) | ||
}) | ||
|
||
const getStyles = (themeName: 'light' | 'dark' = 'light') => | ||
StyleSheet.create({ | ||
container: {}, | ||
listContainer: { | ||
backgroundColor: theme[themeName].suggestionsListBackgroundColor, | ||
width: '100%', | ||
zIndex: 9, | ||
borderRadius: 5, | ||
shadowColor: theme[themeName || 'light'].shadowColor, | ||
shadowOffset: { | ||
width: 0, | ||
height: 12, | ||
}, | ||
shadowOpacity: 0.3, | ||
shadowRadius: 15.46, | ||
|
||
elevation: 20, | ||
}, | ||
itemSeparator: { | ||
height: 1, | ||
width: '100%', | ||
backgroundColor: theme[themeName || 'light'].itemSeparatorColor, | ||
}, | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import type { FC, ComponentType } from 'react' | ||
import React, { useEffect, useRef } from 'react' | ||
import type { ViewProps } from 'react-native' | ||
import { Animated, Easing } from 'react-native' | ||
|
||
interface WithFadeAnimationProps { | ||
containerStyle?: ViewProps['style'] | ||
} | ||
|
||
export const withFadeAnimation = <P extends object>( | ||
WrappedComponent: ComponentType<P>, | ||
{ containerStyle }: WithFadeAnimationProps = {}, | ||
): FC<P> => { | ||
Check failure on line 13 in mobile/components/AutocompleteDropdown-v4.3.1/HOC/withFadeAnimation.tsx GitHub Actions / build (lts/*)
|
||
return (props: P) => { | ||
Check warning on line 14 in mobile/components/AutocompleteDropdown-v4.3.1/HOC/withFadeAnimation.tsx GitHub Actions / build (lts/*)
Check failure on line 14 in mobile/components/AutocompleteDropdown-v4.3.1/HOC/withFadeAnimation.tsx GitHub Actions / build (lts/*)
Check warning on line 14 in mobile/components/AutocompleteDropdown-v4.3.1/HOC/withFadeAnimation.tsx GitHub Actions / build (lts/*)
|
||
const opacityAnimationValue = useRef(new Animated.Value(0)).current | ||
|
||
useEffect(() => { | ||
Animated.timing(opacityAnimationValue, { | ||
duration: 800, | ||
toValue: 1, | ||
useNativeDriver: true, | ||
easing: Easing.bezier(0.3, 0.58, 0.25, 0.99), | ||
}).start() | ||
}, [opacityAnimationValue]) | ||
|
||
return ( | ||
<Animated.View style={[containerStyle, { opacity: opacityAnimationValue }]}> | ||
<WrappedComponent {...props} /> | ||
</Animated.View> | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import type { FC } from 'react' | ||
import React, { memo } from 'react' | ||
import { StyleSheet, Text, View } from 'react-native' | ||
import { withFadeAnimation } from './HOC/withFadeAnimation' | ||
|
||
interface NothingFoundProps { | ||
emptyResultText?: string | ||
} | ||
|
||
export const NothingFound: FC<NothingFoundProps> = memo(({ ...props }) => { | ||
Check failure on line 10 in mobile/components/AutocompleteDropdown-v4.3.1/NothingFound.tsx GitHub Actions / build (lts/*)
|
||
const EL = withFadeAnimation( | ||
() => ( | ||
<View style={{ ...styles.container }}> | ||
<Text style={styles.text}>{props.emptyResultText || 'Nothing found'}</Text> | ||
</View> | ||
), | ||
{}, | ||
) | ||
return <EL /> | ||
}) | ||
|
||
const styles = StyleSheet.create({ | ||
container: { | ||
padding: 10, | ||
}, | ||
text: { textAlign: 'center' }, | ||
}) |