382 lines
8.6 KiB
TypeScript
382 lines
8.6 KiB
TypeScript
import {cloneElement, Fragment, isValidElement, useMemo} from 'react'
|
|
import {
|
|
Pressable,
|
|
type StyleProp,
|
|
type TextStyle,
|
|
View,
|
|
type ViewStyle,
|
|
} from 'react-native'
|
|
import {Trans, useLingui} from '@lingui/react/macro'
|
|
import flattenReactChildren from 'react-keyed-flatten-children'
|
|
|
|
import {atoms as a, useTheme} from '#/alf'
|
|
import {Button, ButtonText} from '#/components/Button'
|
|
import * as Dialog from '#/components/Dialog'
|
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
import {
|
|
Context,
|
|
ItemContext,
|
|
useMenuContext,
|
|
useMenuItemContext,
|
|
} from '#/components/Menu/context'
|
|
import {
|
|
type ContextType,
|
|
type GroupProps,
|
|
type ItemIconProps,
|
|
type ItemProps,
|
|
type ItemTextProps,
|
|
type TriggerProps,
|
|
} from '#/components/Menu/types'
|
|
import {Text} from '#/components/Typography'
|
|
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
|
|
|
|
// iOS 26's floaty sheet presentation subtracts the bottom safe-area inset from
|
|
// the requested detent, which eats the visible bottom padding of short (e.g.
|
|
// single-item) menus. Flooring the native sheet height restores that padding.
|
|
const IOS_MENU_MIN_HEIGHT = 128
|
|
|
|
export {
|
|
type DialogControlProps as MenuControlProps,
|
|
useDialogControl as useMenuControl,
|
|
} from '#/components/Dialog'
|
|
|
|
export {useMenuContext}
|
|
|
|
export function Root({
|
|
children,
|
|
control,
|
|
}: React.PropsWithChildren<{
|
|
control?: Dialog.DialogControlProps
|
|
}>) {
|
|
const defaultControl = Dialog.useDialogControl()
|
|
const context = useMemo<ContextType>(
|
|
() => ({
|
|
control: control || defaultControl,
|
|
}),
|
|
[control, defaultControl],
|
|
)
|
|
|
|
return <Context.Provider value={context}>{children}</Context.Provider>
|
|
}
|
|
|
|
export function Trigger({
|
|
children,
|
|
label,
|
|
role = 'button',
|
|
hint,
|
|
}: TriggerProps) {
|
|
const context = useMenuContext()
|
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
const {
|
|
state: pressed,
|
|
onIn: onPressIn,
|
|
onOut: onPressOut,
|
|
} = useInteractionState()
|
|
|
|
return children({
|
|
IS_NATIVE: true,
|
|
control: context.control,
|
|
state: {
|
|
hovered: false,
|
|
focused,
|
|
pressed,
|
|
},
|
|
props: {
|
|
ref: null,
|
|
onPress: context.control.open,
|
|
onFocus,
|
|
onBlur,
|
|
onPressIn,
|
|
onPressOut,
|
|
accessibilityHint: hint,
|
|
accessibilityLabel: label,
|
|
accessibilityRole: role,
|
|
},
|
|
})
|
|
}
|
|
|
|
export function Outer({
|
|
children,
|
|
showCancel,
|
|
}: React.PropsWithChildren<{
|
|
showCancel?: boolean
|
|
style?: StyleProp<ViewStyle>
|
|
onCloseAutoFocus?: (event: Event) => void
|
|
}>) {
|
|
const context = useMenuContext()
|
|
const {t: l} = useLingui()
|
|
|
|
return (
|
|
<Dialog.Outer
|
|
control={context.control}
|
|
nativeOptions={{
|
|
preventExpansion: true,
|
|
minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
|
|
}}>
|
|
<Dialog.Handle />
|
|
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
|
<Context.Provider value={context}>
|
|
<Dialog.ScrollableInner label={l`Menu`}>
|
|
<View style={[a.gap_lg]}>
|
|
{children}
|
|
{IS_NATIVE && showCancel && <Cancel />}
|
|
</View>
|
|
</Dialog.ScrollableInner>
|
|
</Context.Provider>
|
|
</Dialog.Outer>
|
|
)
|
|
}
|
|
|
|
export function Item({
|
|
children,
|
|
label,
|
|
style,
|
|
onPress,
|
|
destructive = false,
|
|
...rest
|
|
}: ItemProps) {
|
|
const t = useTheme()
|
|
const context = useMenuContext()
|
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
const {
|
|
state: pressed,
|
|
onIn: onPressIn,
|
|
onOut: onPressOut,
|
|
} = useInteractionState()
|
|
|
|
return (
|
|
<Pressable
|
|
{...rest}
|
|
accessibilityHint=""
|
|
accessibilityLabel={label}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
onPress={e => {
|
|
if (IS_ANDROID) {
|
|
/**
|
|
* Below fix for iOS doesn't work for Android, this does.
|
|
*/
|
|
onPress?.(e)
|
|
context.control.close()
|
|
} else if (IS_IOS) {
|
|
/**
|
|
* Fixes a subtle bug on iOS
|
|
* {@link https://github.com/bluesky-social/social-app/pull/5849/files#diff-de516ef5e7bd9840cd639213301df38cf03acfcad5bda85a1d63efd249ba79deL124-L127}
|
|
*/
|
|
context.control.close(() => {
|
|
onPress?.(e)
|
|
})
|
|
}
|
|
}}
|
|
onPressIn={e => {
|
|
onPressIn()
|
|
rest.onPressIn?.(e)
|
|
}}
|
|
onPressOut={e => {
|
|
onPressOut()
|
|
rest.onPressOut?.(e)
|
|
}}
|
|
style={[
|
|
a.flex_row,
|
|
a.align_center,
|
|
a.gap_sm,
|
|
a.px_md,
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
a.border,
|
|
t.atoms.bg_contrast_25,
|
|
t.atoms.border_contrast_low,
|
|
{minHeight: 44, paddingVertical: 10},
|
|
style,
|
|
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
|
]}>
|
|
<ItemContext.Provider
|
|
value={{disabled: Boolean(rest.disabled), destructive}}>
|
|
{children}
|
|
</ItemContext.Provider>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
export function ItemText({children, style}: ItemTextProps) {
|
|
const t = useTheme()
|
|
const {disabled, destructive} = useMenuItemContext()
|
|
return (
|
|
<Text
|
|
numberOfLines={1}
|
|
ellipsizeMode="middle"
|
|
style={[
|
|
a.flex_1,
|
|
a.text_md,
|
|
a.font_semi_bold,
|
|
t.atoms.text_contrast_high,
|
|
style,
|
|
destructive && {color: t.palette.negative_500},
|
|
disabled && t.atoms.text_contrast_low,
|
|
]}>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
|
|
const t = useTheme()
|
|
const {disabled, destructive} = useMenuItemContext()
|
|
return (
|
|
<Comp
|
|
size="lg"
|
|
fill={
|
|
fill
|
|
? fill({disabled})
|
|
: disabled
|
|
? t.atoms.text_contrast_low.color
|
|
: destructive
|
|
? t.palette.negative_500
|
|
: t.atoms.text_contrast_medium.color
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function ItemRadio({selected}: {selected: boolean}) {
|
|
const t = useTheme()
|
|
return (
|
|
<View
|
|
style={[
|
|
a.justify_center,
|
|
a.align_center,
|
|
a.rounded_full,
|
|
t.atoms.border_contrast_high,
|
|
{
|
|
borderWidth: 1,
|
|
height: 20,
|
|
width: 20,
|
|
},
|
|
]}>
|
|
{selected ? (
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.rounded_full,
|
|
{height: 14, width: 14},
|
|
selected
|
|
? {
|
|
backgroundColor: t.palette.primary_500,
|
|
}
|
|
: {},
|
|
]}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* NATIVE ONLY - for adding non-pressable items to the menu
|
|
*
|
|
* @platform ios, android
|
|
*/
|
|
export function ContainerItem({
|
|
children,
|
|
style,
|
|
}: {
|
|
children: React.ReactNode
|
|
style?: StyleProp<ViewStyle>
|
|
}) {
|
|
const t = useTheme()
|
|
return (
|
|
<View
|
|
style={[
|
|
a.flex_row,
|
|
a.align_center,
|
|
a.gap_sm,
|
|
a.px_md,
|
|
a.rounded_lg,
|
|
a.curve_continuous,
|
|
a.border,
|
|
t.atoms.bg_contrast_25,
|
|
t.atoms.border_contrast_low,
|
|
{paddingVertical: 10},
|
|
style,
|
|
]}>
|
|
{children}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function LabelText({
|
|
children,
|
|
style,
|
|
}: {
|
|
children: React.ReactNode
|
|
style?: StyleProp<TextStyle>
|
|
}) {
|
|
const t = useTheme()
|
|
return (
|
|
<Text
|
|
style={[
|
|
a.font_semi_bold,
|
|
t.atoms.text_contrast_medium,
|
|
{marginBottom: -8},
|
|
style,
|
|
]}>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export function Group({children, style}: GroupProps) {
|
|
const t = useTheme()
|
|
return (
|
|
<View
|
|
style={[
|
|
a.rounded_lg,
|
|
a.curve_continuous,
|
|
a.overflow_hidden,
|
|
a.border,
|
|
t.atoms.border_contrast_low,
|
|
style,
|
|
]}>
|
|
{flattenReactChildren(children).map((child, i) => {
|
|
return isValidElement(child) &&
|
|
(child.type === Item || child.type === ContainerItem) ? (
|
|
<Fragment key={i}>
|
|
{i > 0 ? (
|
|
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
|
) : null}
|
|
{cloneElement(child, {
|
|
// @ts-expect-error cloneElement is not aware of the types
|
|
style: {
|
|
borderRadius: 0,
|
|
borderWidth: 0,
|
|
},
|
|
})}
|
|
</Fragment>
|
|
) : null
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function Cancel() {
|
|
const {t: l} = useLingui()
|
|
const context = useMenuContext()
|
|
|
|
return (
|
|
<Button
|
|
label={l`Close this dialog`}
|
|
size="small"
|
|
variant="ghost"
|
|
color="secondary"
|
|
onPress={() => context.control.close()}>
|
|
<ButtonText>
|
|
<Trans>Cancel</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export function Divider() {
|
|
return null
|
|
}
|