mobile tags input and autocomplete
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]}>
|
||||||
|
|||||||
@@ -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(
|
||||||
const tag = sanitize(value)
|
(tag: string) => {
|
||||||
|
addTags(tags.filter(t => t !== tag))
|
||||||
|
},
|
||||||
|
[tags, addTags],
|
||||||
|
)
|
||||||
|
|
||||||
// enforce max hashtag length
|
const addTagAndReset = React.useCallback(
|
||||||
if (tag.length > 0 && tag.length <= 64) {
|
(value: string) => {
|
||||||
handleChangeTags(uniq([...tags, tag]).slice(0, max))
|
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('')
|
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(
|
||||||
setValue(v)
|
async (v: string) => {
|
||||||
}, [])
|
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 style={styles.outer}>
|
<View>
|
||||||
{!tags.length && (
|
<Pressable
|
||||||
<FontAwesomeIcon
|
accessibilityRole="button"
|
||||||
icon="tags"
|
style={styles.selectedTags}
|
||||||
size={14}
|
onPress={() => {
|
||||||
style={pal.textLight as FontAwesomeIconStyle}
|
sheet.current?.snapToIndex(0)
|
||||||
/>
|
}}>
|
||||||
)}
|
<View style={[pal.viewLight, styles.button]}>
|
||||||
{tags.map(tag => (
|
{tags.length ? (
|
||||||
<EditableTag key={tag} value={tag} onRemove={removeTag} />
|
<Text type="md-medium" style={[pal.textLight]}>
|
||||||
))}
|
Add +
|
||||||
{tags.length >= max ? null : (
|
</Text>
|
||||||
<TextInput
|
) : (
|
||||||
ref={input}
|
<>
|
||||||
value={value}
|
<FontAwesomeIcon
|
||||||
onKeyPress={onKeyPress}
|
icon="tags"
|
||||||
onSubmitEditing={onSubmitEditing}
|
size={12}
|
||||||
onChangeText={onChangeText}
|
style={pal.textLight as FontAwesomeIconStyle}
|
||||||
blurOnSubmit={false}
|
/>
|
||||||
style={[styles.input, pal.textLight]}
|
<Text type="md-medium" style={[pal.textLight]}>
|
||||||
placeholder="Enter a tag and press enter"
|
Click to add tags to your post
|
||||||
autoCapitalize="none"
|
</Text>
|
||||||
autoCorrect={false}
|
</>
|
||||||
autoComplete="off"
|
)}
|
||||||
accessible={true}
|
</View>
|
||||||
accessibilityLabel="Add tags to your post"
|
|
||||||
accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`}
|
{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>
|
</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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
|
<View
|
||||||
<RoutesContainer>
|
testID="mobileShellView"
|
||||||
<ShellInner />
|
style={[styles.outerContainer, pal.view]}>
|
||||||
</RoutesContainer>
|
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||||
</View>
|
<RoutesContainer>
|
||||||
|
<ShellInner />
|
||||||
|
</RoutesContainer>
|
||||||
|
</View>
|
||||||
|
</Provider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user