import * as React from 'react'; import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList } from 'react-native'; import { Component, ReactElement } from 'react'; import { _ } from '@joplin/lib/locale'; type ValueType = string; export interface DropdownListItem { label: string; value: ValueType; // Depth corresponds with indentation and can be used to // create tree structures. depth?: number; } export type OnValueChangedListener = (newValue: ValueType)=> void; interface DropdownProps { listItemStyle?: ViewStyle; itemListStyle?: ViewStyle; itemWrapperStyle?: ViewStyle; headerWrapperStyle?: ViewStyle; headerStyle?: TextStyle; itemStyle?: TextStyle; disabled?: boolean; labelTransform?: 'trim'; items: DropdownListItem[]; selectedValue: ValueType|null; onValueChange?: OnValueChangedListener; // Shown to the right of the dropdown when closed, hidden when opened. // Avoids abrupt size transitions that would be caused by externally resizing the space // available for the dropdown on open/close. coverableChildrenRight?: ReactElement[]|ReactElement; } interface DropdownState { headerSize: LayoutRectangle; listVisible: boolean; } class Dropdown extends Component { private headerRef: View; public constructor(props: DropdownProps) { super(props); this.headerRef = null; this.state = { headerSize: { x: 0, y: 0, width: 0, height: 0 }, listVisible: false, }; } private updateHeaderCoordinates = () => { if (!this.headerRef) return; // https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element this.headerRef.measure((_fx, _fy, width, height, px, py) => { const lastLayout = this.state.headerSize; if (px !== lastLayout.x || py !== lastLayout.y || width !== lastLayout.width || height !== lastLayout.height) { this.setState({ headerSize: { x: px, y: py, width: width, height: height }, }); } }); }; private onOpenList = () => { // On iOS, we need to re-measure just before opening the list. Measurements from just after // onLayout can be inaccurate in some cases (in the past, this had caused the menu to be // drawn far offscreen). this.updateHeaderCoordinates(); this.setState({ listVisible: true }); }; private onCloseList = () => { this.setState({ listVisible: false }); }; private onListLoad = (listRef: FlatList|null) => { if (!listRef) return; for (let i = 0; i < this.props.items.length; i++) { const item = this.props.items[i]; if (item.value === this.props.selectedValue) { listRef.scrollToIndex({ index: i, animated: false }); break; } } }; public render() { const items = this.props.items; const itemHeight = 60; const windowHeight = Dimensions.get('window').height - 50; const windowWidth = Dimensions.get('window').width; // Dimensions doesn't return quite the right dimensions so leave an extra gap to make // sure nothing is off screen. const listMaxHeight = windowHeight; const listHeight = Math.min(items.length * itemHeight, listMaxHeight); const maxListTop = windowHeight - listHeight; const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height); const dropdownWidth = this.state.headerSize.width; const wrapperStyle: ViewStyle = { width: this.state.headerSize.width, height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear) top: listTop, left: this.state.headerSize.x, position: 'absolute', }; const backgroundCloseButtonStyle: ViewStyle = { position: 'absolute', top: 0, left: 0, height: windowHeight, width: windowWidth, }; const itemListStyle = { ...(this.props.itemListStyle ? this.props.itemListStyle : {}), borderWidth: 1, borderColor: '#ccc' }; const itemWrapperStyle: ViewStyle = { ...(this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}), flex: 1, flexBasis: 'auto', justifyContent: 'center', height: itemHeight, paddingLeft: 20, paddingRight: 10, }; const headerWrapperStyle: ViewStyle = { ...(this.props.headerWrapperStyle ? this.props.headerWrapperStyle : {}), height: 35, flex: 1, flexDirection: 'row', alignItems: 'center', }; const headerStyle = { ...(this.props.headerStyle ? this.props.headerStyle : {}), flex: 1 }; const headerArrowStyle = { ...(this.props.headerStyle ? this.props.headerStyle : {}), flex: 0, marginRight: 10 }; const itemStyle = { ...(this.props.itemStyle ? this.props.itemStyle : {}) }; let headerLabel = '...'; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.value === this.props.selectedValue) { headerLabel = item.label; break; } } if (this.props.labelTransform && this.props.labelTransform === 'trim') { headerLabel = headerLabel.trim(); } const itemRenderer = ({ item }: { item: DropdownListItem }) => { const key = item.value ? item.value.toString() : '__null'; // The top item ("Move item to notebook...") has a null value. const indentWidth = Math.min((item.depth ?? 0) * 32, dropdownWidth * 2 / 3); return ( { this.onCloseList(); if (this.props.onValueChange) this.props.onValueChange(item.value); }} > {item.label} ); }; // Use a separate screen-reader-only button for closing the menu. If we // allow the background to be focusable, instead, the focus order might be // incorrect on some devices. For example, the background button might be focused // when navigating near the middle of the dropdown's list. const screenReaderCloseMenuButton = ( {_('Close dropdown')} ); return ( (this.headerRef = ref)} > {headerLabel} {'▼'} {this.state.listVisible ? null : this.props.coverableChildrenRight} ({ length: itemHeight, offset: itemHeight * index, index, })} /> {screenReaderCloseMenuButton} ); } } export default Dropdown; export { Dropdown };