mobile tags input and autocomplete

This commit is contained in:
Eric Bailey
2023-10-12 15:04:22 -05:00
parent 9f9f877ce1
commit d7f7cb3128
7 changed files with 418 additions and 90 deletions
+14 -5
View File
@@ -4,8 +4,14 @@ import {RootStoreModel} from '../root-store'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import {isObj, hasProp, isStrArray} from 'lib/type-guards' import {isObj, hasProp, isStrArray} from 'lib/type-guards'
function uniq(arr: string[]) {
return Array.from(new Set(arr))
}
/** /**
* Used only to persist recent tags across app restarts. * Used only to persist recent tags across app restarts.
*
* TODO may want an LRU?
*/ */
export class RecentTagsModel { export class RecentTagsModel {
_tags: string[] = [] _tags: string[] = []
@@ -62,6 +68,11 @@ export class TagsAutocompleteModel {
this.rootStore.recentTags.add(tag) this.rootStore.recentTags.add(tag)
} }
clear() {
this.query = ''
this.searchedTags = []
}
get suggestions() { get suggestions() {
if (!this.isActive) { if (!this.isActive) {
return [] return []
@@ -87,9 +98,9 @@ export class TagsAutocompleteModel {
// Fuse allows weighting values too, if we ever need it // Fuse allows weighting values too, if we ever need it
const fuse = new Fuse(items) const fuse = new Fuse(items)
// search amongst mixed set of tags // search amongst mixed set of tags
const results = fuse.search(this.query) const results = fuse.search(this.query).map(r => r.item)
// backfill again in case search has no results
return results.slice(0, 9).map(r => r.item) return uniq([...results, ...items]).slice(0, 9)
} }
async search(query: string) { async search(query: string) {
@@ -98,8 +109,6 @@ export class TagsAutocompleteModel {
await this.lock.acquireAsync() await this.lock.acquireAsync()
try { try {
// another query was set before we got our chance
if (this.query !== this.query) return
await this._search() await this._search()
} finally { } finally {
this.lock.release() this.lock.release()
+53
View File
@@ -81,6 +81,59 @@ export function EditableTag({
) )
} }
export function TagButton({
value,
icon = 'x',
onClick,
}: {
value: string
icon?: React.ComponentProps<typeof FontAwesomeIcon>['icon']
onClick?: (tag: string) => void
}) {
const pal = usePalette('default')
const [hovered, setHovered] = React.useState(false)
const hoverIn = React.useCallback(() => {
setHovered(true)
}, [setHovered])
const hoverOut = React.useCallback(() => {
setHovered(false)
}, [setHovered])
return (
<Pressable
accessibilityRole="button"
onPress={() => onClick?.(value)}
onPointerEnter={hoverIn}
onPointerLeave={hoverOut}
style={state => [
pal.viewLight,
styles.editableTag,
{
opacity: state.pressed || state.focused ? 0.8 : 1,
outline: 0,
paddingRight: 6,
},
]}>
<Text type="md-medium" style={[pal.textLight]}>
#{value}
</Text>
<FontAwesomeIcon
icon={icon}
style={
{
opacity: hovered ? 1 : 0.5,
color: pal.textLight.color,
marginTop: 1,
} as FontAwesomeIconStyle
}
size={10}
/>
</Pressable>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
editableTag: { editableTag: {
flexDirection: 'row', flexDirection: 'row',
+1 -6
View File
@@ -446,18 +446,13 @@ export const ComposePost = observer(function ComposePost({
<View <View
style={[ style={[
pal.border,
{ {
borderTopWidth: 1,
paddingVertical: 10, paddingVertical: 10,
marginTop: 10, marginTop: 10,
paddingHorizontal: 15, paddingHorizontal: 15,
}, },
]}> ]}>
<TagInput <TagInput onChangeTags={onChangeTags} />
onChangeTags={onChangeTags}
tagsAutocompleteModel={tagsAutocompleteModel}
/>
</View> </View>
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
+217 -56
View File
@@ -1,36 +1,35 @@
import React from 'react' import React from 'react'
import { import {
TextInput,
View, View,
StyleSheet, StyleSheet,
NativeSyntheticEvent, NativeSyntheticEvent,
TextInputKeyPressEventData, TextInputKeyPressEventData,
Platform, Platform,
Pressable,
ScrollView,
} from 'react-native' } from 'react-native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetTextInput,
} from '@gorhom/bottom-sheet'
import {Portal} from 'view/com/util/Portal'
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
import {isWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {EditableTag} from 'view/com/Tag' import {TagButton} from 'view/com/Tag'
import {Text} from 'view/com/util/text/Text'
import * as Sheet from 'view/com/sheets/Base'
import {useStores} from 'state/index'
import {ActivityIndicator} from 'react-native'
function uniq(tags: string[]) { function uniq(tags: string[]) {
return Array.from(new Set(tags)) return Array.from(new Set(tags))
} }
// function sanitize(tagString: string, { max }: { max: number }) {
// const sanitized = tagString.replace(/^#/, '')
// .split(/\s/)
// .map(t => t.trim())
// .map(t => t.replace(/^#/, ''))
// return uniq(sanitized)
// .slice(0, max)
// }
function sanitize(tagString: string) { function sanitize(tagString: string) {
return tagString.trim().replace(/^#/, '') return tagString.trim().replace(/^#/, '')
} }
@@ -41,14 +40,28 @@ export function TagInput({
}: { }: {
max?: number max?: number
onChangeTags: (tags: string[]) => void onChangeTags: (tags: string[]) => void
tagsAutocompleteModel: TagsAutocompleteModel
}) { }) {
const store = useStores()
const model = React.useMemo(() => new TagsAutocompleteModel(store), [store])
const sheet = React.useRef<BottomSheet>(null)
const pal = usePalette('default') const pal = usePalette('default')
const input = React.useRef<TextInput>(null) const input = React.useRef<HTMLInputElement>(null)
const [value, setValue] = React.useState('') const [value, setValue] = React.useState('')
const [tags, setTags] = React.useState<string[]>([]) const [tags, setTags] = React.useState<string[]>([])
const [selectedItemIndex, setSelectedItemIndex] = React.useState(0)
const [suggestions, setSuggestions] = React.useState<string[]>([])
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const handleChangeTags = React.useCallback( const reset = React.useCallback(() => {
setValue('')
model.setActive(false)
model.clear()
setSelectedItemIndex(0)
setSuggestions([])
}, [model, setValue, setSelectedItemIndex, setSuggestions])
const addTags = React.useCallback(
(_tags: string[]) => { (_tags: string[]) => {
setTags(_tags) setTags(_tags)
onChangeTags(_tags) onChangeTags(_tags)
@@ -56,90 +69,222 @@ export function TagInput({
[onChangeTags, setTags], [onChangeTags, setTags],
) )
const onSubmitEditing = React.useCallback(() => { const removeTag = React.useCallback(
(tag: string) => {
addTags(tags.filter(t => t !== tag))
},
[tags, addTags],
)
const addTagAndReset = React.useCallback(
(value: string) => {
const tag = sanitize(value) const tag = sanitize(value)
// enforce max hashtag length // enforce max hashtag length
if (tag.length > 0 && tag.length <= 64) { if (tag.length > 0 && tag.length <= 64) {
handleChangeTags(uniq([...tags, tag]).slice(0, max)) addTags(uniq([...tags, tag]).slice(0, max))
} }
if (isWeb) {
setValue('') setValue('')
input.current?.focus() input.current?.focus()
} else { },
// This is a hack to get the input to clear on iOS/Android, and only [max, tags, setValue, addTags],
// positive values work here )
setTimeout(() => {
setValue('') const onSubmitEditing = React.useCallback(() => {
input.current?.focus() const item = suggestions[selectedItemIndex]
}, 1) addTagAndReset(item || value)
} }, [value, suggestions, selectedItemIndex, addTagAndReset])
}, [max, value, tags, setValue, handleChangeTags])
const onKeyPress = React.useCallback( const onKeyPress = React.useCallback(
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => { (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
if (e.nativeEvent.key === 'Backspace' && value === '') { const {key} = e.nativeEvent
handleChangeTags(tags.slice(0, -1))
} else if (e.nativeEvent.key === ' ') { if (key === 'Backspace' && value === '') {
addTags(tags.slice(0, -1))
} else if (key === ' ') {
e.preventDefault() // prevents an additional space on web e.preventDefault() // prevents an additional space on web
onSubmitEditing() addTagAndReset(value)
}
if (key === 'Escape') {
reset()
} else if (key === 'ArrowUp') {
e.preventDefault()
setSelectedItemIndex(
(selectedItemIndex + suggestions.length - 1) % suggestions.length,
)
} else if (key === 'ArrowDown') {
e.preventDefault()
setSelectedItemIndex((selectedItemIndex + 1) % suggestions.length)
} }
}, },
[value, tags, handleChangeTags, onSubmitEditing], [
value,
tags,
selectedItemIndex,
suggestions.length,
reset,
setSelectedItemIndex,
addTags,
addTagAndReset,
],
) )
const onChangeText = React.useCallback((v: string) => { const onChangeText = React.useCallback(
async (v: string) => {
setValue(v) setValue(v)
}, [])
const removeTag = React.useCallback( if (v.length > 0) {
(tag: string) => { model.setActive(true)
handleChangeTags(tags.filter(t => t !== tag)) await model.search(v)
setSuggestions(model.suggestions)
} else {
model.clear()
setSuggestions(model.suggestions)
}
}, },
[tags, handleChangeTags], [model, setValue],
)
const onCloseSheet = React.useCallback(() => {
reset()
setIsInitialLoad(true)
}, [reset, setIsInitialLoad])
const onSheetChange = React.useCallback(
async (index: number) => {
if (index > -1) {
model.setActive(true)
await model.search('') // get default results
setSuggestions(model.suggestions)
setIsInitialLoad(false)
}
},
[model, setIsInitialLoad, setSuggestions],
) )
return ( return (
<View>
<Pressable
accessibilityRole="button"
style={styles.selectedTags}
onPress={() => {
sheet.current?.snapToIndex(0)
}}>
<View style={[pal.viewLight, styles.button]}>
{tags.length ? (
<Text type="md-medium" style={[pal.textLight]}>
Add +
</Text>
) : (
<>
<FontAwesomeIcon
icon="tags"
size={12}
style={pal.textLight as FontAwesomeIconStyle}
/>
<Text type="md-medium" style={[pal.textLight]}>
Click to add tags to your post
</Text>
</>
)}
</View>
{tags.map(tag => (
<View key={tag} style={[pal.viewLight, styles.button]}>
<Text type="md-medium" style={[pal.textLight]}>
#{tag}
</Text>
</View>
))}
</Pressable>
<Portal>
<BottomSheet
ref={sheet}
index={-1}
snapPoints={[200]}
enablePanDownToClose
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
backdropComponent={props => (
<BottomSheetBackdrop
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
/>
)}
handleIndicatorStyle={{backgroundColor: pal.text.color}}
handleStyle={{display: 'none'}}
onChange={onSheetChange}
onClose={onCloseSheet}>
<Sheet.Outer>
<Sheet.Handle />
<View style={styles.outer}> <View style={styles.outer}>
{!tags.length && (
<FontAwesomeIcon <FontAwesomeIcon
icon="tags" icon="tags"
size={14} size={14}
style={pal.textLight as FontAwesomeIconStyle} style={pal.textLight as FontAwesomeIconStyle}
/> />
)}
{tags.map(tag => ( {tags.map(tag => (
<EditableTag key={tag} value={tag} onRemove={removeTag} /> <TagButton key={tag} value={tag} onClick={removeTag} />
))} ))}
{tags.length >= max ? null : (
<TextInput <BottomSheetTextInput
ref={input} placeholder="Add tags..."
value={value} value={value}
style={styles.input}
onChangeText={onChangeText}
onKeyPress={onKeyPress} onKeyPress={onKeyPress}
onSubmitEditing={onSubmitEditing} onSubmitEditing={onSubmitEditing}
onChangeText={onChangeText}
blurOnSubmit={false}
style={[styles.input, pal.textLight]}
placeholder="Enter a tag and press enter"
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
accessible={true}
accessibilityLabel="Add tags to your post"
accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`}
/> />
)} </View>
<View style={{marginHorizontal: -20}}>
<ScrollView horizontal>
<View style={styles.suggestions}>
{isInitialLoad && <ActivityIndicator />}
{suggestions
.filter(s => !tags.find(t => t === s))
.map(suggestion => {
return (
<TagButton
key={suggestion}
icon="plus"
value={suggestion}
onClick={addTagAndReset}
/>
)
})}
</View>
</ScrollView>
</View>
</Sheet.Outer>
</BottomSheet>
</Portal>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
selectedTags: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
},
outer: { outer: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
marginBottom: 20,
}, },
input: { input: {
flexGrow: 1, flexGrow: 1,
@@ -152,4 +297,20 @@ const styles = StyleSheet.create({
paddingTop: 4, paddingTop: 4,
paddingBottom: 4, paddingBottom: 4,
}, },
suggestions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 20,
paddingVertical: 8,
},
button: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flexShrink: 1,
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 20,
},
}) })
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import {View, StyleSheet, Dimensions} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
export function Outer(props: React.PropsWithChildren<{}>) {
const pal = usePalette('default')
return (
<>
<View style={[pal.view, styles.background]} />
<View style={styles.content}>{props.children}</View>
</>
)
}
export function Handle() {
const pal = usePalette('default')
return (
<View style={[styles.handle, {backgroundColor: pal.border.borderColor}]} />
)
}
const styles = StyleSheet.create({
background: {
...StyleSheet.absoluteFillObject,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
zIndex: -1,
},
content: {
paddingVertical: 40,
paddingHorizontal: 20,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
overflow: 'hidden',
},
handle: {
position: 'absolute',
top: 12,
alignSelf: 'center',
width: 80,
height: 6,
borderRadius: 10,
},
})
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
type Component = React.ReactElement
type ContextType = {
outlet: Component | null
append(id: string, component: Component): void
remove(id: string): void
}
type ComponentMap = {
[id: string]: Component
}
export const Context = React.createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
})
export function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
return (
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
export function Outlet() {
const ctx = React.useContext(Context)
return ctx.outlet
}
export function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
return null
}
+7 -1
View File
@@ -10,6 +10,7 @@ import {
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Drawer} from 'react-native-drawer-layout' import {Drawer} from 'react-native-drawer-layout'
import {useNavigationState} from '@react-navigation/native' import {useNavigationState} from '@react-navigation/native'
import {Provider, Outlet} from 'view/com/util/Portal'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {ModalsContainer} from 'view/com/modals/Modal' import {ModalsContainer} from 'view/com/modals/Modal'
import {Lightbox} from 'view/com/lightbox/Lightbox' import {Lightbox} from 'view/com/lightbox/Lightbox'
@@ -79,6 +80,7 @@ const ShellInner = observer(function ShellInnerImpl() {
/> />
<ModalsContainer /> <ModalsContainer />
<Lightbox /> <Lightbox />
<Outlet />
</> </>
) )
}) })
@@ -88,12 +90,16 @@ export const Shell: React.FC = observer(function ShellImpl() {
const theme = useTheme() const theme = useTheme()
return ( return (
<SafeAreaProvider style={pal.view}> <SafeAreaProvider style={pal.view}>
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> <Provider>
<View
testID="mobileShellView"
style={[styles.outerContainer, pal.view]}>
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} /> <StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
<RoutesContainer> <RoutesContainer>
<ShellInner /> <ShellInner />
</RoutesContainer> </RoutesContainer>
</View> </View>
</Provider>
</SafeAreaProvider> </SafeAreaProvider>
) )
}) })