authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:14-07:00
log4fb9dfc8b8600d830b36da1c15c5f0cd21627e77
tree4833b52d13464699314b4e3e271cf9e571876d32
parent7230f6b1be4d1ad839b9a6ab45d0d70b4abc9a54
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): centralize platform header rendering (#12388)


16 files changed, 1042 insertions(+), 382 deletions(-)

apps/mobile/src/components/ScreenHeader.android.tsx created+203
......@@ -0,0 +1,203 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4import { NativeStackScreenOptions } from "../native/StackHeader";
5import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";
6import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
7import { AndroidScreenHeader } from "./AndroidScreenHeader";
8import { ScreenHeaderButton } from "./ScreenHeaderButton.android";
9import { SymbolView } from "./AppSymbol";
10import { ControlPillMenu } from "./ControlPill";
11import { MaterialSearchField } from "./MaterialSearchField";
12import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
13import type { ScreenHeaderProps } from "./ScreenHeader.types";
14
15export function ScreenHeader(props: ScreenHeaderProps) {
16 const { search } = props;
17 const insets = useSafeAreaInsets();
18 const { themeVariables } = useAppearancePreferences();
19 const inputRef = useRef<TextInput>(null);
20 const [searchOpen, setSearchOpen] = useState(false);
21 const searching = search !== undefined && (searchOpen || search.value.length > 0);
22 const onSearchChange = search?.onChangeText;
23 const closeSearch = useCallback(() => {
24 onSearchChange?.("");
25 setSearchOpen(false);
26 Keyboard.dismiss();
27 }, [onSearchChange]);
28 useEffect(() => {
29 if (!searching || search?.mode === "inline") return;
30 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
31 closeSearch();
32 return true;
33 });
34 return () => subscription.remove();
35 }, [closeSearch, searching, search?.mode]);
36 const menuView = props.menus?.map((menu) => (
37 <ControlPillMenu
38 key={menu.title}
39 actions={androidHeaderMenuActions(menu.items)}
40 isAnchoredToRight
41 title={menu.status ?? menu.title}
42 onPressAction={({ nativeEvent }) => {
43 const action = findHeaderMenuAction(menu.items, nativeEvent.event);
44 if (action && !action.disabled) action.onPress();
45 }}
46 >
47 {search?.mode === "inline" ? (
48 <Pressable
49 accessibilityLabel={menu.title}
50 accessibilityRole="button"
51 className="size-11 items-center justify-center rounded-full bg-subtle"
52 >
53 <SymbolView
54 name={menu.icon}
55 size={16}
56 tintColorClassName="accent-icon"
57 type="monochrome"
58 />
59 </Pressable>
60 ) : (
61 <ScreenHeaderButton accessibilityLabel={menu.title} icon={menu.icon} />
62 )}
63 </ControlPillMenu>
64 ));
65 const options = (
66 <NativeStackScreenOptions
67 options={{
68 ...props.options,
69 ...(props.matchSearchSurface
70 ? { contentStyle: { backgroundColor: themeVariables["--color-header"] } }
71 : undefined),
72 headerShown: false,
73 title: props.title,
74 }}
75 optionsVersion={props.optionsVersion}
76 />
77 );
78 if (search?.mode === "inline") {
79 return (
80 <>
81 {options}
82 <View
83 className="border-b border-header-border bg-header px-3 pb-2.5"
84 style={{ paddingTop: Math.max(insets.top, 12), borderBottomWidth: 0 }}
85 >
86 <View className="min-h-12 flex-row items-center gap-2">
87 {props.onBack ? (
88 <Pressable
89 accessibilityLabel="Navigate up"
90 accessibilityRole="button"
91 hitSlop={8}
92 onPress={props.onBack}
93 className="size-11 items-center justify-center"
94 >
95 <SymbolView
96 name="chevron.left"
97 size={24}
98 tintColorClassName="accent-foreground"
99 type="monochrome"
100 />
101 </Pressable>
102 ) : null}
103 <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5">
104 <SymbolView
105 name="magnifyingglass"
106 size={17}
107 tintColorClassName="accent-icon"
108 type="monochrome"
109 />
110 <TextInput
111 accessibilityLabel={search.placeholder}
112 autoCapitalize="none"
113 onChangeText={search.onChangeText}
114 value={search.value}
115 placeholder={search.placeholder}
116 placeholderTextColorClassName="accent-placeholder"
117 className="flex-1 py-2 text-base font-sans text-foreground"
118 />
119 </View>
120 {menuView}
121 </View>
122 </View>
123 </>
124 );
125 }
126 const header = (
127 <AndroidScreenHeader
128 title={props.title}
129 subtitle={props.subtitle}
130 onBack={props.onBack}
131 leading={props.sidebar !== false ? <AndroidWorkspaceSidebarButton /> : undefined}
132 hideBottomBorder={props.hideBottomBorder}
133 actions={
134 search
135 ? [
136 {
137 accessibilityLabel: search.placeholder,
138 icon: "magnifyingglass",
139 onPress: () => setSearchOpen(true),
140 },
141 ...(search.onRefresh
142 ? [
143 {
144 accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh",
145 icon: "arrow.clockwise" as const,
146 onPress: search.onRefresh,
147 },
148 ]
149 : []),
150 ...(props.actions ?? []),
151 ]
152 : props.actions
153 }
154 trailing={
155 <>
156 {menuView}
157 {props.trailing}
158 </>
159 }
160 />
161 );
162 return (
163 <>
164 {options}
165 {search ? (
166 <View>
167 <View
168 pointerEvents={searching ? "none" : "auto"}
169 accessibilityElementsHidden={searching}
170 importantForAccessibility={searching ? "no-hide-descendants" : "auto"}
171 style={{ opacity: searching ? 0 : 1 }}
172 >
173 {header}
174 </View>
175 {searching ? (
176 <View
177 className="absolute inset-0 bg-header px-2 pb-2"
178 style={{ paddingTop: Math.max(insets.top, 12) }}
179 >
180 <View className="min-h-14 flex-1 flex-row items-center gap-1">
181 <ScreenHeaderButton
182 accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"}
183 icon="arrow.left"
184 onPress={closeSearch}
185 />
186 <MaterialSearchField
187 inputRef={inputRef}
188 accessibilityLabel={search.placeholder}
189 clearAccessibilityLabel={search.clearAccessibilityLabel ?? "Clear search"}
190 placeholder={search.placeholder}
191 value={search.value}
192 onChangeText={search.onChangeText}
193 />
194 </View>
195 </View>
196 ) : null}
197 </View>
198 ) : (
199 header
200 )}
201 </>
202 );
203}
apps/mobile/src/components/ScreenHeader.tsx created+164
......@@ -0,0 +1,164 @@
1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
2import { useId } from "react";
3import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
4import { ScreenHeaderButton } from "./ScreenHeaderButton";
5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
6import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
7import {
8 createNativeMailSearchToolbarItem,
9 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
10} from "../features/layout/native-mail-search-toolbar";
11import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
12import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";
13import type { AppSymbolName } from "./AppSymbol";
14
15function iosIcon(icon: AppSymbolName) {
16 return typeof icon === "string" ? icon : icon.ios;
17}
18
19type MailMenu = NonNullable<HeaderBarButtonMailSearchToolbarItem["filterMenu"]>;
20function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["items"] {
21 return items.map((item) =>
22 "items" in item
23 ? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) }
24 : {
25 type: "action",
26 title: item.title,
27 state: item.selected ? "on" : "off",
28 disabled: item.disabled,
29 onPress: item.onPress,
30 },
31 );
32}
33
34export function ScreenHeader(props: ScreenHeaderProps) {
35 const headerId = useId();
36 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
37 const { themeVariables } = useAppearancePreferences();
38 const { search, menus } = props;
39 const menu = menus?.[0];
40 const compactSearch =
41 search !== undefined &&
42 (search.compactToolbar ?? !layout.usesSplitView) &&
43 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
44 const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
45 const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;
46 return (
47 <>
48 <NativeStackScreenOptions
49 optionsVersion={[
50 props.optionsVersion,
51 compactSearch ? menu : undefined,
52 compactSearch ? refresh : undefined,
53 ]}
54 options={{
55 headerShown: true,
56 title: props.title,
57 unstable_headerSubtitle: props.subtitle || undefined,
58 ...(props.matchSearchSurface
59 ? { contentStyle: { backgroundColor: themeVariables["--color-sheet-solid"] } }
60 : undefined),
61 ...(search
62 ? {
63 unstable_headerToolbarItems: compactSearch
64 ? () => [
65 createNativeMailSearchToolbarItem({
66 showsSearchDismissButton: true,
67 placeholder: search.compactPlaceholder ?? search.placeholder,
68 onSearchTextChange: search.onChangeText,
69 searchTextChangeId: `${headerId}-search-text`,
70 ...(refresh
71 ? {
72 composeButtonId: `${headerId}-refresh`,
73 composeSystemImageName: "arrow.clockwise",
74 onComposePress: refresh,
75 }
76 : undefined),
77 ...(menu
78 ? {
79 filterButtonId: `${headerId}-filter`,
80 filterSystemImageName: iosIcon(menu.icon),
81 filterMenu: { title: menu.title, items: mailMenuItems(menu.items) },
82 }
83 : undefined),
84 }),
85 ]
86 : undefined,
87 headerSearchBarOptions: compactSearch
88 ? undefined
89 : {
90 allowToolbarIntegration: true,
91 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline"
92 ? { placement: "integratedButton" as const }
93 : undefined),
94 autoCapitalize: "none",
95 hideNavigationBar: false,
96 ...(search.mode === "inline" ? { obscureBackground: false } : undefined),
97 placeholder: search.placeholder,
98 onChangeText: (event) => search.onChangeText(event.nativeEvent.text),
99 onCancelButtonPress: () => search.onChangeText(""),
100 },
101 }
102 : undefined),
103 ...props.options,
104 }}
105 />
106 {props.sidebar !== false && layout.usesSplitView ? (
107 <NativeHeaderToolbar placement="left">
108 {props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? (
109 <ScreenHeaderButton
110 {...props.backInSplitView}
111 onPress={props.backInSplitView.onPress ?? props.onBack}
112 />
113 ) : null}
114 <ScreenHeaderButton
115 accessibilityLabel={
116 panes.primarySidebarVisible ? `Maximize ${props.title.toLowerCase()}` : "Show threads"
117 }
118 icon={
119 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
120 }
121 onPress={togglePrimarySidebar}
122 separateBackground={
123 props.backInSplitView ? props.backInSplitView.separateBackground : true
124 }
125 />
126 </NativeHeaderToolbar>
127 ) : null}
128 {(props.actions?.length ||
129 (!compactSearch && refresh) ||
130 visibleMenus.length ||
131 props.trailing) &&
132 props.options?.unstable_headerRightItems === undefined ? (
133 <NativeHeaderToolbar placement="right">
134 {props.actions?.map((action) => (
135 <ScreenHeaderButton
136 key={action.accessibilityLabel}
137 {...action}
138 icon={iosIcon(action.icon)}
139 separateBackground
140 />
141 ))}
142 {refresh && !compactSearch ? (
143 <ScreenHeaderButton
144 accessibilityLabel={search?.refreshAccessibilityLabel}
145 icon="arrow.clockwise"
146 onPress={refresh}
147 separateBackground
148 />
149 ) : null}
150 {visibleMenus.map(createNativeHeaderMenu)}
151 {props.trailing}
152 </NativeHeaderToolbar>
153 ) : null}
154 </>
155 );
156}
157
158export type {
159 ScreenHeaderAction,
160 ScreenHeaderMenu,
161 ScreenHeaderMenuItem,
162 ScreenHeaderProps,
163 ScreenHeaderSearch,
164} from "./ScreenHeader.types";
apps/mobile/src/components/ScreenHeader.types.ts created+76
......@@ -0,0 +1,76 @@
1import type { ReactNode } from "react";
2import type { ColorValue } from "react-native";
3import type { AppNativeStackNavigationOptions } from "../native/StackHeader";
4import type { AppSymbolName } from "./AppSymbol";
5
6export interface ScreenHeaderAction {
7 readonly accessibilityLabel: string;
8 readonly icon: AppSymbolName;
9 readonly onPress: () => void;
10 readonly disabled?: boolean;
11 readonly selected?: boolean;
12 readonly tintColor?: ColorValue;
13}
14
15export type ScreenHeaderMenuItem =
16 | {
17 readonly id: string;
18 readonly title: string;
19 readonly icon?: string;
20 readonly subtitle?: string;
21 readonly disabled?: boolean;
22 readonly selected?: boolean;
23 readonly onPress: () => void;
24 }
25 | {
26 readonly id: string;
27 readonly title?: string;
28 readonly icon?: string;
29 readonly inline?: boolean;
30 readonly items: ReadonlyArray<ScreenHeaderMenuItem>;
31 };
32
33export interface ScreenHeaderMenu {
34 readonly title: string;
35 readonly icon: AppSymbolName;
36 readonly status?: string;
37 readonly separateBackground?: boolean;
38 readonly items: ReadonlyArray<ScreenHeaderMenuItem>;
39}
40
41export interface ScreenHeaderSearch {
42 readonly value: string;
43 readonly onChangeText: (value: string) => void;
44 readonly placeholder: string;
45 readonly mode?: "inline" | "collapsible";
46 readonly compactToolbar?: boolean;
47 readonly compactPlaceholder?: string;
48 readonly closeAccessibilityLabel?: string;
49 readonly clearAccessibilityLabel?: string;
50 readonly onRefresh?: () => void;
51 readonly refreshAccessibilityLabel?: string;
52 readonly refreshInToolbar?: boolean;
53}
54
55export interface ScreenHeaderProps {
56 readonly title: string;
57 readonly subtitle?: string;
58 readonly actions?: ReadonlyArray<ScreenHeaderAction>;
59 readonly menus?: ReadonlyArray<ScreenHeaderMenu>;
60 readonly search?: ScreenHeaderSearch;
61 readonly onBack?: () => void;
62 readonly sidebar?: boolean;
63 readonly backInSplitView?: {
64 readonly accessibilityLabel: string;
65 readonly icon: string;
66 readonly separateBackground?: boolean;
67 readonly onPress?: () => void;
68 };
69 readonly hideBottomBorder?: boolean;
70 readonly trailing?: ReactNode;
71 /** Search scenes use sheet colors on iOS and header colors on Android. */
72 readonly matchSearchSurface?: boolean;
73 /** Custom native layouts can override options without duplicating the header renderer. */
74 readonly options?: AppNativeStackNavigationOptions;
75 readonly optionsVersion?: unknown;
76}
apps/mobile/src/components/ScreenHeaderButton.android.tsx created+1
......@@ -0,0 +1 @@
1export { AndroidHeaderIconButton as ScreenHeaderButton } from "./AndroidScreenHeader";
apps/mobile/src/components/ScreenHeaderButton.tsx created+4
......@@ -0,0 +1,4 @@
1import { NativeHeaderToolbar } from "../native/StackHeader";
2export const ScreenHeaderButton: (
3 props: Parameters<typeof NativeHeaderToolbar.Button>[0] & { readonly selected?: boolean },
4) => ReturnType<typeof NativeHeaderToolbar.Button> = NativeHeaderToolbar.Button;
apps/mobile/src/components/headerMenu.android.ts created+43
......@@ -0,0 +1,43 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import type { ScreenHeaderMenuItem } from "./ScreenHeader.types";
3
4export function androidHeaderMenuActions(items: ReadonlyArray<ScreenHeaderMenuItem>): MenuAction[] {
5 return items.flatMap((item): MenuAction[] =>
6 "items" in item
7 ? item.inline && !item.title
8 ? androidHeaderMenuActions(item.items)
9 : [
10 {
11 id: item.id,
12 title: item.title ?? "",
13 image: item.icon,
14 subactions: androidHeaderMenuActions(item.items),
15 },
16 ]
17 : [
18 {
19 id: item.id,
20 title: item.title,
21 subtitle: item.subtitle,
22 image: item.icon,
23 state: item.selected ? "on" : undefined,
24 attributes: item.disabled ? { disabled: true } : undefined,
25 },
26 ],
27 );
28}
29
30export function findHeaderMenuAction(
31 items: ReadonlyArray<ScreenHeaderMenuItem>,
32 id: string,
33): Extract<ScreenHeaderMenuItem, { readonly onPress: () => void }> | undefined {
34 for (const item of items) {
35 if ("items" in item) {
36 const action = findHeaderMenuAction(item.items, id);
37 if (action) return action;
38 } else if (item.id === id) {
39 return item;
40 }
41 }
42 return undefined;
43}
apps/mobile/src/components/headerMenu.test.ts created+66
......@@ -0,0 +1,66 @@
1import { describe, expect, it } from "vite-plus/test";
2import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
3import type { ScreenHeaderMenuItem } from "./ScreenHeader.types";
4
5const items: ReadonlyArray<ScreenHeaderMenuItem> = [
6 {
7 id: "sessions",
8 title: "Sessions",
9 items: [
10 {
11 id: "terminal-session:main",
12 title: "Main shell",
13 subtitle: "Running · workspace",
14 selected: true,
15 onPress: () => {},
16 },
17 { id: "closed", title: "Closed shell", disabled: true, onPress: () => {} },
18 ],
19 },
20];
21
22describe("header menus", () => {
23 it("keeps named sections as Android submenus", () => {
24 const actions = androidHeaderMenuActions([
25 {
26 id: "text-size",
27 title: "Text size",
28 inline: true,
29 items: [{ id: "font-increase", title: "A+", onPress: () => {} }],
30 },
31 ]);
32 expect(actions[0]?.title).toBe("Text size");
33 expect(actions[0]?.subactions?.[0]?.id).toBe("font-increase");
34 });
35
36 it("flattens inline groups while keeping their action IDs", () => {
37 const actions = androidHeaderMenuActions([
38 { id: "modes", inline: true, items: [{ id: "code", title: "Code", onPress: () => {} }] },
39 ]);
40 expect(actions.map(({ id }) => id)).toEqual(["code"]);
41 });
42
43 it("keeps stable action IDs, selection, and disabled state inside submenus", () => {
44 const menu = androidHeaderMenuActions(items);
45 expect(menu[0]?.id).toBe("sessions");
46 expect(menu[0]?.subactions?.[0]).toMatchObject({
47 id: "terminal-session:main",
48 title: "Main shell",
49 subtitle: "Running · workspace",
50 state: "on",
51 });
52 expect(menu[0]?.subactions?.[1]).toMatchObject({
53 id: "closed",
54 attributes: { disabled: true },
55 });
56 });
57
58 it("resolves nested action IDs without dispatching group or missing IDs", () => {
59 expect(findHeaderMenuAction(items, "terminal-session:main")).toMatchObject({
60 title: "Main shell",
61 selected: true,
62 });
63 expect(findHeaderMenuAction(items, "sessions")).toBeUndefined();
64 expect(findHeaderMenuAction(items, "removed-session")).toBeUndefined();
65 });
66});
apps/mobile/src/components/nativeHeaderMenu.ios.tsx created+44
......@@ -0,0 +1,44 @@
1import { NativeHeaderToolbar } from "../native/StackHeader";
2import type { ScreenHeaderMenu, ScreenHeaderMenuItem } from "./ScreenHeader.types";
3
4function renderMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>) {
5 return items.map((item) =>
6 "items" in item ? (
7 <NativeHeaderToolbar.Menu
8 key={item.id}
9 title={item.title}
10 icon={item.icon}
11 inline={item.inline}
12 >
13 {renderMenuItems(item.items)}
14 </NativeHeaderToolbar.Menu>
15 ) : (
16 <NativeHeaderToolbar.MenuAction
17 key={item.id}
18 icon={item.icon}
19 subtitle={item.subtitle}
20 disabled={item.disabled}
21 isOn={item.selected}
22 onPress={item.onPress}
23 >
24 {item.title}
25 </NativeHeaderToolbar.MenuAction>
26 ),
27 );
28}
29
30/** Returns direct native items for toolbar serialization, including nested menu data. */
31export function createNativeHeaderMenu(menu: ScreenHeaderMenu) {
32 return (
33 <NativeHeaderToolbar.Menu
34 key={menu.title}
35 title={menu.title}
36 accessibilityLabel={menu.title}
37 icon={typeof menu.icon === "string" ? menu.icon : menu.icon.ios}
38 separateBackground={menu.separateBackground ?? true}
39 >
40 {menu.status ? <NativeHeaderToolbar.Label>{menu.status}</NativeHeaderToolbar.Label> : null}
41 {renderMenuItems(menu.items)}
42 </NativeHeaderToolbar.Menu>
43 );
44}
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+2-22
......@@ -1,6 +1,5 @@
11import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
22import { CameraView, useCameraPermissions } from "expo-camera";
3import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
43import {
54 StackActions,
65 useNavigation,
......@@ -194,7 +193,8 @@ export function ConnectionsNewRouteScreen({
194193 actions={[
195194 {
196195 accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
197 icon: showScanner ? "xmark" : "camera",
196 icon: showScanner ? "xmark" : Platform.OS === "ios" ? "qrcode.viewfinder" : "camera",
197 tintColor: headerIconColor,
198198 onPress: () => {
199199 if (showScanner) {
200200 closeScanner();
......@@ -205,26 +205,6 @@ export function ConnectionsNewRouteScreen({
205205 },
206206 ]}
207207 >
208 <NativeStackScreenOptions
209 options={{ title: showScanner ? "Scan QR Code" : "Add Environment" }}
210 />
211 {Platform.OS !== "android" ? (
212 <NativeHeaderToolbar placement="right">
213 <NativeHeaderToolbar.Button
214 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
215 onPress={() => {
216 if (showScanner) {
217 closeScanner();
218 } else {
219 void openScanner();
220 }
221 }}
222 separateBackground
223 tintColor={headerIconColor}
224 />
225 </NativeHeaderToolbar>
226 ) : null}
227
228208 <ScrollView
229209 contentInsetAdjustmentBehavior="automatic"
230210 showsVerticalScrollIndicator={false}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+1-17
......@@ -1,9 +1,7 @@
11import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { NativeHeaderToolbar } from "../../native/StackHeader";
32import { useNavigation } from "@react-navigation/native";
43import type { EnvironmentId } from "@t3tools/contracts";
54import { useCallback, useState } from "react";
6import { Platform } from "react-native";
75import { useSafeAreaInsets } from "react-native-safe-area-context";
86
97import { SettingsScreen } from "./components/SettingsScreen";
......@@ -82,6 +80,7 @@ export function SettingsEnvironmentsRouteScreen() {
8280 {
8381 accessibilityLabel: "Add environment",
8482 icon: "plus",
83 tintColor: headerIconColor,
8584 onPress: () =>
8685 navigation.navigate("SettingsSheet", {
8786 screen: "SettingsContent",
......@@ -90,21 +89,6 @@ export function SettingsEnvironmentsRouteScreen() {
9089 },
9190 ]}
9291 >
93 {Platform.OS !== "android" ? (
94 <NativeHeaderToolbar placement="right">
95 <NativeHeaderToolbar.Button
96 icon="plus"
97 onPress={() =>
98 navigation.navigate("SettingsSheet", {
99 screen: "SettingsContent",
100 params: { screen: "SettingsEnvironmentNew" },
101 })
102 }
103 separateBackground
104 tintColor={headerIconColor}
105 />
106 </NativeHeaderToolbar>
107 ) : null}
10892 <ScrollView
10993 contentInsetAdjustmentBehavior="automatic"
11094 showsVerticalScrollIndicator={false}
apps/mobile/src/features/settings/components/SettingsScreen.tsx+12-18
......@@ -1,16 +1,15 @@
11import { useNavigation } from "@react-navigation/native";
2import type { ComponentProps, ReactNode } from "react";
2import type { ReactNode } from "react";
33import { Platform, View } from "react-native";
44
5import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader";
5import type { ScreenHeaderProps } from "../../../components/ScreenHeader.types";
6import { ScreenHeader } from "../../../components/ScreenHeader";
67import { MaterialScreenContent as SettingsScreenContent } from "../../../components/MaterialScreenContent";
7import { NativeStackScreenOptions } from "../../../native/StackHeader";
8import { AndroidWorkspaceSidebarButton } from "../../layout/workspace-sidebar-toolbar";
98
109export { SettingsScreenContent };
1110
1211export function SettingsScreen(
13 props: Pick<ComponentProps<typeof AndroidScreenHeader>, "title" | "actions" | "trailing"> & {
12 props: Pick<ScreenHeaderProps, "title" | "actions" | "trailing"> & {
1413 readonly children: ReactNode;
1514 /** A native form sheet already owns its rounded outer frame. */
1615 readonly formSheet?: boolean;
......@@ -20,19 +19,14 @@ export function SettingsScreen(
2019
2120 return (
2221 <View collapsable={false} className="flex-1 bg-sheet">
23 {Platform.OS === "android" ? (
24 <>
25 <NativeStackScreenOptions options={{ headerShown: false }} />
26 <AndroidScreenHeader
27 title={props.title}
28 leading={props.formSheet ? undefined : <AndroidWorkspaceSidebarButton />}
29 actions={props.actions}
30 trailing={props.trailing}
31 onBack={() => navigation.goBack()}
32 hideBottomBorder={!props.formSheet}
33 />
34 </>
35 ) : null}
22 <ScreenHeader
23 title={props.title}
24 actions={props.actions}
25 trailing={Platform.OS === "android" ? props.trailing : undefined}
26 sidebar={Platform.OS === "android" && !props.formSheet}
27 onBack={() => navigation.goBack()}
28 hideBottomBorder={!props.formSheet}
29 />
3630 {props.formSheet ? (
3731 props.children
3832 ) : (
apps/mobile/src/features/threads/ThreadGitControls.android.tsx created+30
......@@ -0,0 +1,30 @@
1import type {
2 ThreadGitControls as IosThreadGitControls,
3 ThreadGitMenu as IosThreadGitMenu,
4 useThreadGitRightHeaderItems as useIosThreadGitRightHeaderItems,
5} from "./ThreadGitControls";
6
7export type { ThreadGitMenuProps } from "./ThreadGitControls";
8
9type ThreadGitControlsProps = Parameters<typeof IosThreadGitControls>[0];
10const EMPTY_HEADER_ITEMS: ReturnType<typeof useIosThreadGitRightHeaderItems> = [];
11
12export function useThreadGitRightHeaderItems(_props: ThreadGitControlsProps) {
13 return EMPTY_HEADER_ITEMS;
14}
15
16export function useThreadGitCenterHeaderItems(_props: ThreadGitControlsProps) {
17 return EMPTY_HEADER_ITEMS;
18}
19
20export function ThreadGitControls(_props: ThreadGitControlsProps) {
21 return null;
22}
23
24export function ThreadGitMenu(_props: Parameters<typeof IosThreadGitMenu>[0]) {
25 return null;
26}
27
28export function useThreadGitMenuDefinition(_props: Parameters<typeof IosThreadGitMenu>[0]) {
29 return null;
30}
apps/mobile/src/features/threads/ThreadGitControls.tsx+56-39
......@@ -1,3 +1,5 @@
1import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios";
2import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types";
13import {
24 EnvironmentId,
35 type GitRunStackedActionResult,
......@@ -426,6 +428,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
426428 ) : null}
427429 {showActionControls ? (
428430 <NativeHeaderToolbar.Menu
431 accessibilityLabel="Open terminal"
429432 icon="terminal"
430433 disabled={!props.canOpenTerminal}
431434 separateBackground
......@@ -489,7 +492,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
489492 separateBackground
490493 />
491494 ) : null}
492 {showActionControls ? <ThreadGitMenu {...props} /> : null}
495 {showActionControls ? createNativeHeaderMenu(threadGitMenuDefinition(props, model)) : null}
493496 </NativeHeaderToolbar>
494497 );
495498}
......@@ -500,43 +503,57 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
500503 * chat header and the review screen's toolbar.
501504 */
502505export function ThreadGitMenu(props: ThreadGitMenuProps) {
503 const model = useThreadGitControlModel(props);
506 const menu = useThreadGitMenuDefinition(props);
507 return menu ? createNativeHeaderMenu(menu) : null;
508}
504509
505 return (
506 <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
507 <NativeHeaderToolbar.MenuAction
508 icon="point.topleft.down.curvedto.point.bottomright.up"
509 disabled
510 onPress={() => {}}
511 subtitle={compactMenuStatus(props.gitStatus)}
512 >
513 <NativeHeaderToolbar.Label>
514 {compactMenuBranchLabel(model.currentBranchLabel)}
515 </NativeHeaderToolbar.Label>
516 </NativeHeaderToolbar.MenuAction>
517 <NativeHeaderToolbar.MenuAction
518 icon={model.quickActionIcon}
519 disabled={model.quickAction.disabled}
520 onPress={() => void model.runQuickAction()}
521 subtitle={model.quickActionHint ?? undefined}
522 >
523 <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label>
524 </NativeHeaderToolbar.MenuAction>
525 <NativeHeaderToolbar.MenuAction
526 icon="text.bubble"
527 disabled={!model.isRepo}
528 onPress={model.openReview}
529 subtitle="Turn diffs and worktree changes"
530 >
531 <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label>
532 </NativeHeaderToolbar.MenuAction>
533 <NativeHeaderToolbar.MenuAction
534 icon="ellipsis"
535 onPress={model.openGitInspector}
536 subtitle="Commit, files, branches"
537 >
538 <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label>
539 </NativeHeaderToolbar.MenuAction>
540 </NativeHeaderToolbar.Menu>
541 );
510/** Returns menu data because native toolbars serialize direct items rather than rendering component children. */
511export function useThreadGitMenuDefinition(props: ThreadGitMenuProps): ScreenHeaderMenu | null {
512 return threadGitMenuDefinition(props, useThreadGitControlModel(props));
513}
514
515function threadGitMenuDefinition(
516 props: ThreadGitMenuProps,
517 model: ReturnType<typeof useThreadGitControlModel>,
518): ScreenHeaderMenu {
519 return {
520 title: "Git controls",
521 icon: "point.topleft.down.curvedto.point.bottomright.up",
522 separateBackground: false,
523 items: [
524 {
525 id: "git-status",
526 title: compactMenuBranchLabel(model.currentBranchLabel),
527 icon: "point.topleft.down.curvedto.point.bottomright.up",
528 disabled: true,
529 subtitle: compactMenuStatus(props.gitStatus),
530 onPress: () => {},
531 },
532 {
533 id: "git-quick-action",
534 title: model.quickAction.label,
535 icon: model.quickActionIcon,
536 disabled: model.quickAction.disabled,
537 subtitle: model.quickActionHint ?? undefined,
538 onPress: () => {
539 void model.runQuickAction();
540 },
541 },
542 {
543 id: "git-review",
544 title: "Review changes",
545 icon: "text.bubble",
546 disabled: !model.isRepo,
547 subtitle: "Turn diffs and worktree changes",
548 onPress: model.openReview,
549 },
550 {
551 id: "git-more",
552 title: "More",
553 icon: "ellipsis",
554 subtitle: "Commit, files, branches",
555 onPress: model.openGitInspector,
556 },
557 ],
558 };
542559}
apps/mobile/src/native/NativeHeaderToolbar.android.tsx created+43
......@@ -0,0 +1,43 @@
1import type { NativeHeaderToolbar as IosNativeHeaderToolbar } from "./NativeHeaderToolbar";
2
3function NativeHeaderToolbarRoot(_props: Parameters<typeof IosNativeHeaderToolbar>[0]) {
4 return null;
5}
6
7function NativeHeaderToolbarButton(_props: Parameters<typeof IosNativeHeaderToolbar.Button>[0]) {
8 return null;
9}
10
11function NativeHeaderToolbarMenu(_props: Parameters<typeof IosNativeHeaderToolbar.Menu>[0]) {
12 return null;
13}
14
15function NativeHeaderToolbarMenuAction(
16 _props: Parameters<typeof IosNativeHeaderToolbar.MenuAction>[0],
17) {
18 return null;
19}
20
21function NativeHeaderToolbarLabel(_props: Parameters<typeof IosNativeHeaderToolbar.Label>[0]) {
22 return null;
23}
24
25function NativeHeaderToolbarSpacer(_props: Parameters<typeof IosNativeHeaderToolbar.Spacer>[0]) {
26 return null;
27}
28
29function NativeHeaderToolbarSearchBarSlot() {
30 return null;
31}
32
33// Native header item factories are iOS-only; Android owns its in-flow header.
34export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
35 Button: NativeHeaderToolbarButton,
36 Label: NativeHeaderToolbarLabel,
37 Menu: Object.assign(NativeHeaderToolbarMenu, {
38 Action: NativeHeaderToolbarMenuAction,
39 }),
40 MenuAction: NativeHeaderToolbarMenuAction,
41 SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
42 Spacer: NativeHeaderToolbarSpacer,
43});
apps/mobile/src/native/NativeHeaderToolbar.tsx created+294
......@@ -0,0 +1,294 @@
1import { useNavigation, type ParamListBase } from "@react-navigation/native";
2import type {
3 NativeStackHeaderItem,
4 NativeStackHeaderItemMenu,
5 NativeStackNavigationOptions,
6 NativeStackNavigationProp,
7} from "@react-navigation/native-stack";
8import {
9 Children,
10 isValidElement,
11 useLayoutEffect,
12 useMemo,
13 type ReactElement,
14 type ReactNode,
15} from "react";
16import type { ColorValue } from "react-native";
17
18function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {
19 return useNavigation<NativeStackNavigationProp<ParamListBase>>();
20}
21
22function labelFromChildren(children: ReactNode): string {
23 const parts: string[] = [];
24 Children.forEach(children, (child) => {
25 if (typeof child === "string" || typeof child === "number") {
26 parts.push(String(child));
27 } else if (isValidElement<{ children?: ReactNode }>(child)) {
28 parts.push(labelFromChildren(child.props.children));
29 }
30 });
31 return parts.join("");
32}
33
34type NativeStackHeaderIcon = NonNullable<
35 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
36>;
37type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
38 unstable_headerToolbarItems?: () => NativeStackHeaderItem[];
39};
40
41function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
42 if (typeof icon !== "string") {
43 return undefined;
44 }
45 return { type: "sfSymbol", name: icon as never };
46}
47
48type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };
49
50function elementTypeName(element: ReactElement): string | undefined {
51 const type = element.type;
52 if (typeof type === "function") {
53 return (type as { displayName?: string; name?: string }).displayName ?? type.name;
54 }
55 return undefined;
56}
57
58function convertMenuAction(
59 element: ReactElement<ToolbarElementProps>,
60): NativeStackHeaderItemMenu["menu"]["items"][number] | null {
61 const typeName = elementTypeName(element);
62 if (typeName === "NativeHeaderToolbarMenuAction") {
63 const label = labelFromChildren(element.props.children);
64 return {
65 type: "action",
66 label,
67 description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined,
68 disabled: Boolean(element.props.disabled),
69 icon: iconFromProp(element.props.icon),
70 onPress:
71 typeof element.props.onPress === "function"
72 ? (element.props.onPress as () => void)
73 : () => undefined,
74 state: element.props.isOn === true ? "on" : undefined,
75 destructive: Boolean(element.props.destructive),
76 discoverabilityLabel:
77 typeof element.props.discoverabilityLabel === "string"
78 ? element.props.discoverabilityLabel
79 : undefined,
80 };
81 }
82
83 if (typeName === "NativeHeaderToolbarMenu") {
84 return {
85 type: "submenu",
86 label:
87 typeof element.props.title === "string"
88 ? element.props.title
89 : labelFromChildren(element.props.children),
90 icon: iconFromProp(element.props.icon),
91 inline: Boolean(element.props.inline),
92 items: collectMenuItems(element.props.children),
93 };
94 }
95
96 return null;
97}
98
99function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] {
100 const items: NativeStackHeaderItemMenu["menu"]["items"] = [];
101 Children.forEach(children, (child) => {
102 if (!isValidElement<ToolbarElementProps>(child)) {
103 return;
104 }
105 const item = convertMenuAction(child);
106 if (item) {
107 items.push(item);
108 return;
109 }
110 items.push(...collectMenuItems(child.props.children));
111 });
112 return items;
113}
114
115function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
116 if (!isValidElement<ToolbarElementProps>(child)) {
117 return null;
118 }
119
120 const typeName = elementTypeName(child);
121 if (typeName === "NativeHeaderToolbarButton") {
122 return {
123 type: "button",
124 label: typeof child.props.label === "string" ? child.props.label : "",
125 accessibilityLabel:
126 typeof child.props.accessibilityLabel === "string"
127 ? child.props.accessibilityLabel
128 : undefined,
129 disabled: Boolean(child.props.disabled),
130 icon: iconFromProp(child.props.icon),
131 onPress:
132 typeof child.props.onPress === "function"
133 ? (child.props.onPress as () => void)
134 : () => undefined,
135 sharesBackground: !child.props.separateBackground,
136 tintColor: child.props.tintColor as ColorValue | undefined,
137 variant: "plain",
138 };
139 }
140
141 if (typeName === "NativeHeaderToolbarMenu") {
142 return {
143 type: "menu",
144 label: typeof child.props.title === "string" ? child.props.title : "",
145 accessibilityLabel:
146 typeof child.props.accessibilityLabel === "string"
147 ? child.props.accessibilityLabel
148 : undefined,
149 disabled: Boolean(child.props.disabled),
150 icon: iconFromProp(child.props.icon),
151 menu: {
152 title: typeof child.props.title === "string" ? child.props.title : undefined,
153 items: collectMenuItems(child.props.children),
154 },
155 sharesBackground: !child.props.separateBackground,
156 tintColor: child.props.tintColor as ColorValue | undefined,
157 variant: "plain",
158 };
159 }
160
161 if (typeName === "NativeHeaderToolbarSpacer") {
162 return {
163 type: "spacing",
164 spacing: typeof child.props.width === "number" ? child.props.width : 8,
165 flexible: Boolean(child.props.flexible),
166 } as NativeStackHeaderItem;
167 }
168
169 return null;
170}
171
172function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
173 const items: NativeStackHeaderItem[] = [];
174 Children.forEach(children, (child) => {
175 const item = convertToolbarChild(child);
176 if (item) {
177 if (item.type === "spacing") {
178 // Native inserts spacing items at `index`, treating a missing index
179 // as 0 — which would move the spacer in front of earlier siblings.
180 (item as { index?: number }).index = items.length;
181 }
182 items.push(item);
183 }
184 });
185 return items;
186}
187
188function NativeHeaderToolbarRoot(props: {
189 readonly placement?: "left" | "right" | "bottom";
190 readonly children?: ReactNode;
191}) {
192 const navigation = useNativeStackNavigation();
193 const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
194
195 // Swap toolbar owners before paint so split and compact headers cannot clear each other.
196 useLayoutEffect(() => {
197 if (!navigation) {
198 return;
199 }
200 if (props.placement === "bottom") {
201 navigation.setOptions({
202 unstable_headerToolbarItems: () => items,
203 } as NativeStackOptionsWithToolbar);
204 return () => {
205 navigation.setOptions({
206 unstable_headerToolbarItems: () => [],
207 } as NativeStackOptionsWithToolbar);
208 };
209 }
210 if (props.placement === "left") {
211 navigation.setOptions({ unstable_headerLeftItems: () => items });
212 return () => {
213 navigation.setOptions({ unstable_headerLeftItems: () => [] });
214 };
215 }
216 navigation.setOptions({ unstable_headerRightItems: () => items });
217 return () => {
218 navigation.setOptions({ unstable_headerRightItems: () => [] });
219 };
220 }, [items, navigation, props.placement]);
221
222 return null;
223}
224
225function NativeHeaderToolbarButton(_props: {
226 readonly accessibilityLabel?: string;
227 readonly disabled?: boolean;
228 readonly icon?: string;
229 readonly label?: string;
230 readonly onPress?: () => void;
231 readonly separateBackground?: boolean;
232 readonly tintColor?: ColorValue;
233}) {
234 return null;
235}
236NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton";
237
238function NativeHeaderToolbarMenu(_props: {
239 readonly accessibilityLabel?: string;
240 readonly children?: ReactNode;
241 readonly disabled?: boolean;
242 readonly icon?: string;
243 readonly inline?: boolean;
244 readonly separateBackground?: boolean;
245 readonly tintColor?: ColorValue;
246 readonly title?: string;
247}) {
248 return null;
249}
250NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu";
251
252function NativeHeaderToolbarMenuAction(_props: {
253 readonly children?: ReactNode;
254 readonly destructive?: boolean;
255 readonly disabled?: boolean;
256 readonly discoverabilityLabel?: string;
257 readonly icon?: string;
258 readonly isOn?: boolean;
259 readonly onPress?: () => void;
260 readonly subtitle?: string;
261}) {
262 return null;
263}
264NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
265
266function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
267 return null;
268}
269NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
270
271function NativeHeaderToolbarSpacer(_props: {
272 readonly flexible?: boolean;
273 readonly sharesBackground?: boolean;
274 readonly width?: number;
275}) {
276 return null;
277}
278NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer";
279
280function NativeHeaderToolbarSearchBarSlot() {
281 return null;
282}
283NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot";
284
285export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
286 Button: NativeHeaderToolbarButton,
287 Label: NativeHeaderToolbarLabel,
288 Menu: Object.assign(NativeHeaderToolbarMenu, {
289 Action: NativeHeaderToolbarMenuAction,
290 }),
291 MenuAction: NativeHeaderToolbarMenuAction,
292 SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
293 Spacer: NativeHeaderToolbarSpacer,
294});
apps/mobile/src/native/StackHeader.tsx+3-286
......@@ -1,22 +1,13 @@
11import { useNavigation, type ParamListBase } from "@react-navigation/native";
22import type {
3 NativeStackHeaderItem,
4 NativeStackHeaderItemMenu,
53 NativeStackNavigationOptions,
64 NativeStackNavigationProp,
75} from "@react-navigation/native-stack";
8import {
9 Children,
10 isValidElement,
11 useEffect,
12 useLayoutEffect,
13 useMemo,
14 useRef,
15 type ReactElement,
16 type ReactNode,
17} from "react";
6import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
187import type { ColorValue } from "react-native";
198
9export { NativeHeaderToolbar } from "./NativeHeaderToolbar";
10
2011export {
2112 nativeHeaderScrollEdgeEffects,
2213 nativeTopScrollEdgeEffect,
......@@ -196,277 +187,3 @@ export function NativeStackScreenOptions(props: {
196187
197188 return null;
198189}
199
200function labelFromChildren(children: ReactNode): string {
201 const parts: string[] = [];
202 Children.forEach(children, (child) => {
203 if (typeof child === "string" || typeof child === "number") {
204 parts.push(String(child));
205 } else if (isValidElement<{ children?: ReactNode }>(child)) {
206 parts.push(labelFromChildren(child.props.children));
207 }
208 });
209 return parts.join("");
210}
211
212type NativeStackHeaderIcon = NonNullable<
213 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
214>;
215type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
216 unstable_headerToolbarItems?: () => NativeStackHeaderItem[];
217};
218
219function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
220 if (typeof icon !== "string") {
221 return undefined;
222 }
223 return { type: "sfSymbol", name: icon as never };
224}
225
226type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };
227
228function elementTypeName(element: ReactElement): string | undefined {
229 const type = element.type;
230 if (typeof type === "function") {
231 return (type as { displayName?: string; name?: string }).displayName ?? type.name;
232 }
233 return undefined;
234}
235
236function convertMenuAction(
237 element: ReactElement<ToolbarElementProps>,
238): NativeStackHeaderItemMenu["menu"]["items"][number] | null {
239 const typeName = elementTypeName(element);
240 if (typeName === "NativeHeaderToolbarMenuAction") {
241 const label = labelFromChildren(element.props.children);
242 return {
243 type: "action",
244 label,
245 description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined,
246 disabled: Boolean(element.props.disabled),
247 icon: iconFromProp(element.props.icon),
248 onPress:
249 typeof element.props.onPress === "function"
250 ? (element.props.onPress as () => void)
251 : () => undefined,
252 state: element.props.isOn === true ? "on" : undefined,
253 destructive: Boolean(element.props.destructive),
254 discoverabilityLabel:
255 typeof element.props.discoverabilityLabel === "string"
256 ? element.props.discoverabilityLabel
257 : undefined,
258 };
259 }
260
261 if (typeName === "NativeHeaderToolbarMenu") {
262 return {
263 type: "submenu",
264 label:
265 typeof element.props.title === "string"
266 ? element.props.title
267 : labelFromChildren(element.props.children),
268 icon: iconFromProp(element.props.icon),
269 inline: Boolean(element.props.inline),
270 items: collectMenuItems(element.props.children),
271 };
272 }
273
274 return null;
275}
276
277function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] {
278 const items: NativeStackHeaderItemMenu["menu"]["items"] = [];
279 Children.forEach(children, (child) => {
280 if (!isValidElement<ToolbarElementProps>(child)) {
281 return;
282 }
283 const item = convertMenuAction(child);
284 if (item) {
285 items.push(item);
286 return;
287 }
288 items.push(...collectMenuItems(child.props.children));
289 });
290 return items;
291}
292
293function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
294 if (!isValidElement<ToolbarElementProps>(child)) {
295 return null;
296 }
297
298 const typeName = elementTypeName(child);
299 if (typeName === "NativeHeaderToolbarButton") {
300 return {
301 type: "button",
302 label: typeof child.props.label === "string" ? child.props.label : "",
303 accessibilityLabel:
304 typeof child.props.accessibilityLabel === "string"
305 ? child.props.accessibilityLabel
306 : undefined,
307 disabled: Boolean(child.props.disabled),
308 icon: iconFromProp(child.props.icon),
309 onPress:
310 typeof child.props.onPress === "function"
311 ? (child.props.onPress as () => void)
312 : () => undefined,
313 sharesBackground: !child.props.separateBackground,
314 tintColor: child.props.tintColor as ColorValue | undefined,
315 variant: "plain",
316 };
317 }
318
319 if (typeName === "NativeHeaderToolbarMenu") {
320 return {
321 type: "menu",
322 label: typeof child.props.title === "string" ? child.props.title : "",
323 accessibilityLabel:
324 typeof child.props.accessibilityLabel === "string"
325 ? child.props.accessibilityLabel
326 : undefined,
327 disabled: Boolean(child.props.disabled),
328 icon: iconFromProp(child.props.icon),
329 menu: {
330 title: typeof child.props.title === "string" ? child.props.title : undefined,
331 items: collectMenuItems(child.props.children),
332 },
333 sharesBackground: !child.props.separateBackground,
334 tintColor: child.props.tintColor as ColorValue | undefined,
335 variant: "plain",
336 };
337 }
338
339 if (typeName === "NativeHeaderToolbarSpacer") {
340 return {
341 type: "spacing",
342 spacing: typeof child.props.width === "number" ? child.props.width : 8,
343 flexible: Boolean(child.props.flexible),
344 } as NativeStackHeaderItem;
345 }
346
347 return null;
348}
349
350function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
351 const items: NativeStackHeaderItem[] = [];
352 Children.forEach(children, (child) => {
353 const item = convertToolbarChild(child);
354 if (item) {
355 if (item.type === "spacing") {
356 // Native inserts spacing items at `index`, treating a missing index
357 // as 0 — which would move the spacer in front of earlier siblings.
358 (item as { index?: number }).index = items.length;
359 }
360 items.push(item);
361 }
362 });
363 return items;
364}
365
366function NativeHeaderToolbarRoot(props: {
367 readonly placement?: "left" | "right" | "bottom";
368 readonly children?: ReactNode;
369}) {
370 const navigation = useNativeStackNavigation();
371 const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
372
373 // Swap toolbar owners before paint so split and compact headers cannot clear each other.
374 useLayoutEffect(() => {
375 if (!navigation) {
376 return;
377 }
378 if (props.placement === "bottom") {
379 navigation.setOptions({
380 unstable_headerToolbarItems: () => items,
381 } as NativeStackOptionsWithToolbar);
382 return () => {
383 navigation.setOptions({
384 unstable_headerToolbarItems: () => [],
385 } as NativeStackOptionsWithToolbar);
386 };
387 }
388 if (props.placement === "left") {
389 navigation.setOptions({ unstable_headerLeftItems: () => items });
390 return () => {
391 navigation.setOptions({ unstable_headerLeftItems: () => [] });
392 };
393 }
394 navigation.setOptions({ unstable_headerRightItems: () => items });
395 return () => {
396 navigation.setOptions({ unstable_headerRightItems: () => [] });
397 };
398 }, [items, navigation, props.placement]);
399
400 return null;
401}
402
403function NativeHeaderToolbarButton(_props: {
404 readonly accessibilityLabel?: string;
405 readonly disabled?: boolean;
406 readonly icon?: string;
407 readonly label?: string;
408 readonly onPress?: () => void;
409 readonly separateBackground?: boolean;
410 readonly tintColor?: ColorValue;
411}) {
412 return null;
413}
414NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton";
415
416function NativeHeaderToolbarMenu(_props: {
417 readonly accessibilityLabel?: string;
418 readonly children?: ReactNode;
419 readonly disabled?: boolean;
420 readonly icon?: string;
421 readonly inline?: boolean;
422 readonly separateBackground?: boolean;
423 readonly tintColor?: ColorValue;
424 readonly title?: string;
425}) {
426 return null;
427}
428NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu";
429
430function NativeHeaderToolbarMenuAction(_props: {
431 readonly children?: ReactNode;
432 readonly destructive?: boolean;
433 readonly disabled?: boolean;
434 readonly discoverabilityLabel?: string;
435 readonly icon?: string;
436 readonly isOn?: boolean;
437 readonly onPress?: () => void;
438 readonly subtitle?: string;
439}) {
440 return null;
441}
442NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
443
444function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
445 return null;
446}
447NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
448
449function NativeHeaderToolbarSpacer(_props: {
450 readonly flexible?: boolean;
451 readonly sharesBackground?: boolean;
452 readonly width?: number;
453}) {
454 return null;
455}
456NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer";
457
458function NativeHeaderToolbarSearchBarSlot() {
459 return null;
460}
461NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot";
462
463export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
464 Button: NativeHeaderToolbarButton,
465 Label: NativeHeaderToolbarLabel,
466 Menu: Object.assign(NativeHeaderToolbarMenu, {
467 Action: NativeHeaderToolbarMenuAction,
468 }),
469 MenuAction: NativeHeaderToolbarMenuAction,
470 SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
471 Spacer: NativeHeaderToolbarSpacer,
472});