diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Dialog/index.web.tsx | 36 | ||||
-rw-r--r-- | src/components/Menu/index.tsx | 9 | ||||
-rw-r--r-- | src/components/Prompt.tsx | 9 | ||||
-rw-r--r-- | src/components/dialogs/GifSelect.tsx | 6 | ||||
-rw-r--r-- | src/lib/hooks/useWebBodyScrollLock.ts | 1 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 18 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/EmojiPicker.web.tsx | 19 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 14 | ||||
-rw-r--r-- | src/view/com/util/UserBanner.tsx | 16 | ||||
-rw-r--r-- | src/view/screens/Storybook/Dialogs.tsx | 53 | ||||
-rw-r--r-- | src/view/screens/Storybook/Menus.tsx | 4 | ||||
-rw-r--r-- | src/view/shell/Composer.web.tsx | 111 |
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, |