From f3d564449d371fa37cb946fecb59c5d58023e808 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 8 Oct 2024 05:22:56 -0500 Subject: [PATCH 1/6] 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 --- package.json | 10 +- src/components/Dialog/index.web.tsx | 32 +- src/view/com/util/UserAvatar.tsx | 9 +- src/view/com/util/UserBanner.tsx | 11 +- src/view/screens/Storybook/Dialogs.tsx | 54 +++ src/view/screens/Storybook/Menus.tsx | 4 +- yarn.lock | 506 ++++++++++--------------- 7 files changed, 279 insertions(+), 347 deletions(-) diff --git a/package.json b/package.json index 9b56efcaf5..906b24c812 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,10 @@ "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.7.1", "@miblanchard/react-native-slider": "^2.3.1", - "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-dismissable-layer": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-focus-guards": "^1.1.1", + "@radix-ui/react-focus-scope": "^1.1.0", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-masked-view/masked-view": "0.3.0", "@react-native-menu/menu": "^1.1.0", @@ -278,7 +281,10 @@ "**/zod": "3.23.8", "**/expo-constants": "16.0.1", "**/expo-device": "6.0.2", - "@react-native/babel-preset": "0.74.1" + "@react-native/babel-preset": "0.74.1", + "@radix-ui/react-dropdown-menu": "2.1.2", + "@radix-ui/react-context-menu": "2.2.2", + "@radix-ui/react-focus-scope": "1.1.0" }, "jest": { "preset": "jest-expo/ios", diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 7b9cfb6931..576bc8f415 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 ( - + - {children} + ])}> + + {children} + ) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 43555ccb47..dbd68f8ef5 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' @@ -346,10 +346,7 @@ let EditableUserAvatar = ({ {({props}) => ( - + {avatar ? ( - + )} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 622cb2129d..2815de3bd8 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' @@ -90,14 +90,11 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( - + {({props}) => ( - + {banner ? ( - + )} diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index a0a2a27551..08c679428d 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"> Open all dialogs @@ -95,6 +101,15 @@ export function Dialogs() { Open basic dialog + + + )} + + + + console.log('item 1')}> + Item 1 + + console.log('item 2')}> + Item 2 + + + + + + + + + + Date: Tue, 8 Oct 2024 14:16:56 +0300 Subject: [PATCH 2/6] use DismissableLayer/FocusScope for composer --- src/lib/hooks/useWebBodyScrollLock.ts | 1 + src/view/com/composer/Composer.tsx | 18 --- .../text-input/web/EmojiPicker.web.tsx | 19 +-- src/view/shell/Composer.web.tsx | 111 +++++++++++------- 4 files changed, 82 insertions(+), 67 deletions(-) diff --git a/src/lib/hooks/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts index 0dcf911fe4..c63c23b29c 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 55c2d81ab3..8cc8fba0d9 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 ad3bb30eca..1d5dad4861 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 */} e.stopPropagation()}> - { - return (await import('./EmojiPickerData.json')).default - }} - onEmojiSelect={onInsert} - autoFocus={true} - /> + evt.preventDefault()} + onDismiss={close}> + { + return (await import('./EmojiPickerData.json')).default + }} + onEmojiSelect={onInsert} + autoFocus={true} + /> + diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index ee1ed66226..d25cae0109 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 + } + + return +} + +function Inner({state}: {state: ComposerOpts}) { + const ref = useComposerCancelRef() + const {isModalActive} = useModals() + const t = useTheme() + const {gtMobile} = useBreakpoints() const [pickerState, setPickerState] = React.useState({ isOpen: false, pos: {top: 0, left: 0, right: 0, bottom: 0}, @@ -39,49 +57,58 @@ export function Composer({}: {winHeight: number}) { })) }, []) - // rendering - // = - - if (!isActive) { - return - } + useFocusGuards() return ( - - - - - - + + evt.preventDefault()} + onInteractOutside={evt => evt.preventDefault()} + onDismiss={() => { + // TEMP: remove when all modals are ALF'd -sfn + if (!isModalActive) { + ref.current?.onPressCancel() + } + }}> + + + + + + ) } 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, From b9db86d05372aee8bea015a2c90bfd791442347f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 8 Oct 2024 14:24:15 +0300 Subject: [PATCH 3/6] fix storybook dialog --- src/view/screens/Storybook/Dialogs.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index 08c679428d..e6fcef555f 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -201,8 +201,8 @@ export function Dialogs() { - - + +

Dialog with Menu

@@ -233,10 +233,9 @@ export function Dialogs() {
- - - - + + + From 194d8da6a51f5e7e3ee9487c2abf1fbd25809d65 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 7 Oct 2024 14:15:04 +0300 Subject: [PATCH 4/6] thread Portal through Prompt and avatar/banner --- src/components/Menu/index.tsx | 9 ++++++--- src/components/Prompt.tsx | 9 ++++++++- src/components/dialogs/GifSelect.tsx | 6 +++--- src/view/com/util/UserAvatar.tsx | 5 ++++- src/view/com/util/UserBanner.tsx | 5 ++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index a22f43cf8d..12cf1866e5 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 + Portal?: PortalComponent }>) { const context = React.useContext(Context) const {_} = useLingui() @@ -87,15 +90,15 @@ export function Outer({ return ( + nativeOptions={{preventExpansion: true}} + Portal={Portal}> {/* Re-wrap with context since Dialogs are portal-ed to root */} - + {children} {isNative && showCancel && } - diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index fc6919af89..c47f0d64af 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 }>) { const {gtMobile} = useBreakpoints() const titleId = React.useId() @@ -42,7 +45,11 @@ export function Outer({ ) return ( - + 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') @@ -363,7 +366,7 @@ let EditableUserAvatar = ({ )} - + {isNative && ( void + Portal?: PortalComponent }) { const pal = usePalette('default') const theme = useTheme() @@ -115,7 +118,7 @@ export function UserBanner({ )} - + {isNative && ( Date: Tue, 8 Oct 2024 14:45:13 +0300 Subject: [PATCH 5/6] fix dialog style regression --- src/components/Dialog/index.web.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 576bc8f415..1a20311d33 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -189,7 +189,11 @@ export function Inner({ }, flatten(style), ])}> - + {children} From 5885a8cb0af881bb9e929aa41906b4a469822af4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 8 Oct 2024 20:00:30 +0300 Subject: [PATCH 6/6] remove tamagui --- package.json | 1 - yarn.lock | 25 ------------------------- 2 files changed, 26 deletions(-) diff --git a/package.json b/package.json index 906b24c812..d18c9642a7 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.9.26", "@sentry/react-native": "5.24.3", - "@tamagui/focus-scope": "^1.84.1", "@tanstack/query-async-storage-persister": "^5.25.0", "@tanstack/react-query": "^5.8.1", "@tanstack/react-query-persist-client": "^5.25.0", diff --git a/yarn.lock b/yarn.lock index 0e1ba38eb2..35b5c7a4e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6572,31 +6572,6 @@ "@svgr/plugin-svgo" "^5.5.0" loader-utils "^2.0.0" -"@tamagui/compose-refs@1.84.1": - version "1.84.1" - resolved "https://registry.yarnpkg.com/@tamagui/compose-refs/-/compose-refs-1.84.1.tgz#244735edc3ac2e617389297f005d5bc25872465f" - integrity sha512-oZ0rUmQABlGm/QKQITxAW9WLV3qjyq1ehgoWcZVmtc1Kc/hkFQe2J+wRQV726CmTAnuUgUXi3eoNMwBVoZksfQ== - -"@tamagui/constants@1.84.1": - version "1.84.1" - resolved "https://registry.yarnpkg.com/@tamagui/constants/-/constants-1.84.1.tgz#62e41837dbe844d14e255f3eea9c2583044d2509" - integrity sha512-QmvyCqtEIugqXutQI35GJQ1hlpSapYCdOHx9QlgsOWjAY34pu55MaY/tDrQeQ0AUmI/qx30vy7TsCJxB4QFEoQ== - -"@tamagui/focus-scope@^1.84.1": - version "1.84.1" - resolved "https://registry.yarnpkg.com/@tamagui/focus-scope/-/focus-scope-1.84.1.tgz#e9f061184048c75f87da023f54b9c5abccdd460d" - integrity sha512-0E1Wc3jmKhafETfH1dUuJYmGK1bDNA/9TySbOeTjTToxUoL3V0G2W5JSwSMCDqR1Bl+xrGlGwzXTUhouw8qSog== - dependencies: - "@tamagui/compose-refs" "1.84.1" - "@tamagui/use-event" "1.84.1" - -"@tamagui/use-event@1.84.1": - version "1.84.1" - resolved "https://registry.yarnpkg.com/@tamagui/use-event/-/use-event-1.84.1.tgz#a095a1bde9c40c4a397226c57c3fa32f6018f504" - integrity sha512-U88WCxvMz7ZSfMFMJEFbG3tJjK/Lf+PHlmtYvlx1V+YiqRBoj5+milzoM8PclENn5vZMiJW0ozYRgzI/cdE7Eg== - dependencies: - "@tamagui/constants" "1.84.1" - "@tanstack/query-async-storage-persister@^5.25.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.25.0.tgz#0e8a2a781b8e32a81a5d02a688d6fcdfd055235b"