From fc82d2f6d5e8a93f0e7ce4861c5205c8a4b49c30 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 8 Oct 2024 20:00:49 +0300 Subject: 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 * 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 --- src/components/Dialog/index.web.tsx | 36 +++---- src/components/Menu/index.tsx | 9 +- src/components/Prompt.tsx | 9 +- src/components/dialogs/GifSelect.tsx | 6 +- src/lib/hooks/useWebBodyScrollLock.ts | 1 + src/view/com/composer/Composer.tsx | 18 ---- .../composer/text-input/web/EmojiPicker.web.tsx | 19 ++-- src/view/com/util/UserAvatar.tsx | 14 +-- src/view/com/util/UserBanner.tsx | 16 +-- src/view/screens/Storybook/Dialogs.tsx | 53 ++++++++++ src/view/screens/Storybook/Menus.tsx | 4 +- src/view/shell/Composer.web.tsx | 111 +++++++++++++-------- 12 files changed, 185 insertions(+), 111 deletions(-) (limited to 'src') 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 ( - + - {children} + ])}> + + {children} + ) 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 + 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 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 }>) { const {gtMobile} = useBreakpoints() const titleId = React.useId() @@ -42,7 +45,11 @@ export function Outer({ ) return ( - + { - 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 */} 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/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 = ({ {({props}) => ( - + {avatar ? ( - + )} - + {isNative && ( 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 ? ( - + {({props}) => ( - + {banner ? ( - + )} - + {isNative && ( 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 + + + + + + + + + + } + + 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, -- cgit 1.4.1