diff --git a/README.md b/README.md index a963b53..ee2a865 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,10 @@ function Multi() { function Multi() { const [options, setOptions] = React.useState([ - { id: 1, name: 'Ruben von der Vein', gender: 'girl' }, + { id: 1, name: 'Ruben von der Vein', gender: 'boy' }, { id: 2, name: 'Pjotr Versjuurre', gender: 'boy' }, { id: 3, name: 'Bjart von Klef', gender: 'boy' }, - { id: 4, name: 'Riesjard Lindhoe', gender: 'boy' } + { id: 4, name: 'Riesjard Lindhoe', gender: 'girl' } ]) const onEndReached = () => { // fetch more items (paginated) e.g: diff --git a/example/package.json b/example/package.json index b3a7bc5..d6bbd03 100644 --- a/example/package.json +++ b/example/package.json @@ -19,7 +19,8 @@ "react-native": "0.63.4", "react-native-paper": "^4.9.2", "react-native-unimodules": "~0.12.0", - "react-native-web": "~0.14.9" + "react-native-web": "~0.14.9", + "use-popper": "^1.1.6" }, "devDependencies": { "@babel/core": "~7.12.10", diff --git a/example/src/Advanced.tsx b/example/src/Advanced.tsx index a642561..4ada687 100644 --- a/example/src/Advanced.tsx +++ b/example/src/Advanced.tsx @@ -4,13 +4,22 @@ import * as React from 'react'; // @ts-ignore import { Autocomplete } from 'react-native-paper-autocomplete'; -function Advanced({ multiple }: { multiple: boolean }) { +function Advanced({ + multiple, + mode, + dense, +}: { + multiple?: boolean; + mode?: 'flat' | 'outlined' | undefined; + dense?: boolean; +}) { const [options] = React.useState([ { id: 1, name: 'Ruben von der Vein', gender: 'girl' }, { id: 2, name: 'Pjotr Versjuurre', gender: 'boy' }, { id: 3, name: 'Bjart von Klef', gender: 'boy' }, { id: 4, name: 'Riesjard Lindhoe', gender: 'boy' }, ]); + const [value, setValue] = React.useState( multiple ? [options[0], options[1]] : options[0] ); @@ -30,8 +39,10 @@ function Advanced({ multiple }: { multiple: boolean }) { options={options} // if you want to group on something groupBy={(option) => option.gender} + dense={dense} //@ts-ignore inputProps={{ + mode: mode, placeholder: 'Select user', // ...all other props which are available in react native paper onChangeText: (_) => { diff --git a/example/src/App.tsx b/example/src/App.tsx index e84b40f..9e28431 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,23 +1,24 @@ import * as React from 'react'; import Advanced from './Advanced'; import { - StyleSheet, - ScrollView, - View, - Linking, - Image, Animated, + Image, + Linking, Platform, + ScrollView, + StyleSheet, + View, } from 'react-native'; import { - Title, Button, - Text, - Provider as PaperProvider, - useTheme, overlay, Paragraph, + Provider as PaperProvider, + Subheading, + Text, + Title, + useTheme, } from 'react-native-paper'; function AppInner() { @@ -80,16 +81,47 @@ function AppInner() { ]} > + Single and multiple - - + + + - + + + + Input modes + + Outlined + + + Dense outlined + + + Flat + + + Dense flat + + + + + + + @@ -157,7 +189,7 @@ const styles = StyleSheet.create({ marginTop: 24, padding: 24, alignSelf: 'center', - flex: 1, + // flex: 1, }, contentInline: { padding: 0, @@ -183,7 +215,7 @@ const styles = StyleSheet.create({ buttons: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 24 }, pickButton: { marginTop: 6 }, buttonSeparator: { width: 6 }, - enter: { height: 12 }, + enter: { height: 12, zIndex: 100 }, label: { width: 100, fontSize: 16 }, row: { paddingTop: 12, paddingBottom: 12, flexDirection: 'row' }, customModal: { diff --git a/example/yarn.lock b/example/yarn.lock index 33fb4e6..aa62851 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -4801,6 +4801,11 @@ deprecated-decorator@^0.1.6: resolved "https://registry.npmjs.org/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37" integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc= +dequal@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-1.0.0.tgz#41c6065e70de738541c82cdbedea5292277a017e" + integrity sha512-/Nd1EQbQbI9UbSHrMiKZjFLrXSnU328iQdZKPQf78XQI6C+gutkFUeoHpG5J08Ioa6HeRbRNFpSIclh1xyG0mw== + des.js@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" @@ -9735,6 +9740,11 @@ pnp-webpack-plugin@^1.5.0: dependencies: ts-pnp "^1.1.6" +popper.js@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== + portfinder@^1.0.26: version "1.0.28" resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" @@ -12550,6 +12560,21 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-deep-compare@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/use-deep-compare/-/use-deep-compare-0.1.0.tgz#7fd775047ab7126dfc11e8c19f6926b3112c03d0" + integrity sha512-WZG0iTvyo+6csYuCfoFxpYSK5nAifyO4jVia+yJTSooOo8EurzgzCE8ZMIcVpRQOwqolF0Otve94DrdGNYynFA== + dependencies: + dequal "1.0.0" + +use-popper@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/use-popper/-/use-popper-1.1.6.tgz#51d578084d92dd941dda466dea4cb0c3d2c5379b" + integrity sha512-iinEv3/meG+hdN/zcIKzdX/oa7b2xj8K/F1cckCeWTUE2Ne96YrXQqVVkenOOUky2vHyyHwUxx74pyP2SNgZUg== + dependencies: + popper.js "1.15.0" + use-deep-compare "0.1.0" + use-subscription@^1.0.0: version "1.5.1" resolved "https://registry.npmjs.org/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" diff --git a/package.json b/package.json index cbf175c..25d7c44 100644 --- a/package.json +++ b/package.json @@ -156,5 +156,9 @@ } ] ] + }, + "dependencies": { + "@popperjs/core": "^2.10.1", + "react-popper": "^2.2.5" } } diff --git a/src/Autocomplete.tsx b/src/Autocomplete.tsx index 9a41a74..ca04fb9 100644 --- a/src/Autocomplete.tsx +++ b/src/Autocomplete.tsx @@ -1,29 +1,26 @@ import * as React from 'react'; import { - TextInputProps, - TouchableWithoutFeedback, - View, - ViewStyle, - StyleSheet, - TextInput as NativeTextInput, + FlatList, + FlatListProps, LayoutChangeEvent, LayoutRectangle, - Platform, - TextInputFocusEventData, NativeSyntheticEvent, - FlatList, + Platform, SectionList, - useWindowDimensions, - FlatListProps, + StyleSheet, + TextInput as NativeTextInput, + TextInputFocusEventData, TextInputKeyPressEventData, + TextInputProps, + useWindowDimensions, + View, + ViewStyle, } from 'react-native'; import { ActivityIndicator, Chip, IconButton, List, - Portal, - Surface, TextInput, useTheme, } from 'react-native-paper'; @@ -32,8 +29,10 @@ import Color from 'color'; import useLatest from './useLatest'; import useAutomaticScroller from './useAutomaticScroller'; import AutocompleteItem from './AutocompleteItem'; - -// https://ej2.syncfusion.com/react/documentation/drop-down-list/accessibility/ +import { usePopper } from 'react-popper'; +// @ts-ignore +import ClickOutside from './Outside'; +import Popper from './Popper'; type PaperInputProps = React.ComponentProps; @@ -60,6 +59,7 @@ export interface AutocompleteBaseProps { groupBy?: (option: ItemT) => string; renderInput?: (params: TextInputProps) => any; style?: ViewStyle; + maxHeight?: number; getOptionLabel?: (option: ItemT) => string; getOptionDescription?: (option: ItemT) => string | number; getOptionValue?: (option: ItemT) => string | number; @@ -75,15 +75,21 @@ export interface AutocompleteBaseProps { export interface AutocompleteMultipleProps extends AutocompleteBaseProps { multiple: true; + dense?: boolean; value: ItemT[] | null | undefined; onChange: (v: ItemT[]) => void; + onPressArrow?: () => void; + outerValue?: string; } export interface AutocompleteSingleProps extends AutocompleteBaseProps { multiple?: undefined | false; + dense?: boolean; value: ItemT | null | undefined; onChange: (v: ItemT | undefined) => void; + onPressArrow?: () => void; + outerValue?: string; } export function defaultFilterOptions( @@ -138,7 +144,7 @@ const defaultLayout: LayoutRectangle = { export default function Autocomplete( props: AutocompleteMultipleProps | AutocompleteSingleProps ) { - const window = useWindowDimensions(); + const windowConst = useWindowDimensions(); const theme = useTheme(); const { loading, @@ -150,6 +156,7 @@ export default function Autocomplete( options, style, value, + maxHeight, getOptionValue = (option: ItemT) => (option as any).id || (option as any).key || (option as any).value, getOptionLabel = (option: ItemT) => @@ -171,6 +178,33 @@ export default function Autocomplete( const inputRef = React.useRef(null); const [inputValue, setInputValue] = React.useState(defaultValue || ''); const [visible, setVisible] = React.useState(false); + const ref = React.createRef(); + const outerRef = React.useRef(ref); + + React.useEffect(() => { + if (props.outerValue !== inputValue && props.outerValue !== undefined) { + setInputValue(props.outerValue); + } + }, [props.outerValue, inputValue]); + + // React.useEffect(() => { + // const ref = outerRef.current; + // console.log(outerRef); + // const listener = (e: WheelEvent) => { + // console.log('WHEEELL'); + // e.preventDefault(); + // + // return false; + // }; + // + // if (ref) { + // ref.addEventListener('wheel', listener, { passive: false }); + // return () => { + // ref.removeEventListener('wheel', listener); + // }; + // } + // return; + // }, []); const getOptionLabelRef = useLatest(getOptionLabel); React.useEffect(() => { @@ -215,7 +249,7 @@ export default function Autocomplete( const inputLayoutRef = useLatest(inputLayout); const recalculateLayout = React.useCallback(() => { - if (Platform.OS !== 'web') { + if (Platform.OS === 'web') { return; } @@ -352,6 +386,17 @@ export default function Autocomplete( onChangeSingle, values, ]); + + const [referenceRef, setReferenceRef] = React.useState(null); + const [popperRef, setPopperRef] = React.useState(null); + + const { styles, attributes, update } = usePopper(referenceRef, popperRef, { + placement: 'top-start', + strategy: 'fixed', + // onFirstUpdate: (state) => + // console.log('Popper positioned on', state.placement), + // modifiers: [], + }); const press = React.useCallback( (o: ItemT) => { if (multiple) { @@ -362,6 +407,10 @@ export default function Autocomplete( onChangeSingle(o); setVisible(false); } + + if (update && Platform.OS === 'web') { + update(); + } }, [multiple, setInputValue, onChangeMultiple, onChangeSingle, values] ); @@ -393,6 +442,7 @@ export default function Autocomplete( const highlightedColor = theme.dark ? Color(theme.colors.text).alpha(0.2).rgb().string() : Color(theme.colors.text).alpha(0.1).rgb().string(); + const innerListProps = { testID: 'autocomplete-list', renderItem: ({ @@ -469,6 +519,9 @@ export default function Autocomplete( setHighlightedIndex(data?.length - 1); } break; + case 'Escape': + setVisible(false); + break; default: } }, @@ -490,167 +543,170 @@ export default function Autocomplete( } return theme.colors.background; }, [theme, inputStyle]); - return ( - 0 ? ' ' : ''} - {...inputProps} - style={[ - // @ts-ignore - inputProps.style, - styles.full, - { - height: hasMultipleValue - ? shouldEnter - ? chipsLayout.height + 36 + 46 - : chipsLayout.height + 36 - : undefined, - }, - ]} - //@ts-ignore - accessibilityHasPopup={true} - render={(params) => { - const { paddingTop, paddingLeft } = StyleSheet.flatten( - params.style - ); - return ( - - ); - }} - /> + + 0 ? ' ' : ''} + {...inputProps} + dense={props.dense} + style={[ + // @ts-ignore + inputProps.style, + innerStyles.full, + { + height: hasMultipleValue + ? shouldEnter + ? chipsLayout.height + 36 + 46 + : chipsLayout.height + 36 + : undefined, + }, + ]} + //@ts-ignore + accessibilityHasPopup={true} + render={(params) => { + const { paddingTop, paddingLeft } = StyleSheet.flatten( + params.style + ); + return ( + + ); + }} + /> + {/*// @ts-ignore*/} { - if (visible) { - inputRef.current?.blur(); + if (props.onPressArrow) { + props.onPressArrow(); } else { - inputRef.current?.focus(); + if (visible) { + inputRef.current?.blur(); + } else { + inputRef.current?.focus(); + } } }} /> - - {multiple && ( - - {values?.map((o) => ( - remove(o)} - style={styles.chip} - > - {getOptionLabel(o)} - - ))} - - )} - {loading ? : null} - {visible ? ( - + {multiple && ( - setVisible(false)}> - - - {visible && ( - // @ts-ignore - { - setVisible(false); - setInputValue(''); - if (multiple) { - onChangeMultiple([]); - } else { - onChangeSingle(undefined); - } - }} - /> - )} - - {groupBy ? ( - - {...listProps} - {...innerListProps} - sections={sections} - renderSectionHeader={({ section: { title } }: any) => ( - // @ts-ignore - {title} - )} - /> - ) : ( - - {...listProps} - {...innerListProps} - getItemLayout={getFlatListItemLayout} - data={data} - /> - )} - + {values?.map((o) => ( + remove(o)} + style={innerStyles.chip} + > + {getOptionLabel(o)} + + ))} - - ) : null} + )} + {(inputValue || (multiple && values && values.length > 0)) && ( + { + setVisible(false); + setInputValue(''); + if (multiple) { + onChangeMultiple([]); + } else { + onChangeSingle(undefined); + } + }} + touchSoundDisabled={undefined} + /> + )} + + {loading ? : null} + {visible && ( + { + setVisible(false); + }} + attributes={attributes} + styles={styles} + setPopperRef={setPopperRef} + visible={visible} + dropdownWidth={dropdownWidth} + outerRef={outerRef} + maxHeight={maxHeight} + surfaceStyle={[ + innerStyles.surface, + { + top: inputLayout.y + inputLayout.height, // change maxHeight too! + left: inputLayout.x + textInputLeft, + minWidth: dropdownWidth, + borderRadius: theme.roundness, + maxHeight: + windowConst.height - (inputLayout.y + inputLayout.height), + zIndex: 10000, + }, + ]} + > + {groupBy ? ( + + {...listProps} + {...innerListProps} + sections={sections} + renderSectionHeader={({ section: { title } }: any) => ( + // @ts-ignore + {title} + )} + /> + ) : ( + + {...listProps} + {...innerListProps} + getItemLayout={getFlatListItemLayout} + data={data} + /> + )} + + )} ); } @@ -665,13 +721,8 @@ function usePrevious( return ref.current; } -const styles = StyleSheet.create({ - modalBackground: { - flex: 1, - }, - menu: { - position: 'relative', - }, +const innerStyles = StyleSheet.create({ + menu: { position: 'relative' }, chipsWrapper: { flexDirection: 'row', position: 'absolute', @@ -681,11 +732,19 @@ const styles = StyleSheet.create({ }, chip: { marginRight: 6, marginBottom: 6, flexShrink: 1 }, surface: { + // @ts-ignore position: 'absolute', overflow: 'hidden', }, - inputContainer: { alignItems: 'center', flexDirection: 'row' }, - full: { flex: 1 }, + inputContainer: { + alignItems: 'center', + flexDirection: 'row', + }, + full: { + flex: 1, + // @ts-ignore + position: Platform.OS === 'web' ? 'static' : 'relative', + }, arrowIconButton: { position: 'absolute', bottom: 5, diff --git a/src/Popper.tsx b/src/Popper.tsx new file mode 100644 index 0000000..fffc22c --- /dev/null +++ b/src/Popper.tsx @@ -0,0 +1,38 @@ +import { Portal, Surface } from 'react-native-paper'; +import { StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; +import * as React from 'react'; + +export default function Popper({ + onPressOutside, + children, + surfaceStyle, + outerRef, + visible, +}: { + onPressOutside: () => any; + children: any; + setPopperRef: any; + attributes: any; + styles: any; + dropdownWidth: number; + outerRef: any; + surfaceStyle: any; + maxHeight?: number; + visible?: boolean; +}) { + return ( + + + + + + {children} + + + ); +} diff --git a/src/Popper.web.tsx b/src/Popper.web.tsx new file mode 100644 index 0000000..4ea9688 --- /dev/null +++ b/src/Popper.web.tsx @@ -0,0 +1,82 @@ +import { View } from 'react-native'; +import { Surface, useTheme } from 'react-native-paper'; +import * as React from 'react'; +import ReactDOM from 'react-dom'; + +export default function PopperAbstraction({ + onPressOutside, + children, + setPopperRef, + attributes, + styles, + dropdownWidth, + maxHeight, +}: { + onPressOutside: () => any; + children: any; + setPopperRef: any; + attributes: any; + styles: any; + dropdownWidth: number; + outerRef: any; + surfaceStyle: any; + maxHeight?: number; + visible?: boolean; +}) { + const theme = useTheme(); + const ref = React.useRef(); + + useOnClickOutside(ref, onPressOutside); + return ( + <> + {ReactDOM.createPortal( + + + + {children} + + + , + document.querySelector('body')! + )} + + ); +} + +// Hook +function useOnClickOutside(ref: any, handler: any) { + React.useEffect( + () => { + const listener = (event: any) => { + // Do nothing if clicking ref's element or descendent elements + if (!ref.current || ref.current.contains(event.target)) { + return; + } + handler(event); + }; + document.addEventListener('mousedown', listener); + document.addEventListener('touchstart', listener); + return () => { + document.removeEventListener('mousedown', listener); + document.removeEventListener('touchstart', listener); + }; + }, + // Add ref and handler to effect dependencies + // It's worth noting that because passed in handler is a new ... + // ... function on every render that will cause this effect ... + // ... callback/cleanup to run every render. It's not a big deal ... + // ... but to optimize you can wrap handler in useCallback before ... + // ... passing it into this hook. + [ref, handler] + ); +} diff --git a/tsconfig.json b/tsconfig.json index 0084744..4893da9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,10 @@ "importsNotUsedAsValues": "error", "forceConsistentCasingInFileNames": true, "jsx": "react", - "lib": ["esnext"], + "lib": [ + "esnext", + "dom" + ], "module": "esnext", "moduleResolution": "node", "noFallthroughCasesInSwitch": true, diff --git a/yarn.lock b/yarn.lock index 8c12aa7..8fd6823 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1629,6 +1629,11 @@ dependencies: "@octokit/openapi-types" "^7.2.3" +"@popperjs/core@^2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be" + integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw== + "@react-native-community/cli-debugger-ui@^4.13.1": version "4.13.1" resolved "https://registry.npmjs.org/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-4.13.1.tgz#07de6d4dab80ec49231de1f1fbf658b4ad39b32c" @@ -7839,6 +7844,11 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -7923,6 +7933,14 @@ react-native@0.63.4: use-subscription "^1.0.0" whatwg-fetch "^3.0.0" +react-popper@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96" + integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-refresh@^0.4.0: version "0.4.3" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.3.tgz#966f1750c191672e76e16c2efa569150cc73ab53" @@ -9500,6 +9518,13 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.x" +warning@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + wcwidth@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"