2024-03-26 13:35:15 +02:00
|
|
|
import * as React from 'react';
|
2024-04-07 15:38:51 +02:00
|
|
|
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList } from 'react-native';
|
2024-03-26 13:35:15 +02:00
|
|
|
import { Component, ReactElement } from 'react';
|
2023-01-10 19:13:32 +02:00
|
|
|
import { _ } from '@joplin/lib/locale';
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2022-08-27 14:36:59 +02:00
|
|
|
type ValueType = string;
|
|
|
|
export interface DropdownListItem {
|
|
|
|
label: string;
|
|
|
|
value: ValueType;
|
2024-02-09 01:17:17 +02:00
|
|
|
|
|
|
|
// Depth corresponds with indentation and can be used to
|
|
|
|
// create tree structures.
|
|
|
|
depth?: number;
|
2022-08-27 14:36:59 +02:00
|
|
|
}
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2022-08-27 14:36:59 +02:00
|
|
|
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[];
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2022-08-27 14:36:59 +02:00
|
|
|
selectedValue: ValueType|null;
|
|
|
|
onValueChange?: OnValueChangedListener;
|
2024-03-26 13:35:15 +02:00
|
|
|
|
|
|
|
// 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;
|
2022-08-27 14:36:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
interface DropdownState {
|
|
|
|
headerSize: LayoutRectangle;
|
|
|
|
listVisible: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
class Dropdown extends Component<DropdownProps, DropdownState> {
|
2024-03-26 13:35:15 +02:00
|
|
|
private headerRef: View;
|
2022-08-27 14:36:59 +02:00
|
|
|
|
|
|
|
public constructor(props: DropdownProps) {
|
|
|
|
super(props);
|
|
|
|
|
|
|
|
this.headerRef = null;
|
|
|
|
this.state = {
|
2017-11-19 01:59:07 +02:00
|
|
|
headerSize: { x: 0, y: 0, width: 0, height: 0 },
|
|
|
|
listVisible: false,
|
2022-08-27 14:36:59 +02:00
|
|
|
};
|
2017-11-19 01:59:07 +02:00
|
|
|
}
|
|
|
|
|
2024-04-07 15:38:51 +02:00
|
|
|
private updateHeaderCoordinates = () => {
|
2024-03-26 13:35:15 +02:00
|
|
|
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 },
|
|
|
|
});
|
|
|
|
}
|
2017-12-08 01:24:14 +02:00
|
|
|
});
|
2024-03-26 13:35:15 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
private onOpenList = () => {
|
2024-04-07 15:38:51 +02:00
|
|
|
// 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();
|
2024-03-26 13:35:15 +02:00
|
|
|
this.setState({ listVisible: true });
|
|
|
|
};
|
|
|
|
private onCloseList = () => {
|
|
|
|
this.setState({ listVisible: false });
|
|
|
|
};
|
2024-09-21 13:58:01 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2022-08-27 14:36:59 +02:00
|
|
|
public render() {
|
2017-11-19 01:59:07 +02:00
|
|
|
const items = this.props.items;
|
|
|
|
const itemHeight = 60;
|
|
|
|
const windowHeight = Dimensions.get('window').height - 50;
|
2023-01-10 19:13:32 +02:00
|
|
|
const windowWidth = Dimensions.get('window').width;
|
2017-11-19 01:59:07 +02:00
|
|
|
|
|
|
|
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
|
|
|
|
// sure nothing is off screen.
|
|
|
|
const listMaxHeight = windowHeight;
|
2018-12-16 18:18:24 +02:00
|
|
|
const listHeight = Math.min(items.length * itemHeight, listMaxHeight);
|
2017-11-19 01:59:07 +02:00
|
|
|
const maxListTop = windowHeight - listHeight;
|
|
|
|
const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height);
|
|
|
|
|
2024-02-09 01:17:17 +02:00
|
|
|
const dropdownWidth = this.state.headerSize.width;
|
2023-01-10 19:13:32 +02:00
|
|
|
const wrapperStyle: ViewStyle = {
|
2022-06-21 12:50:10 +02:00
|
|
|
width: this.state.headerSize.width,
|
2017-11-19 01:59:07 +02:00
|
|
|
height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear)
|
2023-01-10 19:13:32 +02:00
|
|
|
top: listTop,
|
|
|
|
left: this.state.headerSize.x,
|
|
|
|
position: 'absolute',
|
|
|
|
};
|
|
|
|
|
|
|
|
const backgroundCloseButtonStyle: ViewStyle = {
|
|
|
|
position: 'absolute',
|
|
|
|
top: 0,
|
|
|
|
left: 0,
|
|
|
|
height: windowHeight,
|
|
|
|
width: windowWidth,
|
2017-11-19 01:59:07 +02:00
|
|
|
};
|
|
|
|
|
2023-06-01 13:02:36 +02:00
|
|
|
const itemListStyle = { ...(this.props.itemListStyle ? this.props.itemListStyle : {}), borderWidth: 1,
|
|
|
|
borderColor: '#ccc' };
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2024-02-09 01:17:17 +02:00
|
|
|
const itemWrapperStyle: ViewStyle = {
|
|
|
|
...(this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}),
|
|
|
|
flex: 1,
|
2024-08-02 15:51:49 +02:00
|
|
|
flexBasis: 'auto',
|
2017-11-19 01:59:07 +02:00
|
|
|
justifyContent: 'center',
|
|
|
|
height: itemHeight,
|
|
|
|
paddingLeft: 20,
|
2024-02-09 01:17:17 +02:00
|
|
|
paddingRight: 10,
|
|
|
|
};
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2024-03-26 13:35:15 +02:00
|
|
|
const headerWrapperStyle: ViewStyle = {
|
|
|
|
...(this.props.headerWrapperStyle ? this.props.headerWrapperStyle : {}),
|
|
|
|
height: 35,
|
2017-11-19 01:59:07 +02:00
|
|
|
flex: 1,
|
|
|
|
flexDirection: 'row',
|
2024-03-26 13:35:15 +02:00
|
|
|
alignItems: 'center',
|
|
|
|
};
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2023-06-01 13:02:36 +02:00
|
|
|
const headerStyle = { ...(this.props.headerStyle ? this.props.headerStyle : {}), flex: 1 };
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2023-06-01 13:02:36 +02:00
|
|
|
const headerArrowStyle = { ...(this.props.headerStyle ? this.props.headerStyle : {}), flex: 0,
|
|
|
|
marginRight: 10 };
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2023-06-01 13:02:36 +02:00
|
|
|
const itemStyle = { ...(this.props.itemStyle ? this.props.itemStyle : {}) };
|
2017-11-19 01:59:07 +02:00
|
|
|
|
|
|
|
let headerLabel = '...';
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
|
|
const item = items[i];
|
2017-11-23 20:47:51 +02:00
|
|
|
if (item.value === this.props.selectedValue) {
|
|
|
|
headerLabel = item.label;
|
|
|
|
break;
|
|
|
|
}
|
2017-11-19 01:59:07 +02:00
|
|
|
}
|
|
|
|
|
2022-08-27 14:36:59 +02:00
|
|
|
if (this.props.labelTransform && this.props.labelTransform === 'trim') {
|
|
|
|
headerLabel = headerLabel.trim();
|
|
|
|
}
|
2018-12-16 18:18:24 +02:00
|
|
|
|
2023-08-27 13:42:42 +02:00
|
|
|
const itemRenderer = ({ item }: { item: DropdownListItem }) => {
|
2022-09-30 13:13:29 +02:00
|
|
|
const key = item.value ? item.value.toString() : '__null'; // The top item ("Move item to notebook...") has a null value.
|
2024-02-09 01:17:17 +02:00
|
|
|
const indentWidth = Math.min((item.depth ?? 0) * 32, dropdownWidth * 2 / 3);
|
|
|
|
|
2017-11-19 01:59:07 +02:00
|
|
|
return (
|
2019-07-29 15:43:53 +02:00
|
|
|
<TouchableOpacity
|
2024-02-09 01:17:17 +02:00
|
|
|
style={itemWrapperStyle}
|
2023-01-10 19:13:32 +02:00
|
|
|
accessibilityRole="menuitem"
|
2022-08-27 14:36:59 +02:00
|
|
|
key={key}
|
2019-07-29 15:43:53 +02:00
|
|
|
onPress={() => {
|
2024-03-26 13:35:15 +02:00
|
|
|
this.onCloseList();
|
2019-07-29 15:43:53 +02:00
|
|
|
if (this.props.onValueChange) this.props.onValueChange(item.value);
|
|
|
|
}}
|
|
|
|
>
|
2024-02-09 01:17:17 +02:00
|
|
|
<Text ellipsizeMode="tail" numberOfLines={1} style={{ ...itemStyle, marginStart: indentWidth }} key={key}>
|
2019-07-29 15:43:53 +02:00
|
|
|
{item.label}
|
|
|
|
</Text>
|
2017-11-19 01:59:07 +02:00
|
|
|
</TouchableOpacity>
|
|
|
|
);
|
2019-07-29 15:43:53 +02:00
|
|
|
};
|
2017-11-19 01:59:07 +02:00
|
|
|
|
2023-01-10 19:13:32 +02:00
|
|
|
// 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 = (
|
|
|
|
<TouchableWithoutFeedback
|
|
|
|
accessibilityRole='button'
|
2024-03-26 13:35:15 +02:00
|
|
|
onPress={this.onCloseList}
|
2023-01-10 19:13:32 +02:00
|
|
|
>
|
|
|
|
<Text style={{
|
|
|
|
opacity: 0,
|
|
|
|
height: 0,
|
|
|
|
}}>{_('Close dropdown')}</Text>
|
|
|
|
</TouchableWithoutFeedback>
|
|
|
|
);
|
|
|
|
|
2017-11-19 01:59:07 +02:00
|
|
|
return (
|
2019-07-29 15:43:53 +02:00
|
|
|
<View style={{ flex: 1, flexDirection: 'column' }}>
|
2024-03-26 13:35:15 +02:00
|
|
|
<View
|
|
|
|
style={{ flexDirection: 'row', flex: 1, alignItems: 'center' }}
|
|
|
|
onLayout={this.updateHeaderCoordinates}
|
2022-08-27 14:36:59 +02:00
|
|
|
ref={ref => (this.headerRef = ref)}
|
2019-07-29 15:43:53 +02:00
|
|
|
>
|
2024-03-26 13:35:15 +02:00
|
|
|
<TouchableOpacity
|
|
|
|
style={headerWrapperStyle}
|
|
|
|
disabled={this.props.disabled}
|
|
|
|
onPress={this.onOpenList}
|
2024-08-02 15:51:49 +02:00
|
|
|
role='button'
|
2024-03-26 13:35:15 +02:00
|
|
|
>
|
|
|
|
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>
|
|
|
|
{headerLabel}
|
|
|
|
</Text>
|
|
|
|
<Text style={headerArrowStyle}>{'▼'}</Text>
|
|
|
|
</TouchableOpacity>
|
|
|
|
{this.state.listVisible ? null : this.props.coverableChildrenRight}
|
|
|
|
</View>
|
2019-07-29 15:43:53 +02:00
|
|
|
<Modal
|
|
|
|
transparent={true}
|
2024-03-26 13:35:15 +02:00
|
|
|
animationType='fade'
|
2019-07-29 15:43:53 +02:00
|
|
|
visible={this.state.listVisible}
|
2024-03-26 13:35:15 +02:00
|
|
|
onRequestClose={this.onCloseList}
|
2023-11-15 02:42:27 +02:00
|
|
|
supportedOrientations={['landscape', 'portrait']}
|
2019-07-29 15:43:53 +02:00
|
|
|
>
|
|
|
|
<TouchableWithoutFeedback
|
2023-01-10 19:13:32 +02:00
|
|
|
accessibilityElementsHidden={true}
|
|
|
|
importantForAccessibility='no-hide-descendants'
|
2024-08-02 15:51:49 +02:00
|
|
|
aria-hidden={true}
|
2024-03-26 13:35:15 +02:00
|
|
|
onPress={this.onCloseList}
|
2023-01-10 19:13:32 +02:00
|
|
|
style={backgroundCloseButtonStyle}
|
2019-07-29 15:43:53 +02:00
|
|
|
>
|
2023-01-10 19:13:32 +02:00
|
|
|
<View style={{ flex: 1 }}/>
|
2017-11-19 01:59:07 +02:00
|
|
|
</TouchableWithoutFeedback>
|
2023-01-10 19:13:32 +02:00
|
|
|
|
|
|
|
<View
|
|
|
|
accessibilityRole='menu'
|
|
|
|
style={wrapperStyle}>
|
2023-08-27 13:42:42 +02:00
|
|
|
<FlatList
|
2024-09-21 13:58:01 +02:00
|
|
|
ref={this.onListLoad}
|
2023-01-10 19:13:32 +02:00
|
|
|
style={itemListStyle}
|
2023-08-27 13:42:42 +02:00
|
|
|
data={this.props.items}
|
|
|
|
renderItem={itemRenderer}
|
|
|
|
getItemLayout={(_data, index) => ({
|
|
|
|
length: itemHeight,
|
|
|
|
offset: itemHeight * index,
|
|
|
|
index,
|
|
|
|
})}
|
2023-01-10 19:13:32 +02:00
|
|
|
/>
|
|
|
|
</View>
|
|
|
|
|
|
|
|
{screenReaderCloseMenuButton}
|
2017-11-19 01:59:07 +02:00
|
|
|
</Modal>
|
|
|
|
</View>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-27 14:36:59 +02:00
|
|
|
export default Dropdown;
|
|
|
|
export { Dropdown };
|