Files
bsky-social-app/src/components/Dialog/index.web.tsx
T
Tomek Zawadzki 2d313d806f Unblock React Compiler for 18 components with value blocks inside try
React Compiler cannot lower a conditional expression - `&&`, `||`, `??`, `?.`,
a ternary - inside a try block. Three techniques, picked per site:

- split `if (a && b)` into nested ifs, where there is no `else` to break
- hoist the expression into a const above the try, where it does not depend on
  anything the try produces
- move it into a module-scope helper, where it does

Optional calls become `if (f) f()`, which keeps the arguments unevaluated when
the callback is absent, exactly as `f?.()` does.

Skipped components: 125 -> 107.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:57:00 +02:00

380 lines
9.9 KiB
TypeScript

import {
forwardRef,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import {
FlatList,
type FlatListProps,
type GestureResponderEvent,
type LayoutChangeEvent,
Pressable,
type ScrollView,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {type ListMethods} from '#/view/com/util/List'
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Context} from '#/components/Dialog/context'
import {
type DialogControlProps,
type DialogInnerProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
export {Input} from '#/components/forms/TextField'
// 100 minus 10vh of paddingVertical
export const WEB_DIALOG_HEIGHT = '80vh'
const stopPropagation = (e: any) => e.stopPropagation()
const preventDefault = (e: any) => e.preventDefault()
export function Outer({
children,
control,
onOpen,
onClose,
webOptions,
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = useState(false)
const {setDialogIsOpen} = useDialogStateControlContext()
const open = useCallback(() => {
onOpen?.()
setDialogIsOpen(control.id, true)
setIsOpen(true)
}, [setIsOpen, setDialogIsOpen, control.id, onOpen])
const close = useCallback<DialogControlProps['close']>(
cb => {
setDialogIsOpen(control.id, false)
setIsOpen(false)
try {
// Nested rather than `&&`: React Compiler cannot lower a logical
// expression in a test position inside a `try`.
if (cb) {
if (typeof cb === 'function') {
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
// 'Step 1', 'Step 3', 'Step 2'.
setTimeout(cb)
}
}
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
message: e.message,
})
}
onClose?.()
},
[control.id, onClose, setDialogIsOpen],
)
const handleBackgroundPress = useCallback(
async (e: GestureResponderEvent) => {
webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close()
},
[webOptions, close],
)
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[close, open],
)
const context = useMemo(
() => ({
close,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
isHeightConstrained: false,
}),
[close],
)
return (
<>
{isOpen && (
<Portal>
<Context.Provider value={context}>
<RemoveScrollBar />
<Pressable
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close active dialog`)}
onPress={handleBackgroundPress}>
<View
style={[
web(a.fixed),
a.inset_0,
a.z_10,
a.px_xl,
webOptions?.alignCenter ? a.justify_center : undefined,
a.align_center,
{
overflowY: 'auto',
paddingVertical: gtMobile ? '10vh' : a.pt_xl.paddingTop,
},
]}>
<Backdrop />
{/**
* This is needed to prevent centered dialogs from overflowing
* above the screen, and provides a "natural" centering so that
* stacked dialogs appear relatively aligned.
*/}
<View
style={[
a.w_full,
a.z_20,
a.align_center,
web({minHeight: '60vh', position: 'static'}),
]}>
{children}
</View>
</View>
</Pressable>
</Context.Provider>
</Portal>
)}
</>
)
}
/**
* @deprecated use `Dialog.ScrollableInner` instead
*/
export function Inner({
children,
style,
label,
accessibilityLabelledBy,
accessibilityDescribedBy,
header,
footer,
contentContainerStyle,
}: DialogInnerProps) {
const t = useTheme()
const {close} = useContext(Context)
const {gtMobile} = useBreakpoints()
const {reduceMotionEnabled} = useA11y()
FocusGuards.useFocusGuards()
return (
<FocusScope.FocusScope loop asChild trapped>
<View
role="dialog"
aria-role="dialog"
aria-label={label}
aria-labelledby={accessibilityLabelledBy}
aria-describedby={accessibilityDescribedBy}
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
// note: flatten is required for some reason -sfn
style={flatten([
a.relative,
a.rounded_md,
a.w_full,
a.border,
t.atoms.bg,
{
cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children.
maxWidth: 600,
borderColor: t.palette.contrast_200,
shadowColor: t.palette.black,
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30,
},
!reduceMotionEnabled && a.zoom_fade_in,
style,
])}>
<DismissableLayer.DismissableLayer
onInteractOutside={preventDefault}
onFocusOutside={preventDefault}
onDismiss={close}
style={{height: '100%', display: 'flex', flexDirection: 'column'}}>
{header}
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
{children}
</View>
{footer}
</DismissableLayer.DismissableLayer>
</View>
</FocusScope.FocusScope>
)
}
/*
* There is no inner ScrollView on web - the ref is accepted for parity with
* the native variant and never attached, so native-only scrolling code in
* shared callers stays a no-op here.
*/
export function ScrollableInner({
ref: _ref,
...props
}: DialogInnerProps & {
ref?: React.Ref<React.ComponentRef<typeof ScrollView>>
}) {
return <Inner {...props} />
}
export const InnerFlatList = forwardRef<
ListMethods,
FlatListProps<any> & {label?: string} & {
webInnerStyle?: StyleProp<ViewStyle>
webInnerContentContainerStyle?: StyleProp<ViewStyle>
footer?: React.ReactNode
}
>(function InnerFlatList(
{
label,
style,
webInnerStyle,
webInnerContentContainerStyle,
footer,
...props
},
ref,
) {
const {gtMobile} = useBreakpoints()
return (
<Inner
/*
* Most shared callers cannot pass a label since the native variant has
* no such prop; aria-label is simply absent for them, as before.
*/
label={label as string}
style={[
a.overflow_hidden,
a.px_0,
web({maxHeight: WEB_DIALOG_HEIGHT}),
webInnerStyle,
]}
contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
<FlatList
/*
* The FlatList instance satisfies the (web) ListMethods interface
* shared callers hold their refs as, except scrollToTop, which no
* platform-agnostic caller can use since the native ListMethods
* (FlatList) lacks it too.
*/
ref={ref as React.Ref<FlatList>}
style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]}
{...props}
/>
{footer}
</Inner>
)
})
export function FlatListFooter({
children,
onLayout,
border = true,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
border?: boolean
}) {
const t = useTheme()
return (
<View
onLayout={onLayout}
style={[
a.absolute,
a.bottom_0,
a.w_full,
a.z_10,
t.atoms.bg,
border && a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_md,
]}>
{children}
</View>
)
}
export function Close() {
const {_} = useLingui()
const {close} = useContext(Context)
return (
<View
style={[
a.absolute,
a.z_10,
{
top: a.pt_md.paddingTop,
right: a.pr_md.paddingRight,
},
]}>
<Button
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={() => close()}
label={_(msg`Close active dialog`)}>
<ButtonIcon icon={X} size="md" />
</Button>
</View>
)
}
/*
* The drag handle only exists on the native bottom sheet; props are accepted
* for parity with the native variant.
*/
export function Handle(_props: {difference?: boolean; fill?: string}) {
return null
}
export function Backdrop() {
const t = useTheme()
const {reduceMotionEnabled} = useA11y()
return (
<View style={{opacity: 0.8}}>
<View
style={[
a.fixed,
a.inset_0,
{backgroundColor: t.palette.black},
!reduceMotionEnabled && a.fade_in,
]}
/>
</View>
)
}