about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-12-20 13:59:33 -0600
committerGitHub <noreply@github.com>2024-12-20 19:59:33 +0000
commit8116d12c15495fa192e92f5bfb75cb561bb16402 (patch)
treed8291bc888d6423ccb7f242877c9293283156e83 /src
parent8a3dfcb9d0860eb8f8112a84dcf32ae522f77069 (diff)
downloadvoidsky-8116d12c15495fa192e92f5bfb75cb561bb16402.tar.zst
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>
Diffstat (limited to 'src')
-rw-r--r--src/screens/Messages/components/MessageInput.web.tsx28
-rw-r--r--src/screens/Messages/components/MessagesList.tsx2
-rw-r--r--src/state/shell/composer/index.tsx3
-rw-r--r--src/view/com/composer/Composer.tsx9
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx87
-rw-r--r--src/view/shell/Composer.web.tsx20
6 files changed, 96 insertions, 53 deletions
diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx
index 72e0382a9..bac163685 100644
--- a/src/screens/Messages/components/MessageInput.web.tsx
+++ b/src/screens/Messages/components/MessageInput.web.tsx
@@ -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={[
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index ce189459e..071ce1cd7 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -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
diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index 7138945f4..f1ea41c64 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -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}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index e4b09cf0f..c9e40530e 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -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>()
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index c72172902..f5e6a987c 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -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>
   )
 }
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 47a86ed24..cfd9f6280 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -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 => ({