1import { type RefObject, use, useRef, useState } from "react";
2import { Keyboard, type TextInputInstance, View } from "react-native";
3import { useHardwareBack } from "../../lib/backHandler";
4import { useToolbarLayout } from "./useToolbarLayout";
5import { BottomSheetContext } from "./BottomSheetContext";
6import { RoutedFormSheetContext } from "./RoutedFormSheetContext";
7import { NativeStackScreenOptions } from "../../native/StackHeader";
8import { useSidebarToggle } from "../../features/layout/useSidebarToggle";
9import { useAdaptiveWorkspaceLayout } from "../../features/layout/adaptive-workspace-context";
10import { InspectorCloseContext } from "../../features/layout/inspector-bar-store";
11import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
12import { AndroidHeaderIconButton, AndroidScreenHeader } from "./AndroidScreenHeader.android";
13import { ScreenHeaderButton } from "./ScreenHeaderButton.android";
14import { Menu } from "./Menu";
15import { Button } from "./Button";
16import { SearchField } from "./SearchField";
17import { findAppBarMenuItem, menuActionsFromAppBarItems } from "./menuActionItems";
18import type { AppBarMenuItem, AppBarProps, AppBarSearch } from "./AppBar.types";
19import { useHasWorkspaceAppBar, WorkspaceAppBar } from "./WorkspaceAppBar.android";
20
21function selectMenuItem(items: ReadonlyArray<AppBarMenuItem>, id: string) {
22 const action = findAppBarMenuItem(items, id);
23 if (action && !action.disabled) action.onPress();
24}
25
26interface SearchState {
27 readonly searching: boolean;
28 readonly open: () => void;
29 readonly close: () => void;
30 readonly inputRef: RefObject<TextInputInstance | null>;
31}
32
33export function AppBar(barProps: AppBarProps) {
34 const { layout } = useAdaptiveWorkspaceLayout();
35 const props = {
36 ...barProps,
37 onBack:
38 barProps.onBack ??
39 (layout.usesSplitView ? barProps.backInSplitView?.onPress : barProps.onBackToList),
40 };
41 return props.search ? (
42 <SearchableAppBar {...props} search={props.search} />
43 ) : (
44 <AppBarView {...props} />
45 );
46}
47
48// Only a bar with search registers for hardware back: its focus check needs a navigation
49// context, and sheet headers such as Arrange threads render outside one.
50function SearchableAppBar(props: AppBarProps & { readonly search: AppBarSearch }) {
51 const { search } = props;
52 const [searchOpen, setSearchOpen] = useState(false);
53 const inputRef = useRef<TextInputInstance>(null);
54 const inline = search.mode === "inline";
55 const controlled = search.active !== undefined;
56 const searching =
57 inline || (controlled ? search.active === true : searchOpen || search.value.length > 0);
58 const open = () => {
59 if (!controlled) setSearchOpen(true);
60 search.onOpen?.();
61 inputRef.current?.focus();
62 };
63 const close = () => {
64 if (!controlled) {
65 search.onChangeText("");
66 setSearchOpen(false);
67 Keyboard.dismiss();
68 }
69 search.onClose?.();
70 };
71 useHardwareBack(searching && !inline && !controlled, () => {
72 close();
73 return true;
74 });
75 return <AppBarView {...props} searchState={{ searching, open, close, inputRef }} />;
76}
77
78function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) {
79 const { search, searchState } = props;
80 const inFormSheet = use(RoutedFormSheetContext);
81 const { paddingTop, paddingBottom } = useToolbarLayout(props.sheet || inFormSheet);
82 const { themeVariables } = useAppearancePreferences();
83 const inWorkspace = useHasWorkspaceAppBar();
84 const sidebarToggle = useSidebarToggle();
85 const closeInspector = use(InspectorCloseContext);
86 // A bottom sheet owns the pull at its top, so refresh needs a button there.
87 const inBottomSheet = use(BottomSheetContext);
88 const inline = search?.mode === "inline";
89 const searching = searchState?.searching === true;
90 const actions = props.refresh
91 ? [
92 ...(props.actions ?? []),
93 { ...props.refresh, icon: "arrow.clockwise" as const, overflowOnly: !inBottomSheet },
94 ]
95 : props.actions;
96 const openSearch = () => searchState?.open();
97 const closeSearch = () => searchState?.close();
98
99 const searchLabel = search?.accessibilityLabel ?? search?.placeholder ?? "";
100 const searchField =
101 search && searchState ? (
102 <SearchField
103 inputRef={searchState.inputRef}
104 accessibilityLabel={searchLabel}
105 clearAccessibilityLabel={search.clearAccessibilityLabel ?? "Clear search"}
106 placeholder={
107 search.compactToolbar
108 ? (search.compactPlaceholder ?? search.placeholder)
109 : search.placeholder
110 }
111 value={search.value}
112 onChangeText={search.onChangeText}
113 autoFocus={!inline}
114 />
115 ) : null;
116
117 if (inWorkspace) {
118 const menu = props.menus?.[0];
119 const icon = typeof menu?.icon === "string" ? menu.icon : menu?.icon.android;
120 return (
121 <WorkspaceAppBar
122 title={props.title}
123 titleContent={props.titleContent}
124 search={
125 search
126 ? {
127 accessibilityLabel: searchLabel,
128 active: searching,
129 field: searchField,
130 onOpen: openSearch,
131 onClose: closeSearch,
132 }
133 : undefined
134 }
135 filter={
136 menu
137 ? {
138 accessibilityLabel: menu.title,
139 customized: icon?.endsWith(".fill") === true,
140 actions: menuActionsFromAppBarItems(menu.items),
141 onSelect: (id) => selectMenuItem(menu.items, id),
142 }
143 : undefined
144 }
145 />
146 );
147 }
148
149 const menuView = props.menus?.map((menu) => (
150 <Menu
151 key={menu.title}
152 actions={menuActionsFromAppBarItems(menu.items)}
153 isAnchoredToRight
154 title={menu.status ?? menu.title}
155 onPressAction={({ nativeEvent }) => selectMenuItem(menu.items, nativeEvent.event)}
156 >
157 <ScreenHeaderButton accessibilityLabel={menu.title} icon={menu.icon} />
158 </Menu>
159 ));
160 const confirmButton = props.confirm ? (
161 <View className="self-center">
162 <Button
163 label={props.confirm.label}
164 tone="text"
165 loading={props.confirm.loading}
166 disabled={props.confirm.disabled}
167 onPress={props.confirm.onPress}
168 />
169 </View>
170 ) : null;
171 const options =
172 props.embedded || props.sheet ? null : (
173 <NativeStackScreenOptions
174 options={{
175 ...(props.matchSearchSurface
176 ? { contentStyle: { backgroundColor: themeVariables["--color-header"] } }
177 : undefined),
178 headerShown: false,
179 title: props.title,
180 }}
181 optionsVersion={props.optionsVersion}
182 />
183 );
184 const header = (
185 <AndroidScreenHeader
186 title={props.title}
187 titleContent={props.titleContent}
188 subtitle={props.subtitle}
189 onBack={props.onClose ? undefined : props.onBack}
190 leading={
191 (props.onClose ? (
192 <AndroidHeaderIconButton
193 accessibilityLabel={props.closeAccessibilityLabel ?? "Close"}
194 icon="xmark"
195 onPress={props.onClose}
196 />
197 ) : undefined) ??
198 props.leading ??
199 // Only a real button: any leading content takes the bar's navigation slot.
200 (sidebarToggle && !props.sheet ? (
201 <AndroidHeaderIconButton
202 accessibilityLabel={sidebarToggle.accessibilityLabel}
203 icon={sidebarToggle.icon}
204 onPress={sidebarToggle.onPress}
205 />
206 ) : undefined)
207 }
208 embedded={props.sheet}
209 hideBottomBorder={props.hideBottomBorder}
210 actions={
211 search && !inline
212 ? [
213 {
214 accessibilityLabel: searchLabel,
215 icon: "magnifyingglass",
216 onPress: openSearch,
217 },
218 ...(actions ?? []),
219 ]
220 : actions
221 }
222 trailing={
223 menuView?.length || props.trailing || props.confirm || closeInspector ? (
224 <>
225 {menuView}
226 {props.trailing}
227 {confirmButton}
228 {closeInspector ? (
229 <AndroidHeaderIconButton
230 accessibilityLabel="Close inspector"
231 icon="xmark"
232 onPress={closeInspector}
233 />
234 ) : null}
235 </>
236 ) : undefined
237 }
238 />
239 );
240 if (!search) {
241 return (
242 <>
243 {options}
244 {header}
245 </>
246 );
247 }
248 return (
249 <>
250 {options}
251 <View>
252 <View
253 pointerEvents={searching ? "none" : "auto"}
254 accessibilityElementsHidden={searching}
255 importantForAccessibility={searching ? "no-hide-descendants" : "auto"}
256 style={{ opacity: searching ? 0 : 1 }}
257 >
258 {header}
259 </View>
260 {searching ? (
261 <View
262 className="absolute inset-0 bg-header"
263 // Match Material TopAppBar's 4dp icon inset so buttons stay where the bar drew them.
264 style={{ paddingTop, paddingBottom, paddingHorizontal: 4 }}
265 >
266 <View className="flex-1 flex-row items-center gap-1">
267 {inline ? (
268 props.onClose ? (
269 <ScreenHeaderButton
270 accessibilityLabel={props.closeAccessibilityLabel ?? "Close"}
271 icon="xmark"
272 onPress={props.onClose}
273 />
274 ) : props.onBack ? (
275 <ScreenHeaderButton
276 accessibilityLabel="Navigate up"
277 icon="arrow.left"
278 onPress={props.onBack}
279 />
280 ) : null
281 ) : (
282 <ScreenHeaderButton
283 accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"}
284 icon="arrow.left"
285 onPress={closeSearch}
286 />
287 )}
288 {searchField}
289 {inline ? menuView : null}
290 {inline ? confirmButton : null}
291 </View>
292 </View>
293 ) : null}
294 </View>
295 </>
296 );
297}