add detached variant to modal

This commit is contained in:
vineyardbovines
2026-03-05 11:57:19 -05:00
parent 1ae77aa997
commit aa1fbf4628
4 changed files with 144 additions and 7 deletions
+1
View File
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false, disableDrag: false,
setDisableDrag: () => {}, setDisableDrag: () => {},
isWithinDialog: false, isWithinDialog: false,
type: 'sheet',
}) })
Context.displayName = 'DialogContext' Context.displayName = 'DialogContext'
+124 -3
View File
@@ -1,6 +1,7 @@
import React, {useImperativeHandle} from 'react' import React, {useImperativeHandle} from 'react'
import { import {
type LayoutChangeEvent, type LayoutChangeEvent,
Modal,
type NativeScrollEvent, type NativeScrollEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
Pressable, Pressable,
@@ -49,7 +50,112 @@ export * from '#/components/Dialog/utils'
export const Input = createInput(TextInput) export const Input = createInput(TextInput)
export function Outer({ export function Outer(props: React.PropsWithChildren<DialogOuterProps>) {
if (props.type === 'alert') {
return <OuterAlert {...props} />
}
return <OuterSheet {...props} />
}
function OuterAlert({
children,
control,
onClose,
testID,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const [isOpen, setIsOpen] = React.useState(false)
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen} = useDialogStateControlContext()
const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) {
try {
cb()
} catch (e: any) {
logger.error(e || 'Error running close callback')
}
}
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(() => {
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
setIsOpen(true)
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
setIsOpen(false)
}, [])
const onDismiss = React.useCallback(() => {
setDialogIsOpen(control.id, false)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
const context = React.useMemo(
() => ({
close,
isNativeDialog: true,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
type: 'alert' as const,
}),
[close],
)
return (
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={() => close()}
onDismiss={onDismiss}>
<Pressable
accessibilityRole="button"
onPress={() => close()}
style={[
a.flex_1,
a.justify_center,
a.align_center,
a.px_xl,
{backgroundColor: 'rgba(0,0,0,0.5)'},
]}>
<Pressable
accessibilityRole="button"
onPress={e => e.stopPropagation()}
style={[
t.atoms.bg,
a.w_full,
{borderRadius: 48, maxWidth: 320},
a.overflow_hidden,
]}>
<Context.Provider value={context}>
<View testID={testID}>{children}</View>
</Context.Provider>
</Pressable>
</Pressable>
</Modal>
)
}
function OuterSheet({
children, children,
control, control,
onClose, onClose,
@@ -155,6 +261,7 @@ export function Outer({
disableDrag, disableDrag,
setDisableDrag, setDisableDrag,
isWithinDialog: true, isWithinDialog: true,
type: 'sheet' as const,
}), }),
[close, snapPoint, disableDrag, setDisableDrag], [close, snapPoint, disableDrag, setDisableDrag],
) )
@@ -206,8 +313,18 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
{children, contentContainerStyle, header, ...props}, {children, contentContainerStyle, header, ...props},
ref, ref,
) { ) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() const {nativeSnapPoint, disableDrag, setDisableDrag, type} =
useDialogContext()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
if (type === 'alert') {
return (
<View style={[a.p_2xl, contentContainerStyle]} {...props}>
{header}
{children}
</View>
)
}
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
let paddingBottom = 0 let paddingBottom = 0
@@ -375,7 +492,11 @@ export function Handle({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {screenReaderEnabled} = useA11y() const {screenReaderEnabled} = useA11y()
const {close} = useDialogContext() const {close, type} = useDialogContext()
if (type === 'alert') {
return null
}
return ( return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}> <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
+12 -4
View File
@@ -45,6 +45,7 @@ export function Outer({
control, control,
onClose, onClose,
webOptions, webOptions,
type = 'sheet',
}: React.PropsWithChildren<DialogOuterProps>) { }: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -96,6 +97,8 @@ export function Outer({
[close, open], [close, open],
) )
const isAlert = type === 'alert'
const context = React.useMemo( const context = React.useMemo(
() => ({ () => ({
close, close,
@@ -104,8 +107,9 @@ export function Outer({
disableDrag: false, disableDrag: false,
setDisableDrag: () => {}, setDisableDrag: () => {},
isWithinDialog: true, isWithinDialog: true,
type,
}), }),
[close], [close, type],
) )
return ( return (
@@ -124,7 +128,9 @@ export function Outer({
a.inset_0, a.inset_0,
a.z_10, a.z_10,
a.px_xl, a.px_xl,
webOptions?.alignCenter ? a.justify_center : undefined, webOptions?.alignCenter || isAlert
? a.justify_center
: undefined,
a.align_center, a.align_center,
{ {
overflowY: 'auto', overflowY: 'auto',
@@ -165,9 +171,10 @@ export function Inner({
contentContainerStyle, contentContainerStyle,
}: DialogInnerProps) { }: DialogInnerProps) {
const t = useTheme() const t = useTheme()
const {close} = React.useContext(Context) const {close, type} = React.useContext(Context)
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {reduceMotionEnabled} = useA11y() const {reduceMotionEnabled} = useA11y()
const isAlert = type === 'alert'
FocusGuards.useFocusGuards() FocusGuards.useFocusGuards()
return ( return (
<FocusScope.FocusScope loop asChild trapped> <FocusScope.FocusScope loop asChild trapped>
@@ -189,12 +196,13 @@ export function Inner({
a.border, a.border,
t.atoms.bg, t.atoms.bg,
{ {
maxWidth: 600, maxWidth: isAlert ? 320 : 600,
borderColor: t.palette.contrast_200, borderColor: t.palette.contrast_200,
shadowColor: t.palette.black, shadowColor: t.palette.black,
shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30, shadowRadius: 30,
}, },
isAlert && {borderRadius: 36},
!reduceMotionEnabled && a.zoom_fade_in, !reduceMotionEnabled && a.zoom_fade_in,
style, style,
])}> ])}>
+7
View File
@@ -45,6 +45,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>> setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog // in the event that the hook is used outside of a dialog
isWithinDialog: boolean isWithinDialog: boolean
type: 'sheet' | 'alert'
} }
export type DialogControlOpenOptions = { export type DialogControlOpenOptions = {
@@ -65,6 +66,12 @@ export type DialogOuterProps = {
alignCenter?: boolean alignCenter?: boolean
onBackgroundPress?: (e: GestureResponderEvent) => void onBackgroundPress?: (e: GestureResponderEvent) => void
} }
/**
* The presentation style of the dialog.
* - `sheet` (default): Bottom sheet on native, standard modal on web.
* - `alert`: Centered alert modal on all platforms.
*/
type?: 'sheet' | 'alert'
testID?: string testID?: string
} }