add desktop TagInput autocomplete

This commit is contained in:
Eric Bailey
2023-10-11 15:09:37 -05:00
parent bfdf41a4c3
commit 9f9f877ce1
6 changed files with 339 additions and 2 deletions
+1
View File
@@ -116,6 +116,7 @@
"mobx-utils": "^6.0.6",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
"pind": "^0.5.0",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
+9 -1
View File
@@ -109,7 +109,15 @@ export class TagsAutocompleteModel {
// TODO hook up to search type-ahead
async _search() {
runInAction(() => {
this.searchedTags = []
this.searchedTags = [
'bluesky',
'code',
'coding',
'dev',
'developer',
'development',
'devlife',
]
})
}
}
+4 -1
View File
@@ -454,7 +454,10 @@ export const ComposePost = observer(function ComposePost({
paddingHorizontal: 15,
},
]}>
<TagInput onChangeTags={onChangeTags} />
<TagInput
onChangeTags={onChangeTags}
tagsAutocompleteModel={tagsAutocompleteModel}
/>
</View>
<View style={[pal.border, styles.bottomBar]}>
+2
View File
@@ -12,6 +12,7 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
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'
@@ -40,6 +41,7 @@ export function TagInput({
}: {
max?: number
onChangeTags: (tags: string[]) => void
tagsAutocompleteModel: TagsAutocompleteModel
}) {
const pal = usePalette('default')
const input = React.useRef<TextInput>(null)
+311
View File
@@ -0,0 +1,311 @@
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<HTMLDivElement>(null)
const input = React.useRef<HTMLInputElement>(null)
const inputWidth = input.current
? input.current.getBoundingClientRect().width
: 200
const [value, setValue] = React.useState('')
const [tags, setTags] = React.useState<string[]>([])
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<TextInputKeyPressEventData>) => {
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 (
<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 as any}
id="tags-autocomplete-input"
role={'listbox' as any}
aria-controls="tags-autocomplete-dropdown"
aria-haspopup="listbox"
aria-expanded={dropdownIsOpen}
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.`}
/>
)}
<Pin
pinned={Boolean(dropdownIsOpen && dropdownItems.length)}
to={input}
at="bottomLeft"
from="topLeft"
style={{width: inputWidth}}>
<View
ref={dropdown as any}
style={[pal.view, pal.borderDark, styles.dropdown]}
role={'listbox' as any}
id="tags-autocomplete-dropdown">
{dropdownItems.map((item, index) => {
const isFirst = index === 0
const isLast = index === dropdownItems.length - 1
return (
<Pressable
accessibilityRole="button"
key={item.value}
onPress={() => 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,
]}>
<Text type="md" style={pal.textLight} numberOfLines={1}>
{item.label}
</Text>
</Pressable>
)
})}
</View>
</Pin>
</View>
)
}
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,
},
})
+12
View File
@@ -14421,6 +14421,13 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pind@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/pind/-/pind-0.5.0.tgz#9f04be810b0c91aac0bb0976a57ba1d76126d0c8"
integrity sha512-xK7GlJl2AeQQaYbtxoGOsGVa0Ms8M4B3CPIIohC2gmeDBRrxtBSWyzPc6cgdG4AEEvQETt//bIBETPy8VFO+BA==
dependencies:
tackjs "^3.0.1"
pinkie-promise@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
@@ -17517,6 +17524,11 @@ symbol-tree@^3.2.4:
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
tackjs@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/tackjs/-/tackjs-3.0.1.tgz#d69af48df5921320953d28131a8ae110ae11ea06"
integrity sha512-a/0vc4RWqG/9mYeldQ9uhGGTicc4LCr0EPpjV0IIKB21QkmFfkN559wu/Z4rprOzWDnEjUzdwsqIIrQC1H+rpg==
tailwindcss@^3.0.2:
version "3.3.3"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"