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 use,
14 type ReactElement,
15 type ReactNode,
16} from "react";
17import type { ColorValue } from "react-native";
18import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens";
19import { NativeWorkspaceTabActivityContext } from "./v5-workspace-context";
20
21function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {
22 return useNavigation<NativeStackNavigationProp<ParamListBase>>();
23}
24
25function labelFromChildren(children: ReactNode): string {
26 const parts: string[] = [];
27 Children.forEach(children, (child) => {
28 if (typeof child === "string" || typeof child === "number") {
29 parts.push(String(child));
30 } else if (isValidElement<{ children?: ReactNode }>(child)) {
31 parts.push(labelFromChildren(child.props.children));
32 }
33 });
34 return parts.join("");
35}
36
37type NativeStackHeaderIcon = NonNullable<
38 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
39>;
40type NativeStackHeaderAxisBehavior = Extract<
41 NativeStackHeaderItem,
42 { type: "button" }
43>["axisBehavior"];
44type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem;
45type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
46 unstable_headerToolbarItems?: () => NativeToolbarItem[];
47};
48
49function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
50 if (typeof icon === "string") return { type: "sfSymbol", name: icon as never };
51 if (
52 typeof icon === "object" &&
53 icon !== null &&
54 "type" in icon &&
55 icon.type === "image" &&
56 "source" in icon
57 ) {
58 return icon as NativeStackHeaderIcon;
59 }
60 return undefined;
61}
62
63type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };
64
65function elementTypeName(element: ReactElement): string | undefined {
66 const type = element.type;
67 if (typeof type === "function") {
68 return (type as { displayName?: string; name?: string }).displayName ?? type.name;
69 }
70 return undefined;
71}
72
73function convertMenuAction(
74 element: ReactElement<ToolbarElementProps>,
75): NativeStackHeaderItemMenu["menu"]["items"][number] | null {
76 const typeName = elementTypeName(element);
77 if (typeName === "NativeHeaderToolbarMenuAction") {
78 const label = labelFromChildren(element.props.children);
79 return {
80 type: "action",
81 label,
82 description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined,
83 disabled: Boolean(element.props.disabled),
84 icon: iconFromProp(element.props.icon),
85 onPress:
86 typeof element.props.onPress === "function"
87 ? (element.props.onPress as () => void)
88 : () => undefined,
89 state: element.props.isOn === true ? "on" : undefined,
90 destructive: Boolean(element.props.destructive),
91 discoverabilityLabel:
92 typeof element.props.discoverabilityLabel === "string"
93 ? element.props.discoverabilityLabel
94 : undefined,
95 };
96 }
97
98 if (typeName === "NativeHeaderToolbarMenu") {
99 return {
100 type: "submenu",
101 label:
102 typeof element.props.title === "string"
103 ? element.props.title
104 : labelFromChildren(element.props.children),
105 icon: iconFromProp(element.props.icon),
106 inline: Boolean(element.props.inline),
107 items: collectMenuItems(element.props.children),
108 };
109 }
110
111 return null;
112}
113
114function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] {
115 const items: NativeStackHeaderItemMenu["menu"]["items"] = [];
116 Children.forEach(children, (child) => {
117 if (!isValidElement<ToolbarElementProps>(child)) {
118 return;
119 }
120 const item = convertMenuAction(child);
121 if (item) {
122 items.push(item);
123 return;
124 }
125 items.push(...collectMenuItems(child.props.children));
126 });
127 return items;
128}
129
130function convertToolbarChild(child: ReactNode): NativeToolbarItem | null {
131 if (!isValidElement<ToolbarElementProps>(child)) {
132 return null;
133 }
134
135 const typeName = elementTypeName(child);
136 if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) {
137 return { type: "custom", element: child.props.children };
138 }
139 if (typeName === "NativeHeaderToolbarSearchBarSlot") {
140 return { type: "searchBarPlacement" };
141 }
142
143 if (typeName === "NativeHeaderToolbarButton") {
144 return {
145 type: "button",
146 label:
147 typeof child.props.label === "string"
148 ? child.props.label
149 : String(child.props.accessibilityLabel ?? ""),
150 accessibilityLabel:
151 typeof child.props.accessibilityLabel === "string"
152 ? child.props.accessibilityLabel
153 : undefined,
154 axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior,
155 disabled: Boolean(child.props.disabled),
156 icon: iconFromProp(child.props.icon),
157 onPress:
158 typeof child.props.onPress === "function"
159 ? (child.props.onPress as () => void)
160 : () => undefined,
161 sharesBackground: !child.props.separateBackground,
162 tintColor: child.props.tintColor as ColorValue | undefined,
163 variant:
164 child.props.variant === "done" || child.props.variant === "prominent"
165 ? child.props.variant
166 : "plain",
167 identifier: typeof child.props.identifier === "string" ? child.props.identifier : undefined,
168 };
169 }
170
171 if (typeName === "NativeHeaderToolbarMenu") {
172 return {
173 type: "menu",
174 label: typeof child.props.title === "string" ? child.props.title : "",
175 accessibilityLabel:
176 typeof child.props.accessibilityLabel === "string"
177 ? child.props.accessibilityLabel
178 : undefined,
179 disabled: Boolean(child.props.disabled),
180 axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior,
181 icon: iconFromProp(child.props.icon),
182 menu: {
183 title: typeof child.props.title === "string" ? child.props.title : undefined,
184 items: collectMenuItems(child.props.children),
185 },
186 sharesBackground: !child.props.separateBackground,
187 tintColor: child.props.tintColor as ColorValue | undefined,
188 variant:
189 child.props.variant === "done" || child.props.variant === "prominent"
190 ? child.props.variant
191 : "plain",
192 identifier: typeof child.props.identifier === "string" ? child.props.identifier : undefined,
193 };
194 }
195
196 if (typeName === "NativeHeaderToolbarSpacer") {
197 return {
198 type: "spacing",
199 spacing: typeof child.props.width === "number" ? child.props.width : 8,
200 flexible: Boolean(child.props.flexible),
201 } as NativeStackHeaderItem;
202 }
203
204 return null;
205}
206
207function collectToolbarItems(children: ReactNode): NativeToolbarItem[] {
208 const items: NativeToolbarItem[] = [];
209 Children.forEach(children, (child) => {
210 const item = convertToolbarChild(child);
211 if (item) {
212 if (item.type === "spacing") {
213 // Native inserts spacing items at `index`, treating a missing index
214 // as 0 — which would move the spacer in front of earlier siblings.
215 (item as { index?: number }).index = items.length;
216 }
217 items.push(item);
218 }
219 });
220 return items;
221}
222
223function NativeHeaderToolbarRoot(props: {
224 readonly placement?: "left" | "right" | "bottom";
225 readonly children?: ReactNode;
226}) {
227 const navigation = useNativeStackNavigation();
228 const tabActivity = use(NativeWorkspaceTabActivityContext);
229 const tabActive = tabActivity?.active ?? true;
230 const isTabActive = tabActivity?.isActive;
231 const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
232
233 // Update items in place. Clearing them on every render causes UIKit to run a
234 // second transition and detach custom views while the stack is animating.
235 useLayoutEffect(() => {
236 if (!navigation || !tabActive) {
237 return;
238 }
239 if (props.placement === "bottom") {
240 navigation.setOptions({
241 unstable_headerToolbarItems: () => items,
242 } as NativeStackOptionsWithToolbar);
243 return;
244 }
245 // UIKit's search placement item belongs to the toolbar, never a header group.
246 const headerItems = items.filter(
247 (item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement",
248 );
249 if (props.placement === "left") {
250 navigation.setOptions({ unstable_headerLeftItems: () => headerItems });
251 return;
252 }
253 navigation.setOptions({ unstable_headerRightItems: () => headerItems });
254 }, [items, navigation, props.placement, tabActive]);
255
256 useLayoutEffect(() => {
257 if (!navigation) return;
258 return () => {
259 if (isTabActive && !isTabActive()) return;
260 if (props.placement === "bottom") {
261 navigation.setOptions({
262 unstable_headerToolbarItems: () => [],
263 } as NativeStackOptionsWithToolbar);
264 } else if (props.placement === "left") {
265 navigation.setOptions({ unstable_headerLeftItems: () => [] });
266 } else {
267 navigation.setOptions({ unstable_headerRightItems: () => [] });
268 }
269 };
270 }, [navigation, props.placement, isTabActive]);
271
272 return null;
273}
274
275function NativeHeaderToolbarButton(_props: {
276 readonly accessibilityLabel?: string;
277 readonly axisBehavior?: NativeStackHeaderAxisBehavior;
278 readonly disabled?: boolean;
279 readonly icon?: string | Extract<NativeStackHeaderIcon, { type: "image" }>;
280 readonly label?: string;
281 readonly onPress?: () => void;
282 readonly separateBackground?: boolean;
283 readonly tintColor?: ColorValue;
284 readonly variant?: "plain" | "done" | "prominent";
285 readonly identifier?: string;
286}) {
287 return null;
288}
289NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton";
290
291function NativeHeaderToolbarMenu(_props: {
292 readonly accessibilityLabel?: string;
293 readonly axisBehavior?: NativeStackHeaderAxisBehavior;
294 readonly children?: ReactNode;
295 readonly disabled?: boolean;
296 readonly icon?: string;
297 readonly identifier?: string;
298 readonly inline?: boolean;
299 readonly separateBackground?: boolean;
300 readonly tintColor?: ColorValue;
301 readonly title?: string;
302 readonly variant?: "plain" | "done" | "prominent";
303}) {
304 return null;
305}
306NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu";
307
308function NativeHeaderToolbarMenuAction(_props: {
309 readonly children?: ReactNode;
310 readonly destructive?: boolean;
311 readonly disabled?: boolean;
312 readonly discoverabilityLabel?: string;
313 readonly icon?: string;
314 readonly isOn?: boolean;
315 readonly onPress?: () => void;
316 readonly subtitle?: string;
317}) {
318 return null;
319}
320NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
321
322/** Renders an arbitrary element as a bar item, e.g. a spinner standing in for a button. */
323function NativeHeaderToolbarCustom(_props: { readonly children: ReactElement }) {
324 return null;
325}
326NativeHeaderToolbarCustom.displayName = "NativeHeaderToolbarCustom";
327
328function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
329 return null;
330}
331NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
332
333function NativeHeaderToolbarSpacer(_props: {
334 readonly flexible?: boolean;
335 readonly sharesBackground?: boolean;
336 readonly width?: number;
337}) {
338 return null;
339}
340NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer";
341
342function NativeHeaderToolbarSearchBarSlot() {
343 return null;
344}
345NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot";
346
347export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
348 Button: NativeHeaderToolbarButton,
349 Custom: NativeHeaderToolbarCustom,
350 Label: NativeHeaderToolbarLabel,
351 Menu: Object.assign(NativeHeaderToolbarMenu, {
352 Action: NativeHeaderToolbarMenuAction,
353 }),
354 MenuAction: NativeHeaderToolbarMenuAction,
355 SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
356 Spacer: NativeHeaderToolbarSpacer,
357});