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
+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 => ({