1import type {
2 NativeStackHeaderItem,
3 NativeStackHeaderItemMenu,
4 NativeStackNavigationOptions,
5} from "@react-navigation/native-stack";
6import { isValidElement, useEffect, useRef } from "react";
7import { Platform, StyleSheet } from "react-native";
8import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme";
9import { useNativeLayoutMetrics } from "../native/native-layout-metrics";
10import {
11 SearchBar,
12 Stack,
13 type HeaderBarButtonMailSearchToolbarItem,
14 type SearchBarCommands,
15 type StackHeaderConfigProps,
16 type StackHeaderMenuElementIOS,
17 type StackHeaderMenuIOS,
18 type HeaderBarButtonItemWithMenu,
19 type HeaderBarButtonSearchBarPlacementItem,
20} from "react-native-screens";
21
22type HeaderItem = NonNullable<NonNullable<StackHeaderConfigProps["ios"]>["leadingItems"]>[number];
23type ToolbarItem =
24 | NativeStackHeaderItem
25 | HeaderBarButtonMailSearchToolbarItem
26 | HeaderBarButtonSearchBarPlacementItem;
27type Options = NativeStackNavigationOptions & {
28 readonly unstable_navigationItemStyle?: "editor" | "navigator";
29 readonly headerSubtitle?: string;
30 readonly unstable_headerSubtitle?: string;
31 readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string };
32 readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[];
33 readonly unstable_headerToolbarItems?: () => ToolbarItem[];
34};
35
36type MenuElementInput =
37 | NativeStackHeaderItemMenu["menu"]["items"][number]
38 | HeaderBarButtonItemWithMenu["menu"]["items"][number];
39type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] };
40
41function convertIcon(
42 icon:
43 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
44 | HeaderBarButtonItemWithMenu["icon"],
45) {
46 if (!icon) return undefined;
47 if (icon.type === "image")
48 return icon.tinted === false
49 ? { type: "imageSource" as const, imageSource: icon.source }
50 : { type: "templateSource" as const, templateSource: icon.source };
51 return icon;
52}
53
54function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS {
55 const children = menu.items.flatMap<StackHeaderMenuElementIOS>((item, index) => {
56 if ("hidden" in item && item.hidden) return [];
57 const childId = `${id}:${index}`;
58 if (item.type === "submenu") {
59 return [
60 {
61 ...convertMenu({ items: item.items }, childId),
62 title: "label" in item ? item.label : item.title,
63 displayInline:
64 "inline" in item
65 ? item.inline
66 : "displayInline" in item
67 ? item.displayInline
68 : undefined,
69 },
70 ];
71 }
72 // Use actions even for checked entries: the app owns their durable state.
73 return [
74 {
75 id: childId,
76 type: "menuItem" as const,
77 itemType: "action" as const,
78 title: "label" in item ? item.label : item.title,
79 icon: convertIcon(item.icon),
80 onPress: item.disabled ? undefined : item.onPress,
81 keepsMenuPresented: item.keepsMenuPresented,
82 disabled: item.disabled,
83 destructive: item.destructive,
84 subtitle:
85 ("subtitle" in item ? item.subtitle : undefined) ??
86 ("description" in item ? item.description : undefined),
87 state: item.state,
88 },
89 ];
90 });
91 return { id, type: "menu", title: menu.title, children };
92}
93
94function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] {
95 return items.map((item, index): HeaderItem => {
96 const id =
97 item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`);
98 switch (item.type) {
99 case "spacing":
100 return "flexible" in item && item.flexible === true
101 ? { type: "spacer", id, sizing: "flexible" }
102 : { type: "spacer", id, sizing: "fixed", width: item.spacing };
103 case "custom":
104 return {
105 type: "item",
106 id,
107 render: () => item.element,
108 hidesSharedBackground: item.hidesSharedBackground,
109 };
110 case "menu":
111 return {
112 type: "item",
113 id,
114 identifier: item.identifier,
115 title: item.label || item.accessibilityLabel || undefined,
116 axisBehavior: item.axisBehavior,
117 icon: convertIcon(item.icon),
118 menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`),
119 variant: item.variant,
120 tintColor: item.tintColor,
121 hidesSharedBackground: item.hidesSharedBackground,
122 };
123 case "button":
124 return {
125 type: "item",
126 id,
127 identifier: item.identifier,
128 title: item.label || item.accessibilityLabel || undefined,
129 axisBehavior: item.axisBehavior,
130 icon: convertIcon(item.icon),
131 onPress: item.disabled ? undefined : item.onPress,
132 variant: item.variant,
133 tintColor: item.tintColor,
134 hidesSharedBackground: item.hidesSharedBackground,
135 };
136 }
137 });
138}
139
140/** Translate the existing screen options to v5's native header API. */
141export function V5StackHeader(props: {
142 readonly options: Options;
143 readonly canGoBack: boolean;
144 readonly primary?: boolean;
145}) {
146 const { options } = props;
147 const theme = useMobileNavigationTheme();
148 const nativeMetrics = useNativeLayoutMetrics();
149 const searchRef = useRef<SearchBarCommands>(null);
150 const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack };
151 const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading");
152 const trailing = convertItems(
153 [
154 ...(options.unstable_headerCenterItems?.() ?? []),
155 ...(options.unstable_headerRightItems?.(itemProps) ?? []),
156 ],
157 "trailing",
158 );
159 const toolbar = options.unstable_headerToolbarItems?.() ?? [];
160 const mailSearch = toolbar.find(
161 (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar",
162 );
163 const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad;
164 const bottom = toolbar.flatMap<HeaderItem>((item, index) => {
165 if (item.type === "mailSearchToolbar") return [];
166 if (item.type === "searchBarPlacement")
167 return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }];
168 return convertItems([item], `toolbar:${index}`);
169 });
170 if (mailSearch) {
171 if (mailSearch.filterMenu)
172 bottom.push({
173 type: "item",
174 id: mailSearch.filterButtonId ?? "filter",
175 axisBehavior: bottomSearch ? "horizontalOnly" : undefined,
176 icon: {
177 type: "sfSymbol",
178 name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease",
179 },
180 menu: convertMenu(mailSearch.filterMenu, "filter-menu"),
181 });
182 if (bottomSearch) {
183 if (bottom.length)
184 bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 });
185 bottom.push({
186 type: "item",
187 id: "home-search",
188 axisBehavior: "horizontalOnly",
189 searchBarPlacement: true,
190 });
191 if (mailSearch.onComposePress)
192 bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });
193 }
194 if (mailSearch.onComposePress)
195 bottom.push({
196 type: "item",
197 id: mailSearch.composeButtonId ?? "compose",
198 axisBehavior: bottomSearch ? "horizontalOnly" : undefined,
199 identifier: "workspace-trailing-action",
200 icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" },
201 onPress: mailSearch.onComposePress,
202 });
203 }
204 // Duo keeps compose at the bottom of a visible sidebar. On a single panel,
205 // the primary column owns the rail too; its separate UIToolbar is inaccessible.
206 if (
207 nativeMetrics &&
208 nativeMetrics.verticalBarEdge !== "none" &&
209 (!props.primary || nativeMetrics.horizontalSizeClass === "compact")
210 ) {
211 trailing.push(...bottom);
212 bottom.length = 0;
213 }
214 const left = options.headerLeft?.(itemProps);
215 const right = options.headerRight?.(itemProps);
216 if (!options.unstable_headerLeftItems && isValidElement(left))
217 leading.push({ type: "item", id: "custom-left", render: () => left });
218 if (!options.unstable_headerRightItems && isValidElement(right))
219 trailing.push({ type: "item", id: "custom-right", render: () => right });
220 const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title;
221 const titleElement =
222 typeof options.headerTitle === "function"
223 ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor })
224 : undefined;
225 const searchOptions = options.headerSearchBarOptions;
226 const autofocusRef = searchOptions?.ref ?? searchRef;
227 const searchHasFocus = useRef(false);
228 useEffect(() => {
229 searchHasFocus.current = false;
230 if (!searchOptions?.autoFocus) return;
231 // UIKit attaches integrated search during the toolbar transition. Native
232 // onFocus acknowledges readiness; a single frame can reach it too early.
233 const deadline = Date.now() + 500;
234 let frame = 0;
235 const focusWhenAttached = () => {
236 if (searchHasFocus.current) return;
237 autofocusRef.current?.focus();
238 if (Date.now() < deadline) frame = requestAnimationFrame(focusWhenAttached);
239 };
240 frame = requestAnimationFrame(focusWhenAttached);
241 return () => cancelAnimationFrame(frame);
242 }, [searchOptions?.autoFocus, autofocusRef]);
243 const titleStyle = StyleSheet.flatten(options.headerTitleStyle);
244 const headerStyle = StyleSheet.flatten(options.headerStyle);
245 const appearance = {
246 // A clear color also removes UIKit's material. Let UIKit supply it for
247 // translucent bars so scrolling content can appear beneath native blur.
248 backgroundColor: options.headerTransparent
249 ? undefined
250 : (headerStyle?.backgroundColor ?? theme.colors.card),
251 shadowColor: options.headerShadowVisible === false ? "transparent" : undefined,
252 titleFontSize: titleStyle?.fontSize,
253 titleFontFamily: titleStyle?.fontFamily,
254 titleFontWeight: titleStyle?.fontWeight,
255 titleFontColor: titleStyle?.color,
256 subtitleFontSize: options.headerSubtitleStyle?.fontSize,
257 subtitleFontColor: options.headerSubtitleStyle?.color,
258 };
259 return (
260 <Stack.HeaderConfig
261 hidden={options.headerShown === false}
262 transparent={options.headerTransparent}
263 title={title}
264 subtitle={options.unstable_headerSubtitle ?? options.headerSubtitle}
265 backButtonHidden={options.headerBackVisible === false}
266 ios={{
267 navigationItemStyle: options.unstable_navigationItemStyle,
268 tintColor: options.headerTintColor,
269 searchBar:
270 mailSearch || searchOptions ? (
271 <SearchBar
272 {...searchOptions}
273 ref={searchOptions?.ref ?? searchRef}
274 hideNavigationBar={false}
275 hideWhenScrolling={false}
276 allowToolbarIntegration={searchOptions?.allowToolbarIntegration ?? true}
277 placement={bottomSearch ? "integrated" : (searchOptions?.placement ?? "automatic")}
278 placeholder={mailSearch?.placeholder ?? searchOptions?.placeholder ?? "Search"}
279 onFocus={(event) => {
280 searchHasFocus.current = true;
281 searchOptions?.onFocus?.(event);
282 }}
283 onChangeText={(event) => {
284 mailSearch?.onSearchTextChange?.(event.nativeEvent.text);
285 searchOptions?.onChangeText?.(event);
286 }}
287 />
288 ) : undefined,
289 toolbarItems: bottom,
290 leadingItems: leading,
291 trailingItems: trailing,
292 titleItem: isValidElement(titleElement)
293 ? { id: "title", render: () => titleElement }
294 : undefined,
295 largeTitleEnabled: options.headerLargeTitle,
296 backButtonDisplayMode: options.headerBackButtonDisplayMode ?? "minimal",
297 backButtonMenuEnabled: options.headerBackButtonMenuEnabled ?? true,
298 backButtonTitle: options.headerBackTitle,
299 standardAppearance: appearance,
300 scrollEdgeAppearance: appearance,
301 }}
302 />
303 );
304}