Fix Emoji picker focus (#7217)
* Only portal the emoji picker where needed * Add optional portal prop to emoji picker * Use FocusScope to our advantage * Pare back, add guards, fix focus trap * Don't return focus to emoji button * Set DM input position on emoji insert * Let the caller determine next focus node --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import {Pressable, StyleSheet, View} from 'react-native'
|
|||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import Graphemer from 'graphemer'
|
import Graphemer from 'graphemer'
|
||||||
|
import {flushSync} from 'react-dom'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
|
|
||||||
import {isSafari, isTouchDevice} from '#/lib/browser'
|
import {isSafari, isTouchDevice} from '#/lib/browser'
|
||||||
@@ -106,11 +107,19 @@ export function MessageInput({
|
|||||||
|
|
||||||
const onEmojiInserted = React.useCallback(
|
const onEmojiInserted = React.useCallback(
|
||||||
(emoji: Emoji) => {
|
(emoji: Emoji) => {
|
||||||
const position = textAreaRef.current?.selectionStart ?? 0
|
if (!textAreaRef.current) {
|
||||||
setMessage(
|
return
|
||||||
message =>
|
}
|
||||||
message.slice(0, position) + emoji.native + message.slice(position),
|
const position = textAreaRef.current.selectionStart ?? 0
|
||||||
)
|
textAreaRef.current.focus()
|
||||||
|
flushSync(() => {
|
||||||
|
setMessage(
|
||||||
|
message =>
|
||||||
|
message.slice(0, position) + emoji.native + message.slice(position),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
textAreaRef.current.selectionStart = position + emoji.native.length
|
||||||
|
textAreaRef.current.selectionEnd = position + emoji.native.length
|
||||||
},
|
},
|
||||||
[setMessage],
|
[setMessage],
|
||||||
)
|
)
|
||||||
@@ -148,7 +157,14 @@ export function MessageInput({
|
|||||||
<Button
|
<Button
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||||
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
|
openEmojiPicker?.({
|
||||||
|
top: py,
|
||||||
|
left: px,
|
||||||
|
right: px,
|
||||||
|
bottom: py,
|
||||||
|
nextFocusRef:
|
||||||
|
textAreaRef as unknown as React.MutableRefObject<HTMLElement>,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function MessagesList({
|
|||||||
const [emojiPickerState, setEmojiPickerState] =
|
const [emojiPickerState, setEmojiPickerState] =
|
||||||
React.useState<EmojiPickerState>({
|
React.useState<EmojiPickerState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
||||||
})
|
})
|
||||||
|
|
||||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|||||||
import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
|
import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
|
||||||
import {purgeTemporaryImageFiles} from '#/state/gallery'
|
import {purgeTemporaryImageFiles} from '#/state/gallery'
|
||||||
import {precacheResolveLinkQuery} from '#/state/queries/resolve-link'
|
import {precacheResolveLinkQuery} from '#/state/queries/resolve-link'
|
||||||
|
import type {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
|
||||||
export interface ComposerOptsPostRef {
|
export interface ComposerOptsPostRef {
|
||||||
@@ -29,7 +30,7 @@ export interface ComposerOpts {
|
|||||||
onPost?: (postUri: string | undefined) => void
|
onPost?: (postUri: string | undefined) => void
|
||||||
quote?: AppBskyFeedDefs.PostView
|
quote?: AppBskyFeedDefs.PostView
|
||||||
mention?: string // handle of user to mention
|
mention?: string // handle of user to mention
|
||||||
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
|
||||||
text?: string
|
text?: string
|
||||||
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
||||||
videoUri?: {uri: string; width: number; height: number}
|
videoUri?: {uri: string; width: number; height: number}
|
||||||
|
|||||||
@@ -530,7 +530,14 @@ export const ComposePost = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
const rect = textInput.current?.getCursorPosition()
|
||||||
|
if (rect) {
|
||||||
|
openEmojiPicker?.({
|
||||||
|
...rect,
|
||||||
|
nextFocusRef:
|
||||||
|
textInput as unknown as React.MutableRefObject<HTMLElement>,
|
||||||
|
})
|
||||||
|
}
|
||||||
}, [openEmojiPicker])
|
}, [openEmojiPicker])
|
||||||
|
|
||||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||||
GestureResponderEvent,
|
|
||||||
TouchableWithoutFeedback,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
|
import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
|
||||||
|
import {FocusScope} from '@radix-ui/react-focus-scope'
|
||||||
|
|
||||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, flatten} from '#/alf'
|
||||||
import {Portal} from '#/components/Portal'
|
import {Portal} from '#/components/Portal'
|
||||||
|
|
||||||
const HEIGHT_OFFSET = 40
|
const HEIGHT_OFFSET = 40
|
||||||
@@ -33,6 +31,7 @@ export interface EmojiPickerPosition {
|
|||||||
left: number
|
left: number
|
||||||
right: number
|
right: number
|
||||||
bottom: number
|
bottom: number
|
||||||
|
nextFocusRef: React.MutableRefObject<HTMLElement> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmojiPickerState {
|
export interface EmojiPickerState {
|
||||||
@@ -51,6 +50,7 @@ interface IProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPicker({state, close, pinToTop}: IProps) {
|
export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
const {height, width} = useWindowDimensions()
|
const {height, width} = useWindowDimensions()
|
||||||
|
|
||||||
const isShiftDown = React.useRef(false)
|
const isShiftDown = React.useRef(false)
|
||||||
@@ -119,48 +119,63 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
|
|||||||
|
|
||||||
if (!state.isOpen) return null
|
if (!state.isOpen) return null
|
||||||
|
|
||||||
const onPressBackdrop = (e: GestureResponderEvent) => {
|
|
||||||
// @ts-ignore web only
|
|
||||||
if (e.nativeEvent?.pointerId === -1) return
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<TouchableWithoutFeedback
|
<FocusScope
|
||||||
accessibilityRole="button"
|
loop
|
||||||
onPress={onPressBackdrop}
|
trapped
|
||||||
accessibilityViewIsModal>
|
onUnmountAutoFocus={e => {
|
||||||
|
const nextFocusRef = state.pos.nextFocusRef
|
||||||
|
const node = nextFocusRef?.current
|
||||||
|
if (node) {
|
||||||
|
e.preventDefault()
|
||||||
|
node.focus()
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={_(msg`Close emoji picker`)}
|
||||||
|
accessibilityHint={_(msg`Tap to close the emoji picker`)}
|
||||||
|
onPress={close}
|
||||||
|
style={[a.fixed, a.inset_0]}
|
||||||
|
/>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={flatten([
|
||||||
a.fixed,
|
a.fixed,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.h_full,
|
a.h_full,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
|
a.z_10,
|
||||||
{
|
{
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
},
|
},
|
||||||
]}>
|
])}>
|
||||||
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
|
<View style={[{position: 'absolute'}, position]}>
|
||||||
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
|
<DismissableLayer
|
||||||
<View style={[{position: 'absolute'}, position]}>
|
onFocusOutside={evt => evt.preventDefault()}
|
||||||
<DismissableLayer
|
onDismiss={close}>
|
||||||
onFocusOutside={evt => evt.preventDefault()}
|
<Picker
|
||||||
onDismiss={close}>
|
data={async () => {
|
||||||
<Picker
|
return (await import('./EmojiPickerData.json')).default
|
||||||
data={async () => {
|
}}
|
||||||
return (await import('./EmojiPickerData.json')).default
|
onEmojiSelect={onInsert}
|
||||||
}}
|
autoFocus={true}
|
||||||
onEmojiSelect={onInsert}
|
/>
|
||||||
autoFocus={true}
|
</DismissableLayer>
|
||||||
/>
|
</View>
|
||||||
</DismissableLayer>
|
|
||||||
</View>
|
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={_(msg`Close emoji picker`)}
|
||||||
|
accessibilityHint={_(msg`Tap to close the emoji picker`)}
|
||||||
|
onPress={close}
|
||||||
|
style={[a.fixed, a.inset_0]}
|
||||||
|
/>
|
||||||
|
</FocusScope>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {useModals} from '#/state/modals'
|
|||||||
import {ComposerOpts, useComposerState} from '#/state/shell/composer'
|
import {ComposerOpts, useComposerState} from '#/state/shell/composer'
|
||||||
import {
|
import {
|
||||||
EmojiPicker,
|
EmojiPicker,
|
||||||
|
EmojiPickerPosition,
|
||||||
EmojiPickerState,
|
EmojiPickerState,
|
||||||
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import {useBreakpoints, useTheme} from '#/alf'
|
import {useBreakpoints, useTheme} from '#/alf'
|
||||||
@@ -42,16 +43,19 @@ function Inner({state}: {state: ComposerOpts}) {
|
|||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
|
const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
||||||
})
|
})
|
||||||
|
|
||||||
const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => {
|
const onOpenPicker = React.useCallback(
|
||||||
if (!pos) return
|
(pos: EmojiPickerPosition | undefined) => {
|
||||||
setPickerState({
|
if (!pos) return
|
||||||
isOpen: true,
|
setPickerState({
|
||||||
pos,
|
isOpen: true,
|
||||||
})
|
pos,
|
||||||
}, [])
|
})
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const onClosePicker = React.useCallback(() => {
|
const onClosePicker = React.useCallback(() => {
|
||||||
setPickerState(prev => ({
|
setPickerState(prev => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user