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:
Eric Bailey
2024-12-20 13:59:33 -06:00
committed by GitHub
parent 8a3dfcb9d0
commit 8116d12c15
6 changed files with 96 additions and 53 deletions
@@ -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
+2 -1
View File
@@ -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}
+8 -1
View File
@@ -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>
) )
} }
+12 -8
View File
@@ -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 => ({