Basic outline tags on web
This commit is contained in:
@@ -162,6 +162,7 @@
|
|||||||
"nanoid": "^5.0.5",
|
"nanoid": "^5.0.5",
|
||||||
"normalize-url": "^8.0.0",
|
"normalize-url": "^8.0.0",
|
||||||
"patch-package": "^6.5.1",
|
"patch-package": "^6.5.1",
|
||||||
|
"pind": "^0.5.0",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"psl": "^1.9.0",
|
"psl": "^1.9.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function OutlineTags(_props: {
|
||||||
|
max?: number
|
||||||
|
initialTags?: string[]
|
||||||
|
onChangeTags: (tags: string[]) => void
|
||||||
|
}) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TextInputKeyPressEventData,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import {TextInputFocusEventData} from 'react-native'
|
||||||
|
import {Pin} from 'pind'
|
||||||
|
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {tagAutocompleteModel} from '#/view/com/composer/text-input/tagsAutocompleteState'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonProps,ButtonText} from '#/components/Button'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basically `sanitizeHashtag` from `@atproto/api`, but ignores trailing
|
||||||
|
* punctuation in case the user intends to use `_` or `-`.
|
||||||
|
*/
|
||||||
|
export function sanitizeHashtagOnChange(hashtag: string) {
|
||||||
|
return hashtag.replace(/^\d+/g, '').slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagButton({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
children: string
|
||||||
|
onPress: ButtonProps['onPress']
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label="Remove tag"
|
||||||
|
size="tiny"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={onPress}>
|
||||||
|
<ButtonText style={[a.text_sm]}>#{children}</ButtonText>
|
||||||
|
<ButtonIcon icon={X} position="right" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutlineTags({
|
||||||
|
max = 8,
|
||||||
|
initialTags = [],
|
||||||
|
onChangeTags,
|
||||||
|
}: {
|
||||||
|
max?: number
|
||||||
|
initialTags?: string[]
|
||||||
|
onChangeTags: (tags: string[]) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const dropdown = React.useRef<HTMLDivElement>(null)
|
||||||
|
const input = React.useRef<HTMLInputElement>(null)
|
||||||
|
const inputWidth = input.current
|
||||||
|
? input.current.getBoundingClientRect().width
|
||||||
|
: 200
|
||||||
|
const model = React.useMemo(
|
||||||
|
() => tagAutocompleteModel({currentDid: currentAccount?.did!}),
|
||||||
|
[currentAccount],
|
||||||
|
)
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [value, setValue] = React.useState('')
|
||||||
|
const [tags, setTags] = React.useState<string[]>(initialTags)
|
||||||
|
const [dropdownItems, setDropdownItems] = React.useState<
|
||||||
|
{value: string; label: string}[]
|
||||||
|
>([])
|
||||||
|
const [selectedItemIndex, setSelectedItemIndex] = React.useState(0)
|
||||||
|
|
||||||
|
const dropdownIsActive = Boolean(value.length)
|
||||||
|
|
||||||
|
const closeDropdownAndReset = React.useCallback(() => {
|
||||||
|
setValue('')
|
||||||
|
setSelectedItemIndex(0)
|
||||||
|
setDropdownItems([])
|
||||||
|
}, [setSelectedItemIndex, setDropdownItems])
|
||||||
|
|
||||||
|
const addTags = React.useCallback(
|
||||||
|
(_tags: string[]) => {
|
||||||
|
const _t = _tags.slice(0, max)
|
||||||
|
setTags(_t)
|
||||||
|
onChangeTags(_t)
|
||||||
|
},
|
||||||
|
[onChangeTags, setTags, max],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeTag = React.useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
addTags(tags.filter(t => t !== tag))
|
||||||
|
},
|
||||||
|
[tags, addTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addTagAndReset = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const tag = sanitizeHashtagOnChange(value).replace(/^#{1}/, '')
|
||||||
|
|
||||||
|
// enforce max hashtag length
|
||||||
|
if (tag.length > 0 && tag.length <= 64) {
|
||||||
|
addTags(Array.from(new Set([...tags, tag])).slice(0, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
model.save(tag)
|
||||||
|
setValue('')
|
||||||
|
input.current?.focus()
|
||||||
|
closeDropdownAndReset()
|
||||||
|
},
|
||||||
|
[max, tags, closeDropdownAndReset, setValue, addTags, model],
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
closeDropdownAndReset()
|
||||||
|
} else if (key === ' ') {
|
||||||
|
e.preventDefault() // prevents an additional space on web
|
||||||
|
addTagAndReset(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropdownIsActive) {
|
||||||
|
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)
|
||||||
|
} else if (
|
||||||
|
isWeb &&
|
||||||
|
key === 'Tab' &&
|
||||||
|
// @ts-ignore web only
|
||||||
|
!e.nativeEvent.shiftKey
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmitEditing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
value,
|
||||||
|
tags,
|
||||||
|
dropdownIsActive,
|
||||||
|
selectedItemIndex,
|
||||||
|
dropdownItems.length,
|
||||||
|
closeDropdownAndReset,
|
||||||
|
setSelectedItemIndex,
|
||||||
|
addTags,
|
||||||
|
addTagAndReset,
|
||||||
|
onSubmitEditing,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const search = React.useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
await model.search(value)
|
||||||
|
setDropdownItems(
|
||||||
|
model.suggestions.map(s => ({
|
||||||
|
value: s.value,
|
||||||
|
label: s.value,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[model, setDropdownItems],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onChangeText = React.useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
const tag = sanitizeHashtagOnChange(value)
|
||||||
|
|
||||||
|
setValue(tag)
|
||||||
|
|
||||||
|
if (tag.length) {
|
||||||
|
search(tag)
|
||||||
|
} else {
|
||||||
|
closeDropdownAndReset()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setValue, closeDropdownAndReset, search],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onBlur = React.useCallback(
|
||||||
|
(e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const target = e.nativeEvent.relatedTarget as HTMLElement | undefined
|
||||||
|
|
||||||
|
if (
|
||||||
|
!tags.length &&
|
||||||
|
(!target || !target.id.includes('tag_autocomplete_option'))
|
||||||
|
) {
|
||||||
|
setValue('')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tags, setValue],
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View ref={containerRef as any} style={[a.px_sm]}>
|
||||||
|
<View style={[a.flex_row, a.align_start, a.flex_wrap, a.gap_sm]}>
|
||||||
|
{tags.map((tag, i) => (
|
||||||
|
<TagButton key={tag + i} onPress={() => removeTag(tag)}>
|
||||||
|
{tag}
|
||||||
|
</TagButton>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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={dropdownIsActive}
|
||||||
|
value={value}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="Add outline tags"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Pin
|
||||||
|
pinned={Boolean(value.length)}
|
||||||
|
to={input}
|
||||||
|
at="bottomLeft"
|
||||||
|
from="topLeft"
|
||||||
|
style={{width: inputWidth}}>
|
||||||
|
<View
|
||||||
|
ref={dropdown as any}
|
||||||
|
style={[t.atoms.bg, t.atoms.border_contrast_low, 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
|
||||||
|
id={`tag_autocomplete_option_${item.value}`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
key={item.value}
|
||||||
|
onPress={() => addTagAndReset(item.value)}
|
||||||
|
style={state => [
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
styles.dropdownItem,
|
||||||
|
{
|
||||||
|
backgroundColor: state.hovered
|
||||||
|
? t.atoms.bg_contrast_25.backgroundColor
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
selectedItemIndex === index
|
||||||
|
? t.atoms.bg_contrast_25
|
||||||
|
: undefined,
|
||||||
|
isFirst
|
||||||
|
? styles.firstResult
|
||||||
|
: isLast
|
||||||
|
? styles.lastResult
|
||||||
|
: undefined,
|
||||||
|
]}>
|
||||||
|
<Text 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: 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -105,6 +105,7 @@ import * as Toast from '#/view/com/util/Toast'
|
|||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {OutlineTags} from '#/components/Composer/OutlineTags'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
@@ -744,6 +745,8 @@ export const ComposePost = ({
|
|||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
|
|
||||||
|
<OutlineTags onChangeTags={() => {}} />
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
|
style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@@ -16762,6 +16762,13 @@ pify@^4.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
|
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
|
||||||
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
|
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:
|
pinkie-promise@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
|
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
|
||||||
@@ -20034,6 +20041,11 @@ symbol-tree@^3.2.4:
|
|||||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||||
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
|
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:
|
tailwindcss@^3.0.2:
|
||||||
version "3.3.3"
|
version "3.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
|
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
|
||||||
|
|||||||
Reference in New Issue
Block a user