Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Eric Bailey
Dan Abramov
and committed by
GitHub
8116d12c 8a3dfcb9

+96 -53
+22 -6
src/screens/Messages/components/MessageInput.web.tsx
··· 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import Graphemer from 'graphemer' 6 + import {flushSync} from 'react-dom' 6 7 import TextareaAutosize from 'react-textarea-autosize' 7 8 8 9 import {isSafari, isTouchDevice} from '#/lib/browser' ··· 106 107 107 108 const onEmojiInserted = React.useCallback( 108 109 (emoji: Emoji) => { 109 - const position = textAreaRef.current?.selectionStart ?? 0 110 - setMessage( 111 - message => 112 - message.slice(0, position) + emoji.native + message.slice(position), 113 - ) 110 + if (!textAreaRef.current) { 111 + return 112 + } 113 + const position = textAreaRef.current.selectionStart ?? 0 114 + textAreaRef.current.focus() 115 + flushSync(() => { 116 + setMessage( 117 + message => 118 + message.slice(0, position) + emoji.native + message.slice(position), 119 + ) 120 + }) 121 + textAreaRef.current.selectionStart = position + emoji.native.length 122 + textAreaRef.current.selectionEnd = position + emoji.native.length 114 123 }, 115 124 [setMessage], 116 125 ) ··· 148 157 <Button 149 158 onPress={e => { 150 159 e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => { 151 - openEmojiPicker?.({top: py, left: px, right: px, bottom: py}) 160 + openEmojiPicker?.({ 161 + top: py, 162 + left: px, 163 + right: px, 164 + bottom: py, 165 + nextFocusRef: 166 + textAreaRef as unknown as React.MutableRefObject<HTMLElement>, 167 + }) 152 168 }) 153 169 }} 154 170 style={[
+1 -1
src/screens/Messages/components/MessagesList.tsx
··· 101 101 const [emojiPickerState, setEmojiPickerState] = 102 102 React.useState<EmojiPickerState>({ 103 103 isOpen: false, 104 - pos: {top: 0, left: 0, right: 0, bottom: 0}, 104 + pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 105 105 }) 106 106 107 107 // 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
src/state/shell/composer/index.tsx
··· 13 13 import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers' 14 14 import {purgeTemporaryImageFiles} from '#/state/gallery' 15 15 import {precacheResolveLinkQuery} from '#/state/queries/resolve-link' 16 + import type {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' 16 17 import * as Toast from '#/view/com/util/Toast' 17 18 18 19 export interface ComposerOptsPostRef { ··· 29 30 onPost?: (postUri: string | undefined) => void 30 31 quote?: AppBskyFeedDefs.PostView 31 32 mention?: string // handle of user to mention 32 - openEmojiPicker?: (pos: DOMRect | undefined) => void 33 + openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void 33 34 text?: string 34 35 imageUris?: {uri: string; width: number; height: number; altText?: string}[] 35 36 videoUri?: {uri: string; width: number; height: number}
+8 -1
src/view/com/composer/Composer.tsx
··· 530 530 } 531 531 532 532 const onEmojiButtonPress = useCallback(() => { 533 - openEmojiPicker?.(textInput.current?.getCursorPosition()) 533 + const rect = textInput.current?.getCursorPosition() 534 + if (rect) { 535 + openEmojiPicker?.({ 536 + ...rect, 537 + nextFocusRef: 538 + textInput as unknown as React.MutableRefObject<HTMLElement>, 539 + }) 540 + } 534 541 }, [openEmojiPicker]) 535 542 536 543 const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
+51 -36
src/view/com/composer/text-input/web/EmojiPicker.web.tsx
··· 1 1 import React from 'react' 2 - import { 3 - GestureResponderEvent, 4 - TouchableWithoutFeedback, 5 - useWindowDimensions, 6 - View, 7 - } from 'react-native' 2 + import {Pressable, useWindowDimensions, View} from 'react-native' 8 3 import Picker from '@emoji-mart/react' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 9 6 import {DismissableLayer} from '@radix-ui/react-dismissable-layer' 7 + import {FocusScope} from '@radix-ui/react-focus-scope' 10 8 11 9 import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 12 - import {atoms as a} from '#/alf' 10 + import {atoms as a, flatten} from '#/alf' 13 11 import {Portal} from '#/components/Portal' 14 12 15 13 const HEIGHT_OFFSET = 40 ··· 33 31 left: number 34 32 right: number 35 33 bottom: number 34 + nextFocusRef: React.MutableRefObject<HTMLElement> | null 36 35 } 37 36 38 37 export interface EmojiPickerState { ··· 51 50 } 52 51 53 52 export function EmojiPicker({state, close, pinToTop}: IProps) { 53 + const {_} = useLingui() 54 54 const {height, width} = useWindowDimensions() 55 55 56 56 const isShiftDown = React.useRef(false) ··· 119 119 120 120 if (!state.isOpen) return null 121 121 122 - const onPressBackdrop = (e: GestureResponderEvent) => { 123 - // @ts-ignore web only 124 - if (e.nativeEvent?.pointerId === -1) return 125 - close() 126 - } 127 - 128 122 return ( 129 123 <Portal> 130 - <TouchableWithoutFeedback 131 - accessibilityRole="button" 132 - onPress={onPressBackdrop} 133 - accessibilityViewIsModal> 124 + <FocusScope 125 + loop 126 + trapped 127 + onUnmountAutoFocus={e => { 128 + const nextFocusRef = state.pos.nextFocusRef 129 + const node = nextFocusRef?.current 130 + if (node) { 131 + e.preventDefault() 132 + node.focus() 133 + } 134 + }}> 135 + <Pressable 136 + accessible 137 + accessibilityLabel={_(msg`Close emoji picker`)} 138 + accessibilityHint={_(msg`Tap to close the emoji picker`)} 139 + onPress={close} 140 + style={[a.fixed, a.inset_0]} 141 + /> 142 + 134 143 <View 135 - style={[ 144 + style={flatten([ 136 145 a.fixed, 137 146 a.w_full, 138 147 a.h_full, 139 148 a.align_center, 149 + a.z_10, 140 150 { 141 151 top: 0, 142 152 left: 0, 143 153 right: 0, 144 154 }, 145 - ]}> 146 - {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} 147 - <TouchableWithoutFeedback onPress={e => e.stopPropagation()}> 148 - <View style={[{position: 'absolute'}, position]}> 149 - <DismissableLayer 150 - onFocusOutside={evt => evt.preventDefault()} 151 - onDismiss={close}> 152 - <Picker 153 - data={async () => { 154 - return (await import('./EmojiPickerData.json')).default 155 - }} 156 - onEmojiSelect={onInsert} 157 - autoFocus={true} 158 - /> 159 - </DismissableLayer> 160 - </View> 161 - </TouchableWithoutFeedback> 155 + ])}> 156 + <View style={[{position: 'absolute'}, position]}> 157 + <DismissableLayer 158 + onFocusOutside={evt => evt.preventDefault()} 159 + onDismiss={close}> 160 + <Picker 161 + data={async () => { 162 + return (await import('./EmojiPickerData.json')).default 163 + }} 164 + onEmojiSelect={onInsert} 165 + autoFocus={true} 166 + /> 167 + </DismissableLayer> 168 + </View> 162 169 </View> 163 - </TouchableWithoutFeedback> 170 + 171 + <Pressable 172 + accessible 173 + accessibilityLabel={_(msg`Close emoji picker`)} 174 + accessibilityHint={_(msg`Tap to close the emoji picker`)} 175 + onPress={close} 176 + style={[a.fixed, a.inset_0]} 177 + /> 178 + </FocusScope> 164 179 </Portal> 165 180 ) 166 181 }
+12 -8
src/view/shell/Composer.web.tsx
··· 9 9 import {ComposerOpts, useComposerState} from '#/state/shell/composer' 10 10 import { 11 11 EmojiPicker, 12 + EmojiPickerPosition, 12 13 EmojiPickerState, 13 14 } from '#/view/com/composer/text-input/web/EmojiPicker.web' 14 15 import {useBreakpoints, useTheme} from '#/alf' ··· 42 43 const {gtMobile} = useBreakpoints() 43 44 const [pickerState, setPickerState] = React.useState<EmojiPickerState>({ 44 45 isOpen: false, 45 - pos: {top: 0, left: 0, right: 0, bottom: 0}, 46 + pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 46 47 }) 47 48 48 - const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => { 49 - if (!pos) return 50 - setPickerState({ 51 - isOpen: true, 52 - pos, 53 - }) 54 - }, []) 49 + const onOpenPicker = React.useCallback( 50 + (pos: EmojiPickerPosition | undefined) => { 51 + if (!pos) return 52 + setPickerState({ 53 + isOpen: true, 54 + pos, 55 + }) 56 + }, 57 + [], 58 + ) 55 59 56 60 const onClosePicker = React.useCallback(() => { 57 61 setPickerState(prev => ({