joplin/packages/app-mobile/components/ComboBox.tsx

616 lines
17 KiB
TypeScript

import * as React from 'react';
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, ScrollViewProps, StyleSheet, TextInput, TextInputProps, useWindowDimensions, View, ViewProps, ViewStyle } from 'react-native';
import { TouchableRipple, Text } from 'react-native-paper';
import { connect } from 'react-redux';
import { AppState } from '../utils/types';
import { themeStyle } from './global-style';
import Icon from './Icon';
import { RefObject, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import SearchInput from './SearchInput';
import focusView from '../utils/focusView';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
import useKeyboardState from '../utils/hooks/useKeyboardState';
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
export interface Option {
title: string;
icon: string|undefined;
accessibilityHint: string|undefined;
onPress?: ()=> void;
// True if pressing this option removes it. Used for working around
// focus issues.
willRemoveOnPress: boolean;
}
export type OnItemSelected = (item: Option, index: number)=> void;
interface BaseProps {
themeId: number;
items: Option[];
alwaysExpand: boolean;
placeholder: string;
onItemSelected: OnItemSelected;
style: ViewStyle;
searchInputProps?: TextInputProps;
searchResultProps?: ScrollViewProps;
}
type OnAddItem = (content: string)=> void;
type OnCanAddItem = (item: string)=> boolean;
type Props = BaseProps & ({
onAddItem: OnAddItem|null;
canAddItem: OnCanAddItem;
}|{
onAddItem?: undefined;
canAddItem?: undefined;
});
const optionKeyExtractor = (option: Option) => option.title;
interface UseSearchResultsOptions {
search: string;
setSearch: (search: string)=> void;
options: Option[];
onAddItem: null|OnAddItem;
canAddItem: OnCanAddItem;
}
const useSearchResults = ({
search, setSearch, options, onAddItem, canAddItem,
}: UseSearchResultsOptions) => {
const collatorLocale = getCollatorLocale();
const results = useMemo(() => {
const collator = getCollator(collatorLocale);
const lowerSearch = search?.toLowerCase();
return options
.filter(option => option.title.toLowerCase().includes(lowerSearch))
.sort((a, b) => {
if (a.title === b.title) return 0;
// Full matches should go first
if (a.title.toLowerCase() === lowerSearch) return -1;
if (b.title.toLowerCase() === lowerSearch) return 1;
return collator.compare(a.title, b.title);
});
}, [search, options, collatorLocale]);
const canAdd = (
!!onAddItem
&& search.trim()
&& results[0]?.title !== search
&& canAddItem(search)
);
// Use a ref to prevent unnecessary rerenders if onAddItem changes
const addCurrentSearch = useRef(()=>{});
addCurrentSearch.current = () => {
onAddItem(search);
AccessibilityInfo.announceForAccessibility(_('Added new: %s', search));
setSearch('');
};
return useMemo(() => {
if (!canAdd) return results;
return [
...results,
{
title: _('Add new'),
icon: 'fas fa-plus',
accessibilityHint: undefined,
willRemoveOnPress: true,
onPress: () => {
addCurrentSearch.current?.();
},
},
];
}, [canAdd, results]);
};
interface SelectedIndexControl {
onNextResult: ()=> void;
onPreviousResult: ()=> void;
onFirstResult: ()=> void;
onLastResult: ()=> void;
}
const useSelectedIndex = (search: string, searchResults: Option[]) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
if (search) {
setSelectedIndex(0);
} else {
const hasResults = !!searchResults.length;
setSelectedIndex(hasResults ? 0 : -1);
}
}, [searchResults, search]);
const resultCount = searchResults.length;
const selectedIndexControl: SelectedIndexControl = useMemo(() => ({
onNextResult: () => {
setSelectedIndex(index => {
return Math.min(index + 1, resultCount - 1);
});
},
onPreviousResult: () => {
setSelectedIndex(index => {
return Math.max(index - 1, 0);
});
},
onFirstResult: () => {
setSelectedIndex(0);
},
onLastResult: () => {
setSelectedIndex(resultCount - 1);
},
}), [resultCount]);
return { selectedIndex, selectedIndexControl };
};
const useStyles = (themeId: number, showSearchResults: boolean) => {
const { fontScale, height: screenHeight } = useWindowDimensions();
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
// Allow the search results size to decrease when the keyboard is visible.
const searchResultsHeight = Math.max(128, Math.min(200, (screenHeight - keyboardHeight) / 3));
const menuItemHeight = 40 * fontScale;
const theme = themeStyle(themeId);
const styles = useMemo(() => {
const borderRadius = 4;
const itemMarginVertical = 8;
return StyleSheet.create({
root: {
flexDirection: 'column',
overflow: 'hidden',
borderRadius,
backgroundColor: theme.backgroundColor,
borderColor: theme.dividerColor,
borderWidth: showSearchResults ? 1 : 0,
},
searchInputContainer: {
borderRadius,
backgroundColor: theme.backgroundColor,
borderColor: theme.dividerColor,
borderWidth: 1,
...(showSearchResults ? {
borderTopWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 0,
} : {}),
},
tagSearchHelp: {
color: theme.colorFaded,
marginTop: 6,
},
searchInput: {
minHeight: 32,
},
searchResults: {
height: searchResultsHeight,
flexGrow: 1,
flexShrink: 1,
...(showSearchResults ? {} : {
display: 'none',
}),
},
optionIcon: {
color: theme.color,
fontSize: theme.fontSizeSmaller,
textAlign: 'center',
paddingLeft: 4,
paddingRight: 4,
},
optionLabel: {
fontSize: theme.fontSize,
color: theme.color,
paddingInlineStart: 3,
},
optionContent: {
flexDirection: 'row',
alignItems: 'center',
borderRadius,
height: menuItemHeight - itemMarginVertical,
marginTop: itemMarginVertical / 2,
marginBottom: itemMarginVertical / 2,
paddingHorizontal: 3,
},
optionContentSelected: {
backgroundColor: theme.selectedColor,
},
});
}, [theme, menuItemHeight, searchResultsHeight, showSearchResults]);
return { menuItemHeight, styles };
};
type Styles = ReturnType<typeof useStyles>['styles'];
interface SearchResultProps {
text: string;
icon: string;
selected: boolean;
styles: Styles;
}
const SearchResult: React.FC<SearchResultProps> = ({
text, styles, selected, icon: iconName,
}) => {
const icon = iconName ? <Icon
style={styles.optionIcon}
name={iconName}
// Description is provided by adjacent text
accessibilityLabel={null}
/> : null;
return (
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
{icon}
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.optionLabel}
>{text}</Text>
</View>
);
};
interface ResultWrapperProps extends ViewProps {
index: number;
item: Option;
}
interface SearchResultContainerProps {
onItemSelected: OnItemSelected;
selectedIndex: number;
baseId: string;
resultCount: number;
searchInputRef: RefObject<TextInput>;
// Used to determine focus
resultsHideOnPress: boolean;
}
const useSearchResultContainerComponent = ({
onItemSelected, selectedIndex, baseId, resultCount, searchInputRef, resultsHideOnPress,
}: SearchResultContainerProps): React.FC<ResultWrapperProps> => {
const listItemsRef = useRef<Record<number, View>>({});
const eventQueue = useMemo(() => {
const queue = new AsyncActionQueue(100);
// Don't allow skipping any onItemSelected calls:
queue.setCanSkipTaskHandler(() => false);
return queue;
}, []);
const onItemPressRef = useRef(onItemSelected);
onItemPressRef.current = (item, index) => {
let focusTarget = null;
if (resultsHideOnPress) {
focusTarget = searchInputRef.current;
} else if (Platform.OS === 'android' && item.willRemoveOnPress) {
// Workaround for an accessibility bug on Android: By default, when an item is removed
// from the list of results, focus can occasionally jump to the start of the document.
// To prevent this, manually move focus to the next item before the results list changes:
const adjacentView = listItemsRef.current[index + 1] ?? listItemsRef.current[index - 1];
focusTarget = adjacentView ?? searchInputRef.current;
}
if (focusTarget) {
focusView('ComboBox::focusAfterPress', focusTarget);
eventQueue.push(() => {
onItemSelected(item, index);
});
} else {
onItemSelected(item, index);
}
};
// For the correct accessibility structure, the `TouchableRipple`s need to be siblings.
return useMemo(() => ({ index, item, children, ...rest }) => (
<TouchableRipple
{...rest}
ref={(item) => {
listItemsRef.current[index] = item;
}}
onPress={() => { onItemPressRef.current(item, index); }}
// On web, focus is controlled using the arrow keys. On other
// platforms, arrow key navigation is not available and each item
// needs to be focusable
tabIndex={Platform.OS === 'web' ? -1 : undefined}
role={Platform.OS === 'web' ? 'option' : 'button'}
accessibilityHint={item.accessibilityHint}
aria-selected={index === selectedIndex}
nativeID={`${baseId}-${index}`}
testID={`search-result-${index}`}
aria-setsize={resultCount}
aria-posinset={index + 1}
><View>{children}</View></TouchableRipple>
), [selectedIndex, baseId, resultCount]);
};
const useShowSearchResults = (alwaysExpand: boolean, search: string) => {
const [showSearchResults, setShowSearchResults] = useState(alwaysExpand);
const showResultsRef = useRef(showSearchResults);
showResultsRef.current = showSearchResults;
useEffect(() => {
if (alwaysExpand) {
setShowSearchResults(true);
}
}, [alwaysExpand]);
useEffect(() => {
if (search.length > 0 && !showResultsRef.current) {
setShowSearchResults(true);
}
}, [search]);
return { showSearchResults, setShowSearchResults };
};
interface AnnounceSelectionOptions {
enabled: boolean;
selectedResultTitle: string|undefined;
resultCount: number;
searchQuery: string;
}
const useAnnounceSelection = ({ selectedResultTitle, resultCount, enabled, searchQuery }: AnnounceSelectionOptions) => {
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const announcement = (() => {
if (!searchQuery) return '';
if (resultCount === 0) return _('No results');
if (selectedResultTitle) return _('Selected: %s', selectedResultTitle);
return '';
})();
useEffect(() => {
if (enabledRef.current && announcement) {
AccessibilityInfo.announceForAccessibility(announcement);
}
}, [announcement]);
};
const useSelectionAutoScroll = (
listRef: RefObject<NestableFlatListControl|null>, results: Option[], selectedIndex: number,
) => {
const resultsRef = useRef(results);
resultsRef.current = results;
useEffect(() => {
if (resultsRef.current?.length && selectedIndex >= 0) {
listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.4 });
}
}, [selectedIndex, listRef]);
};
interface UseInputEventHandlersProps {
selectedIndexControl: SelectedIndexControl;
onItemSelected: OnItemSelected;
selectedIndex: number;
selectedResult: Option|null;
alwaysExpand: boolean;
showSearchResults: boolean;
setShowSearchResults: (show: boolean)=> void;
setSearch: (search: string)=> void;
}
const useInputEventHandlers = ({
selectedIndexControl,
onItemSelected: propsOnItemSelected, setShowSearchResults, alwaysExpand,
setSearch, selectedResult, selectedIndex, showSearchResults,
}: UseInputEventHandlersProps) => {
const propsOnItemSelectedRef = useRef(propsOnItemSelected);
propsOnItemSelectedRef.current = propsOnItemSelected;
const onItemSelected = useCallback((item: Option, index: number) => {
let result;
if (item.onPress) {
result = item.onPress();
} else {
result = propsOnItemSelectedRef.current(item, index);
}
if (!alwaysExpand) {
setSearch('');
setShowSearchResults(false);
}
return result;
}, [setShowSearchResults, alwaysExpand, setSearch]);
const onSubmit = useCallback(() => {
if (selectedResult) {
onItemSelected(selectedResult, selectedIndex);
setSearch('');
}
}, [onItemSelected, selectedResult, selectedIndex, setSearch]);
// For now, onKeyPress only works on web.
// See https://github.com/react-native-community/discussions-and-proposals/issues/249
type KeyPressEvent = { key: string };
const onKeyPress = useCallback((event: NativeSyntheticEvent<KeyPressEvent>) => {
const key = event.nativeEvent.key;
const isDownArrow = key === 'ArrowDown';
const isUpArrow = key === 'ArrowUp';
if (!showSearchResults && (isDownArrow || isUpArrow)) {
setShowSearchResults(true);
if (isUpArrow) {
selectedIndexControl.onLastResult();
} else {
selectedIndexControl.onFirstResult();
}
event.preventDefault();
} else if (key === 'ArrowDown') {
selectedIndexControl.onNextResult();
event.preventDefault();
} else if (key === 'ArrowUp') {
selectedIndexControl.onPreviousResult();
event.preventDefault();
} else if (key === 'Enter' && Platform.OS === 'web') {
// This case is necessary on web to prevent the
// search input from becoming defocused after
// pressing "enter". Enter key behavior is handled
// elsewhere for other platforms.
event.preventDefault();
onSubmit();
setSearch('');
} else if (key === 'Escape' && !alwaysExpand) {
setShowSearchResults(false);
event.preventDefault();
}
}, [onSubmit, setSearch, selectedIndexControl, setShowSearchResults, showSearchResults, alwaysExpand]);
return { onKeyPress, onItemSelected, onSubmit };
};
const ComboBox: React.FC<Props> = ({
themeId,
items,
onItemSelected: propsOnItemSelected,
placeholder,
onAddItem,
canAddItem,
style: rootStyle,
alwaysExpand,
searchInputProps,
searchResultProps,
}) => {
const [search, setSearch] = useState('');
const { showSearchResults, setShowSearchResults } = useShowSearchResults(alwaysExpand, search);
const { styles, menuItemHeight } = useStyles(themeId, showSearchResults);
const results = useSearchResults({
search,
setSearch,
options: items,
onAddItem,
canAddItem,
});
const { selectedIndex, selectedIndexControl } = useSelectedIndex(search, results);
const searchInputRef = useRef<TextInput|null>(null);
const listRef = useRef<NestableFlatListControl|null>(null);
useSelectionAutoScroll(listRef, results, selectedIndex);
useAnnounceSelection({
// On web, announcements are handled natively based on accessibility roles.
// Manual announcements are only needed on iOS and Android:
enabled: Platform.OS !== 'web',
selectedResultTitle: results[selectedIndex]?.title,
searchQuery: search,
resultCount: results.length,
});
const { onItemSelected, onKeyPress, onSubmit } = useInputEventHandlers({
selectedIndexControl,
onItemSelected: propsOnItemSelected,
selectedIndex,
selectedResult: results[selectedIndex],
alwaysExpand,
showSearchResults,
setShowSearchResults,
setSearch,
});
const baseId = useId();
const SearchResultWrapper = useSearchResultContainerComponent({
onItemSelected, selectedIndex, baseId, searchInputRef, resultCount: results.length,
resultsHideOnPress: !alwaysExpand,
});
type RenderEvent = { item: Option; index: number };
const renderItem = useCallback(({ item, index }: RenderEvent) => {
return <SearchResult
text={item.title}
styles={styles}
selected={index === selectedIndex}
icon={item.icon ?? ''}
/>;
}, [selectedIndex, styles]);
const webProps = {
onKeyDown: onKeyPress,
};
const activeId = `${baseId}-${selectedIndex}`;
const searchResults = <NestableFlatList
keyboardShouldPersistTaps="handled"
ref={listRef}
data={results}
{...searchResultProps}
CellRendererComponent={SearchResultWrapper}
itemHeight={menuItemHeight}
contentWrapperProps={{
// A better role would be 'listbox', but that isn't supported by RN.
role: Platform.OS === 'web' ? 'listbox' as Role : undefined,
'aria-activedescendant': activeId,
nativeID: `menuBox-${baseId}`,
onKeyPress,
// Allow focusing the results list directly on web. It has been observed
// that certain screen readers on web sometimes fail to read changes to the results list.
// Being able to navigate directly to the results list may help users in this case.
tabIndex: Platform.OS === 'web' ? 0 : undefined,
} as ViewProps}
style={styles.searchResults}
keyExtractor={optionKeyExtractor}
extraData={renderItem}
renderItem={renderItem}
/>;
const helpComponent = <Text style={styles.tagSearchHelp}>{_('To create a new tag, type the name and press enter.')}</Text>;
return <View style={[styles.root, rootStyle]} {...webProps}>
<SearchInput
inputRef={searchInputRef}
themeId={themeId}
containerStyle={styles.searchInputContainer}
style={styles.searchInput}
value={search}
onChangeText={setSearch}
onKeyPress={onKeyPress}
onSubmitEditing={onSubmit}
submitBehavior='submit'
placeholder={placeholder}
aria-activedescendant={showSearchResults ? activeId : undefined}
aria-controls={`menuBox-${baseId}`}
// Certain accessibility properties only work well on web:
{...(Platform.OS === 'web' ? {
role: 'combobox',
'aria-autocomplete': 'list',
'aria-expanded': showSearchResults,
'aria-label': placeholder,
} : {})}
{...searchInputProps}
/>
{searchResults}
{!showSearchResults && helpComponent}
</View>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
}))(ComboBox);