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 {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.
*
* TODO may want an LRU?
*/
export class RecentTagsModel {
_tags: string[] = []
@@ -62,6 +68,11 @@ export class TagsAutocompleteModel {
this.rootStore.recentTags.add(tag)
}
clear() {
this.query = ''
this.searchedTags = []
}
get suggestions() {
if (!this.isActive) {
return []
@@ -87,9 +98,9 @@ export class TagsAutocompleteModel {
// Fuse allows weighting values too, if we ever need it
const fuse = new Fuse(items)
// search amongst mixed set of tags
const results = fuse.search(this.query)
return results.slice(0, 9).map(r => r.item)
const results = fuse.search(this.query).map(r => r.item)
// backfill again in case search has no results
return uniq([...results, ...items]).slice(0, 9)
}
async search(query: string) {
@@ -98,8 +109,6 @@ export class TagsAutocompleteModel {
await this.lock.acquireAsync()
try {
// another query was set before we got our chance
if (this.query !== this.query) return
await this._search()
} finally {
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({
editableTag: {
flexDirection: 'row',
+1 -6
View File
@@ -446,18 +446,13 @@ export const ComposePost = observer(function ComposePost({
<View
style={[
pal.border,
{
borderTopWidth: 1,
paddingVertical: 10,
marginTop: 10,
paddingHorizontal: 15,
},
]}>
<TagInput
onChangeTags={onChangeTags}
tagsAutocompleteModel={tagsAutocompleteModel}
/>
<TagInput onChangeTags={onChangeTags} />
</View>
<View style={[pal.border, styles.bottomBar]}>
+234 -73
View File
@@ -1,36 +1,35 @@
import React from 'react'
import {
TextInput,
View,
StyleSheet,
NativeSyntheticEvent,
TextInputKeyPressEventData,
Platform,
Pressable,
ScrollView,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} 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 {isWeb} from 'platform/detection'
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[]) {
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) {
return tagString.trim().replace(/^#/, '')
}
@@ -41,14 +40,28 @@ export function TagInput({
}: {
max?: number
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 input = React.useRef<TextInput>(null)
const input = React.useRef<HTMLInputElement>(null)
const [value, setValue] = React.useState('')
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[]) => {
setTags(_tags)
onChangeTags(_tags)
@@ -56,90 +69,222 @@ export function TagInput({
[onChangeTags, setTags],
)
const onSubmitEditing = React.useCallback(() => {
const tag = sanitize(value)
const removeTag = React.useCallback(
(tag: string) => {
addTags(tags.filter(t => t !== tag))
},
[tags, addTags],
)
// enforce max hashtag length
if (tag.length > 0 && tag.length <= 64) {
handleChangeTags(uniq([...tags, tag]).slice(0, max))
}
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))
}
if (isWeb) {
setValue('')
input.current?.focus()
} else {
// This is a hack to get the input to clear on iOS/Android, and only
// positive values work here
setTimeout(() => {
setValue('')
input.current?.focus()
}, 1)
}
}, [max, value, tags, setValue, handleChangeTags])
},
[max, tags, setValue, addTags],
)
const onSubmitEditing = React.useCallback(() => {
const item = suggestions[selectedItemIndex]
addTagAndReset(item || value)
}, [value, suggestions, selectedItemIndex, addTagAndReset])
const onKeyPress = React.useCallback(
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
if (e.nativeEvent.key === 'Backspace' && value === '') {
handleChangeTags(tags.slice(0, -1))
} else if (e.nativeEvent.key === ' ') {
const {key} = e.nativeEvent
if (key === 'Backspace' && value === '') {
addTags(tags.slice(0, -1))
} else if (key === ' ') {
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) => {
setValue(v)
}, [])
const onChangeText = React.useCallback(
async (v: string) => {
setValue(v)
const removeTag = React.useCallback(
(tag: string) => {
handleChangeTags(tags.filter(t => t !== tag))
if (v.length > 0) {
model.setActive(true)
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 (
<View style={styles.outer}>
{!tags.length && (
<FontAwesomeIcon
icon="tags"
size={14}
style={pal.textLight as FontAwesomeIconStyle}
/>
)}
{tags.map(tag => (
<EditableTag key={tag} value={tag} onRemove={removeTag} />
))}
{tags.length >= max ? null : (
<TextInput
ref={input}
value={value}
onKeyPress={onKeyPress}
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>
<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}>
<FontAwesomeIcon
icon="tags"
size={14}
style={pal.textLight as FontAwesomeIconStyle}
/>
{tags.map(tag => (
<TagButton key={tag} value={tag} onClick={removeTag} />
))}
<BottomSheetTextInput
placeholder="Add tags..."
value={value}
style={styles.input}
onChangeText={onChangeText}
onKeyPress={onKeyPress}
onSubmitEditing={onSubmitEditing}
/>
</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>
)
}
const styles = StyleSheet.create({
selectedTags: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
},
outer: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
marginBottom: 20,
},
input: {
flexGrow: 1,
@@ -152,4 +297,20 @@ const styles = StyleSheet.create({
paddingTop: 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
}
+12 -6
View File
@@ -10,6 +10,7 @@ import {
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Drawer} from 'react-native-drawer-layout'
import {useNavigationState} from '@react-navigation/native'
import {Provider, Outlet} from 'view/com/util/Portal'
import {useStores} from 'state/index'
import {ModalsContainer} from 'view/com/modals/Modal'
import {Lightbox} from 'view/com/lightbox/Lightbox'
@@ -79,6 +80,7 @@ const ShellInner = observer(function ShellInnerImpl() {
/>
<ModalsContainer />
<Lightbox />
<Outlet />
</>
)
})
@@ -88,12 +90,16 @@ export const Shell: React.FC = observer(function ShellImpl() {
const theme = useTheme()
return (
<SafeAreaProvider style={pal.view}>
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
<RoutesContainer>
<ShellInner />
</RoutesContainer>
</View>
<Provider>
<View
testID="mobileShellView"
style={[styles.outerContainer, pal.view]}>
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
<RoutesContainer>
<ShellInner />
</RoutesContainer>
</View>
</Provider>
</SafeAreaProvider>
)
})