| 1 | import { useState, type ReactNode } from "react"; |
| 2 | import { ActivityIndicator, View } from "react-native"; |
| 3 | |
| 4 | import type { AppSymbolName } from "./AppSymbol"; |
| 5 | import { AppText as Text } from "./AppText"; |
| 6 | import { cn } from "../lib/cn"; |
| 7 | import { MaterialIconButton } from "./MaterialIconButton"; |
| 8 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; |
| 9 | import { splitAndroidHeaderActions } from "./headerMenu.android"; |
| 10 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; |
| 11 | import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; |
| 12 | |
| 13 | export interface AndroidHeaderAction { |
| 14 | readonly accessibilityLabel: string; |
| 15 | readonly icon: AppSymbolName; |
| 16 | readonly onPress: () => void; |
| 17 | readonly disabled?: boolean; |
| 18 | readonly loading?: boolean; |
| 19 | readonly selected?: boolean; |
| 20 | readonly destructive?: boolean; |
| 21 | readonly overflowOnly?: boolean; |
| 22 | } |
| 23 | |
| 24 | export function AndroidHeaderIconButton(props: { |
| 25 | readonly accessibilityLabel: string; |
| 26 | readonly icon: AppSymbolName; |
| 27 | readonly onPress?: () => void; |
| 28 | readonly disabled?: boolean; |
| 29 | readonly selected?: boolean; |
| 30 | }) { |
| 31 | return <MaterialIconButton {...props} tintColorClassName="accent-header-foreground" />; |
| 32 | } |
| 33 | |
| 34 | export function AndroidScreenHeader(props: { |
| 35 | readonly title: string; |
| 36 | readonly titleContent?: ReactNode; |
| 37 | readonly subtitle?: string | null; |
| 38 | readonly actions?: ReadonlyArray<AndroidHeaderAction>; |
| 39 | readonly leading?: ReactNode; |
| 40 | readonly trailing?: ReactNode; |
| 41 | readonly onBack?: () => void; |
| 42 | readonly embedded?: boolean; |
| 43 | readonly hideBottomBorder?: boolean; |
| 44 | }) { |
| 45 | const titleTypography = useScaledTextRole("title"); |
| 46 | const subtitleTypography = useScaledTextRole("label"); |
| 47 | const { height: materialToolbarHeight, ...headerPadding } = useMaterialToolbarLayout( |
| 48 | props.embedded, |
| 49 | ); |
| 50 | const [headerWidth, setHeaderWidth] = useState(0); |
| 51 | const { visibleActions, overflowActions } = splitAndroidHeaderActions( |
| 52 | props.actions ?? [], |
| 53 | headerWidth >= 600, |
| 54 | ); |
| 55 | |
| 56 | return ( |
| 57 | <View |
| 58 | onLayout={(event) => setHeaderWidth(event.nativeEvent.layout.width)} |
| 59 | className="border-b border-header-border bg-header px-2" |
| 60 | style={{ |
| 61 | ...headerPadding, |
| 62 | borderBottomWidth: props.hideBottomBorder ? 0 : undefined, |
| 63 | }} |
| 64 | > |
| 65 | <View style={{ minHeight: materialToolbarHeight }} className="flex-row items-center gap-1"> |
| 66 | {props.onBack ? ( |
| 67 | <MaterialIconButton |
| 68 | accessibilityLabel="Navigate up" |
| 69 | icon="arrow.left" |
| 70 | tintColorClassName="accent-header-foreground" |
| 71 | onPress={props.onBack} |
| 72 | /> |
| 73 | ) : null} |
| 74 | |
| 75 | {props.leading} |
| 76 | |
| 77 | <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}> |
| 78 | {props.titleContent ?? ( |
| 79 | <Text numberOfLines={1} style={titleTypography} className="text-header-foreground"> |
| 80 | {props.title} |
| 81 | </Text> |
| 82 | )} |
| 83 | {props.subtitle ? ( |
| 84 | <Text |
| 85 | numberOfLines={1} |
| 86 | style={subtitleTypography} |
| 87 | className="mt-px text-[13px] font-t3-medium text-foreground-muted" |
| 88 | > |
| 89 | {props.subtitle} |
| 90 | </Text> |
| 91 | ) : null} |
| 92 | </View> |
| 93 | |
| 94 | {visibleActions.map((action) => |
| 95 | action.loading ? ( |
| 96 | <View |
| 97 | key={action.accessibilityLabel} |
| 98 | accessibilityLabel={action.accessibilityLabel} |
| 99 | className="size-12 items-center justify-center" |
| 100 | > |
| 101 | <ActivityIndicator colorClassName="accent-header-foreground" /> |
| 102 | </View> |
| 103 | ) : ( |
| 104 | <AndroidHeaderIconButton |
| 105 | key={action.accessibilityLabel} |
| 106 | accessibilityLabel={action.accessibilityLabel} |
| 107 | disabled={action.disabled} |
| 108 | selected={action.selected} |
| 109 | icon={action.icon} |
| 110 | onPress={action.onPress} |
| 111 | /> |
| 112 | ), |
| 113 | )} |
| 114 | {overflowActions.length > 0 ? ( |
| 115 | <AndroidAnchoredMenu |
| 116 | actions={overflowActions.map((action, index) => ({ |
| 117 | id: String(index), |
| 118 | title: action.accessibilityLabel, |
| 119 | attributes: { |
| 120 | disabled: Boolean(action.disabled), |
| 121 | destructive: action.destructive, |
| 122 | state: action.selected ? "on" : undefined, |
| 123 | }, |
| 124 | }))} |
| 125 | onPressAction={({ nativeEvent }) => |
| 126 | overflowActions[Number(nativeEvent.event)]?.onPress() |
| 127 | } |
| 128 | > |
| 129 | {(open) => ( |
| 130 | <MaterialIconButton |
| 131 | accessibilityLabel="More actions" |
| 132 | icon="ellipsis" |
| 133 | tintColorClassName="accent-header-foreground" |
| 134 | onPress={open} |
| 135 | /> |
| 136 | )} |
| 137 | </AndroidAnchoredMenu> |
| 138 | ) : null} |
| 139 | {props.trailing} |
| 140 | </View> |
| 141 | </View> |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | export function AndroidSheetHeader( |
| 146 | props: Omit<Parameters<typeof AndroidScreenHeader>[0], "embedded">, |
| 147 | ) { |
| 148 | return <AndroidScreenHeader {...props} embedded />; |
| 149 | } |