You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-10 22:11:50 +02:00
Mobile: Implement tag screen redesign (#12551)
This commit is contained in:
@@ -633,6 +633,8 @@ packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
|
||||
packages/app-mobile/components/CameraView/utils/testing.js
|
||||
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
|
||||
packages/app-mobile/components/Checkbox.js
|
||||
packages/app-mobile/components/ComboBox.test.js
|
||||
packages/app-mobile/components/ComboBox.js
|
||||
packages/app-mobile/components/DialogManager/PromptButton.js
|
||||
packages/app-mobile/components/DialogManager/PromptDialog.js
|
||||
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
|
||||
@@ -661,6 +663,7 @@ packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
packages/app-mobile/components/Modal.js
|
||||
packages/app-mobile/components/ModalDialog.js
|
||||
packages/app-mobile/components/NestableFlatList.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
|
||||
@@ -711,9 +714,12 @@ packages/app-mobile/components/ScreenHeader/WarningBanner.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBox.js
|
||||
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
|
||||
packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SideMenu.js
|
||||
packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/TagEditor.test.js
|
||||
packages/app-mobile/components/TagEditor.js
|
||||
packages/app-mobile/components/TextInput.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -608,6 +608,8 @@ packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
|
||||
packages/app-mobile/components/CameraView/utils/testing.js
|
||||
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
|
||||
packages/app-mobile/components/Checkbox.js
|
||||
packages/app-mobile/components/ComboBox.test.js
|
||||
packages/app-mobile/components/ComboBox.js
|
||||
packages/app-mobile/components/DialogManager/PromptButton.js
|
||||
packages/app-mobile/components/DialogManager/PromptDialog.js
|
||||
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
|
||||
@@ -636,6 +638,7 @@ packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
packages/app-mobile/components/Modal.js
|
||||
packages/app-mobile/components/ModalDialog.js
|
||||
packages/app-mobile/components/NestableFlatList.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
|
||||
@@ -686,9 +689,12 @@ packages/app-mobile/components/ScreenHeader/WarningBanner.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBox.js
|
||||
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
|
||||
packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SideMenu.js
|
||||
packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/TagEditor.test.js
|
||||
packages/app-mobile/components/TagEditor.js
|
||||
packages/app-mobile/components/TextInput.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
|
90
packages/app-mobile/components/ComboBox.test.tsx
Normal file
90
packages/app-mobile/components/ComboBox.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import createMockReduxStore from '../utils/testing/createMockReduxStore';
|
||||
import TestProviderStack from './testing/TestProviderStack';
|
||||
import ComboBox, { OnItemSelected, Option } from './ComboBox';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Item {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
items: Item[];
|
||||
onItemSelected?: OnItemSelected;
|
||||
}
|
||||
|
||||
const store = createMockReduxStore();
|
||||
const WrappedComboBox: React.FC<WrapperProps> = ({
|
||||
items,
|
||||
onItemSelected = jest.fn(),
|
||||
}: WrapperProps) => {
|
||||
const mappedItems = useMemo(() => {
|
||||
return items.map((item): Option => ({
|
||||
title: item.title,
|
||||
icon: undefined,
|
||||
accessibilityHint: undefined,
|
||||
willRemoveOnPress: false,
|
||||
}));
|
||||
}, [items]);
|
||||
|
||||
return <TestProviderStack store={store}>
|
||||
<ComboBox
|
||||
items={mappedItems}
|
||||
alwaysExpand={true}
|
||||
style={{}}
|
||||
onItemSelected={onItemSelected}
|
||||
placeholder={'Test combobox'}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const getSearchInput = () => {
|
||||
return screen.getByPlaceholderText('Test combobox');
|
||||
};
|
||||
const getSearchResults = () => {
|
||||
return screen.getAllByTestId(/^search-result-/);
|
||||
};
|
||||
|
||||
describe('ComboBox', () => {
|
||||
test('should list all items when the search query is empty', () => {
|
||||
const testItems = [
|
||||
{ title: 'test 1' },
|
||||
{ title: 'test 2' },
|
||||
{ title: 'test 3' },
|
||||
];
|
||||
const { unmount } = render(
|
||||
<WrappedComboBox
|
||||
items={testItems}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getSearchInput()).toHaveTextContent('');
|
||||
expect(getSearchResults()).toHaveLength(3);
|
||||
|
||||
// Manually unmounting prevents a warning
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('changing the search query should limit which items are visible', () => {
|
||||
const testItems = [
|
||||
{ title: 'a' },
|
||||
{ title: 'b' },
|
||||
{ title: 'c' },
|
||||
{ title: 'aa' },
|
||||
];
|
||||
const { unmount } = render(
|
||||
<WrappedComboBox items={testItems}/>,
|
||||
);
|
||||
|
||||
expect(getSearchResults()).toHaveLength(4);
|
||||
fireEvent.changeText(getSearchInput(), 'a');
|
||||
|
||||
const updatedResults = getSearchResults();
|
||||
expect(updatedResults[0]).toHaveTextContent('a');
|
||||
expect(updatedResults[1]).toHaveTextContent('aa');
|
||||
expect(updatedResults).toHaveLength(2);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
598
packages/app-mobile/components/ComboBox.tsx
Normal file
598
packages/app-mobile/components/ComboBox.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import * as React from 'react';
|
||||
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, 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';
|
||||
const naturalCompare = require('string-natural-compare');
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 results = useMemo(() => {
|
||||
return options
|
||||
.filter(option => option.title.startsWith(search))
|
||||
.sort((a, b) => {
|
||||
if (a.title === b.title) return 0;
|
||||
// Full matches should go first
|
||||
if (a.title === search) return -1;
|
||||
if (b.title === search) return 1;
|
||||
return naturalCompare(a.title, b.title);
|
||||
});
|
||||
}, [search, options]);
|
||||
|
||||
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 } = useWindowDimensions();
|
||||
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: 200,
|
||||
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, 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
|
||||
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') {
|
||||
// This case is necessary on web to prevent the
|
||||
// search input from becoming defocused after
|
||||
// pressing "enter".
|
||||
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,
|
||||
}) => {
|
||||
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
|
||||
ref={listRef}
|
||||
data={results}
|
||||
|
||||
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}
|
||||
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);
|
@@ -1,6 +1,6 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { TextStyle, Text } from 'react-native';
|
||||
import { TextStyle, Text, StyleProp } from 'react-native';
|
||||
|
||||
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
|
||||
const AntIcon = require('react-native-vector-icons/AntDesign').default;
|
||||
@@ -9,7 +9,7 @@ const Ionicon = require('react-native-vector-icons/Ionicons').default;
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
style: TextStyle;
|
||||
style: StyleProp<TextStyle>;
|
||||
|
||||
// If `null` is given, the content must be labeled elsewhere.
|
||||
accessibilityLabel: string|null;
|
||||
|
@@ -5,8 +5,8 @@
|
||||
import * as React from 'react';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform, Role } from 'react-native';
|
||||
import { useState, useMemo, useCallback, useRef, Ref } from 'react';
|
||||
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform, Role, StyleProp, View } from 'react-native';
|
||||
import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu';
|
||||
import Icon from './Icon';
|
||||
import AccessibleView from './accessibility/AccessibleView';
|
||||
@@ -15,11 +15,13 @@ type ButtonClickListener = ()=> void;
|
||||
interface ButtonProps {
|
||||
onPress: ButtonClickListener;
|
||||
|
||||
pressableRef?: Ref<View>;
|
||||
|
||||
// Accessibility label and text shown in a tooltip
|
||||
description: string;
|
||||
|
||||
iconName: string;
|
||||
iconStyle: TextStyle;
|
||||
iconStyle: StyleProp<TextStyle>;
|
||||
|
||||
themeId: number;
|
||||
|
||||
@@ -87,6 +89,7 @@ const IconButton = (props: ButtonProps) => {
|
||||
|
||||
const button = (
|
||||
<Pressable
|
||||
ref={props.pressableRef}
|
||||
onPress={props.onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
|
@@ -1,91 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Text, View, StyleSheet, Button, TextStyle, ViewStyle } from 'react-native';
|
||||
import { useMemo } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { themeStyle } from './global-style';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
import Modal from './Modal';
|
||||
import { PrimaryButton } from './buttons';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Button } from 'react-native-paper';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
ContentComponent: ReactNode;
|
||||
children: React.ReactNode;
|
||||
|
||||
buttonBarEnabled: boolean;
|
||||
title: string;
|
||||
okTitle: string;
|
||||
cancelTitle: string;
|
||||
onOkPress: ()=> void;
|
||||
onCancelPress: ()=> void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
||||
}
|
||||
|
||||
class ModalDialog extends React.Component<Props, State> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private styles_: any;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
private styles() {
|
||||
const themeId = this.props.themeId;
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
const styles: Record<string, ViewStyle|TextStyle> = {
|
||||
modalContentWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
margin: 20,
|
||||
padding: 10,
|
||||
borderRadius: 5,
|
||||
elevation: 10,
|
||||
maxWidth: 600,
|
||||
maxHeight: 500,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignSelf: 'center',
|
||||
marginVertical: 'auto',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
padding: theme.margin,
|
||||
},
|
||||
modalContentWrapper2: {
|
||||
flex: 1,
|
||||
title: theme.headerStyle,
|
||||
contentWrapper: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
title: { ...theme.normalText, borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
paddingBottom: 10,
|
||||
fontWeight: 'bold' },
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.dividerColor,
|
||||
paddingTop: 10,
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.margin,
|
||||
marginTop: theme.marginTop,
|
||||
},
|
||||
};
|
||||
// Ensures that screen-reader-only headings have size (necessary for focusing/reading them).
|
||||
invisibleHeading: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
const ModalDialog: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
public override render() {
|
||||
const ContentComponent = this.props.ContentComponent;
|
||||
const buttonBarEnabled = this.props.buttonBarEnabled !== false;
|
||||
|
||||
return (
|
||||
<Modal transparent={true} visible={true} onRequestClose={() => {}} containerStyle={this.styles().modalContentWrapper}>
|
||||
<Text style={this.styles().title}>{this.props.title}</Text>
|
||||
<View style={this.styles().modalContentWrapper2}>{ContentComponent}</View>
|
||||
<View style={this.styles().buttonRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button disabled={!buttonBarEnabled} title={_('OK')} onPress={this.props.onOkPress}></Button>
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 5 }}>
|
||||
<Button disabled={!buttonBarEnabled} title={_('Cancel')} onPress={this.props.onCancelPress}></Button>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
transparent={true}
|
||||
visible={true}
|
||||
onRequestClose={null}
|
||||
containerStyle={styles.container}
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
>
|
||||
<View style={styles.contentWrapper}>{props.children}</View>
|
||||
<View style={styles.buttonRow}>
|
||||
<View
|
||||
// This heading makes it easier for screen readers to jump to the
|
||||
// actions list. Without a heading here, it can be difficult to locate the "ok" and "cancel"
|
||||
// buttons.
|
||||
role='heading'
|
||||
aria-label={_('Actions')}
|
||||
accessible={true}
|
||||
style={styles.invisibleHeading}
|
||||
/>
|
||||
<Button disabled={!props.buttonBarEnabled} onPress={props.onCancelPress}>{props.cancelTitle}</Button>
|
||||
<PrimaryButton disabled={!props.buttonBarEnabled} onPress={props.onOkPress}>{props.okTitle}</PrimaryButton>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDialog;
|
||||
|
127
packages/app-mobile/components/NestableFlatList.tsx
Normal file
127
packages/app-mobile/components/NestableFlatList.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from 'react';
|
||||
import { Ref, useRef, useImperativeHandle, useState, useCallback } from 'react';
|
||||
import { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView, ScrollViewProps, View, ViewProps } from 'react-native';
|
||||
|
||||
interface RenderEvent<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface ScrollToOptions {
|
||||
index: number;
|
||||
viewPosition: number;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
export interface NestableFlatListControl {
|
||||
scrollToIndex(options: ScrollToOptions): void;
|
||||
}
|
||||
|
||||
interface CellRendererProps<T> {
|
||||
index: number;
|
||||
item: T;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// For compatibility, these props should be mostly compatible with the
|
||||
// native FlatList props:
|
||||
interface Props<T> extends ScrollViewProps {
|
||||
ref: Ref<NestableFlatListControl>;
|
||||
data: T[];
|
||||
itemHeight: number;
|
||||
CellRendererComponent?: React.ComponentType<CellRendererProps<T>>;
|
||||
renderItem: (event: RenderEvent<T>)=> React.ReactNode;
|
||||
keyExtractor: (item: T)=> string;
|
||||
extraData: unknown;
|
||||
|
||||
// Additional props.
|
||||
// The contentWrapperProps can be used to improve accessibility by
|
||||
// applying certain content roles to the <View> that directly contains
|
||||
// the list's content. At least on web, applying these props directly to the ScrollView may
|
||||
// not work due to additional <View>s added by React Native.
|
||||
contentWrapperProps?: ViewProps;
|
||||
}
|
||||
|
||||
// This component allows working around restrictions on nesting React Native's built-in
|
||||
// <FlatList> within <ScrollView>s. For the most part, this component's interface should
|
||||
// be compatible with the <FlatList> API.
|
||||
//
|
||||
// See https://github.com/facebook/react-native/issues/31697.
|
||||
const NestableFlatList = function<T>({
|
||||
ref,
|
||||
itemHeight,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
data,
|
||||
CellRendererComponent = React.Fragment,
|
||||
contentWrapperProps,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const scrollViewRef = useRef<ScrollView|null>(null);
|
||||
const [scroll, setScroll] = useState(0);
|
||||
const [listHeight, setListHeight] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
scrollToIndex: ({ index, animated, viewPosition }) => {
|
||||
const offset = Math.max(0, index * itemHeight - viewPosition * listHeight);
|
||||
scrollViewRef.current.scrollTo({
|
||||
y: offset,
|
||||
animated,
|
||||
});
|
||||
// onScroll events don't seem to be sent when scrolling with .scrollTo.
|
||||
// The scroll offset needs to be updated manually:
|
||||
setScroll(offset);
|
||||
},
|
||||
};
|
||||
}, [itemHeight, listHeight]);
|
||||
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
setScroll(event.nativeEvent.contentOffset.y);
|
||||
}, []);
|
||||
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setListHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const bufferSize = 10;
|
||||
const visibleStartIndex = Math.floor(scroll / itemHeight);
|
||||
const visibleEndIndex = Math.ceil((scroll + listHeight) / itemHeight);
|
||||
const startIndex = Math.max(0, visibleStartIndex - bufferSize);
|
||||
const maximumIndex = data.length - 1;
|
||||
const endIndex = Math.min(visibleEndIndex + bufferSize, maximumIndex);
|
||||
const paddingTop = startIndex * itemHeight;
|
||||
const paddingBottom = (maximumIndex - endIndex) * itemHeight;
|
||||
|
||||
const renderVisibleItems = () => {
|
||||
const result: React.ReactNode[] = [];
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
result.push(
|
||||
<CellRendererComponent
|
||||
index={i}
|
||||
item={data[i]}
|
||||
key={keyExtractor(data[i])}
|
||||
>
|
||||
{renderItem({ item: data[i], index: i })}
|
||||
</CellRendererComponent>,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return <ScrollView
|
||||
ref={scrollViewRef}
|
||||
onScroll={onScroll}
|
||||
onLayout={onLayout}
|
||||
{...rest}
|
||||
>
|
||||
<View {...contentWrapperProps}>
|
||||
<View style={{ height: paddingTop }}/>
|
||||
{renderVisibleItems()}
|
||||
<View style={{ height: paddingBottom }}/>
|
||||
</View>
|
||||
</ScrollView>;
|
||||
};
|
||||
|
||||
export default NestableFlatList;
|
87
packages/app-mobile/components/SearchInput.tsx
Normal file
87
packages/app-mobile/components/SearchInput.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import TextInput from './TextInput';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Ref, useCallback, useMemo } from 'react';
|
||||
import { themeStyle } from './global-style';
|
||||
import IconButton from './IconButton';
|
||||
import Icon from './Icon';
|
||||
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
inputRef?: Ref<ReactNativeTextInput>;
|
||||
value: string;
|
||||
onChangeText: (text: string)=> void;
|
||||
themeId: number;
|
||||
containerStyle: ViewStyle;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number, hasContent: boolean) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
root: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputStyle: {
|
||||
fontSize: theme.fontSize,
|
||||
flexGrow: 1,
|
||||
borderWidth: 0,
|
||||
borderBlockColor: 'transparent',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
closeButton: hasContent ? {} : {
|
||||
opacity: 0,
|
||||
},
|
||||
icon: {
|
||||
color: theme.colorFaded,
|
||||
fontSize: theme.fontSizeLarger,
|
||||
width: 32,
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'center',
|
||||
alignContent: 'center',
|
||||
},
|
||||
});
|
||||
}, [themeId, hasContent]);
|
||||
};
|
||||
|
||||
const SearchInput: React.FC<SearchInputProps> = ({ inputRef, themeId, value, containerStyle, style, onChangeText, ...rest }) => {
|
||||
const styles = useStyles(themeId, !!value);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
onChangeText('');
|
||||
}, [onChangeText]);
|
||||
|
||||
return <View style={[styles.root, containerStyle]}>
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
name='material magnify'
|
||||
accessibilityLabel={null}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.inputStyle, style]}
|
||||
themeId={themeId}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
underlineColorAndroid='transparent'
|
||||
{...rest}
|
||||
/>
|
||||
<IconButton
|
||||
iconName='material close'
|
||||
onPress={onClear}
|
||||
description={_('Clear search')}
|
||||
disabled={value.length === 0}
|
||||
iconStyle={[styles.icon, styles.closeButton]}
|
||||
themeId={themeId}
|
||||
/>
|
||||
</View>;
|
||||
};
|
||||
|
||||
export default SearchInput;
|
123
packages/app-mobile/components/TagEditor.test.tsx
Normal file
123
packages/app-mobile/components/TagEditor.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import createMockReduxStore from '../utils/testing/createMockReduxStore';
|
||||
import TestProviderStack from './testing/TestProviderStack';
|
||||
import { TagEntity } from '@joplin/lib/services/database/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import TagEditor, { TagEditorMode } from './TagEditor';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface WrapperProps {
|
||||
allTags: TagEntity[];
|
||||
initialTags: string[];
|
||||
onTagsChanged: (tags: string[])=> void;
|
||||
}
|
||||
|
||||
const emptyStyle = {};
|
||||
|
||||
const store = createMockReduxStore();
|
||||
const WrappedTagEditor: React.FC<WrapperProps> = props => {
|
||||
const [tags, setTags] = useState(props.initialTags);
|
||||
|
||||
useEffect(() => {
|
||||
props.onTagsChanged(tags);
|
||||
}, [tags, props.onTagsChanged]);
|
||||
|
||||
return <TestProviderStack store={store}>
|
||||
<TagEditor
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
style={emptyStyle}
|
||||
onTagsChange={setTags}
|
||||
mode={TagEditorMode.Large}
|
||||
allTags={props.allTags}
|
||||
tags={tags}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
describe('TagEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
});
|
||||
|
||||
test('clicking "remove" should remove a tag', async () => {
|
||||
const initialTags = ['test', 'testing'];
|
||||
let currentTags = initialTags;
|
||||
const onTagsChanged = (tags: string[]) => {
|
||||
currentTags = tags;
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<WrappedTagEditor
|
||||
allTags={initialTags.map(t => ({ title: t }))}
|
||||
initialTags={initialTags}
|
||||
onTagsChanged={onTagsChanged}
|
||||
/>,
|
||||
);
|
||||
|
||||
const removeButton = screen.getByRole('button', { name: 'Remove testing' });
|
||||
fireEvent.press(removeButton);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
await jest.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(currentTags).toEqual(['test']);
|
||||
|
||||
// Manually unmount to prevent warnings
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('clicking on search result should add it as a tag', () => {
|
||||
const initialTags = ['test'];
|
||||
let currentTags = initialTags;
|
||||
const onTagsChanged = (tags: string[]) => {
|
||||
currentTags = tags;
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<WrappedTagEditor
|
||||
allTags={[...initialTags, 'new tag 1'].map(t => ({ title: t }))}
|
||||
initialTags={initialTags}
|
||||
onTagsChanged={onTagsChanged}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search tags');
|
||||
fireEvent.changeText(searchInput, 'new');
|
||||
|
||||
const searchResult = screen.getByRole('button', { name: 'new tag 1' });
|
||||
fireEvent.press(searchResult);
|
||||
expect(currentTags).toEqual(['test', 'new tag 1']);
|
||||
|
||||
// Manually unmount to prevent warnings
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('searching for a tag and pressing "add new" should add a new tag', () => {
|
||||
const initialTags = ['test'];
|
||||
let currentTags = initialTags;
|
||||
const onTagsChanged = (tags: string[]) => {
|
||||
currentTags = tags;
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<WrappedTagEditor
|
||||
allTags={initialTags.map(t => ({ title: t }))}
|
||||
initialTags={initialTags}
|
||||
onTagsChanged={onTagsChanged}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search tags');
|
||||
fireEvent.changeText(searchInput, 'create');
|
||||
|
||||
const addNewButton = screen.getByRole('button', { name: 'Add new' });
|
||||
fireEvent.press(addNewButton);
|
||||
expect(currentTags).toEqual(['test', 'create']);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
277
packages/app-mobile/components/TagEditor.tsx
Normal file
277
packages/app-mobile/components/TagEditor.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { StyleSheet, View, Text, ScrollView, ViewStyle, Platform, AccessibilityInfo } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { themeStyle } from './global-style';
|
||||
import ComboBox, { Option } from './ComboBox';
|
||||
import IconButton from './IconButton';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TagEntity } from '@joplin/lib/services/database/types';
|
||||
import { Divider } from 'react-native-paper';
|
||||
import focusView from '../utils/focusView';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
|
||||
export enum TagEditorMode {
|
||||
Large,
|
||||
Compact,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
tags: string[];
|
||||
allTags: TagEntity[];
|
||||
mode: TagEditorMode;
|
||||
style: ViewStyle;
|
||||
onTagsChange: (newTags: string[])=> void;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
tag: {
|
||||
borderRadius: 16,
|
||||
paddingStart: 8,
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
color: theme.color3,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
tagText: {
|
||||
color: theme.color3,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
removeTagButton: {
|
||||
color: theme.color3,
|
||||
fontSize: theme.fontSize,
|
||||
padding: 3,
|
||||
},
|
||||
tagBoxRoot: {
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tagBoxScrollView: {
|
||||
borderColor: theme.dividerColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
height: 80,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tagBoxContent: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
header: {
|
||||
...theme.headerStyle,
|
||||
fontSize: theme.fontSize,
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
},
|
||||
divider: {
|
||||
marginTop: theme.margin * 1.4,
|
||||
marginBottom: theme.margin,
|
||||
backgroundColor: theme.dividerColor,
|
||||
},
|
||||
tagSearch: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
noTagsLabel: {
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.colorFaded,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
type Styles = ReturnType<typeof useStyles>;
|
||||
|
||||
interface TagChipProps {
|
||||
title: string;
|
||||
themeId: number;
|
||||
styles: Styles;
|
||||
onRemove: (title: string)=> void;
|
||||
|
||||
autofocus: boolean;
|
||||
onAutoFocusComplete: ()=> void;
|
||||
}
|
||||
|
||||
const TagCard: React.FC<TagChipProps> = props => {
|
||||
const onRemove = useCallback(() => {
|
||||
props.onRemove(props.title);
|
||||
}, [props.title, props.onRemove]);
|
||||
|
||||
const removeButtonRef = useRef<View>(null);
|
||||
useEffect(() => {
|
||||
if (props.autofocus) {
|
||||
focusView('TagEditor::TagCard', removeButtonRef.current);
|
||||
props.onAutoFocusComplete();
|
||||
}
|
||||
}, [props.autofocus, props.onAutoFocusComplete]);
|
||||
|
||||
return <View
|
||||
style={props.styles.tag}
|
||||
role='listitem'
|
||||
>
|
||||
<Text style={props.styles.tagText}>{props.title}</Text>
|
||||
<IconButton
|
||||
pressableRef={removeButtonRef}
|
||||
themeId={props.themeId}
|
||||
description={_('Remove %s', props.title)}
|
||||
iconName='fas fa-times-circle'
|
||||
iconStyle={props.styles.removeTagButton}
|
||||
onPress={onRemove}
|
||||
/>
|
||||
</View>;
|
||||
};
|
||||
|
||||
interface TagsBoxProps {
|
||||
tags: string[];
|
||||
autofocusTag: string;
|
||||
onAutoFocusComplete: ()=> void;
|
||||
styles: Styles;
|
||||
themeId: number;
|
||||
onRemoveTag: (tag: string)=> void;
|
||||
}
|
||||
|
||||
const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||
const onRemoveTag = useCallback((tag: string) => {
|
||||
props.onRemoveTag(tag);
|
||||
}, [props.onRemoveTag]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (props.tags.length) {
|
||||
return props.tags.map(tag => (
|
||||
<TagCard
|
||||
key={`tag-${tag}`}
|
||||
title={tag}
|
||||
styles={props.styles}
|
||||
themeId={props.themeId}
|
||||
onRemove={onRemoveTag}
|
||||
autofocus={props.autofocusTag === tag}
|
||||
onAutoFocusComplete={props.onAutoFocusComplete}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
return <Text
|
||||
style={props.styles.noTagsLabel}
|
||||
>{_('No tags')}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
return <View style={props.styles.tagBoxRoot}>
|
||||
<Text style={props.styles.header} role='heading'>{_('Associated tags:')}</Text>
|
||||
<ScrollView
|
||||
style={props.styles.tagBoxScrollView}
|
||||
// On web, specifying aria-live here announces changes to the associated tags.
|
||||
// However, on Android (and possibly iOS), this breaks focus behavior:
|
||||
aria-live={Platform.OS === 'web' ? 'polite' : undefined}
|
||||
>
|
||||
<View
|
||||
// Accessibility: Marking the list of tags as a list seems to prevent focus from jumping
|
||||
// to the top of the modal after removing a tag.
|
||||
role='list'
|
||||
style={props.styles.tagBoxContent}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>;
|
||||
};
|
||||
|
||||
const normalizeTag = (tagText: string) => tagText.trim().toLowerCase();
|
||||
|
||||
const TagEditor: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
|
||||
const comboBoxItems = useMemo(() => {
|
||||
return props.allTags
|
||||
// Exclude tags already associated with the note
|
||||
.filter(tag => !props.tags.includes(tag.title))
|
||||
.map((tag): Option => {
|
||||
const title = tag.title ?? 'Untitled';
|
||||
return {
|
||||
title,
|
||||
icon: null,
|
||||
accessibilityHint: _('Adds tag'),
|
||||
willRemoveOnPress: true,
|
||||
};
|
||||
});
|
||||
}, [props.tags, props.allTags]);
|
||||
|
||||
const [autofocusTag, setAutofocusTag] = useState('');
|
||||
const onAutoFocusComplete = useCallback(() => {
|
||||
// Clear the auto-focus state so that a different view can be auto-focused in the future
|
||||
setAutofocusTag('');
|
||||
}, []);
|
||||
|
||||
const onAddTag = useCallback((title: string) => {
|
||||
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
|
||||
props.onTagsChange([...props.tags, normalizeTag(title)]);
|
||||
}, [props.tags, props.onTagsChange]);
|
||||
|
||||
const onRemoveTag = useCallback(async (title: string) => {
|
||||
const previousTagIndex = props.tags.indexOf(title);
|
||||
const targetTag = props.tags[previousTagIndex + 1] ?? props.tags[previousTagIndex - 1];
|
||||
setAutofocusTag(targetTag);
|
||||
|
||||
// Workaround: Delay auto-focusing the next tag. On iOS, a brief delay is required to
|
||||
// prevent focus from occasionally jumping away from the tag box.
|
||||
await msleep(100);
|
||||
AccessibilityInfo.announceForAccessibility(_('Removed tag: %s', title));
|
||||
props.onTagsChange(props.tags.filter(tag => tag !== title));
|
||||
}, [props.tags, props.onTagsChange]);
|
||||
|
||||
const onComboBoxSelect = useCallback((item: { title: string }) => {
|
||||
onAddTag(item.title);
|
||||
return { willRemove: true };
|
||||
}, [onAddTag]);
|
||||
|
||||
const allTagsSet = useMemo(() => {
|
||||
return new Set([
|
||||
...props.allTags.map(tag => tag.title),
|
||||
...props.tags,
|
||||
]);
|
||||
}, [props.allTags, props.tags]);
|
||||
|
||||
const onCanAddTag = useCallback((tag: string) => {
|
||||
return !allTagsSet.has(normalizeTag(tag));
|
||||
}, [allTagsSet]);
|
||||
|
||||
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;
|
||||
|
||||
return <View style={props.style}>
|
||||
{showAssociatedTags && <>
|
||||
<TagsBox
|
||||
themeId={props.themeId}
|
||||
styles={styles}
|
||||
tags={props.tags}
|
||||
onRemoveTag={onRemoveTag}
|
||||
autofocusTag={autofocusTag}
|
||||
onAutoFocusComplete={onAutoFocusComplete}
|
||||
/>
|
||||
<Divider style={styles.divider}/>
|
||||
</>}
|
||||
<Text style={styles.header} role='heading'>{_('Add tags:')}</Text>
|
||||
<ComboBox
|
||||
items={comboBoxItems}
|
||||
onItemSelected={onComboBoxSelect}
|
||||
onAddItem={onAddTag}
|
||||
canAddItem={onCanAddTag}
|
||||
alwaysExpand={props.mode === TagEditorMode.Large}
|
||||
style={styles.tagSearch}
|
||||
placeholder={_('Search tags')}
|
||||
searchInputProps={{
|
||||
autoCapitalize: 'none',
|
||||
}}
|
||||
/>
|
||||
</View>;
|
||||
};
|
||||
|
||||
export default TagEditor;
|
@@ -1,9 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Ref, useMemo } from 'react';
|
||||
import { themeStyle } from './global-style';
|
||||
import { TextInput, TextInputProps, StyleSheet } from 'react-native';
|
||||
|
||||
interface Props extends TextInputProps {
|
||||
ref?: Ref<TextInput>;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
|
@@ -1,15 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { StyleSheet, View, Text, FlatList, TouchableOpacity, TextInput } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { themeStyle } from '../global-style';
|
||||
import ModalDialog from '../ModalDialog';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { TagEntity } from '@joplin/lib/services/database/types';
|
||||
import Icon from '../Icon';
|
||||
const naturalCompare = require('string-natural-compare');
|
||||
import TagEditor, { TagEditorMode } from '../TagEditor';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -18,223 +17,57 @@ interface Props {
|
||||
tags: TagEntity[];
|
||||
}
|
||||
|
||||
interface TagListRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
selected: boolean;
|
||||
}
|
||||
const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
const [noteId, setNoteId] = useState(props.noteId);
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
const [noteTags, setNoteTags] = useState<string[]>([]);
|
||||
|
||||
interface State {
|
||||
noteTagIds: string[];
|
||||
tagListData: TagListRecord[];
|
||||
noteId: string|null;
|
||||
newTags: string;
|
||||
savingTags: boolean;
|
||||
tagFilter: string;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (props.noteId) setNoteId(props.noteId);
|
||||
}, [props.noteId]);
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
class NoteTagsDialogComponent extends React.Component<Props, State> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private styles_: any;
|
||||
private labelId_ = `tag-input-${idCounter++}`;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
this.styles_ = {};
|
||||
this.state = {
|
||||
noteTagIds: [],
|
||||
noteId: null,
|
||||
tagListData: [],
|
||||
newTags: '',
|
||||
savingTags: false,
|
||||
tagFilter: '',
|
||||
};
|
||||
}
|
||||
|
||||
private noteHasTag(tagId: string) {
|
||||
for (let i = 0; i < this.state.tagListData.length; i++) {
|
||||
if (this.state.tagListData[i].id === tagId) return this.state.tagListData[i].selected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private newTagTitles() {
|
||||
return this.state.newTags
|
||||
.split(',')
|
||||
.map(t => t.trim().toLowerCase())
|
||||
.filter(t => !!t);
|
||||
}
|
||||
|
||||
private tag_press = (tagId: string) => {
|
||||
const newData = this.state.tagListData.slice();
|
||||
for (let i = 0; i < newData.length; i++) {
|
||||
const t = newData[i];
|
||||
if (t.id === tagId) {
|
||||
const newTag = { ...t };
|
||||
newTag.selected = !newTag.selected;
|
||||
newData[i] = newTag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ tagListData: newData });
|
||||
};
|
||||
|
||||
private renderTag = (data: { item: TagListRecord }) => {
|
||||
const tag = data.item;
|
||||
const hasTag = this.noteHasTag(tag.id);
|
||||
const iconName = hasTag ? 'ionicon checkbox-outline' : 'ionicon square-outline';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tag.id} onPress={() => this.tag_press(tag.id)}
|
||||
style={this.styles().tag}
|
||||
accessibilityRole='checkbox'
|
||||
accessibilityHint={_('Add tag %s to note', tag.title)}
|
||||
aria-checked={hasTag}
|
||||
accessibilityState={{ checked: hasTag }}
|
||||
>
|
||||
<View style={this.styles().tagIconText}>
|
||||
<Icon name={iconName} style={this.styles().tagCheckbox} accessibilityLabel={null} />
|
||||
<Text style={this.styles().tagText}>{tag.title}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
private tagKeyExtractor = (tag: TagListRecord) => tag.id;
|
||||
|
||||
private okButton_press = async () => {
|
||||
this.setState({ savingTags: true });
|
||||
const onOkayPress = useCallback(async () => {
|
||||
setSavingTags(true);
|
||||
|
||||
try {
|
||||
const tagIds = this.state.tagListData.filter(t => t.selected).map(t => t.id);
|
||||
await Tag.setNoteTagsByIds(this.state.noteId, tagIds);
|
||||
|
||||
const extraTitles = this.newTagTitles();
|
||||
for (let i = 0; i < extraTitles.length; i++) {
|
||||
await Tag.addNoteTagByTitle(this.state.noteId, extraTitles[i]);
|
||||
}
|
||||
await Tag.setNoteTagsByTitles(noteId, noteTags);
|
||||
} finally {
|
||||
this.setState({ savingTags: false });
|
||||
setSavingTags(false);
|
||||
}
|
||||
|
||||
if (this.props.onCloseRequested) this.props.onCloseRequested();
|
||||
};
|
||||
props.onCloseRequested?.();
|
||||
}, [props.onCloseRequested, noteId, noteTags]);
|
||||
|
||||
private cancelButton_press = () => {
|
||||
if (this.props.onCloseRequested) this.props.onCloseRequested();
|
||||
};
|
||||
const onCancelPress = useCallback(() => {
|
||||
props.onCloseRequested?.();
|
||||
}, [props.onCloseRequested]);
|
||||
|
||||
private filterTags(allTags: TagListRecord[]) {
|
||||
return allTags.filter((tag) => tag.title.toLowerCase().includes(this.state.tagFilter.toLowerCase()), allTags);
|
||||
}
|
||||
|
||||
public override UNSAFE_componentWillMount() {
|
||||
const noteId = this.props.noteId;
|
||||
this.setState({ noteId: noteId });
|
||||
void this.loadNoteTags(noteId);
|
||||
}
|
||||
|
||||
private async loadNoteTags(noteId: string) {
|
||||
useAsyncEffect(async (event) => {
|
||||
const tags = await Tag.tagsByNoteId(noteId);
|
||||
const tagIds = tags.map(t => t.id);
|
||||
const noteTags = tags.map(t => t.title);
|
||||
if (!event.cancelled) {
|
||||
setNoteTags(noteTags);
|
||||
}
|
||||
}, [noteId]);
|
||||
|
||||
const tagListData = this.props.tags.map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
title: tag.title,
|
||||
selected: tagIds.indexOf(tag.id) >= 0,
|
||||
};
|
||||
});
|
||||
|
||||
tagListData.sort((a, b) => {
|
||||
if (a.selected === b.selected) return naturalCompare(a.title, b.title, { caseInsensitive: true });
|
||||
else if (b.selected === true) return 1;
|
||||
else return -1;
|
||||
});
|
||||
|
||||
this.setState({ tagListData: tagListData });
|
||||
}
|
||||
|
||||
private styles() {
|
||||
const themeId = this.props.themeId;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tag: {
|
||||
padding: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
},
|
||||
tagIconText: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tagText: { ...theme.normalText },
|
||||
tagCheckbox: {
|
||||
marginRight: 8,
|
||||
fontSize: 20,
|
||||
color: theme.color,
|
||||
},
|
||||
tagBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
},
|
||||
newTagBoxLabel: { ...theme.normalText, marginRight: 8 },
|
||||
tagBoxInput: { ...theme.lineInput, flex: 1 },
|
||||
});
|
||||
|
||||
this.styles_[themeId] = styles;
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
public override render() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const dialogContent = (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={this.styles().tagBox}>
|
||||
<Text style={this.styles().newTagBoxLabel} nativeID={this.labelId_}>{_('New tags:')}</Text>
|
||||
<TextInput
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
value={this.state.newTags}
|
||||
onChangeText={value => {
|
||||
this.setState({ newTags: value });
|
||||
}}
|
||||
style={this.styles().tagBoxInput}
|
||||
placeholder={_('tag1, tag2, ...')}
|
||||
accessibilityLabelledBy={this.labelId_}
|
||||
/>
|
||||
</View>
|
||||
<View style={this.styles().tagBox}>
|
||||
<TextInput
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
value={this.state.tagFilter}
|
||||
onChangeText={value => {
|
||||
this.setState({ tagFilter: value });
|
||||
}}
|
||||
placeholder={_('Filter tags')}
|
||||
style={this.styles().tagBoxInput}
|
||||
/>
|
||||
</View>
|
||||
<FlatList data={this.filterTags(this.state.tagListData)} renderItem={this.renderTag} keyExtractor={this.tagKeyExtractor} />
|
||||
</View>
|
||||
);
|
||||
|
||||
return <ModalDialog themeId={this.props.themeId} ContentComponent={dialogContent} title={_('Type new tags or select from list')} onOkPress={this.okButton_press} onCancelPress={this.cancelButton_press} buttonBarEnabled={!this.state.savingTags} />;
|
||||
}
|
||||
}
|
||||
return <ModalDialog
|
||||
themeId={props.themeId}
|
||||
onOkPress={onOkayPress}
|
||||
onCancelPress={onCancelPress}
|
||||
buttonBarEnabled={!savingTags}
|
||||
okTitle={_('Apply')}
|
||||
cancelTitle={_('Cancel')}
|
||||
>
|
||||
<TagEditor
|
||||
themeId={props.themeId}
|
||||
tags={noteTags}
|
||||
allTags={props.tags}
|
||||
onTagsChange={setNoteTags}
|
||||
mode={TagEditorMode.Large}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</ModalDialog>;
|
||||
};
|
||||
|
||||
const NoteTagsDialog = connect((state: AppState) => {
|
||||
return {
|
||||
|
Reference in New Issue
Block a user