From 30aff62d0810b8af1e00e664eb13cdf0ef1bd62b Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 17 Jul 2025 07:50:37 -0700 Subject: [PATCH] Mobile: Implement tag screen redesign (#12551) --- .eslintignore | 6 + .gitignore | 6 + .../app-mobile/components/ComboBox.test.tsx | 90 +++ packages/app-mobile/components/ComboBox.tsx | 598 ++++++++++++++++++ packages/app-mobile/components/Icon.tsx | 4 +- packages/app-mobile/components/IconButton.tsx | 9 +- .../app-mobile/components/ModalDialog.tsx | 131 ++-- .../components/NestableFlatList.tsx | 127 ++++ .../app-mobile/components/SearchInput.tsx | 87 +++ .../app-mobile/components/TagEditor.test.tsx | 123 ++++ packages/app-mobile/components/TagEditor.tsx | 277 ++++++++ packages/app-mobile/components/TextInput.tsx | 3 +- .../components/screens/NoteTagsDialog.tsx | 255 ++------ 13 files changed, 1432 insertions(+), 284 deletions(-) create mode 100644 packages/app-mobile/components/ComboBox.test.tsx create mode 100644 packages/app-mobile/components/ComboBox.tsx create mode 100644 packages/app-mobile/components/NestableFlatList.tsx create mode 100644 packages/app-mobile/components/SearchInput.tsx create mode 100644 packages/app-mobile/components/TagEditor.test.tsx create mode 100644 packages/app-mobile/components/TagEditor.tsx diff --git a/.eslintignore b/.eslintignore index 8d24653d7b..e9ba11dd1d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index c26d0abf1c..968315f7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-mobile/components/ComboBox.test.tsx b/packages/app-mobile/components/ComboBox.test.tsx new file mode 100644 index 0000000000..71a38e0c48 --- /dev/null +++ b/packages/app-mobile/components/ComboBox.test.tsx @@ -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 = ({ + items, + onItemSelected = jest.fn(), +}: WrapperProps) => { + const mappedItems = useMemo(() => { + return items.map((item): Option => ({ + title: item.title, + icon: undefined, + accessibilityHint: undefined, + willRemoveOnPress: false, + })); + }, [items]); + + return + + ; +}; + +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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/packages/app-mobile/components/ComboBox.tsx b/packages/app-mobile/components/ComboBox.tsx new file mode 100644 index 0000000000..675d285e1c --- /dev/null +++ b/packages/app-mobile/components/ComboBox.tsx @@ -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['styles']; + +interface SearchResultProps { + text: string; + icon: string; + selected: boolean; + styles: Styles; +} + +const SearchResult: React.FC = ({ + text, styles, selected, icon: iconName, +}) => { + const icon = iconName ? : null; + + return ( + + {icon} + {text} + + ); +}; + +interface ResultWrapperProps extends ViewProps { + index: number; + item: Option; +} + +interface SearchResultContainerProps { + onItemSelected: OnItemSelected; + selectedIndex: number; + baseId: string; + resultCount: number; + searchInputRef: RefObject; + // Used to determine focus + resultsHideOnPress: boolean; +} + +const useSearchResultContainerComponent = ({ + onItemSelected, selectedIndex, baseId, resultCount, searchInputRef, resultsHideOnPress, +}: SearchResultContainerProps): React.FC => { + const listItemsRef = useRef>({}); + + 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 }) => ( + { + 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} + >{children} + ), [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, 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) => { + 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 = ({ + 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(null); + const listRef = useRef(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 ; + }, [selectedIndex, styles]); + + const webProps = { + onKeyDown: onKeyPress, + }; + const activeId = `${baseId}-${selectedIndex}`; + const searchResults = ; + + const helpComponent = {_('To create a new tag, type the name and press enter.')}; + + return + + {searchResults} + {!showSearchResults && helpComponent} + ; +}; + + +export default connect((state: AppState) => ({ + themeId: state.settings.theme, +}))(ComboBox); diff --git a/packages/app-mobile/components/Icon.tsx b/packages/app-mobile/components/Icon.tsx index ee8e78464b..6ef3029f90 100644 --- a/packages/app-mobile/components/Icon.tsx +++ b/packages/app-mobile/components/Icon.tsx @@ -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; // If `null` is given, the content must be labeled elsewhere. accessibilityLabel: string|null; diff --git a/packages/app-mobile/components/IconButton.tsx b/packages/app-mobile/components/IconButton.tsx index 39ea959558..140d37b5de 100644 --- a/packages/app-mobile/components/IconButton.tsx +++ b/packages/app-mobile/components/IconButton.tsx @@ -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; + // Accessibility label and text shown in a tooltip description: string; iconName: string; - iconStyle: TextStyle; + iconStyle: StyleProp; themeId: number; @@ -87,6 +89,7 @@ const IconButton = (props: ButtonProps) => { const button = ( void; onCancelPress: ()=> void; } -interface State { - -} - -class ModalDialog extends React.Component { - // 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 = { - 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 => { + 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 ( - {}} containerStyle={this.styles().modalContentWrapper}> - {this.props.title} - {ContentComponent} - - - - - - - - - - ); - } -} + return ( + + {props.children} + + + + {props.okTitle} + + + ); +}; export default ModalDialog; diff --git a/packages/app-mobile/components/NestableFlatList.tsx b/packages/app-mobile/components/NestableFlatList.tsx new file mode 100644 index 0000000000..b08d69e9bb --- /dev/null +++ b/packages/app-mobile/components/NestableFlatList.tsx @@ -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 { + item: T; + index: number; +} + +interface ScrollToOptions { + index: number; + viewPosition: number; + animated: boolean; +} + +export interface NestableFlatListControl { + scrollToIndex(options: ScrollToOptions): void; +} + +interface CellRendererProps { + index: number; + item: T; + children: React.ReactNode; +} + +// For compatibility, these props should be mostly compatible with the +// native FlatList props: +interface Props extends ScrollViewProps { + ref: Ref; + data: T[]; + itemHeight: number; + CellRendererComponent?: React.ComponentType>; + renderItem: (event: RenderEvent)=> 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 that directly contains + // the list's content. At least on web, applying these props directly to the ScrollView may + // not work due to additional s added by React Native. + contentWrapperProps?: ViewProps; +} + +// This component allows working around restrictions on nesting React Native's built-in +// within s. For the most part, this component's interface should +// be compatible with the API. +// +// See https://github.com/facebook/react-native/issues/31697. +const NestableFlatList = function({ + ref, + itemHeight, + renderItem, + keyExtractor, + data, + CellRendererComponent = React.Fragment, + contentWrapperProps, + ...rest +}: Props) { + const scrollViewRef = useRef(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) => { + 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( + + {renderItem({ item: data[i], index: i })} + , + ); + } + + return result; + }; + + return + + + {renderVisibleItems()} + + + ; +}; + +export default NestableFlatList; diff --git a/packages/app-mobile/components/SearchInput.tsx b/packages/app-mobile/components/SearchInput.tsx new file mode 100644 index 0000000000..1a185b12fd --- /dev/null +++ b/packages/app-mobile/components/SearchInput.tsx @@ -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; + 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 = ({ inputRef, themeId, value, containerStyle, style, onChangeText, ...rest }) => { + const styles = useStyles(themeId, !!value); + + const onClear = useCallback(() => { + onChangeText(''); + }, [onChangeText]); + + return + + + + ; +}; + +export default SearchInput; diff --git a/packages/app-mobile/components/TagEditor.test.tsx b/packages/app-mobile/components/TagEditor.test.tsx new file mode 100644 index 0000000000..1998c1d5e3 --- /dev/null +++ b/packages/app-mobile/components/TagEditor.test.tsx @@ -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 = props => { + const [tags, setTags] = useState(props.initialTags); + + useEffect(() => { + props.onTagsChanged(tags); + }, [tags, props.onTagsChanged]); + + return + + ; +}; + +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( + ({ 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( + ({ 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( + ({ 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(); + }); +}); + diff --git a/packages/app-mobile/components/TagEditor.tsx b/packages/app-mobile/components/TagEditor.tsx new file mode 100644 index 0000000000..ac011182fd --- /dev/null +++ b/packages/app-mobile/components/TagEditor.tsx @@ -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; + +interface TagChipProps { + title: string; + themeId: number; + styles: Styles; + onRemove: (title: string)=> void; + + autofocus: boolean; + onAutoFocusComplete: ()=> void; +} + +const TagCard: React.FC = props => { + const onRemove = useCallback(() => { + props.onRemove(props.title); + }, [props.title, props.onRemove]); + + const removeButtonRef = useRef(null); + useEffect(() => { + if (props.autofocus) { + focusView('TagEditor::TagCard', removeButtonRef.current); + props.onAutoFocusComplete(); + } + }, [props.autofocus, props.onAutoFocusComplete]); + + return + {props.title} + + ; +}; + +interface TagsBoxProps { + tags: string[]; + autofocusTag: string; + onAutoFocusComplete: ()=> void; + styles: Styles; + themeId: number; + onRemoveTag: (tag: string)=> void; +} + +const TagsBox: React.FC = props => { + const onRemoveTag = useCallback((tag: string) => { + props.onRemoveTag(tag); + }, [props.onRemoveTag]); + + const renderContent = () => { + if (props.tags.length) { + return props.tags.map(tag => ( + + )); + } else { + return {_('No tags')}; + } + }; + + return + {_('Associated tags:')} + + + {renderContent()} + + + ; +}; + +const normalizeTag = (tagText: string) => tagText.trim().toLowerCase(); + +const TagEditor: React.FC = 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 + {showAssociatedTags && <> + + + } + {_('Add tags:')} + + ; +}; + +export default TagEditor; diff --git a/packages/app-mobile/components/TextInput.tsx b/packages/app-mobile/components/TextInput.tsx index d1f729c270..7be06012be 100644 --- a/packages/app-mobile/components/TextInput.tsx +++ b/packages/app-mobile/components/TextInput.tsx @@ -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; themeId: number; } diff --git a/packages/app-mobile/components/screens/NoteTagsDialog.tsx b/packages/app-mobile/components/screens/NoteTagsDialog.tsx index 151c7dbc3b..41ccc39f90 100644 --- a/packages/app-mobile/components/screens/NoteTagsDialog.tsx +++ b/packages/app-mobile/components/screens/NoteTagsDialog.tsx @@ -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 => { + const [noteId, setNoteId] = useState(props.noteId); + const [savingTags, setSavingTags] = useState(false); + const [noteTags, setNoteTags] = useState([]); -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 { - // 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 ( - 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 }} - > - - - {tag.title} - - - ); - }; - - 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 = ( - - - {_('New tags:')} - { - this.setState({ newTags: value }); - }} - style={this.styles().tagBoxInput} - placeholder={_('tag1, tag2, ...')} - accessibilityLabelledBy={this.labelId_} - /> - - - { - this.setState({ tagFilter: value }); - }} - placeholder={_('Filter tags')} - style={this.styles().tagBoxInput} - /> - - - - ); - - return ; - } -} + return + + ; +}; const NoteTagsDialog = connect((state: AppState) => { return {