1import { useCallback, useEffect, useRef, useState } from "react";
2import {
3 BackHandler,
4 Keyboard,
5 Pressable,
6 TextInput,
7 type TextInputInstance,
8 View,
9} from "react-native";
10import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout";
11import { NativeStackScreenOptions } from "../native/StackHeader";
12import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";
13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
14import { AndroidScreenHeader } from "./AndroidScreenHeader";
15import { ScreenHeaderButton } from "./ScreenHeaderButton.android";
16import { SymbolView } from "./AppSymbol";
17import { ControlPillMenu } from "./ControlPill";
18import { MaterialSearchField } from "./MaterialSearchField";
19import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
20import type { ScreenHeaderProps } from "./ScreenHeader.types";
21import { useAndroidControlSizing } from "./useAndroidControlSizing";
22
23export 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}