| 1 | import { |
| 2 | Box, |
| 3 | Column, |
| 4 | DropdownMenu, |
| 5 | DropdownMenuItem, |
| 6 | Host, |
| 7 | RNHostView, |
| 8 | Text, |
| 9 | } from "@expo/ui/jetpack-compose"; |
| 10 | import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; |
| 11 | import { View } from "react-native"; |
| 12 | import { resolveScaledTextRole } from "../lib/appearancePreferences"; |
| 13 | |
| 14 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 15 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 16 | import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; |
| 17 | import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; |
| 18 | |
| 19 | function MenuIcon(props: { |
| 20 | readonly name: AppSymbolName; |
| 21 | readonly destructive?: boolean; |
| 22 | readonly disabled?: boolean; |
| 23 | }) { |
| 24 | const { iconSize } = useAndroidControlSizing(); |
| 25 | return ( |
| 26 | <RNHostView matchContents modifiers={[size(iconSize, iconSize)]}> |
| 27 | <View |
| 28 | style={{ width: iconSize, height: iconSize }} |
| 29 | importantForAccessibility="no-hide-descendants" |
| 30 | > |
| 31 | <SymbolView |
| 32 | name={props.name} |
| 33 | size={iconSize} |
| 34 | type="monochrome" |
| 35 | tintColorClassName={ |
| 36 | props.disabled |
| 37 | ? "accent-icon-subtle" |
| 38 | : props.destructive |
| 39 | ? "accent-danger-foreground" |
| 40 | : "accent-foreground" |
| 41 | } |
| 42 | /> |
| 43 | </View> |
| 44 | </RNHostView> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | /** Native popup positioned at the original trigger, outside virtualized rows. */ |
| 49 | export function MaterialMenuPopup(props: MaterialMenuPopupProps) { |
| 50 | const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); |
| 51 | const { scale, menuItemHeight } = useAndroidControlSizing(); |
| 52 | const body = resolveScaledTextRole("body", appearance.baseFontSize); |
| 53 | const caption = resolveScaledTextRole("caption", appearance.baseFontSize); |
| 54 | const foreground = colors["--color-foreground"]; |
| 55 | const muted = colors["--color-foreground-muted"]; |
| 56 | // A fixed native item height clips wrapped labels; a minimum lets each row grow. |
| 57 | const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; |
| 58 | const items = ( |
| 59 | <> |
| 60 | {props.parent ? ( |
| 61 | <DropdownMenuItem onClick={props.onBack} modifiers={itemModifiers}> |
| 62 | <DropdownMenuItem.LeadingIcon> |
| 63 | <MenuIcon name="arrow.left" /> |
| 64 | </DropdownMenuItem.LeadingIcon> |
| 65 | <DropdownMenuItem.Text> |
| 66 | <Text color={foreground} style={{ typography: "bodyLarge", ...body }}> |
| 67 | {props.parent.title} |
| 68 | </Text> |
| 69 | </DropdownMenuItem.Text> |
| 70 | </DropdownMenuItem> |
| 71 | ) : props.title ? ( |
| 72 | <Text |
| 73 | color={muted} |
| 74 | style={{ typography: "bodySmall", ...caption }} |
| 75 | modifiers={[padding(16 * scale, 8 * scale, 16 * scale, 8 * scale)]} |
| 76 | > |
| 77 | {props.title} |
| 78 | </Text> |
| 79 | ) : null} |
| 80 | {props.actions.map((action, index) => ( |
| 81 | <DropdownMenuItem |
| 82 | key={action.id ?? `${index}-${action.title}`} |
| 83 | enabled={!action.attributes?.disabled} |
| 84 | modifiers={itemModifiers} |
| 85 | onClick={() => props.onPress(action)} |
| 86 | > |
| 87 | {action.image && isAppSymbolName(action.image) ? ( |
| 88 | <DropdownMenuItem.LeadingIcon> |
| 89 | <MenuIcon |
| 90 | name={action.image} |
| 91 | destructive={action.attributes?.destructive} |
| 92 | disabled={action.attributes?.disabled} |
| 93 | /> |
| 94 | </DropdownMenuItem.LeadingIcon> |
| 95 | ) : null} |
| 96 | <DropdownMenuItem.Text> |
| 97 | <Column> |
| 98 | <Text |
| 99 | style={{ typography: "bodyLarge", ...body }} |
| 100 | color={ |
| 101 | action.attributes?.disabled |
| 102 | ? muted |
| 103 | : action.attributes?.destructive |
| 104 | ? colors["--color-danger-foreground"] |
| 105 | : foreground |
| 106 | } |
| 107 | > |
| 108 | {action.title} |
| 109 | </Text> |
| 110 | {action.subtitle ? ( |
| 111 | <Text color={muted} style={{ typography: "bodySmall", ...caption }}> |
| 112 | {action.subtitle} |
| 113 | </Text> |
| 114 | ) : null} |
| 115 | </Column> |
| 116 | </DropdownMenuItem.Text> |
| 117 | {(action.subactions?.length ?? 0) > 0 ? ( |
| 118 | <DropdownMenuItem.TrailingIcon> |
| 119 | <MenuIcon name="chevron.right" disabled={action.attributes?.disabled} /> |
| 120 | </DropdownMenuItem.TrailingIcon> |
| 121 | ) : action.state === "on" ? ( |
| 122 | <DropdownMenuItem.TrailingIcon> |
| 123 | <MenuIcon name="checkmark" disabled={action.attributes?.disabled} /> |
| 124 | </DropdownMenuItem.TrailingIcon> |
| 125 | ) : null} |
| 126 | </DropdownMenuItem> |
| 127 | ))} |
| 128 | </> |
| 129 | ); |
| 130 | if (props.inline) { |
| 131 | return ( |
| 132 | <Host |
| 133 | colorScheme={themeAppearance} |
| 134 | ignoreSafeAreaKeyboardInsets |
| 135 | matchContents |
| 136 | style={{ width: props.menuWidth }} |
| 137 | > |
| 138 | <Column>{items}</Column> |
| 139 | </Host> |
| 140 | ); |
| 141 | } |
| 142 | return ( |
| 143 | <Host |
| 144 | colorScheme={themeAppearance} |
| 145 | ignoreSafeAreaKeyboardInsets |
| 146 | style={{ |
| 147 | position: "absolute", |
| 148 | left: props.anchor.x, |
| 149 | top: props.anchor.y, |
| 150 | width: props.anchor.width, |
| 151 | height: props.anchor.height, |
| 152 | }} |
| 153 | > |
| 154 | <DropdownMenu expanded onDismissRequest={props.onClose} color={colors["--color-card-alt"]}> |
| 155 | <DropdownMenu.Trigger> |
| 156 | <Box modifiers={[size(props.anchor.width, props.anchor.height)]} /> |
| 157 | </DropdownMenu.Trigger> |
| 158 | <DropdownMenu.Items>{items}</DropdownMenu.Items> |
| 159 | </DropdownMenu> |
| 160 | </Host> |
| 161 | ); |
| 162 | } |