diff --git a/shared/chat/conversation/info-panel/settings/min-writer-role.tsx b/shared/chat/conversation/info-panel/settings/min-writer-role.tsx
index 723d0b3f515a..fc1dbb8b99bf 100644
--- a/shared/chat/conversation/info-panel/settings/min-writer-role.tsx
+++ b/shared/chat/conversation/info-panel/settings/min-writer-role.tsx
@@ -140,7 +140,7 @@ const Dropdown = (p: DropdownProps) => {
direction="horizontal"
alignItems="center"
style={styles.dropdown}
- ref={isMobile ? null : popupAnchor}
+ ref={popupAnchor}
onClick={showPopup}
>
diff --git a/shared/chat/conversation/input-area/normal/input.tsx b/shared/chat/conversation/input-area/normal/input.tsx
index 285eda1e0f6f..e89e044e3a45 100644
--- a/shared/chat/conversation/input-area/normal/input.tsx
+++ b/shared/chat/conversation/input-area/normal/input.tsx
@@ -548,7 +548,7 @@ const EmojiButton = function EmojiButton(p: EmojiButtonProps) {
const makePopup = (p: Kb.Popup2Parms) => {
const {attachTo, hidePopup} = p
return (
-
+
({useConversationThreadID: () => 'conv'}
jest.mock('../input-state', () => ({useConversationInput: () => false}))
jest.mock('@/common-adapters', () => {
const actual = jest.requireActual>('@/common-adapters')
- return {...actual, Popup: (p: {children: React.ReactNode}) => <>{p.children}>}
+ return {...actual, AnchoredPopup: (p: {children: React.ReactNode}) => <>{p.children}>}
})
// the suggestors read the caret through the input ref; drive it directly so the
diff --git a/shared/chat/conversation/input-area/suggestors/index.tsx b/shared/chat/conversation/input-area/suggestors/index.tsx
index 670e6e72ceb7..130f4a32bad4 100644
--- a/shared/chat/conversation/input-area/suggestors/index.tsx
+++ b/shared/chat/conversation/input-area/suggestors/index.tsx
@@ -479,20 +479,18 @@ const Popup = (p: PopupProps) => {
const attachRef = inputRef as React.RefObject
return (
-
{isMobile ? {children} : children}
-
+
)
}
diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx
index 101df01d04f2..bda9097be479 100644
--- a/shared/chat/conversation/messages/emoji-row.tsx
+++ b/shared/chat/conversation/messages/emoji-row.tsx
@@ -193,6 +193,7 @@ function EmojiRowContainer(p: OwnProps) {
{showingPicker && message && hasMessageID && (
{
if (isMobile) {
return (
- {
))}
-
+
)
}
return (
- {
/>
-
+
)
}
diff --git a/shared/chat/send-to-chat/conversation-list/choose-conversation.tsx b/shared/chat/send-to-chat/conversation-list/choose-conversation.tsx
index 02d84aec3674..4112efeff894 100644
--- a/shared/chat/send-to-chat/conversation-list/choose-conversation.tsx
+++ b/shared/chat/send-to-chat/conversation-list/choose-conversation.tsx
@@ -17,11 +17,11 @@ const ChooseConversation = (props: Props) => {
const {attachTo, hidePopup} = p
return (
diff --git a/shared/common-adapters/dropdown.tsx b/shared/common-adapters/dropdown.tsx
index 0579ebf4840d..a5022ef785ba 100644
--- a/shared/common-adapters/dropdown.tsx
+++ b/shared/common-adapters/dropdown.tsx
@@ -115,9 +115,9 @@ function Dropdown(p: Props) {
))
return (
diff --git a/shared/common-adapters/floating-menu/index.test.tsx b/shared/common-adapters/floating-menu/index.test.tsx
new file mode 100644
index 000000000000..8f54b7fbad78
--- /dev/null
+++ b/shared/common-adapters/floating-menu/index.test.tsx
@@ -0,0 +1,26 @@
+/** @jest-environment jsdom */
+///
+
+import {cleanup, render} from '@testing-library/react'
+import FloatingMenu from '.'
+
+const items = [{onClick: () => {}, title: 'an item'}]
+
+describe('FloatingMenu visibility', () => {
+ afterEach(() => {
+ cleanup()
+ })
+
+ // 'modal' used to skip this guard and rely on Popup dropping an invisible
+ // popup on the way past. Popup no longer takes visible, so the guard is the
+ // only thing keeping a hidden menu off the screen.
+ test.each([undefined, 'bottomsheet', 'modal'] as const)(
+ 'renders nothing when hidden in %s mode',
+ mode => {
+ const {container} = render(
+ {}} visible={false} />
+ )
+ expect(container.innerHTML).toBe('')
+ }
+ )
+})
diff --git a/shared/common-adapters/floating-menu/index.tsx b/shared/common-adapters/floating-menu/index.tsx
index a0141876b704..8045b9577ba2 100644
--- a/shared/common-adapters/floating-menu/index.tsx
+++ b/shared/common-adapters/floating-menu/index.tsx
@@ -50,9 +50,9 @@ function FloatingMenu(props: Props) {
return unsub
}, [navigation, onHidden])
- // modal mode callers control mounting themselves; sheets present on mount so
- // they must unmount when not visible
- if (!visible && mode !== 'modal') {
+ // sheets present on mount, so an invisible menu must not render at all. Popup
+ // used to catch the modal case on the way past; it no longer takes visible.
+ if (!visible) {
return null
}
@@ -77,9 +77,9 @@ function FloatingMenu(props: Props) {
return (
= {
@@ -24,7 +24,7 @@ export type Props = {
visible: boolean
}
-const Kb = {Box2, Picker, Popup, SafeAreaView, Text}
+const Kb = {Box2, Picker, SafeAreaView, Sheet, Text}
function WrapPicker(p: {
initialValue?: T
@@ -68,10 +68,7 @@ const FloatingPicker = (props: Props): React.React
}
return (
-
+
{props.header}
(props: Props): React.React
/>
-
+
)
}
diff --git a/shared/common-adapters/index.tsx b/shared/common-adapters/index.tsx
index 1f4746683e54..9b9851df5cab 100644
--- a/shared/common-adapters/index.tsx
+++ b/shared/common-adapters/index.tsx
@@ -74,6 +74,10 @@ export {default as Markdown} from './markdown'
export {default as Meta} from './meta'
export {default as NameWithIcon} from './name-with-icon'
export {default as Popup, type PopupProps} from './popup/index'
+export {AnchoredPopup, type AnchoredPopupProps} from './popup/anchored'
+export {ModalCover, type ModalCoverProps} from './popup/modal-cover'
+export {Sheet, type SheetProps} from './popup/sheet'
+export {Portal} from './portal'
export {default as PhoneInput} from './phone-input'
export {default as Placeholder} from './placeholder'
export {default as ProgressBar} from './progress-bar'
diff --git a/shared/common-adapters/popup/anchored.tsx b/shared/common-adapters/popup/anchored.tsx
new file mode 100644
index 000000000000..b70426a15f3d
--- /dev/null
+++ b/shared/common-adapters/popup/anchored.tsx
@@ -0,0 +1,56 @@
+import * as Styles from '@/styles'
+import {Box2} from '../box'
+import FloatingBox from './floating-box'
+import type {AnchoredPopupProps} from './index.shared'
+export type {AnchoredPopupProps} from './index.shared'
+
+export const AnchoredPopup = (props: AnchoredPopupProps) => {
+ const {attachTo, children, containerStyle, hideKeyboard, matchDimension, offset, onHidden} = props
+ const {position, positionFallbacks, propagateOutsideClicks, remeasureHint, style} = props
+ const styles = useStyles()
+
+ if (isMobile) {
+ // on mobile FloatingBox is the portal + keyboard-dismiss overlay this needs,
+ // and there is no positioner, so the anchor and placement props go unused
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {onHidden ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+ )
+}
+
+const useStyles = Styles.createStyleHook(() => ({
+ positioned: Styles.platformStyles({
+ isElectron: {
+ ...Styles.desktopStyles.boxShadow,
+ ...Styles.globalStyles.rounded,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ },
+ }),
+}))
+
+export default AnchoredPopup
diff --git a/shared/common-adapters/popup/bottom-sheet.tsx b/shared/common-adapters/popup/bottom-sheet.tsx
deleted file mode 100644
index 135899218cad..000000000000
--- a/shared/common-adapters/popup/bottom-sheet.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as React from 'react'
-import type {StylesCrossPlatform} from '@/styles'
-import type {BottomSheetModalProps, BottomSheetBackdropProps, BottomSheetFooterProps} from '@gorhom/bottom-sheet'
-import * as _gorhomRaw from '@gorhom/bottom-sheet'
-
-type NativeMethods = {present: () => void; forceClose: () => void}
-type BackdropProps = BottomSheetBackdropProps & {disappearsOnIndex?: number; appearsOnIndex?: number; opacity?: number}
-type ScrollViewProps = {
- style?: StylesCrossPlatform
- contentContainerStyle?: StylesCrossPlatform
- automaticallyAdjustsScrollIndicatorInsets?: boolean
- contentInsetAdjustmentBehavior?: 'automatic' | 'scrollableAxes' | 'never' | 'always'
- scrollIndicatorInsets?: {top?: number; left?: number; bottom?: number; right?: number}
- children?: React.ReactNode
- enableFooterMarginAdjustment?: boolean
- alwaysBounceVertical?: boolean
- overScrollMode?: 'auto' | 'always' | 'never'
-}
-type FooterProps = BottomSheetFooterProps & {bottomInset?: number; children?: React.ReactNode}
-type GorhomModule = {
- BottomSheetModal: React.ForwardRefExoticComponent>
- BottomSheetBackdrop: React.ComponentType
- BottomSheetScrollView: React.ComponentType
- BottomSheetFooter: React.ComponentType
-}
-
-const _gorhom: GorhomModule | null = isMobile ? (_gorhomRaw as unknown as GorhomModule) : null
-
-export class BottomSheetModal extends React.Component {
- private _native: NativeMethods | null = null
-
- present() {
- this._native?.present()
- }
-
- forceClose() {
- this._native?.forceClose()
- }
-
- override render() {
- if (!isMobile) return null
- const {BottomSheetModal: NativeModal} = _gorhom!
- return (
- {
- this._native = r
- }}
- />
- )
- }
-}
-
-export const BottomSheetBackdrop = (_p: BackdropProps) => {
- if (!isMobile) return null
- const {BottomSheetBackdrop: NativeBackdrop} = _gorhom!
- return
-}
-
-export const BottomSheetScrollView = (_p: ScrollViewProps) => {
- if (!isMobile) return null
- const {BottomSheetScrollView: NativeScrollView} = _gorhom!
- return
-}
-
-export const BottomSheetFooter = (_p: FooterProps) => {
- if (!isMobile) return null
- const {BottomSheetFooter: NativeFooter} = _gorhom!
- return
-}
-
-export type {BottomSheetBackdropProps, BottomSheetFooterProps} from '@gorhom/bottom-sheet'
diff --git a/shared/common-adapters/popup/index.shared.tsx b/shared/common-adapters/popup/index.shared.tsx
index 1ee59af203d8..0cf6ac92620e 100644
--- a/shared/common-adapters/popup/index.shared.tsx
+++ b/shared/common-adapters/popup/index.shared.tsx
@@ -2,24 +2,65 @@ import type {MeasureRef} from '@/common-adapters/measure-ref'
import type * as React from 'react'
import type * as Styles from '@/styles'
-export type PopupProps = {
+// Anchored to an on-screen element. Desktop measures the anchor and positions
+// against it; mobile has no positioner and only covers the screen, so it reads
+// none of the placement props.
+export type AnchoredPopupProps = {
children: React.ReactNode
onHidden?: () => void
attachTo?: React.RefObject
- // mobile ignores attachTo and presents a bottom sheet by default; set this
- // to keep the raw anchored portal on mobile (e.g. positioned over an input)
- mobileAnchored?: boolean
position?: Styles.Position
positionFallbacks?: ReadonlyArray
propagateOutsideClicks?: boolean
matchDimension?: boolean
remeasureHint?: number
offset?: number
- style?: Styles.StylesCrossPlatform
containerStyle?: Styles.StylesCrossPlatform
- visible?: boolean
+ // desktop only: styles the shadowed box drawn around the content, which only
+ // exists when onHidden is set. mobile has no such box - use containerStyle.
+ style?: Styles.StylesCrossPlatform
+ // mobile only
hideKeyboard?: boolean
+}
+
+// Mobile bottom sheet. Presents on mount, so callers mount it only while shown.
+export type SheetProps = {
+ children: React.ReactNode
+ onHidden: () => void
+ snapPoints?: Array
+ // pinned below the scrolling content, always visible
+ footer?: React.ReactNode
+ style?: Styles.StylesCrossPlatform
+}
+
+// Desktop full-window cover with the content centered on it.
+export type ModalCoverProps = {
+ children: React.ReactNode
+ onHidden?: () => void
+ style?: Styles.StylesCrossPlatform
+}
+
+type SharedPopupProps = {
+ children: React.ReactNode
+ onHidden: () => void
+ style?: Styles.StylesCrossPlatform
+ // mobile sheet only
snapPoints?: Array
- // mobile sheet only: pinned below the scrolling content, always visible
footer?: React.ReactNode
}
+
+export type PopupProps = SharedPopupProps &
+ (
+ | ({intent: 'menu'} & Pick<
+ AnchoredPopupProps,
+ | 'attachTo'
+ | 'containerStyle'
+ | 'matchDimension'
+ | 'offset'
+ | 'position'
+ | 'positionFallbacks'
+ | 'propagateOutsideClicks'
+ | 'remeasureHint'
+ >)
+ | {intent: 'dialog'}
+ )
diff --git a/shared/common-adapters/popup/index.tsx b/shared/common-adapters/popup/index.tsx
index dce8858115ea..568c4dcf0475 100644
--- a/shared/common-adapters/popup/index.tsx
+++ b/shared/common-adapters/popup/index.tsx
@@ -1,255 +1,47 @@
-import * as React from 'react'
-import * as Styles from '@/styles'
-import {Box2} from '../box'
-import FloatingBox from './floating-box'
-import {EscapeHandler} from '../key-event-handler'
-import {Portal} from '../portal'
-import {
- BottomSheetModal,
- BottomSheetScrollView,
- BottomSheetBackdrop,
- BottomSheetFooter,
- type BottomSheetBackdropProps,
- type BottomSheetFooterProps,
-} from './bottom-sheet'
-import {useSafeAreaInsets} from '../safe-area-view'
-import {initialWindowMetrics} from 'react-native-safe-area-context'
-import {FullWindowOverlay} from 'react-native-screens'
-import {Keyboard} from 'react-native'
+import {AnchoredPopup} from './anchored'
+import {ModalCover} from './modal-cover'
+import {Sheet} from './sheet'
import type {PopupProps} from './index.shared'
export type {PopupProps} from './index.shared'
-// The sheet lives in a FullWindowOverlay, so it needs the window's insets. The
-// nearest SafeAreaProvider can't supply them: a provider nested inside a
-// react-native-screens scene (every modal route) re-measures to ~0.
-const useWindowInsets = () => {
- const local = useSafeAreaInsets()
- const window = initialWindowMetrics?.insets
- return {
- bottom: Math.max(window?.bottom ?? 0, local.bottom),
- top: Math.max(window?.top ?? 0, local.top),
+// The one place the platform rule lives: on mobile every popup presents as a
+// bottom sheet. Callers that need a mode regardless of platform - an overlay
+// pinned to an input, a desktop-only cover - render that mode directly instead.
+function Popup(props: PopupProps) {
+ if (isMobile) {
+ return (
+
+ {props.children}
+
+ )
}
-}
-
-function Backdrop(props: BottomSheetBackdropProps) {
- return
-}
-const FullWindow = ({children}: {children?: React.ReactNode}): React.ReactNode => {
- return isIOS ? {children} : children
-}
-
-function DesktopPopupPositioned(props: PopupProps) {
- const desktopStyles = useDesktopStyles()
- return (
-
- {props.onHidden ? (
-
- {props.children}
-
- ) : (
- props.children
- )}
-
- )
-}
-
-function PopupPositioned(props: PopupProps) {
- // on mobile FloatingBox is the same portal + keyboard-dismiss overlay this needs
- return isMobile ? :
-}
-
-function PopupCentered(props: PopupProps) {
- const desktopStyles = useDesktopStyles()
- const {children, onHidden, style} = props
-
- const [mouseDownOnCover, setMouseDownOnCover] = React.useState(false)
- return (
- {})}>
- {
- if (mouseDownOnCover) {
- onHidden?.()
- }
- }}
- onMouseDown={() => {
- setMouseDownOnCover(true)
- }}
+ if (props.intent === 'menu' && props.attachTo) {
+ return (
+
- {
- setMouseDownOnCover(false)
- e.stopPropagation()
- }}
- onMouseUp={(e: React.BaseSyntheticEvent) => e.stopPropagation()}
- >
-
- {children}
-
-
-
-
- )
-}
-
-function stopBubbling(ev: React.MouseEvent) {
- ev.stopPropagation()
-}
-
-function PopupSheet(props: PopupProps) {
- const nativeStyles = useNativeStyles()
- const {children, footer, onHidden, snapPoints, style} = props
- const {bottom: safeBottom, top: safeTop} = useWindowInsets()
- const bottomRef = React.useRef(null)
- // the sheet's content clears the home indicator plus a margin, so the last row
- // never sits flush with the screen edge
- const contentBottom = safeBottom + Styles.globalMargins.medium
- const indicatorInsets = React.useMemo(() => ({bottom: contentBottom}), [contentBottom])
-
- // the footer floats over the scrolled content down to the screen edge, so the
- // caller's node must bring its own background and bottom safe-area padding
- const renderFooter = React.useCallback(
- (fp: BottomSheetFooterProps) => {footer},
- [footer]
- )
-
- React.useEffect(() => {
- // the sheet covers the bottom of the screen, so a raised keyboard would hide it
- Keyboard.dismiss()
- bottomRef.current?.present()
- return () => {
- bottomRef.current?.forceClose()
- bottomRef.current = null
- }
- }, [])
-
- const setBottomSheetRef = (sheet: BottomSheetModal | null) => {
- bottomRef.current = sheet
+ {props.children}
+
+ )
}
+ // a menu with nothing to anchor to falls back to the cover: the positioner
+ // can't measure a target and would render an invisible box
return (
- dynamic sizing only: sheet hugs content and can't be dragged taller
- snapPoints={snapPoints}
- backgroundStyle={nativeStyles.modalBackground}
- containerComponent={FullWindow}
- handleStyle={nativeStyles.handleStyle}
- handleIndicatorStyle={nativeStyles.handleIndicatorStyle}
- style={nativeStyles.modalStyle}
- backdropComponent={Backdrop}
- onDismiss={onHidden}
- // dynamic sizing clamps to the container (full window via FullWindowOverlay),
- // so without this tall sheets cover the status bar
- topInset={safeTop}
- footerComponent={footer ? renderFooter : undefined}
- >
- {/* a scrollable must be the sheet's direct child: nesting one inside
- BottomSheetView measures unbounded, so tall content clips instead of scrolling */}
-
- {children}
-
-
+
+ {props.children}
+
)
}
-function Popup(props: PopupProps) {
- // sheets present on mount, so an explicitly hidden popup must not render
- if (Object.hasOwn(props, 'visible') && !props.visible) {
- return null
- }
- if (props.attachTo && (!isMobile || props.mobileAnchored)) {
- return
- }
- if (isMobile) {
- if (!props.onHidden) {
- return {props.children}
- }
- return
- }
- return
-}
-
-const useDesktopStyles = Styles.createStyleHook(theme => ({
- centeredContainer: {
- maxHeight: '100%',
- maxWidth: '100%',
- },
- clipContainer: Styles.platformStyles({
- isElectron: {
- ...Styles.desktopStyles.boxShadow,
- ...Styles.globalStyles.flexBoxColumn,
- backgroundColor: theme.white,
- borderRadius: Styles.borderRadius,
- flex: 1,
- maxWidth: '100%',
- position: 'relative',
- },
- }),
- cover: {
- ...Styles.globalStyles.fillAbsolute,
- alignSelf: 'stretch',
- ...Styles.padding(Styles.globalMargins.large, Styles.globalMargins.large, Styles.globalMargins.small),
- },
- positioned: Styles.platformStyles({
- isElectron: {
- ...Styles.desktopStyles.boxShadow,
- ...Styles.globalStyles.rounded,
- overflowX: 'hidden',
- overflowY: 'auto',
- },
- }),
-}))
-
-const useNativeStyles = Styles.createStyleHook(
- theme =>
- ({
- handleIndicatorStyle: {backgroundColor: theme.black_40},
- handleStyle: {backgroundColor: theme.black_05_on_white},
- modalBackground: {backgroundColor: theme.black_05_on_white},
- modalStyle: Styles.platformStyles({
- isAndroid: {
- elevation: 17,
- shadowColor: theme.black_50OrBlack_40,
- shadowOffset: {height: 5, width: 0},
- shadowOpacity: 1,
- shadowRadius: 10,
- },
- }),
- }) as const
-)
-
export default Popup
diff --git a/shared/common-adapters/popup/modal-cover.desktop.tsx b/shared/common-adapters/popup/modal-cover.desktop.tsx
new file mode 100644
index 000000000000..fd0bba624abc
--- /dev/null
+++ b/shared/common-adapters/popup/modal-cover.desktop.tsx
@@ -0,0 +1,78 @@
+import * as React from 'react'
+import * as Styles from '@/styles'
+import {Box2} from '../box'
+import {EscapeHandler} from '../key-event-handler.desktop'
+import type {ModalCoverProps} from './index.shared'
+export type {ModalCoverProps} from './index.shared'
+
+const noop = () => {}
+
+function stopBubbling(ev: React.MouseEvent) {
+ ev.stopPropagation()
+}
+
+export function ModalCover(props: ModalCoverProps) {
+ const styles = useStyles()
+ const {children, onHidden, style} = props
+
+ // a press that starts on the content and ends on the cover must not dismiss,
+ // so the cover only closes when its own mousedown was the one that opened
+ const [mouseDownOnCover, setMouseDownOnCover] = React.useState(false)
+ return (
+
+ {
+ if (mouseDownOnCover) {
+ onHidden?.()
+ }
+ }}
+ onMouseDown={() => {
+ setMouseDownOnCover(true)
+ }}
+ >
+ {
+ setMouseDownOnCover(false)
+ e.stopPropagation()
+ }}
+ onMouseUp={(e: React.BaseSyntheticEvent) => e.stopPropagation()}
+ >
+
+ {children}
+
+
+
+
+ )
+}
+
+const useStyles = Styles.createStyleHook(theme => ({
+ centeredContainer: {
+ maxHeight: '100%',
+ maxWidth: '100%',
+ },
+ clipContainer: Styles.platformStyles({
+ isElectron: {
+ ...Styles.desktopStyles.boxShadow,
+ ...Styles.globalStyles.flexBoxColumn,
+ backgroundColor: theme.white,
+ borderRadius: Styles.borderRadius,
+ flex: 1,
+ maxWidth: '100%',
+ position: 'relative',
+ },
+ }),
+ cover: {
+ ...Styles.globalStyles.fillAbsolute,
+ alignSelf: 'stretch',
+ ...Styles.padding(Styles.globalMargins.large, Styles.globalMargins.large, Styles.globalMargins.small),
+ },
+}))
+
+export default ModalCover
diff --git a/shared/common-adapters/popup/modal-cover.test.tsx b/shared/common-adapters/popup/modal-cover.test.tsx
new file mode 100644
index 000000000000..406293353d03
--- /dev/null
+++ b/shared/common-adapters/popup/modal-cover.test.tsx
@@ -0,0 +1,92 @@
+/** @jest-environment jsdom */
+///
+
+import {cleanup, fireEvent, render, screen} from '@testing-library/react'
+import {Box2} from '../box'
+import {GlobalKeyEventHandler} from '../key-event-handler.desktop'
+import {ModalCover} from './modal-cover.desktop'
+
+const renderCover = (onHidden?: () => void) => {
+ const {container} = render(
+
+
+ content
+
+
+ )
+ return {
+ content: screen.getByText('content'),
+ cover: container.firstElementChild as HTMLElement,
+ }
+}
+
+const pressEscape = () => {
+ fireEvent.keyDown(document.body, {key: 'Escape'})
+}
+
+describe('ModalCover', () => {
+ afterEach(() => {
+ cleanup()
+ })
+
+ test('renders its children on the cover', () => {
+ const {content, cover} = renderCover(() => {})
+ expect(cover.contains(content)).toBe(true)
+ })
+
+ test('a press that starts and ends on the cover dismisses', () => {
+ const onHidden = jest.fn()
+ const {cover} = renderCover(onHidden)
+ fireEvent.mouseDown(cover)
+ fireEvent.mouseUp(cover)
+ expect(onHidden).toHaveBeenCalledTimes(1)
+ })
+
+ test('a release on the cover with no press on it does not dismiss', () => {
+ const onHidden = jest.fn()
+ const {cover} = renderCover(onHidden)
+ fireEvent.mouseUp(cover)
+ expect(onHidden).not.toHaveBeenCalled()
+ })
+
+ // dragging a selection out of the content and releasing on the cover must not
+ // close the popup
+ test('a press that starts on the content and ends on the cover does not dismiss', () => {
+ const onHidden = jest.fn()
+ const {content, cover} = renderCover(onHidden)
+ fireEvent.mouseDown(content)
+ fireEvent.mouseUp(cover)
+ expect(onHidden).not.toHaveBeenCalled()
+ })
+
+ test('a release on the content does not dismiss even after pressing the cover', () => {
+ const onHidden = jest.fn()
+ const {content, cover} = renderCover(onHidden)
+ fireEvent.mouseDown(cover)
+ fireEvent.mouseUp(content)
+ expect(onHidden).not.toHaveBeenCalled()
+ })
+
+ test('escape dismisses', () => {
+ const onHidden = jest.fn()
+ renderCover(onHidden)
+ pressEscape()
+ expect(onHidden).toHaveBeenCalledTimes(1)
+ })
+
+ test('other keys do not dismiss', () => {
+ const onHidden = jest.fn()
+ renderCover(onHidden)
+ fireEvent.keyDown(document.body, {key: 'Enter'})
+ expect(onHidden).not.toHaveBeenCalled()
+ })
+
+ test('without onHidden neither escape nor a cover press throws', () => {
+ const {cover} = renderCover()
+ expect(() => {
+ fireEvent.mouseDown(cover)
+ fireEvent.mouseUp(cover)
+ pressEscape()
+ }).not.toThrow()
+ })
+})
diff --git a/shared/common-adapters/popup/modal-cover.tsx b/shared/common-adapters/popup/modal-cover.tsx
new file mode 100644
index 000000000000..8d40192a28f9
--- /dev/null
+++ b/shared/common-adapters/popup/modal-cover.tsx
@@ -0,0 +1,7 @@
+import type {ModalCoverProps} from './index.shared'
+export type {ModalCoverProps} from './index.shared'
+
+// The cover is a desktop presentation; mobile callers use Sheet.
+export const ModalCover = (_p: ModalCoverProps) => null
+
+export default ModalCover
diff --git a/shared/common-adapters/popup/sheet.native.tsx b/shared/common-adapters/popup/sheet.native.tsx
new file mode 100644
index 000000000000..c8c43ae46199
--- /dev/null
+++ b/shared/common-adapters/popup/sheet.native.tsx
@@ -0,0 +1,127 @@
+import * as React from 'react'
+import * as Styles from '@/styles'
+import {
+ BottomSheetModal,
+ BottomSheetScrollView,
+ BottomSheetBackdrop,
+ BottomSheetFooter,
+ type BottomSheetBackdropProps,
+ type BottomSheetFooterProps,
+} from '@gorhom/bottom-sheet'
+import {useSafeAreaInsets} from '../safe-area-view'
+import {initialWindowMetrics} from 'react-native-safe-area-context'
+import {FullWindowOverlay} from 'react-native-screens'
+import {Keyboard} from 'react-native'
+import type {SheetProps} from './index.shared'
+export type {SheetProps} from './index.shared'
+
+// The sheet lives in a FullWindowOverlay, so it needs the window's insets. The
+// nearest SafeAreaProvider can't supply them: a provider nested inside a
+// react-native-screens scene (every modal route) re-measures to ~0.
+const useWindowInsets = () => {
+ const local = useSafeAreaInsets()
+ const window = initialWindowMetrics?.insets
+ return {
+ bottom: Math.max(window?.bottom ?? 0, local.bottom),
+ top: Math.max(window?.top ?? 0, local.top),
+ }
+}
+
+function Backdrop(props: BottomSheetBackdropProps) {
+ return
+}
+
+const FullWindow = ({children}: {children?: React.ReactNode}): React.ReactNode => {
+ return isIOS ? {children} : children
+}
+
+export function Sheet(props: SheetProps) {
+ const styles = useStyles()
+ const {children, footer, onHidden, snapPoints, style} = props
+ const {bottom: safeBottom, top: safeTop} = useWindowInsets()
+ const bottomRef = React.useRef | null>(null)
+ // the sheet's content clears the home indicator plus a margin, so the last row
+ // never sits flush with the screen edge
+ const contentBottom = safeBottom + Styles.globalMargins.medium
+ const indicatorInsets = React.useMemo(() => ({bottom: contentBottom}), [contentBottom])
+
+ // the footer floats over the scrolled content down to the screen edge, so the
+ // caller's node must bring its own background and bottom safe-area padding
+ const renderFooter = React.useCallback(
+ (fp: BottomSheetFooterProps) => {footer},
+ [footer]
+ )
+
+ React.useEffect(() => {
+ // the sheet covers the bottom of the screen, so a raised keyboard would hide it
+ Keyboard.dismiss()
+ bottomRef.current?.present()
+ return () => {
+ bottomRef.current?.forceClose()
+ bottomRef.current = null
+ }
+ }, [])
+
+ const setBottomSheetRef = (sheet: React.ComponentRef | null) => {
+ bottomRef.current = sheet
+ }
+
+ return (
+ dynamic sizing only: sheet hugs content and can't be dragged taller
+ snapPoints={snapPoints}
+ backgroundStyle={styles.modalBackground}
+ containerComponent={FullWindow}
+ handleStyle={styles.handleStyle}
+ handleIndicatorStyle={styles.handleIndicatorStyle}
+ style={styles.modalStyle}
+ backdropComponent={Backdrop}
+ onDismiss={onHidden}
+ // dynamic sizing clamps to the container (full window via FullWindowOverlay),
+ // so without this tall sheets cover the status bar
+ topInset={safeTop}
+ footerComponent={footer ? renderFooter : undefined}
+ >
+ {/* a scrollable must be the sheet's direct child: nesting one inside
+ BottomSheetView measures unbounded, so tall content clips instead of scrolling */}
+
+ {children}
+
+
+ )
+}
+
+const useStyles = Styles.createStyleHook(
+ theme =>
+ ({
+ handleIndicatorStyle: {backgroundColor: theme.black_40},
+ handleStyle: {backgroundColor: theme.black_05_on_white},
+ modalBackground: {backgroundColor: theme.black_05_on_white},
+ modalStyle: Styles.platformStyles({
+ isAndroid: {
+ elevation: 17,
+ shadowColor: theme.black_50OrBlack_40,
+ shadowOffset: {height: 5, width: 0},
+ shadowOpacity: 1,
+ shadowRadius: 10,
+ },
+ }),
+ }) as const
+)
+
+export default Sheet
diff --git a/shared/common-adapters/popup/sheet.tsx b/shared/common-adapters/popup/sheet.tsx
new file mode 100644
index 000000000000..6a4f5ea4b954
--- /dev/null
+++ b/shared/common-adapters/popup/sheet.tsx
@@ -0,0 +1,7 @@
+import type {SheetProps} from './index.shared'
+export type {SheetProps} from './index.shared'
+
+// Sheets are a mobile presentation; desktop callers use ModalCover or AnchoredPopup.
+export const Sheet = (_p: SheetProps) => null
+
+export default Sheet
diff --git a/shared/common-adapters/popup/use-popup.test.tsx b/shared/common-adapters/popup/use-popup.test.tsx
index ad2b1ead69cf..94a0db2fbbe5 100644
--- a/shared/common-adapters/popup/use-popup.test.tsx
+++ b/shared/common-adapters/popup/use-popup.test.tsx
@@ -91,7 +91,9 @@ describe('usePopup2', () => {
expect(result.current.popupAnchor).toBe(popupAnchor)
})
- test('desktop passes the anchor ref to makePopup, mobile does not', () => {
+ // the platform rule lives in the popup modules, not here: the anchor is handed
+ // over on both platforms and the mobile presentations simply never measure it
+ test('passes the anchor ref to makePopup on every platform', () => {
const parms: Array = []
const spyMake = (p: Popup2Parms) => {
parms.push(p)
@@ -110,7 +112,7 @@ describe('usePopup2', () => {
act(() => {
mobile.result.current.showPopup()
})
- expect(parms[0]?.attachTo).toBeUndefined()
+ expect(parms[0]?.attachTo).toBe(mobile.result.current.popupAnchor)
})
test('a new makePopup identity re-renders the popup contents while showing', () => {
diff --git a/shared/common-adapters/popup/use-popup.tsx b/shared/common-adapters/popup/use-popup.tsx
index 5d8f64b4fde1..14c6627b6e2e 100644
--- a/shared/common-adapters/popup/use-popup.tsx
+++ b/shared/common-adapters/popup/use-popup.tsx
@@ -14,7 +14,6 @@ export const usePopup2 = (makePopup: (p: Popup2Parms) => React.ReactElement | nu
const wasMakePopupRef = React.useRef<(p: Popup2Parms) => React.ReactElement | null>(makePopup)
const [popup, setPopup] = React.useState(null)
const popupAnchor = React.useRef(null)
- const attachTo = isMobile ? undefined : popupAnchor
const lastToggle = React.useRef(0)
const [hidePopup] = React.useState(() => () => {
@@ -38,9 +37,9 @@ export const usePopup2 = (makePopup: (p: Popup2Parms) => React.ReactElement | nu
React.useEffect(() => {
if (makePopup !== wasMakePopupRef.current || showingPopup !== !!popup) {
wasMakePopupRef.current = makePopup
- setPopup(showingPopup ? makePopup({attachTo, hidePopup, showPopup}) : null)
+ setPopup(showingPopup ? makePopup({attachTo: popupAnchor, hidePopup, showPopup}) : null)
}
- }, [attachTo, hidePopup, makePopup, popup, setPopup, showPopup, showingPopup])
+ }, [hidePopup, makePopup, popup, setPopup, showPopup, showingPopup])
return {hidePopup, popup, popupAnchor, showPopup, showingPopup, togglePopup}
}
diff --git a/shared/common-adapters/toast.tsx b/shared/common-adapters/toast.tsx
index 1f3aa6f565d3..4b59d08e0a9c 100644
--- a/shared/common-adapters/toast.tsx
+++ b/shared/common-adapters/toast.tsx
@@ -2,7 +2,8 @@ import * as C from '@/constants'
import * as React from 'react'
import * as Styles from '@/styles'
import {Box2} from './box'
-import Popup from './popup'
+import {AnchoredPopup} from './popup/anchored'
+import {Portal} from './portal'
import {Animated as NativeAnimated, Easing as NativeEasing, useColorScheme} from 'react-native'
import {colors, darkColors} from '@/styles/colors'
import './toast.css'
@@ -19,8 +20,9 @@ type Props = {
}
const Kb = {
+ AnchoredPopup,
Box2,
- Popup,
+ Portal,
}
const positionFallbacks = [] as const
@@ -120,7 +122,7 @@ const Toast = (props: Props) => {
if (!isMobile) {
return (
- {
>
{props.children}
-
+
)
}
return shouldRender ? (
-
+
{
{props.children}
-
+
) : null
}
diff --git a/shared/fs/common/sfmi-popup.tsx b/shared/fs/common/sfmi-popup.tsx
index 2663d6b37e9d..c6e565054641 100644
--- a/shared/fs/common/sfmi-popup.tsx
+++ b/shared/fs/common/sfmi-popup.tsx
@@ -26,7 +26,13 @@ const SFMIPopup = (props: Props) => {
const {attachTo, hidePopup} = p
return (
-
+
{
if (!isMobile) {
return (
- {}} style={styles.desktopCover}>
+ // no onHidden: the reset countdown can't be dismissed by escape or a click away
+
{content}
-
+
)
}
diff --git a/shared/provision/code-page/container.tsx b/shared/provision/code-page/container.tsx
index 20ccf2483acc..f0ee096ca8d5 100644
--- a/shared/provision/code-page/container.tsx
+++ b/shared/provision/code-page/container.tsx
@@ -175,7 +175,7 @@ const CodePageContainer = (op: OwnProps) => {
{!inModal && otherDevice.type === 'desktop' && heyWaitBanner()}
{!inModal && troubleshooting && (
- setTroubleshooting(false)} propagateOutsideClicks={true}>
+ setTroubleshooting(false)}>
{troubleshootingContent()}
)}
diff --git a/shared/teams/common/enable-contacts.tsx b/shared/teams/common/enable-contacts.tsx
index 1dd524f07e8a..3eec789069e2 100644
--- a/shared/teams/common/enable-contacts.tsx
+++ b/shared/teams/common/enable-contacts.tsx
@@ -28,7 +28,7 @@ const EnableContactsPopup = ({noAccess, onClose}: {noAccess: boolean; onClose: (
}
return showingPopup ? (
-
+
diff --git a/shared/teams/common/selection-popup.tsx b/shared/teams/common/selection-popup.tsx
index 4a98af7c65fb..c513f41ffa85 100644
--- a/shared/teams/common/selection-popup.tsx
+++ b/shared/teams/common/selection-popup.tsx
@@ -114,7 +114,7 @@ const JointSelectionPopup = (props: JointSelectionPopupProps) => {
return isMobile ? (
<>
{ 48 ? height - 48 - bottom : -bottom}} />}
- {popup}
+ {popup}
>
) : (
popup
diff --git a/shared/teams/common/use-autocompleter.tsx b/shared/teams/common/use-autocompleter.tsx
index d4eb68d1cab7..b7898b816f49 100644
--- a/shared/teams/common/use-autocompleter.tsx
+++ b/shared/teams/common/use-autocompleter.tsx
@@ -29,6 +29,7 @@ function useAutocompleter(
const {attachTo, hidePopup} = p
return (
{
)
return (
-
+
{content}
)
diff --git a/shared/teams/emojis/add-alias.tsx b/shared/teams/emojis/add-alias.tsx
index 712e05ea1048..c73f18768bd9 100644
--- a/shared/teams/emojis/add-alias.tsx
+++ b/shared/teams/emojis/add-alias.tsx
@@ -207,6 +207,7 @@ const ChooseEmojiDesktop = (props: ChooseEmojiProps) => {
const {attachTo, hidePopup} = p
return (
{open && (
- {})}
hideKeyboard={true}
@@ -428,7 +427,7 @@ export function FloatingRolePicker
-
+
)}
>
)
diff --git a/shared/teams/team/settings-tab/channel-popup.tsx b/shared/teams/team/settings-tab/channel-popup.tsx
index 61530f1c72ed..64dafe08f0a0 100644
--- a/shared/teams/team/settings-tab/channel-popup.tsx
+++ b/shared/teams/team/settings-tab/channel-popup.tsx
@@ -45,7 +45,7 @@ const ChannelPopup = (props: Props) => {
const onAdd = () => onComplete(selected)
return (
-
+
diff --git a/shared/tsconfig.native.json b/shared/tsconfig.native.json
index 399783d67966..5e0ec8e33892 100644
--- a/shared/tsconfig.native.json
+++ b/shared/tsconfig.native.json
@@ -22,10 +22,12 @@
"./chat/conversation/messages/text/coinflip/results.test.tsx",
"./common-adapters/markdown/index.test.tsx",
"./common-adapters/banner.test.tsx",
+ "./common-adapters/floating-menu/index.test.tsx",
"./common-adapters/hot-key.test.tsx",
"./common-adapters/icon.test.tsx",
"./common-adapters/name-with-icon.test.tsx",
"./common-adapters/popup/floating-box/relative-floating-box.test.tsx",
+ "./common-adapters/popup/modal-cover.test.tsx",
"./common-adapters/save-indicator.test.tsx",
"./common-adapters/text.test.tsx",
"./devices/index.test.tsx",