Skip to content

Commit

Permalink
Move Dialogs to Radix (#5648)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
mozzius and estrattonbailey authored Oct 8, 2024
1 parent efcf8a6 commit fc82d2f
Show file tree
Hide file tree
Showing 14 changed files with 389 additions and 449 deletions.
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -82,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",
Expand Down Expand Up @@ -278,7 +280,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",
Expand Down
36 changes: 16 additions & 20 deletions src/components/Dialog/index.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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>
)
Expand Down
9 changes: 6 additions & 3 deletions src/components/Menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ItemTextProps,
TriggerProps,
} from '#/components/Menu/types'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'

export {
Expand Down Expand Up @@ -77,25 +78,27 @@ 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()

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>
Expand Down
9 changes: 8 additions & 1 deletion src/components/Prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/components/dialogs/GifSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/hooks/useWebBodyScrollLock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useEffect} from 'react'

import {isWeb} from '#/platform/detection'

let refCount = 0
Expand Down
18 changes: 0 additions & 18 deletions src/view/com/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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})
}, [])
Expand Down
19 changes: 12 additions & 7 deletions src/view/com/composer/text-input/web/EmojiPicker.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down
14 changes: 7 additions & 7 deletions src/view/com/util/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'

Expand All @@ -50,6 +51,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {

interface EditableUserAvatarProps extends BaseUserAvatarProps {
onSelectNewAvatar: (img: RNImage | null) => void
Portal?: PortalComponent
}

interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
Expand Down Expand Up @@ -266,6 +268,7 @@ let EditableUserAvatar = ({
size,
avatar,
onSelectNewAvatar,
Portal,
}: EditableUserAvatarProps): React.ReactNode => {
const t = useTheme()
const pal = usePalette('default')
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/view/com/util/UserBanner.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,18 +25,21 @@ 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({
type,
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()
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading

0 comments on commit fc82d2f

Please sign in to comment.