| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { |
| 3 | BackHandler, |
| 4 | Keyboard, |
| 5 | Pressable, |
| 6 | TextInput, |
| 7 | type TextInputInstance, |
| 8 | View, |
| 9 | } from "react-native"; |
| 10 | import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; |
| 11 | import { NativeStackScreenOptions } from "../native/StackHeader"; |
| 12 | import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; |
| 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 14 | import { AndroidScreenHeader } from "./AndroidScreenHeader"; |
| 15 | import { ScreenHeaderButton } from "./ScreenHeaderButton.android"; |
| 16 | import { SymbolView } from "./AppSymbol"; |
| 17 | import { ControlPillMenu } from "./ControlPill"; |
| 18 | import { MaterialSearchField } from "./MaterialSearchField"; |
| 19 | import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; |
| 20 | import type { ScreenHeaderProps } from "./ScreenHeader.types"; |
| 21 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 22 | |
| 23 | export function ScreenHeader(props: ScreenHeaderProps) { |
| 24 | const { search } = props; |
| 25 | const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); |
| 26 | const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing(); |
| 27 | const { themeVariables } = useAppearancePreferences(); |
| 28 | const inputRef = useRef<TextInputInstance>(null); |
| 29 | const [searchOpen, setSearchOpen] = useState(false); |
| 30 | const searching = search !== undefined && (searchOpen || search.value.length > 0); |
| 31 | const onSearchChange = search?.onChangeText; |
| 32 | const closeSearch = useCallback(() => { |
| 33 | onSearchChange?.(""); |
| 34 | setSearchOpen(false); |
| 35 | Keyboard.dismiss(); |
| 36 | }, [onSearchChange]); |
| 37 | useEffect(() => { |
| 38 | if (!searching || search?.mode === "inline") return; |
| 39 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { |
| 40 | closeSearch(); |
| 41 | return true; |
| 42 | }); |
| 43 | return () => subscription.remove(); |
| 44 | }, [closeSearch, searching, search?.mode]); |
| 45 | const menuView = props.menus?.map((menu) => ( |
| 46 | <ControlPillMenu |
| 47 | key={menu.title} |
| 48 | actions={androidHeaderMenuActions(menu.items)} |
| 49 | isAnchoredToRight |
| 50 | title={menu.status ?? menu.title} |
| 51 | onPressAction={({ nativeEvent }) => { |
| 52 | const action = findHeaderMenuAction(menu.items, nativeEvent.event); |
| 53 | if (action && !action.disabled) action.onPress(); |
| 54 | }} |
| 55 | > |
| 56 | {search?.mode === "inline" ? ( |
| 57 | <Pressable |
| 58 | accessibilityLabel={menu.title} |
| 59 | accessibilityRole="button" |
| 60 | className="items-center justify-center rounded-full bg-subtle" |
| 61 | style={{ width: buttonSize, height: buttonSize }} |
| 62 | > |
| 63 | <SymbolView |
| 64 | name={menu.icon} |
| 65 | size={smallIconSize} |
| 66 | tintColorClassName="accent-header-foreground" |
| 67 | type="monochrome" |
| 68 | /> |
| 69 | </Pressable> |
| 70 | ) : ( |
| 71 | <ScreenHeaderButton accessibilityLabel={menu.title} icon={menu.icon} /> |
| 72 | )} |
| 73 | </ControlPillMenu> |
| 74 | )); |
| 75 | const options = ( |
| 76 | <NativeStackScreenOptions |
| 77 | options={{ |
| 78 | ...props.options, |
| 79 | ...(props.matchSearchSurface |
| 80 | ? { contentStyle: { backgroundColor: themeVariables["--color-header"] } } |
| 81 | : undefined), |
| 82 | headerShown: false, |
| 83 | title: props.title, |
| 84 | }} |
| 85 | optionsVersion={props.optionsVersion} |
| 86 | /> |
| 87 | ); |
| 88 | if (search?.mode === "inline") { |
| 89 | return ( |
| 90 | <> |
| 91 | {options} |
| 92 | <View |
| 93 | className="border-b border-header-border bg-header px-3" |
| 94 | style={{ |
| 95 | paddingTop, |
| 96 | paddingBottom: 8.75 * scale, |
| 97 | borderBottomWidth: 0, |
| 98 | }} |
| 99 | > |
| 100 | <View className="flex-row items-center gap-2" style={{ minHeight: buttonSize }}> |
| 101 | {props.onBack ? ( |
| 102 | <Pressable |
| 103 | accessibilityLabel="Navigate up" |
| 104 | accessibilityRole="button" |
| 105 | hitSlop={8} |
| 106 | onPress={props.onBack} |
| 107 | className="items-center justify-center" |
| 108 | style={{ width: buttonSize, height: buttonSize }} |
| 109 | > |
| 110 | <SymbolView |
| 111 | name="chevron.left" |
| 112 | size={iconSize} |
| 113 | tintColorClassName="accent-header-foreground" |
| 114 | type="monochrome" |
| 115 | /> |
| 116 | </Pressable> |
| 117 | ) : null} |
| 118 | <View |
| 119 | className="flex-1 flex-row items-center bg-input" |
| 120 | style={{ |
| 121 | minHeight: Math.max(48, 38.5 * scale), |
| 122 | gap: 8.75 * scale, |
| 123 | borderRadius: 14 * scale, |
| 124 | paddingHorizontal: 12.25 * scale, |
| 125 | }} |
| 126 | > |
| 127 | <SymbolView |
| 128 | name="magnifyingglass" |
| 129 | size={Math.round(17 * scale)} |
| 130 | tintColorClassName="accent-header-foreground" |
| 131 | type="monochrome" |
| 132 | /> |
| 133 | <TextInput |
| 134 | accessibilityLabel={search.placeholder} |
| 135 | autoCapitalize="none" |
| 136 | onChangeText={search.onChangeText} |
| 137 | value={search.value} |
| 138 | placeholder={ |
| 139 | search.compactToolbar |
| 140 | ? (search.compactPlaceholder ?? search.placeholder) |
| 141 | : search.placeholder |
| 142 | } |
| 143 | placeholderTextColorClassName="accent-placeholder" |
| 144 | className="flex-1 text-base font-sans text-header-foreground" |
| 145 | style={{ paddingVertical: 7 * scale }} |
| 146 | /> |
| 147 | </View> |
| 148 | {menuView} |
| 149 | </View> |
| 150 | </View> |
| 151 | </> |
| 152 | ); |
| 153 | } |
| 154 | const header = ( |
| 155 | <AndroidScreenHeader |
| 156 | title={props.title} |
| 157 | subtitle={props.subtitle} |
| 158 | onBack={props.onBack} |
| 159 | leading={props.sidebar !== false ? <AndroidWorkspaceSidebarButton /> : undefined} |
| 160 | hideBottomBorder={props.hideBottomBorder} |
| 161 | actions={ |
| 162 | search |
| 163 | ? [ |
| 164 | { |
| 165 | accessibilityLabel: search.placeholder, |
| 166 | icon: "magnifyingglass", |
| 167 | onPress: () => setSearchOpen(true), |
| 168 | }, |
| 169 | ...(search.refreshInToolbar && search.onRefresh |
| 170 | ? [ |
| 171 | { |
| 172 | accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh", |
| 173 | icon: "arrow.clockwise" as const, |
| 174 | onPress: search.onRefresh, |
| 175 | }, |
| 176 | ] |
| 177 | : []), |
| 178 | ...(props.actions ?? []), |
| 179 | ] |
| 180 | : props.actions |
| 181 | } |
| 182 | trailing={ |
| 183 | <> |
| 184 | {menuView} |
| 185 | {props.trailing} |
| 186 | </> |
| 187 | } |
| 188 | /> |
| 189 | ); |
| 190 | return ( |
| 191 | <> |
| 192 | {options} |
| 193 | {search ? ( |
| 194 | <View> |
| 195 | <View |
| 196 | pointerEvents={searching ? "none" : "auto"} |
| 197 | accessibilityElementsHidden={searching} |
| 198 | importantForAccessibility={searching ? "no-hide-descendants" : "auto"} |
| 199 | style={{ opacity: searching ? 0 : 1 }} |
| 200 | > |
| 201 | {header} |
| 202 | </View> |
| 203 | {searching ? ( |
| 204 | <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}> |
| 205 | <View className="flex-1 flex-row items-center gap-1"> |
| 206 | <ScreenHeaderButton |
| 207 | accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"} |
| 208 | icon="arrow.left" |
| 209 | onPress={closeSearch} |
| 210 | /> |
| 211 | <MaterialSearchField |
| 212 | inputRef={inputRef} |
| 213 | accessibilityLabel={search.placeholder} |
| 214 | clearAccessibilityLabel={search.clearAccessibilityLabel ?? "Clear search"} |
| 215 | placeholder={search.placeholder} |
| 216 | value={search.value} |
| 217 | onChangeText={search.onChangeText} |
| 218 | /> |
| 219 | </View> |
| 220 | </View> |
| 221 | ) : null} |
| 222 | </View> |
| 223 | ) : ( |
| 224 | header |
| 225 | )} |
| 226 | </> |
| 227 | ); |
| 228 | } |