1
0
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:
Henry Heino
2025-07-17 07:50:37 -07:00
committed by GitHub
parent 53fe12ab8a
commit 30aff62d08
13 changed files with 1432 additions and 284 deletions

View File

@@ -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
View File

@@ -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

View 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();
});
});

View 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);

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View 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;

View 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;

View 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();
});
});

View 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;

View File

@@ -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;
}

View File

@@ -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 {