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 {useLingui} from '@lingui/react'
import Graphemer from 'graphemer'
import {flushSync} from 'react-dom'
import TextareaAutosize from 'react-textarea-autosize'
import {isSafari, isTouchDevice} from '#/lib/browser'
@@ -106,11 +107,19 @@ export function MessageInput({
const onEmojiInserted = React.useCallback(
(emoji: Emoji) => {
const position = textAreaRef.current?.selectionStart ?? 0
setMessage(
message =>
message.slice(0, position) + emoji.native + message.slice(position),
)
if (!textAreaRef.current) {
return
}
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],
)
@@ -148,7 +157,14 @@ export function MessageInput({
<Button
onPress={e => {
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={[
@@ -101,7 +101,7 @@ export function MessagesList({
const [emojiPickerState, setEmojiPickerState] =
React.useState<EmojiPickerState>({
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
+2 -1
View File
@@ -13,6 +13,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
import {purgeTemporaryImageFiles} from '#/state/gallery'
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'
export interface ComposerOptsPostRef {
@@ -29,7 +30,7 @@ export interface ComposerOpts {
onPost?: (postUri: string | undefined) => void
quote?: AppBskyFeedDefs.PostView
mention?: string // handle of user to mention
openEmojiPicker?: (pos: DOMRect | undefined) => void
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number}
+8 -1
View File
@@ -530,7 +530,14 @@ export const ComposePost = ({
}
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])
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
@@ -1,15 +1,13 @@
import React from 'react'
import {
GestureResponderEvent,
TouchableWithoutFeedback,
useWindowDimensions,
View,
} from 'react-native'
import {Pressable, useWindowDimensions, View} from 'react-native'
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 {FocusScope} from '@radix-ui/react-focus-scope'
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'
const HEIGHT_OFFSET = 40
@@ -33,6 +31,7 @@ export interface EmojiPickerPosition {
left: number
right: number
bottom: number
nextFocusRef: React.MutableRefObject<HTMLElement> | null
}
export interface EmojiPickerState {
@@ -51,6 +50,7 @@ interface IProps {
}
export function EmojiPicker({state, close, pinToTop}: IProps) {
const {_} = useLingui()
const {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false)
@@ -119,48 +119,63 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
if (!state.isOpen) return null
const onPressBackdrop = (e: GestureResponderEvent) => {
// @ts-ignore web only
if (e.nativeEvent?.pointerId === -1) return
close()
}
return (
<Portal>
<TouchableWithoutFeedback
accessibilityRole="button"
onPress={onPressBackdrop}
accessibilityViewIsModal>
<FocusScope
loop
trapped
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
style={[
style={flatten([
a.fixed,
a.w_full,
a.h_full,
a.align_center,
a.z_10,
{
top: 0,
left: 0,
right: 0,
},
]}>
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[{position: 'absolute'}, position]}>
<DismissableLayer
onFocusOutside={evt => evt.preventDefault()}
onDismiss={close}>
<Picker
data={async () => {
return (await import('./EmojiPickerData.json')).default
}}
onEmojiSelect={onInsert}
autoFocus={true}
/>
</DismissableLayer>
</View>
</TouchableWithoutFeedback>
])}>
<View style={[{position: 'absolute'}, position]}>
<DismissableLayer
onFocusOutside={evt => evt.preventDefault()}
onDismiss={close}>
<Picker
data={async () => {
return (await import('./EmojiPickerData.json')).default
}}
onEmojiSelect={onInsert}
autoFocus={true}
/>
</DismissableLayer>
</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>
)
}
+12 -8
View File
@@ -9,6 +9,7 @@ import {useModals} from '#/state/modals'
import {ComposerOpts, useComposerState} from '#/state/shell/composer'
import {
EmojiPicker,
EmojiPickerPosition,
EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {useBreakpoints, useTheme} from '#/alf'
@@ -42,16 +43,19 @@ function Inner({state}: {state: ComposerOpts}) {
const {gtMobile} = useBreakpoints()
const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
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) => {
if (!pos) return
setPickerState({
isOpen: true,
pos,
})
}, [])
const onOpenPicker = React.useCallback(
(pos: EmojiPickerPosition | undefined) => {
if (!pos) return
setPickerState({
isOpen: true,
pos,
})
},
[],
)
const onClosePicker = React.useCallback(() => {
setPickerState(prev => ({