Compare commits

...

21 Commits

Author SHA1 Message Date
vineyardbovines e517a6e970 Merge branch 'main' into dialog-alert-type 2026-05-08 15:33:08 -04:00
vineyardbovines 9a715c4cda Merge branch 'dialog-alert-type' of github.com:bluesky-social/social-app into dialog-alert-type 2026-04-14 08:46:09 -04:00
vineyardbovines 159337f446 clean up styling artifacts 2026-04-14 08:44:04 -04:00
vineyardbovines 3c5a8ed066 add storybook example 2026-04-14 11:37:17 +03:00
Samuel Newman 84fbea9137 fix lint 2026-04-14 11:37:17 +03:00
vineyardbovines 51835f5b41 styling for alert variant 2026-04-14 11:37:17 +03:00
vineyardbovines a4017dbdff wrapper fix 2026-04-14 11:37:17 +03:00
vineyardbovines 023363e6e9 extend for font sizing, etc 2026-04-14 11:37:17 +03:00
vineyardbovines d15358cd1a extend prompt instead 2026-04-14 11:37:17 +03:00
vineyardbovines 3c3208ef11 handle inner components 2026-04-14 11:37:17 +03:00
vineyardbovines ecc5927002 dedupe some shared hooks 2026-04-14 11:37:17 +03:00
vineyardbovines 507290474d add detached variant to modal 2026-04-14 11:37:17 +03:00
vineyardbovines 4542a09ab7 add storybook example 2026-04-13 08:25:18 -04:00
Samuel Newman ddfba8c2c6 fix lint 2026-04-13 12:25:16 +03:00
vineyardbovines bedd7f2d0f styling for alert variant 2026-04-10 14:21:15 -04:00
vineyardbovines 2f878e926f wrapper fix 2026-04-10 14:21:15 -04:00
vineyardbovines 654a5782c6 extend for font sizing, etc 2026-04-10 14:21:15 -04:00
vineyardbovines 3445031205 extend prompt instead 2026-04-10 14:21:15 -04:00
vineyardbovines 2ce8331909 handle inner components 2026-04-10 14:21:15 -04:00
vineyardbovines 6656253749 dedupe some shared hooks 2026-04-10 14:20:49 -04:00
vineyardbovines f1ab948a93 add detached variant to modal 2026-04-10 14:19:46 -04:00
10 changed files with 287 additions and 59 deletions
+1
View File
@@ -0,0 +1 @@
dialog-alert-type@9a715c4cda486d249c0a647d9606fdee51b6c9c9
+1 -1
View File
@@ -36,7 +36,7 @@
},
"src/components/Dialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
"count": 1
}
},
"src/components/Dialog/index.web.tsx": {
@@ -21,9 +21,11 @@ import {
Context as PortalContext,
} from './BottomSheetPortal'
type NativeViewRef = {dismiss: () => void}
const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
ref: React.RefObject<NativeViewRef | null>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')
@@ -46,7 +48,7 @@ export class BottomSheetNativeComponent extends Component<
viewHeight?: number
}
> {
ref = createRef<any>()
ref = createRef<NativeViewRef>()
static contextType = PortalContext
@@ -140,7 +142,7 @@ function BottomSheetNativeComponentInner({
onStateChange: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
nativeViewRef: React.RefObject<View>
nativeViewRef: React.RefObject<NativeViewRef | null>
onLayout?: (event: LayoutChangeEvent) => void
}) {
const insets = useSafeAreaInsets()
+1
View File
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: false,
type: 'sheet',
isHeightConstrained: false,
})
Context.displayName = 'DialogContext'
+119 -48
View File
@@ -10,6 +10,7 @@ import {
Keyboard,
type KeyboardEventListener,
type LayoutChangeEvent,
Modal,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
@@ -30,9 +31,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {ScrollProvider} from '#/lib/ScrollContext'
import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
@@ -42,6 +41,7 @@ import {
type DialogInnerProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {useDialogCallbackQueue} from '#/components/Dialog/utils'
import {createInput} from '#/components/forms/TextField'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
@@ -59,7 +59,92 @@ export * from '#/components/Dialog/utils'
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] = useState(false)
const {enqueueCallback, handleOpen, handleClose} = useDialogCallbackQueue(
control,
onClose,
)
const open: DialogControlProps['open'] = () => {
handleOpen()
setIsOpen(true)
}
const close: DialogControlProps['close'] = cb => {
enqueueCallback(cb)
setIsOpen(false)
}
const onDismiss = () => {
handleClose()
}
useImperativeHandle(control.ref, () => ({open, close}), [open, close])
const context = useMemo(
() => ({
close,
isNativeDialog: true,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
type: 'alert' as const,
isHeightConstrained: false,
}),
[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, maxHeight: '90%'},
a.overflow_hidden,
]}>
<Context.Provider value={context}>
<View testID={testID}>{children}</View>
</Context.Provider>
</Pressable>
</Pressable>
</Modal>
)
}
function OuterSheet({
children,
control,
onClose,
@@ -69,9 +154,8 @@ export function Outer({
const themeName = useThemeName()
const t = useTheme(themeName)
const ref = useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
const {enqueueCallback, handleOpen, handleClose, setFullyExpandedCount} =
useDialogCallbackQueue(control, onClose)
const prevSnapPoint = useRef<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Hidden,
@@ -82,42 +166,20 @@ export function Outer({
BottomSheetSnapPoint.Partial,
)
const callQueuedCallbacks = useCallback(() => {
for (const cb of closeCallbacks.current) {
try {
cb()
} catch (e: any) {
logger.error(e || 'Error running close callback')
}
}
closeCallbacks.current = []
}, [])
const open = useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
const open: DialogControlProps['open'] = () => {
handleOpen()
ref.current?.present()
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
}
// This is the function that we call when we want to dismiss the dialog.
const close = useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
const close: DialogControlProps['close'] = cb => {
enqueueCallback(cb)
ref.current?.dismiss()
}, [])
}
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
// happen before we run this. It is passed to the `BottomSheet` component.
const onCloseAnimationComplete = useCallback(() => {
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
// Runs after the bottom sheet close animation completes.
const onCloseAnimationComplete = () => {
handleClose()
}
const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
const {snapPoint} = e.nativeEvent
@@ -148,14 +210,7 @@ export function Outer({
}
}
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
useImperativeHandle(control.ref, () => ({open, close}), [open, close])
const isHeightConstrained = nativeOptions?.maxHeight != null
@@ -167,6 +222,7 @@ export function Outer({
disableDrag,
setDisableDrag,
isWithinDialog: true,
type: 'sheet' as const,
isHeightConstrained,
}),
[close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained],
@@ -221,8 +277,14 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
{children, contentContainerStyle, header, style, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
useDialogContext()
const {
nativeSnapPoint,
disableDrag,
setDisableDrag,
type,
isHeightConstrained,
} = useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = useState(() =>
@@ -235,6 +297,15 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
if (type === 'alert') {
return (
<View style={[a.p_2xl, contentContainerStyle]} {...props}>
{header}
{children}
</View>
)
}
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!IS_ANDROID) {
return
+12 -4
View File
@@ -52,6 +52,7 @@ export function Outer({
control,
onClose,
webOptions,
type = 'sheet',
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
@@ -103,6 +104,8 @@ export function Outer({
[close, open],
)
const isAlert = type === 'alert'
const context = useMemo(
() => ({
close,
@@ -111,9 +114,10 @@ export function Outer({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
type,
isHeightConstrained: false,
}),
[close],
[close, type],
)
return (
@@ -132,7 +136,9 @@ export function Outer({
a.inset_0,
a.z_10,
a.px_xl,
webOptions?.alignCenter ? a.justify_center : undefined,
webOptions?.alignCenter || isAlert
? a.justify_center
: undefined,
a.align_center,
{
overflowY: 'auto',
@@ -173,9 +179,10 @@ export function Inner({
contentContainerStyle,
}: DialogInnerProps) {
const t = useTheme()
const {close} = useContext(Context)
const {close, type} = useContext(Context)
const {gtMobile} = useBreakpoints()
const {reduceMotionEnabled} = useA11y()
const isAlert = type === 'alert'
FocusGuards.useFocusGuards()
return (
<FocusScope.FocusScope loop asChild trapped>
@@ -198,12 +205,13 @@ export function Inner({
t.atoms.bg,
{
cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children.
maxWidth: 600,
maxWidth: isAlert ? 320 : 600,
borderColor: t.palette.contrast_200,
shadowColor: t.palette.black,
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30,
},
isAlert && {borderRadius: 36},
!reduceMotionEnabled && a.zoom_fade_in,
style,
])}>
+7
View File
@@ -45,6 +45,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog
isWithinDialog: boolean
type: 'sheet' | 'alert'
isHeightConstrained: boolean
}
@@ -66,6 +67,12 @@ export type DialogOuterProps = {
alignCenter?: boolean
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
}
+47 -1
View File
@@ -1,7 +1,53 @@
import {useEffect} from 'react'
import {useEffect, useRef} from 'react'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {type DialogControlProps} from '#/components/Dialog/types'
export function useDialogCallbackQueue(
control: DialogControlProps,
onClose?: () => void,
) {
const closeCallbacks = useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
const callQueuedCallbacks = () => {
for (const cb of closeCallbacks.current) {
try {
cb()
} catch (e) {
logger.error('Error running close callback', {safeMessage: e})
}
}
closeCallbacks.current = []
}
const enqueueCallback = (cb?: () => void) => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
}
const handleOpen = () => {
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
}
const handleClose = () => {
setDialogIsOpen(control.id, false)
callQueuedCallbacks()
onClose?.()
}
return {
enqueueCallback,
handleOpen,
handleClose,
setFullyExpandedCount,
}
}
export function useAutoOpen(control: DialogControlProps, showTimeout?: number) {
useEffect(() => {
if (showTimeout) {
+48 -2
View File
@@ -34,6 +34,7 @@ export function Outer({
control,
testID,
nativeOptions,
type = 'sheet',
webOptions,
onClose,
}: React.PropsWithChildren<{
@@ -43,6 +44,12 @@ export function Outer({
* Native-specific options for the prompt. Extends `BottomSheetViewProps`
*/
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
/**
* The presentation style of the prompt.
* - `sheet` (default): Bottom sheet on native, standard modal on web.
* - `alert`: Centered alert modal on all platforms.
*/
type?: 'sheet' | 'alert'
/**
* Web-specific options for the prompt
*/
@@ -63,10 +70,11 @@ export function Outer({
<Dialog.Outer
control={control}
testID={testID}
type={type}
onClose={onClose}
webOptions={{alignCenter: true, ...webOptions}}
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
<Dialog.Handle />
{type !== 'alert' && <Dialog.Handle />}
<Context.Provider value={context}>
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
@@ -79,11 +87,41 @@ export function Outer({
)
}
export function Icon({
icon: Comp,
color,
}: {
icon: React.ComponentType<SVGIconProps>
color?: ButtonColor
}) {
const t = useTheme()
let iconColor: string
switch (color) {
case 'negative':
iconColor = t.palette.negative_500
break
case 'primary':
iconColor = t.palette.primary_500
break
default:
iconColor = t.atoms.text.color
break
}
return (
<View style={[a.pb_sm, a.align_center]}>
<Comp size="xl" style={{color: iconColor}} />
</View>
)
}
export function TitleText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
const {titleId} = useContext(Context)
const {type} = Dialog.useDialogContext()
return (
<Text
nativeID={titleId}
@@ -93,6 +131,7 @@ export function TitleText({
a.font_semi_bold,
a.pb_xs,
a.leading_snug,
type === 'alert' && a.text_center,
style,
]}>
{children}
@@ -107,6 +146,7 @@ export function DescriptionText({
}: React.PropsWithChildren<{selectable?: boolean} & TextStyleProp>) {
const t = useTheme()
const {descriptionId} = useContext(Context)
const {type} = Dialog.useDialogContext()
return (
<Text
nativeID={descriptionId}
@@ -116,6 +156,7 @@ export function DescriptionText({
a.leading_snug,
t.atoms.text_contrast_high,
a.pb_lg,
type === 'alert' && a.text_center,
style,
]}>
{children}
@@ -227,6 +268,8 @@ export function Basic({
onConfirm,
confirmButtonColor,
showCancel = true,
type,
icon,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
@@ -243,10 +286,13 @@ export function Basic({
onConfirm: (e: GestureResponderEvent) => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
type?: 'sheet' | 'alert'
icon?: React.ComponentType<SVGIconProps>
}>) {
return (
<Outer control={control} testID="confirmModal">
<Outer control={control} testID="confirmModal" type={type}>
<Content>
{icon && <Icon icon={icon} color={confirmButtonColor} />}
<TitleText>{title}</TitleText>
{description && <DescriptionText>{description}</DescriptionText>}
</Content>
+46
View File
@@ -5,6 +5,8 @@ import {useDialogStateControlContext} from '#/state/dialogs'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {H3, P, Text} from '#/components/Typography'
@@ -14,6 +16,8 @@ export function Dialogs() {
const scrollable = Dialog.useDialogControl()
const basic = Dialog.useDialogControl()
const prompt = Prompt.usePromptControl()
const alertPrompt = Prompt.usePromptControl()
const alertPromptCustom = Prompt.usePromptControl()
const withMenu = Dialog.useDialogControl()
const testDialog = Dialog.useDialogControl()
const {closeAllDialogs} = useDialogStateControlContext()
@@ -111,6 +115,24 @@ export function Dialogs() {
<ButtonText>Open prompt</ButtonText>
</Button>
<Button
variant="solid"
color="negative"
size="small"
onPress={() => alertPrompt.open()}
label="Open alert prompt">
<ButtonText>Open alert prompt (Basic)</ButtonText>
</Button>
<Button
variant="solid"
color="negative"
size="small"
onPress={() => alertPromptCustom.open()}
label="Open alert prompt with custom content">
<ButtonText>Open alert prompt (custom content)</ButtonText>
</Button>
<Button
variant="solid"
color="primary"
@@ -175,6 +197,30 @@ export function Dialogs() {
</Prompt.Actions>
</Prompt.Outer>
<Prompt.Basic
control={alertPrompt}
type="alert"
icon={Trash}
title="Delete this post?"
description="This action cannot be undone. The post will be permanently removed from your profile and from everyone's feeds."
confirmButtonCta="Delete"
confirmButtonColor="negative"
onConfirm={() => {}}
/>
<Prompt.Outer control={alertPromptCustom} type="alert">
<Prompt.Icon icon={Warning} color="negative" />
<Prompt.TitleText>Are you sure?</Prompt.TitleText>
<Prompt.DescriptionText>
This is the `alert` variant of the prompt. It renders a centered modal
on all platforms and supports an optional icon above the title.
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action cta="Continue" color="negative" onPress={() => {}} />
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
<Dialog.Outer control={basic}>
<Dialog.Inner label="test">
<H3 nativeID="dialog-title">Dialog</H3>