about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-10-08 20:00:49 +0300
committerGitHub <noreply@github.com>2024-10-08 20:00:49 +0300
commitfc82d2f6d5e8a93f0e7ce4861c5205c8a4b49c30 (patch)
tree51e618db283d28ef241f0e6f2f6180004e6cb6e7 /src
parentefcf8a6ae59d9767bc2289af927a2e12bc39a054 (diff)
downloadvoidsky-fc82d2f6d5e8a93f0e7ce4861c5205c8a4b49c30.tar.zst
Move Dialogs to Radix (#5648)
* Use Redix FocusTrap (#5638)

* Use Redix FocusTrap

* force resolutions on radix libs

* add focus guards

* use @radix-ui/dismissable-layer for escape handling

* fix banner menu keypress by using `Pressable`

* add menu in dialog example to storybook

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* use DismissableLayer/FocusScope for composer

* fix storybook dialog

* thread Portal through Prompt and avatar/banner

* fix dialog style regression

* remove tamagui

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
-rw-r--r--src/components/Dialog/index.web.tsx36
-rw-r--r--src/components/Menu/index.tsx9
-rw-r--r--src/components/Prompt.tsx9
-rw-r--r--src/components/dialogs/GifSelect.tsx6
-rw-r--r--src/lib/hooks/useWebBodyScrollLock.ts1
-rw-r--r--src/view/com/composer/Composer.tsx18
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx19
-rw-r--r--src/view/com/util/UserAvatar.tsx14
-rw-r--r--src/view/com/util/UserBanner.tsx16
-rw-r--r--src/view/screens/Storybook/Dialogs.tsx53
-rw-r--r--src/view/screens/Storybook/Menus.tsx4
-rw-r--r--src/view/shell/Composer.web.tsx111
12 files changed, 185 insertions, 111 deletions
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 7b9cfb693..1a20311d3 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -10,7 +10,9 @@ import {
 import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {FocusScope} from '@tamagui/focus-scope'
+import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
+import {useFocusGuards} from '@radix-ui/react-focus-guards'
+import {FocusScope} from '@radix-ui/react-focus-scope'
 
 import {logger} from '#/logger'
 import {useDialogStateControlContext} from '#/state/dialogs'
@@ -31,6 +33,7 @@ export * from '#/components/Dialog/utils'
 export {Input} from '#/components/forms/TextField'
 
 const stopPropagation = (e: any) => e.stopPropagation()
+const preventDefault = (e: any) => e.preventDefault()
 
 export function Outer({
   children,
@@ -85,21 +88,6 @@ export function Outer({
     [close, open],
   )
 
-  React.useEffect(() => {
-    if (!isOpen) return
-
-    function handler(e: KeyboardEvent) {
-      if (e.key === 'Escape') {
-        e.stopPropagation()
-        close()
-      }
-    }
-
-    document.addEventListener('keydown', handler)
-
-    return () => document.removeEventListener('keydown', handler)
-  }, [close, isOpen])
-
   const context = React.useMemo(
     () => ({
       close,
@@ -168,9 +156,11 @@ export function Inner({
   accessibilityDescribedBy,
 }: DialogInnerProps) {
   const t = useTheme()
+  const {close} = React.useContext(Context)
   const {gtMobile} = useBreakpoints()
+  useFocusGuards()
   return (
-    <FocusScope loop enabled trapped>
+    <FocusScope loop asChild trapped>
       <Animated.View
         role="dialog"
         aria-role="dialog"
@@ -183,7 +173,7 @@ export function Inner({
         onTouchEnd={stopPropagation}
         entering={FadeInDown.duration(100)}
         // exiting={FadeOut.duration(100)}
-        style={[
+        style={flatten([
           a.relative,
           a.rounded_md,
           a.w_full,
@@ -198,8 +188,14 @@ export function Inner({
             shadowRadius: 30,
           },
           flatten(style),
-        ]}>
-        {children}
+        ])}>
+        <DismissableLayer
+          onInteractOutside={preventDefault}
+          onFocusOutside={preventDefault}
+          onDismiss={close}
+          style={{display: 'flex', flexDirection: 'column'}}>
+          {children}
+        </DismissableLayer>
       </Animated.View>
     </FocusScope>
   )
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index a22f43cf8..12cf1866e 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -18,6 +18,7 @@ import {
   ItemTextProps,
   TriggerProps,
 } from '#/components/Menu/types'
+import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 
 export {
@@ -77,9 +78,11 @@ export function Trigger({children, label}: TriggerProps) {
 export function Outer({
   children,
   showCancel,
+  Portal,
 }: React.PropsWithChildren<{
   showCancel?: boolean
   style?: StyleProp<ViewStyle>
+  Portal?: PortalComponent
 }>) {
   const context = React.useContext(Context)
   const {_} = useLingui()
@@ -87,15 +90,15 @@ export function Outer({
   return (
     <Dialog.Outer
       control={context.control}
-      nativeOptions={{preventExpansion: true}}>
+      nativeOptions={{preventExpansion: true}}
+      Portal={Portal}>
       <Dialog.Handle />
       {/* Re-wrap with context since Dialogs are portal-ed to root */}
       <Context.Provider value={context}>
-        <Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.pt_sm]}>
+        <Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.py_sm]}>
           <View style={[a.gap_lg]}>
             {children}
             {isNative && showCancel && <Cancel />}
-            <View style={[{height: a.pb_lg.paddingBottom}]} />
           </View>
         </Dialog.ScrollableInner>
       </Context.Provider>
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index fc6919af8..c47f0d64a 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -8,6 +8,7 @@ import {Button, ButtonColor, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
+import {BottomSheetViewProps} from '../../modules/bottom-sheet'
 
 export {
   type DialogControlProps as PromptControlProps,
@@ -27,10 +28,12 @@ export function Outer({
   control,
   testID,
   Portal,
+  nativeOptions,
 }: React.PropsWithChildren<{
   control: Dialog.DialogControlProps
   testID?: string
   Portal?: PortalComponent
+  nativeOptions?: Omit<BottomSheetViewProps, 'children'>
 }>) {
   const {gtMobile} = useBreakpoints()
   const titleId = React.useId()
@@ -42,7 +45,11 @@ export function Outer({
   )
 
   return (
-    <Dialog.Outer control={control} testID={testID} Portal={Portal}>
+    <Dialog.Outer
+      control={control}
+      testID={testID}
+      Portal={Portal}
+      nativeOptions={{preventExpansion: true, ...nativeOptions}}>
       <Dialog.Handle />
       <Context.Provider value={context}>
         <Dialog.ScrollableInner
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index 6023b5808..c0ed202da 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -23,14 +23,14 @@ import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {ListMethods} from '#/view/com/util/List'
 import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {useThrottledValue} from '#/components/hooks/useThrottledValue'
 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {Button, ButtonIcon, ButtonText} from '../Button'
-import {ListFooter, ListMaybePlaceholder} from '../Lists'
-import {PortalComponent} from '../Portal'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {PortalComponent} from '#/components/Portal'
 
 export function GifSelectDialog({
   controlRef,
diff --git a/src/lib/hooks/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts
index 0dcf911fe..c63c23b29 100644
--- a/src/lib/hooks/useWebBodyScrollLock.ts
+++ b/src/lib/hooks/useWebBodyScrollLock.ts
@@ -1,4 +1,5 @@
 import {useEffect} from 'react'
+
 import {isWeb} from '#/platform/detection'
 
 let refCount = 0
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 55c2d81ab..8cc8fba0d 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -65,7 +65,6 @@ import {useDialogStateControlContext} from '#/state/dialogs'
 import {emitPostCreated} from '#/state/events'
 import {ComposerImage, pasteImage} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
-import {useModals} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
   toPostLanguages,
@@ -146,7 +145,6 @@ export const ComposePost = ({
   const queryClient = useQueryClient()
   const currentDid = currentAccount!.did
   const {data: currentProfile} = useProfileQuery({did: currentDid})
-  const {isModalActive} = useModals()
   const {closeComposer} = useComposerControls()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
@@ -303,22 +301,6 @@ export const ComposePost = ({
     }
   }, [onPressCancel, closeAllDialogs, closeAllModals])
 
-  // listen to escape key on desktop web
-  const onEscape = useCallback(
-    (e: KeyboardEvent) => {
-      if (e.key === 'Escape') {
-        onPressCancel()
-      }
-    },
-    [onPressCancel],
-  )
-  useEffect(() => {
-    if (isWeb && !isModalActive) {
-      window.addEventListener('keydown', onEscape)
-      return () => window.removeEventListener('keydown', onEscape)
-    }
-  }, [onEscape, isModalActive])
-
   const onNewLink = useCallback((uri: string) => {
     dispatch({type: 'embed_add_uri', uri})
   }, [])
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 ad3bb30ec..1d5dad486 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -6,6 +6,7 @@ import {
   View,
 } from 'react-native'
 import Picker from '@emoji-mart/react'
+import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
 
 import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
 import {atoms as a} from '#/alf'
@@ -143,13 +144,17 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
         {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
         <TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
           <View style={[{position: 'absolute'}, position]}>
-            <Picker
-              data={async () => {
-                return (await import('./EmojiPickerData.json')).default
-              }}
-              onEmojiSelect={onInsert}
-              autoFocus={true}
-            />
+            <DismissableLayer
+              onFocusOutside={evt => evt.preventDefault()}
+              onDismiss={close}>
+              <Picker
+                data={async () => {
+                  return (await import('./EmojiPickerData.json')).default
+                }}
+                onEmojiSelect={onInsert}
+                autoFocus={true}
+              />
+            </DismissableLayer>
           </View>
         </TouchableWithoutFeedback>
       </View>
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 43555ccb4..b311f7887 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -1,5 +1,5 @@
 import React, {memo, useMemo} from 'react'
-import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {Image, Pressable, StyleSheet, View} from 'react-native'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import Svg, {Circle, Path, Rect} from 'react-native-svg'
 import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
@@ -30,6 +30,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import {Link} from '#/components/Link'
 import {MediaInsetBorder} from '#/components/MediaInsetBorder'
 import * as Menu from '#/components/Menu'
+import {PortalComponent} from '#/components/Portal'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 
@@ -50,6 +51,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {
 
 interface EditableUserAvatarProps extends BaseUserAvatarProps {
   onSelectNewAvatar: (img: RNImage | null) => void
+  Portal?: PortalComponent
 }
 
 interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
@@ -266,6 +268,7 @@ let EditableUserAvatar = ({
   size,
   avatar,
   onSelectNewAvatar,
+  Portal,
 }: EditableUserAvatarProps): React.ReactNode => {
   const t = useTheme()
   const pal = usePalette('default')
@@ -346,10 +349,7 @@ let EditableUserAvatar = ({
     <Menu.Root>
       <Menu.Trigger label={_(msg`Edit avatar`)}>
         {({props}) => (
-          <TouchableOpacity
-            {...props}
-            activeOpacity={0.8}
-            testID="changeAvatarBtn">
+          <Pressable {...props} testID="changeAvatarBtn">
             {avatar ? (
               <HighPriorityImage
                 testID="userAvatarImage"
@@ -363,10 +363,10 @@ let EditableUserAvatar = ({
             <View style={[styles.editButtonContainer, pal.btn]}>
               <CameraFilled height={14} width={14} style={t.atoms.text} />
             </View>
-          </TouchableOpacity>
+          </Pressable>
         )}
       </Menu.Trigger>
-      <Menu.Outer showCancel>
+      <Menu.Outer showCancel Portal={Portal}>
         <Menu.Group>
           {isNative && (
             <Menu.Item
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 622cb2129..98ff19b5d 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {Pressable, StyleSheet, View} from 'react-native'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Image} from 'expo-image'
 import {ModerationUI} from '@atproto/api'
@@ -25,6 +25,7 @@ import {
 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import * as Menu from '#/components/Menu'
+import {PortalComponent} from '#/components/Portal'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 
 export function UserBanner({
@@ -32,11 +33,13 @@ export function UserBanner({
   banner,
   moderation,
   onSelectNewBanner,
+  Portal,
 }: {
   type?: 'labeler' | 'default'
   banner?: string | null
   moderation?: ModerationUI
   onSelectNewBanner?: (img: RNImage | null) => void
+  Portal?: PortalComponent
 }) {
   const pal = usePalette('default')
   const theme = useTheme()
@@ -90,14 +93,11 @@ export function UserBanner({
 
   // setUserBanner is only passed as prop on the EditProfile component
   return onSelectNewBanner ? (
-    <EventStopper onKeyDown={false}>
+    <EventStopper onKeyDown={true}>
       <Menu.Root>
         <Menu.Trigger label={_(msg`Edit avatar`)}>
           {({props}) => (
-            <TouchableOpacity
-              {...props}
-              activeOpacity={0.8}
-              testID="changeBannerBtn">
+            <Pressable {...props} testID="changeBannerBtn">
               {banner ? (
                 <Image
                   testID="userBannerImage"
@@ -115,10 +115,10 @@ export function UserBanner({
               <View style={[styles.editButtonContainer, pal.btn]}>
                 <CameraFilled height={14} width={14} style={t.atoms.text} />
               </View>
-            </TouchableOpacity>
+            </Pressable>
           )}
         </Menu.Trigger>
-        <Menu.Outer showCancel>
+        <Menu.Outer showCancel Portal={Portal}>
           <Menu.Group>
             {isNative && (
               <Menu.Item
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index a0a2a2755..e6fcef555 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -7,14 +7,19 @@ import {useDialogStateControlContext} from '#/state/dialogs'
 import {atoms as a} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
+import * as Menu from '#/components/Menu'
+import {createPortalGroup} from '#/components/Portal'
 import * as Prompt from '#/components/Prompt'
 import {H3, P, Text} from '#/components/Typography'
 import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
 
+const Portal = createPortalGroup()
+
 export function Dialogs() {
   const scrollable = Dialog.useDialogControl()
   const basic = Dialog.useDialogControl()
   const prompt = Prompt.usePromptControl()
+  const withMenu = Dialog.useDialogControl()
   const testDialog = Dialog.useDialogControl()
   const {closeAllDialogs} = useDialogStateControlContext()
   const unmountTestDialog = Dialog.useDialogControl()
@@ -68,6 +73,7 @@ export function Dialogs() {
           scrollable.open()
           prompt.open()
           basic.open()
+          withMenu.open()
         }}
         label="Open basic dialog">
         <ButtonText>Open all dialogs</ButtonText>
@@ -96,6 +102,15 @@ export function Dialogs() {
       </Button>
 
       <Button
+        variant="outline"
+        color="primary"
+        size="small"
+        onPress={() => withMenu.open()}
+        label="Open dialog with menu in it">
+        <ButtonText>Open dialog with menu in it</ButtonText>
+      </Button>
+
+      <Button
         variant="solid"
         color="primary"
         size="small"
@@ -185,6 +200,44 @@ export function Dialogs() {
         </Dialog.Inner>
       </Dialog.Outer>
 
+      <Dialog.Outer control={withMenu}>
+        <Portal.Provider>
+          <Dialog.Inner label="test">
+            <H3 nativeID="dialog-title">Dialog with Menu</H3>
+            <Menu.Root>
+              <Menu.Trigger label="Open menu">
+                {({props}) => (
+                  <Button
+                    style={a.mt_2xl}
+                    label="Open menu"
+                    color="primary"
+                    variant="solid"
+                    size="large"
+                    {...props}>
+                    <ButtonText>Open Menu</ButtonText>
+                  </Button>
+                )}
+              </Menu.Trigger>
+              <Menu.Outer Portal={Portal.Portal}>
+                <Menu.Group>
+                  <Menu.Item
+                    label="Item 1"
+                    onPress={() => console.log('item 1')}>
+                    <Menu.ItemText>Item 1</Menu.ItemText>
+                  </Menu.Item>
+                  <Menu.Item
+                    label="Item 2"
+                    onPress={() => console.log('item 2')}>
+                    <Menu.ItemText>Item 2</Menu.ItemText>
+                  </Menu.Item>
+                </Menu.Group>
+              </Menu.Outer>
+            </Menu.Root>
+          </Dialog.Inner>
+          <Portal.Outlet />
+        </Portal.Provider>
+      </Dialog.Outer>
+
       <Dialog.Outer control={scrollable}>
         <Dialog.ScrollableInner
           accessibilityDescribedBy="dialog-description"
diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx
index 2f2b14721..3e5c74d86 100644
--- a/src/view/screens/Storybook/Menus.tsx
+++ b/src/view/screens/Storybook/Menus.tsx
@@ -2,9 +2,9 @@ import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
-import * as Menu from '#/components/Menu'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+import * as Menu from '#/components/Menu'
+import {Text} from '#/components/Typography'
 // import {useDialogStateControlContext} from '#/state/dialogs'
 
 export function Menus() {
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index ee1ed6622..d25cae010 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -1,24 +1,42 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
+import {useFocusGuards} from '@radix-ui/react-focus-guards'
+import {FocusScope} from '@radix-ui/react-focus-scope'
 
 import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
-import {useComposerState} from 'state/shell/composer'
+import {useModals} from '#/state/modals'
+import {ComposerOpts, useComposerState} from '#/state/shell/composer'
 import {
   EmojiPicker,
   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 {ComposePost} from '../com/composer/Composer'
+import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
 
 const BOTTOM_BAR_HEIGHT = 61
 
 export function Composer({}: {winHeight: number}) {
-  const t = useTheme()
-  const {gtMobile} = useBreakpoints()
   const state = useComposerState()
   const isActive = !!state
+
   useWebBodyScrollLock(isActive)
 
+  // rendering
+  // =
+
+  if (!isActive) {
+    return <View />
+  }
+
+  return <Inner state={state} />
+}
+
+function Inner({state}: {state: ComposerOpts}) {
+  const ref = useComposerCancelRef()
+  const {isModalActive} = useModals()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
   const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
     isOpen: false,
     pos: {top: 0, left: 0, right: 0, bottom: 0},
@@ -39,49 +57,58 @@ export function Composer({}: {winHeight: number}) {
     }))
   }, [])
 
-  // rendering
-  // =
-
-  if (!isActive) {
-    return <View />
-  }
+  useFocusGuards()
 
   return (
-    <View style={styles.mask} aria-modal accessibilityViewIsModal>
-      <View
-        style={[
-          styles.container,
-          !gtMobile && styles.containerMobile,
-          t.atoms.bg,
-          t.atoms.border_contrast_medium,
-        ]}>
-        <ComposePost
-          replyTo={state.replyTo}
-          quote={state.quote}
-          quoteCount={state?.quoteCount}
-          onPost={state.onPost}
-          mention={state.mention}
-          openEmojiPicker={onOpenPicker}
-          text={state.text}
-          imageUris={state.imageUris}
-        />
-      </View>
-      <EmojiPicker state={pickerState} close={onClosePicker} />
-    </View>
+    <FocusScope loop trapped asChild>
+      <DismissableLayer
+        role="dialog"
+        aria-modal
+        style={{
+          position: 'fixed',
+          top: 0,
+          left: 0,
+          width: '100%',
+          height: '100%',
+          backgroundColor: '#000c',
+          display: 'flex',
+          flexDirection: 'column',
+          alignItems: 'center',
+        }}
+        onFocusOutside={evt => evt.preventDefault()}
+        onInteractOutside={evt => evt.preventDefault()}
+        onDismiss={() => {
+          // TEMP: remove when all modals are ALF'd -sfn
+          if (!isModalActive) {
+            ref.current?.onPressCancel()
+          }
+        }}>
+        <View
+          style={[
+            styles.container,
+            !gtMobile && styles.containerMobile,
+            t.atoms.bg,
+            t.atoms.border_contrast_medium,
+          ]}>
+          <ComposePost
+            cancelRef={ref}
+            replyTo={state.replyTo}
+            quote={state.quote}
+            quoteCount={state?.quoteCount}
+            onPost={state.onPost}
+            mention={state.mention}
+            openEmojiPicker={onOpenPicker}
+            text={state.text}
+            imageUris={state.imageUris}
+          />
+        </View>
+        <EmojiPicker state={pickerState} close={onClosePicker} />
+      </DismissableLayer>
+    </FocusScope>
   )
 }
 
 const styles = StyleSheet.create({
-  mask: {
-    // @ts-ignore
-    position: 'fixed',
-    top: 0,
-    left: 0,
-    width: '100%',
-    height: '100%',
-    backgroundColor: '#000c',
-    alignItems: 'center',
-  },
   container: {
     marginTop: 50,
     maxWidth: 600,