From a5f37eb37fc6edd992c70bdf42e52184b34d696c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Oct 2023 14:38:48 -0500 Subject: [PATCH] move TagInput into dir, use button on desktop --- src/state/models/ui/tags-autocomplete.ts | 2 +- src/view/com/Tag.tsx | 16 +- src/view/com/composer/TagInput.web.tsx | 311 --------------- .../composer/TagInput/TagInputEntryButton.tsx | 76 ++++ .../{TagInput.tsx => TagInput/index.tsx} | 0 src/view/com/composer/TagInput/index.web.tsx | 366 ++++++++++++++++++ 6 files changed, 456 insertions(+), 315 deletions(-) delete mode 100644 src/view/com/composer/TagInput.web.tsx create mode 100644 src/view/com/composer/TagInput/TagInputEntryButton.tsx rename src/view/com/composer/{TagInput.tsx => TagInput/index.tsx} (100%) create mode 100644 src/view/com/composer/TagInput/index.web.tsx diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index 9b1d8b43c5..1f13004d47 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -48,7 +48,7 @@ export class TagsAutocompleteModel { isActive = false query = '' searchedTags: string[] = [] - profileTags: string[] = [] + profileTags: string[] = ['biology'] constructor(public rootStore: RootStoreModel) { makeAutoObservable( diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index f187c8a745..09149c8103 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -109,11 +109,11 @@ export function TagButton({ onPointerLeave={hoverOut} style={state => [ pal.viewLight, - styles.editableTag, + styles.tagButton, { - opacity: state.pressed || state.focused ? 0.8 : 1, outline: 0, - paddingRight: 6, + opacity: state.pressed || state.focused ? 0.6 : 1, + paddingRight: 10, }, ]}> @@ -145,4 +145,14 @@ const styles = StyleSheet.create({ borderRadius: 4, overflow: 'hidden', }, + tagButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexShrink: 1, + paddingVertical: 6, + paddingTop: 5, + paddingHorizontal: 12, + borderRadius: 20, + }, }) diff --git a/src/view/com/composer/TagInput.web.tsx b/src/view/com/composer/TagInput.web.tsx deleted file mode 100644 index 6e384bedeb..0000000000 --- a/src/view/com/composer/TagInput.web.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import React from 'react' -import { - TextInput, - View, - StyleSheet, - NativeSyntheticEvent, - TextInputKeyPressEventData, - Platform, - Pressable, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Pin} from 'pind' - -import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' -import {usePalette} from 'lib/hooks/usePalette' -import {EditableTag} from 'view/com/Tag' -import {Text} from 'view/com/util/text/Text' - -function uniq(tags: string[]) { - return Array.from(new Set(tags)) -} - -function sanitize(tagString: string) { - return tagString.trim().replace(/^#/, '') -} - -export function TagInput({ - max = 8, - onChangeTags, - tagsAutocompleteModel: model, -}: { - max?: number - onChangeTags: (tags: string[]) => void - tagsAutocompleteModel: TagsAutocompleteModel -}) { - const pal = usePalette('default') - const dropdown = React.useRef(null) - const input = React.useRef(null) - const inputWidth = input.current - ? input.current.getBoundingClientRect().width - : 200 - - const [value, setValue] = React.useState('') - const [tags, setTags] = React.useState([]) - const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) - const [dropdownItems, setDropdownItems] = React.useState< - {value: string; label: string}[] - >([]) - const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) - - const close = React.useCallback(() => { - setDropdownIsOpen(false) - model.setActive(false) - setSelectedItemIndex(0) - setDropdownItems([]) - }, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems]) - - const addTags = React.useCallback( - (_tags: string[]) => { - setTags(_tags) - onChangeTags(_tags) - }, - [onChangeTags, setTags], - ) - - const removeTag = React.useCallback( - (tag: string) => { - addTags(tags.filter(t => t !== tag)) - }, - [tags, addTags], - ) - - const addTagAndReset = React.useCallback( - (value: string) => { - const tag = sanitize(value) - - // enforce max hashtag length - if (tag.length > 0 && tag.length <= 64) { - addTags(uniq([...tags, tag]).slice(0, max)) - } - - setValue('') - input.current?.focus() - close() - }, - [max, tags, close, setValue, addTags], - ) - - const onSubmitEditing = React.useCallback(() => { - const item = dropdownItems[selectedItemIndex] - addTagAndReset(item?.value || value) - }, [value, dropdownItems, selectedItemIndex, addTagAndReset]) - - const onKeyPress = React.useCallback( - (e: NativeSyntheticEvent) => { - const {key} = e.nativeEvent - - if (key === 'Backspace' && value === '') { - addTags(tags.slice(0, -1)) - } else if (key === ' ') { - e.preventDefault() // prevents an additional space on web - addTagAndReset(value) - } - - if (dropdownIsOpen) { - if (key === 'Escape') { - close() - } else if (key === 'ArrowUp') { - e.preventDefault() - setSelectedItemIndex( - (selectedItemIndex + dropdownItems.length - 1) % - dropdownItems.length, - ) - } else if (key === 'ArrowDown') { - e.preventDefault() - setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) - } - } - }, - [ - value, - tags, - dropdownIsOpen, - selectedItemIndex, - dropdownItems.length, - close, - setSelectedItemIndex, - addTags, - addTagAndReset, - ], - ) - - const onChangeText = React.useCallback( - async (v: string) => { - setValue(v) - - if (v.length > 0) { - model.setActive(true) - await model.search(v) - - setDropdownItems( - model.suggestions.map(item => ({ - value: item, - label: item, - })), - ) - - setDropdownIsOpen(true) - } else { - close() - } - }, - [model, setValue, setDropdownIsOpen, close], - ) - - React.useEffect(() => { - // outside click - function onClick(e: MouseEvent) { - const drop = dropdown.current - const control = input.current - - if ( - !drop || - !control || - e.target === drop || - e.target === control || - drop.contains(e.target as Node) || - control.contains(e.target as Node) - ) - return - - close() - } - - document.addEventListener('click', onClick) - - return () => { - document.removeEventListener('click', onClick) - } - }, [close]) - - return ( - - {!tags.length && ( - - )} - - {tags.map(tag => ( - - ))} - - {tags.length >= max ? null : ( - - )} - - - - {dropdownItems.map((item, index) => { - const isFirst = index === 0 - const isLast = index === dropdownItems.length - 1 - return ( - addTagAndReset(item.value)} - style={state => [ - pal.border, - styles.dropdownItem, - { - backgroundColor: state.hovered - ? pal.viewLight.backgroundColor - : undefined, - }, - selectedItemIndex === index ? pal.viewLight : undefined, - isFirst - ? styles.firstResult - : isLast - ? styles.lastResult - : undefined, - ]}> - - {item.label} - - - ) - })} - - - - ) -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: 8, - }, - input: { - flexGrow: 1, - minWidth: 100, - fontSize: 15, - lineHeight: Platform.select({ - web: 20, - native: 18, - }), - paddingTop: 4, - paddingBottom: 4, - }, - dropdown: { - width: '100%', - borderRadius: 6, - borderWidth: 1, - borderStyle: 'solid', - padding: 4, - }, - dropdownItem: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - flexDirection: 'row', - paddingHorizontal: 12, - paddingVertical: 8, - gap: 4, - }, - firstResult: { - borderTopLeftRadius: 2, - borderTopRightRadius: 2, - }, - lastResult: { - borderBottomLeftRadius: 2, - borderBottomRightRadius: 2, - }, -}) diff --git a/src/view/com/composer/TagInput/TagInputEntryButton.tsx b/src/view/com/composer/TagInput/TagInputEntryButton.tsx new file mode 100644 index 0000000000..ee7c2ff6e1 --- /dev/null +++ b/src/view/com/composer/TagInput/TagInputEntryButton.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {View, StyleSheet, Pressable} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' + +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export function TagInputEntryButton({ + tags, + onRequestOpen, +}: { + tags: string[] + onRequestOpen: () => void +}) { + const pal = usePalette('default') + return ( + [ + styles.selectedTags, + { + outline: 0, + opacity: state.pressed || state.focused ? 0.6 : 1, + }, + ]} + onPress={onRequestOpen}> + + {tags.length ? ( + + Add + + + ) : ( + <> + + + Click to add tags to your post + + + )} + + + {tags.map(tag => ( + + + #{tag} + + + ))} + + ) +} + +const styles = StyleSheet.create({ + selectedTags: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexShrink: 1, + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 20, + }, +}) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput/index.tsx similarity index 100% rename from src/view/com/composer/TagInput.tsx rename to src/view/com/composer/TagInput/index.tsx diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx new file mode 100644 index 0000000000..44f5cdfd85 --- /dev/null +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -0,0 +1,366 @@ +import React from 'react' +import { + TextInput, + View, + StyleSheet, + NativeSyntheticEvent, + TextInputKeyPressEventData, + Platform, + Pressable, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Pin} from 'pind' + +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {usePalette} from 'lib/hooks/usePalette' +import {TagButton} from 'view/com/Tag' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {TagInputEntryButton} from './TagInputEntryButton' +import {TextInputFocusEventData} from 'react-native' + +function uniq(tags: string[]) { + return Array.from(new Set(tags)) +} + +function sanitize(tagString: string) { + return tagString.trim().replace(/^#/, '') +} + +export function TagInput({ + max = 8, + onChangeTags, +}: { + max?: number + onChangeTags: (tags: string[]) => void +}) { + const store = useStores() + const pal = usePalette('default') + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + const model = React.useMemo(() => new TagsAutocompleteModel(store), [store]) + const containerRef = React.useRef(null) + + const [value, setValue] = React.useState('') + const [tags, setTags] = React.useState([]) + const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) + const [dropdownItems, setDropdownItems] = React.useState< + {value: string; label: string}[] + >([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + const [open, setOpen] = React.useState(false) + + const openTagInput = React.useCallback(async () => { + setOpen(true) + }, [setOpen]) + + const closeDropdownAndReset = React.useCallback(() => { + setDropdownIsOpen(false) + model.setActive(false) + setSelectedItemIndex(0) + setDropdownItems([]) + }, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems]) + + const addTags = React.useCallback( + (_tags: string[]) => { + setTags(_tags) + onChangeTags(_tags) + + if (!_tags.length && document.activeElement !== input.current) + setOpen(false) + }, + [onChangeTags, setTags, setOpen], + ) + + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) + + const addTagAndReset = React.useCallback( + (value: string) => { + const tag = sanitize(value) + + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { + addTags(uniq([...tags, tag]).slice(0, max)) + } + + setValue('') + input.current?.focus() + closeDropdownAndReset() + }, + [max, tags, closeDropdownAndReset, setValue, addTags], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = dropdownItems[selectedItemIndex] + addTagAndReset(item?.value || value) + }, [value, dropdownItems, selectedItemIndex, addTagAndReset]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + const {key} = e.nativeEvent + + if (key === 'Backspace' && value === '') { + addTags(tags.slice(0, -1)) + } else if (key === ' ') { + e.preventDefault() // prevents an additional space on web + addTagAndReset(value) + } + + if (dropdownIsOpen) { + if (key === 'Escape') { + closeDropdownAndReset() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + dropdownItems.length - 1) % + dropdownItems.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) + } + } + }, + [ + value, + tags, + dropdownIsOpen, + selectedItemIndex, + dropdownItems.length, + closeDropdownAndReset, + setSelectedItemIndex, + addTags, + addTagAndReset, + ], + ) + + const onChangeText = React.useCallback( + async (v: string) => { + setValue(v) + + if (v.length > 0) { + model.setActive(true) + await model.search(v) + + setDropdownItems( + model.suggestions.map(item => ({ + value: item, + label: item, + })), + ) + + setDropdownIsOpen(true) + } else { + closeDropdownAndReset() + } + }, + [model, setValue, setDropdownIsOpen, closeDropdownAndReset], + ) + + const onFocus = React.useCallback(async () => { + model.setActive(true) + await model.search('') + + setDropdownItems( + model.suggestions.map(item => ({ + value: item, + label: item, + })), + ) + + setDropdownIsOpen(true) + }, [model, setDropdownIsOpen]) + + const onBlur = React.useCallback( + (e: NativeSyntheticEvent) => { + // @ts-ignore + const target = e.nativeEvent.relatedTarget as HTMLElement | undefined + + if ( + !tags.length && + (!target || !target.id.includes('tag_autocomplete_option')) + ) { + setOpen(false) + } + }, + [tags, setOpen], + ) + + React.useEffect(() => { + // outside click + function onClick(e: MouseEvent) { + const drop = dropdown.current + const control = input.current + + if ( + !drop || + !control || + e.target === drop || + e.target === control || + drop.contains(e.target as Node) || + control.contains(e.target as Node) + ) + return + + closeDropdownAndReset() + } + + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + } + }, [closeDropdownAndReset]) + + return ( + + {!open && ( + + )} + + {open && ( + + + {!tags.length && ( + + )} + + {tags.map(tag => ( + + ))} + + {tags.length >= max ? null : ( + + )} + + + + {dropdownItems.map((item, index) => { + const isFirst = index === 0 + const isLast = index === dropdownItems.length - 1 + return ( + addTagAndReset(item.value)} + style={state => [ + pal.border, + styles.dropdownItem, + { + backgroundColor: state.hovered + ? pal.viewLight.backgroundColor + : undefined, + }, + selectedItemIndex === index ? pal.viewLight : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]}> + + {item.label} + + + ) + })} + + + + + )} + + ) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + input: { + flexGrow: 1, + minWidth: 100, + fontSize: 15, + lineHeight: Platform.select({ + web: 20, + native: 18, + }), + paddingTop: 5, + paddingBottom: 5, + }, + dropdown: { + width: '100%', + borderRadius: 6, + borderWidth: 1, + borderStyle: 'solid', + padding: 4, + }, + dropdownItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 4, + }, + firstResult: { + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + }, + lastResult: { + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + }, +})