authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 13:26:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 13:26:48-07:00
log468ade30495c450ae4e731483187f77bc3c5e42b
treef4e33b1fe991c9ad541f3bc63180cee0e94cd56c
parenta9fb6a80635c77a1d4fd988e0018059e13628eff
signature Signed by PGP key B5690EEEBB952194

feat(mobile): adopt v5 navigation and native iPad columns (#16733)


47 files changed, 1916 insertions(+), 2408 deletions(-)

apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz created
Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz differ
apps/mobile/fingerprint.config.js+2
......@@ -14,5 +14,7 @@ if (!majorVersion) {
1414}
1515
1616module.exports = {
17 // Hash the pinned Screens fork's native source, rather than only its version.
18 nativeModuleSourceType: "files",
1719 extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }],
1820};
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+9-6
......@@ -287,6 +287,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
287287 UILongPressGestureRecognizer *_longPressGestureRecognizer;
288288 UITapGestureRecognizer *_pressGestureRecognizer;
289289 NSArray *_contextAccessibilityElements;
290 BOOL _textLayoutNeedsUpdate;
290291}
291292
292293+ (ComponentDescriptorProvider)componentDescriptorProvider
......@@ -384,16 +385,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
384385 _textView.frame = CGRectZero;
385386 _textView.attributedText = nil;
386387 _contextAccessibilityElements = nil;
388 _textLayoutNeedsUpdate = YES;
387389}
388390
389391- (void)layoutSubviews
390392{
391393 [super layoutSubviews];
392 // _textView's frame is assigned inside drawRect, which only fires when
393 // state changes. Trigger a redraw whenever the host frame moves out from
394 // under it (rotation, parent relayout) so the text view resizes and
395 // onTextLayout re-fires with the new line wrapping.
394 // Resize during UIKit's layout pass. Doing this in drawRect can leave the
395 // text view's drawing surface clipped to its previous width after a pane
396 // expands, even though TextKit has already rewrapped the lines.
396397 if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
398 _textView.frame = _view.frame;
399 _textLayoutNeedsUpdate = YES;
397400 [self setNeedsDisplay];
398401 }
399402}
......@@ -455,7 +458,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
455458 // entirely when nothing actually changed so a JS-side state update made in
456459 // response to onSelectionChange doesn't deselect what the user is selecting.
457460 const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
458 const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame);
461 const BOOL frameChanged = _textLayoutNeedsUpdate;
459462 if (!textChanged && !frameChanged) {
460463 return;
461464 }
......@@ -482,9 +485,9 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
482485 _suppressSelectionChange = NO;
483486 }
484487 if (frameChanged) {
485 _textView.frame = _view.frame;
486488 _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX);
487489 }
490 _textLayoutNeedsUpdate = NO;
488491
489492 // Text attachments have no native link element. Expose their existing runs
490493 // at the measured glyph bounds, without inserting views into text layout.
apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift created+48
......@@ -0,0 +1,48 @@
1import ExpoModulesCore
2import UIKit
3
4/// Reports the body geometry of a native navigation column.
5final class T3LayoutMetricsView: ExpoView {
6 let onMetricsChange = EventDispatcher()
7 private var lastMetrics: NSDictionary?
8
9 required init(appContext: AppContext? = nil) {
10 super.init(appContext: appContext)
11 isUserInteractionEnabled = false
12 registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
13 view.publishMetrics()
14 }
15 }
16
17 override func didMoveToWindow() {
18 super.didMoveToWindow()
19 lastMetrics = nil
20 publishMetrics()
21 }
22
23 override func layoutSubviews() {
24 super.layoutSubviews()
25 publishMetrics()
26 }
27
28 override func safeAreaInsetsDidChange() {
29 super.safeAreaInsetsDidChange()
30 publishMetrics()
31 }
32
33 private func publishMetrics() {
34 guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
35 let metrics: [String: Any] = [
36 "width": bounds.width, "height": bounds.height,
37 "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
38 "safeArea": [
39 "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
40 "left": safeAreaInsets.left, "right": safeAreaInsets.right,
41 ],
42 ]
43 let next = metrics as NSDictionary
44 guard lastMetrics?.isEqual(next) != true else { return }
45 lastMetrics = next
46 onMetricsChange(metrics)
47 }
48}
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+11
......@@ -11,6 +11,17 @@ public final class T3NativeControlsModule: Module {
1111 private var filePresentation: T3NativeFilePresentation?
1212
1313 public func definition() -> ModuleDefinition {
14 Constants {
15 if #available(iOS 26.0, *) {
16 return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil]
17 }
18 return ["supportsWorkspaceColumns": false]
19 }
20 View(T3LayoutMetricsView.self) {
21 ViewName("LayoutMetrics")
22 Events("onMetricsChange")
23 }
24
1425 AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
1526 try self.presentVideo(
1627 url: url,
apps/mobile/package.json+3-2
......@@ -120,7 +120,7 @@
120120 "react-native-nitro-modules": "0.35.9",
121121 "react-native-reanimated": "4.7.0",
122122 "react-native-safe-area-context": "~5.9.1",
123 "react-native-screens": "~4.28.0",
123 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz",
124124 "react-native-shiki-engine": "^0.3.12",
125125 "react-native-svg": "15.15.5",
126126 "react-native-webview": "14.0.1",
......@@ -142,7 +142,8 @@
142142 "install": {
143143 "exclude": [
144144 "react-native-keyboard-controller",
145 "typescript"
145 "typescript",
146 "react-native-screens"
146147 ]
147148 },
148149 "autolinking": {
apps/mobile/src/Stack.tsx+8-4
......@@ -1,3 +1,8 @@
1import {
2 createV5StackNavigator as createNativeStackNavigator,
3 createV5SheetStackNavigator,
4} from "./native/createV5StackNavigator";
5import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator";
16import {
27 createPathConfigForStaticNavigation,
38 getPathFromState,
......@@ -6,7 +11,6 @@ import {
611 useNavigation,
712} from "@react-navigation/native";
813import {
9 createNativeStackNavigator,
1014 createNativeStackScreen,
1115 type NativeStackNavigationOptions,
1216} from "@react-navigation/native-stack";
......@@ -189,7 +193,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
189193 presentation: "fullScreenModal",
190194};
191195
192const SettingsContentStack = createNativeStackNavigator({
196const SettingsContentStack = createV5SheetStackNavigator({
193197 initialRouteName: "Settings",
194198 screenOptions: {
195199 ...GLASS_HEADER_OPTIONS,
......@@ -399,7 +403,7 @@ const SettingsContentStack = createNativeStackNavigator({
399403// The outer stack never owns visible chrome. Settings routes render inside a
400404// nested stack whose native header remains mounted, while Clerk owns auth chrome.
401405// Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump.
402const SettingsSheetStack = createNativeStackNavigator({
406const SettingsSheetStack = createV5SheetStackNavigator({
403407 initialRouteName: "SettingsContent",
404408 screenOptions: {
405409 headerShown: false,
......@@ -670,7 +674,7 @@ function NotFoundScreen() {
670674 );
671675}
672676
673const RootStackConfig = createNativeStackNavigator({
677const RootStackConfig = createWorkspaceStackNavigator({
674678 initialRouteName: "Home",
675679 layout: RootStackLayout,
676680 screenOptions: {
apps/mobile/src/components/CompactBrandTitle.tsx+2-1
......@@ -1,3 +1,4 @@
1import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns";
12import Constants from "expo-constants";
23import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
34import { Platform, View } from "react-native";
......@@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing";
1415 */
1516export function brandTitleOffset(): number {
1617 if (Platform.OS !== "ios") return 0;
17 return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0;
18 return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0;
1819}
1920
2021/**
apps/mobile/src/components/ScreenHeader.tsx+4
......@@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) {
5353 ]}
5454 options={{
5555 headerShown: true,
56 headerBackVisible:
57 !layout.usesSplitView ||
58 !props.backInSplitView ||
59 !(props.backInSplitView.onPress || props.onBack),
5660 title: props.title,
5761 unstable_headerSubtitle: props.subtitle || undefined,
5862 ...(props.matchSearchSurface
apps/mobile/src/features/files/FileTreeBrowser.tsx+47-34
......@@ -1,14 +1,14 @@
1import { LegendList } from "@legendapp/list/react-native";
12import type { ProjectEntry } from "@t3tools/contracts";
23import { SymbolView } from "../../components/AppSymbol";
34import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";
5import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native";
56import { useSafeAreaInsets } from "react-native-safe-area-context";
67
78import { AppText as Text } from "../../components/AppText";
89import { PierreEntryIcon } from "../../components/PierreEntryIcon";
910import { cn } from "../../lib/cn";
1011import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
11import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1212import {
1313 buildFileTree,
1414 flattenFileTree,
......@@ -17,8 +17,6 @@ import {
1717} from "./fileTree";
1818
1919const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>();
20const FILE_TREE_INITIAL_RENDER_COUNT = 20;
21const FILE_TREE_RENDER_BATCH_SIZE = 12;
2220const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000;
2321
2422function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
......@@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: {
4543 readonly selected: boolean;
4644 readonly expanded: boolean;
4745 readonly loaded: boolean;
48 readonly onPressDirectory: (path: string) => void;
46 readonly loading: boolean;
47 readonly onPressDirectory: (path: string, expand: boolean) => void;
4948 readonly onPreviewFile?: (path: string) => void;
5049 readonly onPressFile: (path: string) => void;
5150}) {
......@@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {
6261 }}
6362 onPress={() => {
6463 if (node.kind === "directory") {
65 props.onPressDirectory(node.path);
64 props.onPressDirectory(node.path, !props.expanded);
6665 return;
6766 }
6867 props.onPressFile(node.path);
......@@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: {
9796 >
9897 {node.name}
9998 </Text>
100 {node.kind === "directory" && props.loaded ? (
99 {node.kind === "directory" && props.expanded && props.loading ? (
100 <ActivityIndicator size="small" accessibilityLabel={`Loading ${node.name}`} />
101 ) : node.kind === "directory" && props.loaded ? (
101102 <Text className="text-2xs font-t3-medium text-foreground-tertiary">
102103 {node.children.length}
103104 </Text>
......@@ -110,10 +111,12 @@ export function FileTreeBrowser(props: {
110111 readonly entries: ReadonlyArray<ProjectEntry>;
111112 readonly error: string | null;
112113 readonly isPending: boolean;
114 readonly isRefreshing: boolean;
113115 readonly searchQuery: string;
114116 readonly searchTruncated: boolean;
115117 readonly selectedPath: string | null;
116118 readonly loadedDirectories: ReadonlySet<string>;
119 readonly loadingDirectories: ReadonlySet<string>;
117120 readonly onLoadDirectory: (path: string) => void;
118121 readonly onPreviewFile?: (path: string) => void;
119122 readonly onRefresh: () => void;
......@@ -127,12 +130,13 @@ export function FileTreeBrowser(props: {
127130 const insets = useSafeAreaInsets();
128131 // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
129132 // observed adjustedContentInset bottom (~102) seen in the native trace.
130 const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0;
133 const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0;
131134 const {
132135 onLoadDirectory,
133136 onPreviewFile,
134137 onSelectFile,
135138 loadedDirectories,
139 loadingDirectories,
136140 selectedPath: controlledSelectedPath,
137141 } = props;
138142 const controlledSelectedPathRef = useRef(controlledSelectedPath);
......@@ -184,17 +188,21 @@ export function FileTreeBrowser(props: {
184188 [],
185189 );
186190
187 const toggleDirectory = useCallback((path: string) => {
188 setExpandedPaths((current) => {
189 const next = new Set(current);
190 if (next.has(path)) {
191 next.delete(path);
192 } else {
193 next.add(path);
194 }
195 return next;
196 });
197 }, []);
191 const toggleDirectory = useCallback(
192 (path: string, expand: boolean) => {
193 if (expand) onLoadDirectory(path);
194 setExpandedPaths((current) => {
195 const next = new Set(current);
196 if (next.has(path)) {
197 next.delete(path);
198 } else {
199 next.add(path);
200 }
201 return next;
202 });
203 },
204 [onLoadDirectory],
205 );
198206 const handleSelectFile = useCallback(
199207 (path: string) => {
200208 if (pendingSelectionTimeoutRef.current !== null) {
......@@ -219,6 +227,7 @@ export function FileTreeBrowser(props: {
219227 selected={item.node.kind === "file" && item.node.path === selectedPath}
220228 expanded={expandedPaths.has(item.node.path)}
221229 loaded={loadedDirectories.has(item.node.path)}
230 loading={loadingDirectories.has(item.node.path)}
222231 onPressDirectory={toggleDirectory}
223232 onPreviewFile={onPreviewFile}
224233 onPressFile={handleSelectFile}
......@@ -229,36 +238,40 @@ export function FileTreeBrowser(props: {
229238 handleSelectFile,
230239 onPreviewFile,
231240 loadedDirectories,
241 loadingDirectories,
232242 selectedPath,
233243 toggleDirectory,
234244 ],
235245 );
236246
237 // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and
238 // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"`
239 // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge
240 // blur to a scroll view in the screen's primary position; a scroll view buried in
241 // flex-1 Views is ignored, which is why the tree rendered under the header with no blur.
247 const extraData = useMemo(
248 () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }),
249 [expandedPaths, loadedDirectories, loadingDirectories, selectedPath],
250 );
251
252 // UIKit owns the header inset on every supported iOS version. Keep the
253 // list as direct screen content so automatic inset adjustment can find it.
242254 return (
243 <FlatList
255 <LegendList
256 contentInsetStartAdjustment={headerInset}
244257 alwaysBounceVertical
245258 className="flex-1"
246259 data={visibleNodes}
247260 keyExtractor={(item) => item.node.path}
248 contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"}
261 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
249262 scrollIndicatorInsets={
250 NATIVE_LIQUID_GLASS_SUPPORTED
251 ? { top: headerInset, left: 0, right: 0, bottom: 0 }
252 : undefined
263 Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined
253264 }
254265 keyboardDismissMode="on-drag"
255266 keyboardShouldPersistTaps="handled"
256 initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT}
257 maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE}
258 updateCellsBatchingPeriod={16}
259 windowSize={5}
267 estimatedItemSize={42}
268 recycleItems
269 maintainVisibleContentPosition
270 extraData={extraData}
260271 contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
261 refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
272 refreshControl={
273 <RefreshControl refreshing={props.isRefreshing} onRefresh={props.onRefresh} />
274 }
262275 renderItem={renderItem}
263276 ListHeaderComponent={
264277 <>
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+11-2
......@@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
4343import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
4444import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
4545import { FileMarkdownPreview } from "./FileMarkdownPreview";
46import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack";
4647import { FileTreeBrowser } from "./FileTreeBrowser";
4748import { useFileTreeEntries } from "./useFileTreeEntries";
4849import { preloadWorkspaceFileContents } from "./preload-workspace-file";
......@@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
561562 key={JSON.stringify([environmentId, cwd])}
562563 entries={entriesQuery.entries}
563564 loadedDirectories={entriesQuery.loadedDirectories}
565 loadingDirectories={entriesQuery.loadingDirectories}
564566 onLoadDirectory={entriesQuery.loadDirectory}
565567 error={entriesQuery.error}
566568 isPending={entriesQuery.isPending}
569 isRefreshing={entriesQuery.isRefreshing}
567570 searchQuery={searchQuery}
568571 searchTruncated={entriesQuery.searchTruncated}
569572 selectedPath={null}
......@@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
751754 // Hand the file navigator to the workspace so it renders beside the
752755 // navigator, outside this screen's native header.
753756 const renderWorkspaceInspector = useCallback(
754 () => renderInspector(inspectorHeaderInset),
755 [inspectorHeaderInset, renderInspector],
757 () => (
758 <ThreadInspectorContentStack
759 mode="files"
760 resetKeys={[threadId, cwd]}
761 renderFiles={() => renderInspector(inspectorHeaderInset)}
762 />
763 ),
764 [cwd, inspectorHeaderInset, renderInspector, threadId],
756765 );
757766 useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
758767
apps/mobile/src/features/files/fileTree.ts+3-1
......@@ -77,6 +77,8 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode {
7777 };
7878}
7979
80const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
81
8082function compareNodes(
8183 left: Pick<FileTreeNode, "kind" | "name">,
8284 right: Pick<FileTreeNode, "kind" | "name">,
......@@ -84,7 +86,7 @@ function compareNodes(
8486 if (left.kind !== right.kind) {
8587 return left.kind === "directory" ? -1 : 1;
8688 }
87 return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
89 return fileNameCollator.compare(left.name, right.name);
8890}
8991
9092export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+2-1
......@@ -78,7 +78,6 @@ export function ThreadFileNavigatorPane(props: {
7878 sharesBackground: false,
7979 tintColor: foregroundColor,
8080 type: "button" as const,
81 width: 44,
8281 },
8382 ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"],
8483 [foregroundColor, toggleAuxiliaryPane],
......@@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: {
8988 key={JSON.stringify([props.environmentId, props.cwd])}
9089 entries={entriesQuery.entries}
9190 loadedDirectories={entriesQuery.loadedDirectories}
91 loadingDirectories={entriesQuery.loadingDirectories}
9292 onLoadDirectory={entriesQuery.loadDirectory}
9393 error={
9494 canReadFiles
......@@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: {
9898 : (fileAccess.error ?? "This connection cannot read host files.")
9999 }
100100 isPending={fileAccess.isPending || entriesQuery.isPending}
101 isRefreshing={entriesQuery.isRefreshing}
101102 searchQuery={searchQuery}
102103 searchTruncated={entriesQuery.searchTruncated}
103104 selectedPath={props.selectedPath}
apps/mobile/src/features/files/useFileTreeEntries.ts+38-5
......@@ -1,7 +1,7 @@
11import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
22import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
33import * as Cause from "effect/Cause";
4import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
4import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
55
66import { appAtomRegistry } from "../../state/atom-registry";
77import { projectEnvironment } from "../../state/projects";
......@@ -30,7 +30,8 @@ export function useFileTreeEntries(input: {
3030 })
3131 : null,
3232 );
33 const [revision, render] = useReducer((value: number) => value + 1, 0);
33 const [, render] = useReducer((value: number) => value + 1, 0);
34 const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0);
3435 const refreshVersion = useRef(0);
3536 const directories = useMemo(
3637 () => ({
......@@ -43,6 +44,9 @@ export function useFileTreeEntries(input: {
4344 }),
4445 [cwd, environmentId],
4546 );
47 const [refreshingDirectories, setRefreshingDirectories] = useState<typeof directories | null>(
48 null,
49 );
4650 useEffect(
4751 () => () => {
4852 refreshVersion.current++;
......@@ -83,6 +87,7 @@ export function useFileTreeEntries(input: {
8387 entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
8488 ),
8589 );
90 entriesChanged();
8691 } else {
8792 const error = Cause.squash(result.cause);
8893 directories.errors.set(
......@@ -113,8 +118,8 @@ export function useFileTreeEntries(input: {
113118 }
114119 };
115120 visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/")));
116 return { revision, entries: [...merged.values()], reachableDirectories };
117 }, [directories, revision, rootData, searchData, searching]);
121 return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories };
122 }, [directories, entriesRevision, rootData, searchData, searching]);
118123
119124 const refresh = useCallback(() => {
120125 refreshRoot();
......@@ -126,6 +131,7 @@ export function useFileTreeEntries(input: {
126131 directories.pending.clear();
127132 directories.errors.clear();
128133 const version = ++refreshVersion.current;
134 setRefreshingDirectories(directories);
129135 const remaining = paths.values();
130136 const worker = async () => {
131137 while (version === refreshVersion.current) {
......@@ -134,9 +140,34 @@ export function useFileTreeEntries(input: {
134140 await loadDirectory(next.value, true);
135141 }
136142 };
137 for (let index = 0; index < Math.min(4, paths.size); index++) void worker();
143 const queries =
144 cwd !== null && environmentId !== null
145 ? [
146 projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }),
147 ...(searching
148 ? [
149 projectEnvironment.searchEntries({
150 environmentId,
151 input: { cwd, query: debouncedQuery, limit: 200 },
152 }),
153 ]
154 : []),
155 ]
156 : [];
157 const work = [
158 ...queries.map((atom) =>
159 executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }),
160 ),
161 ...Array.from({ length: Math.min(4, paths.size) }, () => worker()),
162 ];
138163 render();
164 return Promise.all(work).finally(() => {
165 if (version === refreshVersion.current) setRefreshingDirectories(null);
166 });
139167 }, [
168 cwd,
169 debouncedQuery,
170 environmentId,
140171 directories,
141172 loadDirectory,
142173 refreshRoot,
......@@ -147,6 +178,7 @@ export function useFileTreeEntries(input: {
147178
148179 return {
149180 entries: snapshot.entries,
181 isRefreshing: refreshingDirectories === directories,
150182 error:
151183 root.error ??
152184 (searching ? search.error : null) ??
......@@ -158,6 +190,7 @@ export function useFileTreeEntries(input: {
158190 (searching && (query !== debouncedQuery || search.isPending)),
159191 searchTruncated: searching && (search.data?.truncated ?? false),
160192 loadedDirectories: new Set(directories.entries.keys()),
193 loadingDirectories: new Set(directories.pending.keys()),
161194 loadDirectory,
162195 refresh,
163196 };
apps/mobile/src/features/home/HomeHeader.tsx+74-39
......@@ -1,6 +1,8 @@
11import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { useCallback, useRef } from "react";
2import { use, useCallback, useRef } from "react";
3import { Platform } from "react-native";
34import type { SearchBarCommands } from "react-native-screens";
5import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
46import { useUniwindTheme } from "../../lib/useUniwindTheme";
57import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
68import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
......@@ -9,11 +11,14 @@ import {
911 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
1012} from "../layout/native-mail-search-toolbar";
1113import { buildHomeListFilterMenu } from "./home-list-filter-menu";
14import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";
1215import type { HomeHeaderProps } from "./HomeHeader.types";
1316
1417export type { HomeHeaderEnvironment } from "./HomeHeader.types";
1518
1619export function HomeHeader(props: HomeHeaderProps) {
20 const primaryColumn = use(NativePrimaryColumnContext);
21 const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null;
1722 const searchBarRef = useRef<SearchBarCommands>(null);
1823 const iconColor = useUniwindTheme()["--color-icon"];
1924 // The list uses a fixed creation order and ignores sort/group options, so
......@@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) {
3641 // Static header config (glass, title, fonts) lives in Stack.tsx
3742 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
3843 headerTintColor: iconColor,
39 unstable_headerRightItems: () => [
40 withNativeGlassHeaderItem({
41 accessibilityLabel: "Open settings",
42 icon: { name: "ellipsis", type: "sfSymbol" } as const,
43 identifier: "home-settings",
44 label: "",
45 onPress: props.onOpenSettings,
46 type: "button",
47 }),
48 ],
44 unstable_headerRightItems: () =>
45 iPadSidebar
46 ? createSidebarHeaderItems({
47 filterIcon: hasCustomListOptions
48 ? "line.3.horizontal.decrease.circle.fill"
49 : "line.3.horizontal.decrease.circle",
50 filterMenu,
51 onOpenSettings: props.onOpenSettings,
52 })
53 : [
54 withNativeGlassHeaderItem({
55 accessibilityLabel: "Open settings",
56 icon: { name: "ellipsis", type: "sfSymbol" } as const,
57 identifier: "home-settings",
58 label: "",
59 onPress: props.onOpenSettings,
60 type: "button",
61 }),
62 ],
4963 // The keys below are set per-branch (not `undefined`) so a later
5064 // reapply cannot clobber options owned by NativeHeaderToolbar.
51 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
65 ...(iPadSidebar
5266 ? {
53 unstable_headerToolbarItems: () => [
54 createNativeMailSearchToolbarItem({
55 composeButtonId: "home-new-task",
56 composeSystemImageName: "square.and.pencil",
57 filterMenu,
58 filterButtonId: "home-filter",
59 filterSystemImageName: hasCustomListOptions
60 ? "line.3.horizontal.decrease.circle.fill"
61 : "line.3.horizontal.decrease",
62 onComposePress: props.onStartNewTask,
63 onSearchTextChange: props.onSearchQueryChange,
64 placeholder: "Search",
65 searchTextChangeId: "home-search-text",
66 showsSearchDismissButton: true,
67 }),
68 ],
69 }
70 : {
71 // Pre-Liquid-Glass iOS: standard pull-down search in the nav
72 // bar; create + sort live in the plain bottom toolbar below.
7367 headerSearchBarOptions: {
7468 ref: searchBarRef,
7569 autoCapitalize: "none" as const,
7670 hideNavigationBar: false,
71 hideWhenScrolling: false,
72 obscureBackground: false,
73 placement: "stacked" as const,
74 allowToolbarIntegration: false,
7775 placeholder: "Search",
78 onCancelButtonPress: () => {
79 props.onSearchQueryChange("");
76 onCancelButtonPress: () => props.onSearchQueryChange(""),
77 onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text),
78 },
79 unstable_headerToolbarItems: () => [],
80 }
81 : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
82 ? {
83 headerSearchBarOptions: {
84 ref: searchBarRef,
85 autoCapitalize: "none" as const,
86 onCancelButtonPress: () => props.onSearchQueryChange(""),
8087 },
81 onChangeText: (event) => {
82 props.onSearchQueryChange(event.nativeEvent.text);
88 unstable_headerToolbarItems: () => [
89 createNativeMailSearchToolbarItem({
90 composeButtonId: "home-new-task",
91 composeSystemImageName: "square.and.pencil",
92 filterMenu,
93 filterButtonId: "home-filter",
94 filterSystemImageName: hasCustomListOptions
95 ? "line.3.horizontal.decrease.circle.fill"
96 : "line.3.horizontal.decrease",
97 onComposePress: props.onStartNewTask,
98 onSearchTextChange: props.onSearchQueryChange,
99 placeholder: "Search",
100 searchTextChangeId: "home-search-text",
101 showsSearchDismissButton: true,
102 }),
103 ],
104 }
105 : {
106 // Pre-Liquid-Glass iOS: standard pull-down search in the nav
107 // bar; create + sort live in the plain bottom toolbar below.
108 headerSearchBarOptions: {
109 ref: searchBarRef,
110 autoCapitalize: "none" as const,
111 hideNavigationBar: false,
112 placeholder: "Search",
113 onCancelButtonPress: () => {
114 props.onSearchQueryChange("");
115 },
116 onChangeText: (event) => {
117 props.onSearchQueryChange(event.nativeEvent.text);
118 },
83119 },
84 },
85 }),
120 }),
86121 }}
87122 />
88123
89 {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
124 {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
90125 <NativeHeaderToolbar placement="bottom">
91126 <NativeHeaderToolbar.Menu
92127 accessibilityLabel="Filter threads"
apps/mobile/src/features/home/HomeRouteScreen.tsx+11-3
......@@ -1,3 +1,5 @@
1import { use } from "react";
2import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
13import * as Arr from "effect/Array";
24import * as Order from "effect/Order";
35import { useNavigation } from "@react-navigation/native";
......@@ -12,6 +14,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
1214import { useWorkspaceState } from "../../state/workspace";
1315import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1416import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
17import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
1518import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
1619import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1720import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
......@@ -29,6 +32,9 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle
2932
3033export function HomeRouteScreen() {
3134 const { width: windowWidth } = useWindowDimensions();
35 const nativePrimaryColumn = use(NativePrimaryColumnContext);
36 const columnMetrics = useNativeColumnLayoutMetrics();
37 const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth;
3238 const { layout, panes } = useAdaptiveWorkspaceLayout();
3339 const projects = useProjects();
3440 const threads = useNavigationThreadShells();
......@@ -120,7 +126,7 @@ export function HomeRouteScreen() {
120126
121127 // In split layouts the persistent sidebar IS the thread list — Home becomes
122128 // an empty detail pane so selecting a thread never transitions layouts.
123 if (layout.usesSplitView) {
129 if (layout.usesSplitView && !nativePrimaryColumn) {
124130 return (
125131 <>
126132 <NativeStackScreenOptions
......@@ -169,10 +175,12 @@ export function HomeRouteScreen() {
169175 shallow-merged. The brand slot also doubles as the connection
170176 status surface while an environment reconnects. */}
171177 <NativeStackScreenOptions
172 optionsVersion={windowWidth}
178 optionsVersion={headerWidth}
173179 options={{
174180 ...getConnectionAwareBrandHeaderOptions({
175 headerWidth: windowWidth,
181 headerWidth,
182 trailingItemCount:
183 nativePrimaryColumn && Platform.OS === "ios" && Platform.isPad ? 2 : 1,
176184 onOpenEnvironments: () =>
177185 navigation.navigate("SettingsSheet", {
178186 screen: "SettingsContent",
apps/mobile/src/features/home/HomeScreen.tsx+78-15
......@@ -13,15 +13,18 @@ import {
1313import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
1414import { useAtomValue } from "@effect/atom-react";
1515import { useFocusEffect } from "@react-navigation/native";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
1717import {
1818 ActivityIndicator,
1919 Platform,
20 ScrollView,
2021 View,
22 type ScrollViewProps,
2123 type GestureResponderEvent,
2224 type NativeScrollEvent,
2325 type NativeSyntheticEvent,
2426} from "react-native";
27import { ScrollViewMarker } from "react-native-screens";
2528import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2629import { useSafeAreaInsets } from "react-native-safe-area-context";
2730
......@@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState";
3033import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
3134import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
3235import type { SavedRemoteConnection } from "../../lib/connection";
33import { scopedProjectKey } from "../../lib/scopedEntities";
36import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
37import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
38import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
39import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
3440import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3541import { useThreadSearch } from "../../state/queries";
3642import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
......@@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72;
140146// swipe-row-activation), so render further ahead: a fast fling then reaches
141147// rows that are already built instead of rows still being rebuilt.
142148const THREAD_LIST_V2_DRAW_DISTANCE = 1_000;
143const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
144149/**
145150 * Top spacing between the list and the Android custom header. The Android
146151 * header is rendered in-flow above this screen and
......@@ -219,17 +224,61 @@ function HomeTopContentSpacer() {
219224 return <View className="h-4" />;
220225}
221226
227function HomeScrollView(props: ComponentProps<typeof ScrollView>) {
228 const insets = useSafeAreaInsets();
229 const primaryColumn = use(NativePrimaryColumnContext);
230 if (Platform.OS !== "ios") return <ScrollView {...props} />;
231
232 // v5 needs the actual content scroll view registered with its screen controller.
233 // The layout observer and header siblings prevent UIKit from finding it implicitly.
234 return (
235 <ScrollViewMarker
236 style={{ flex: 1 }}
237 scrollEdgeEffects={{
238 ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version),
239 // Retain the existing sidebar fade on iOS 26; iOS 27 uses the native bar material.
240 top: Number.parseInt(String(Platform.Version), 10) >= 27 ? "automatic" : "soft",
241 }}
242 >
243 <ScrollView
244 {...props}
245 // UIKit's scroll-edge material needs the scroll view's own surface color.
246 className={cn(props.className, primaryColumn ? "bg-drawer" : "bg-screen")}
247 // Use the column's measured inset so UIKit recognizes the resting top.
248 // Padding visually clears the bar but leaves the native scroll offset
249 // past its edge, activating scroll-edge protection before any scroll.
250 automaticallyAdjustContentInsets={false}
251 contentInsetAdjustmentBehavior="never"
252 contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
253 />
254 </ScrollViewMarker>
255 );
256}
257
258function renderHomeScrollView(props: ScrollViewProps) {
259 // FlatList clones this element with its cells and ref. Keep the marker inside
260 // the component so those cells remain children of the actual ScrollView.
261 return <HomeScrollView {...props} />;
262}
263
222264/* ─── Main screen ────────────────────────────────────────────────────── */
223265
224266export function HomeScreen(props: HomeScreenProps) {
267 const primaryColumn = use(NativePrimaryColumnContext);
268 const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen";
269 const containerClassName = cn(
270 "flex-1",
271 Platform.OS === "android" ? "bg-header" : contentBackground,
272 );
273 const columnMetrics = useNativeColumnLayoutMetrics();
274 const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null;
275 const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined;
225276 const queuedThreadKeys = useQueuedThreadKeys();
226277 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
227278 const insets = useSafeAreaInsets();
228279 const { fabClearance } = useAndroidControlSizing();
229 const iosBottomToolbarClearance =
230 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
231 ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT
232 : 0;
280 // UIKit's column safe area already includes its bottom toolbar.
281 const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24);
233282 const searchEnvironmentIds = useMemo(
234283 () =>
235284 props.selectedEnvironmentId === null
......@@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) {
563612 inboxReturnAt: threadListInboxReturns.returnedAt,
564613 snoozedShelfExpanded,
565614 settledShelfExpanded,
566 selectedThreadKey: null,
615 selectedThreadKey,
567616 });
568617 }, [
569618 workingShelfEnabled,
570619 workingShelfExpanded,
571620 pendingOrder,
621 selectedThreadKey,
572622 queuedThreadKeys,
573623 nowMinute,
574624 snoozeWakeTick,
......@@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) {
715765 const thread = item.item.thread;
716766 return (
717767 <ThreadListV2Row
768 pane={primaryColumn ? "sidebar" : "screen"}
769 selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey}
770 fullSwipeWidth={fullSwipeWidth}
718771 onNewThreadOnBranch={props.onNewThreadOnBranch}
719772 thread={thread}
720773 variant={item.item.variant}
......@@ -802,6 +855,9 @@ export function HomeScreen(props: HomeScreenProps) {
802855 props.onDeletePendingTask,
803856 props.onSelectPendingTask,
804857 props.onSelectThread,
858 primaryColumn,
859 selectedThreadKey,
860 fullSwipeWidth,
805861 props.onNewThreadOnBranch,
806862 props.savedConnectionsById,
807863 resolveProviderInstance,
......@@ -831,6 +887,7 @@ export function HomeScreen(props: HomeScreenProps) {
831887 listEnvironments,
832888 savedConnectionsById: props.savedConnectionsById,
833889 searchQuery: props.searchQuery,
890 selectedThreadKey,
834891 threadSearchMatchByKey,
835892 // Rows read it for their reorder menu items.
836893 workingShelfEnabled,
......@@ -838,6 +895,7 @@ export function HomeScreen(props: HomeScreenProps) {
838895 [
839896 projectByKey,
840897 props.searchQuery,
898 selectedThreadKey,
841899 props.savedConnectionsById,
842900 listEnvironments,
843901 threadSearchMatchByKey,
......@@ -868,14 +926,15 @@ export function HomeScreen(props: HomeScreenProps) {
868926
869927 if (!hasAnyThreads) {
870928 return (
871 <View className="flex-1 bg-screen android:bg-header">
929 <View className={containerClassName}>
872930 <View
873931 className={cn(
874 "flex-1 items-center justify-center bg-screen px-8",
932 "flex-1 items-center justify-center px-8",
933 contentBackground,
875934 Platform.OS === "android" && "overflow-hidden rounded-t-[28px]",
876935 )}
877936 style={{
878 paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance,
937 paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),
879938 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
880939 }}
881940 >
......@@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) {
9581017 }
9591018
9601019 return (
961 <View className="flex-1 bg-screen android:bg-header">
1020 <View className={containerClassName}>
9621021 <View
9631022 className={
9641023 Platform.OS === "android"
9651024 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
966 : "flex-1 bg-screen"
1025 : cn("flex-1", contentBackground)
9671026 }
9681027 >
9691028 {/* Shared with the iPad sidebar: cells are reused across data
......@@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) {
9761035 onTouchStart={(event) => trackListTouches(event, true)}
9771036 onTouchEnd={(event) => trackListTouches(event, false)}
9781037 onTouchCancel={(event) => trackListTouches(event, false)}
1038 renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined}
9791039 data={threadListV2Items}
9801040 renderItem={renderV2Item}
9811041 keyExtractor={v2KeyExtractor}
......@@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) {
9971057 ListEmptyComponent={v2ListEmpty}
9981058 style={{ flex: 1 }}
9991059 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
1000 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
1060 contentInsetAdjustmentBehavior="never"
1061 contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined}
1062 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
10011063 showsVerticalScrollIndicator={false}
10021064 keyboardDismissMode="on-drag"
10031065 keyboardShouldPersistTaps="handled"
10041066 {...scrollGateHandlers}
10051067 scrollEventThrottle={16}
10061068 contentContainerStyle={{
1069 paddingHorizontal: primaryColumn ? 8 : 0,
10071070 paddingBottom:
10081071 Platform.OS === "ios"
1009 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
1072 ? iosBottomClearance
10101073 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88),
10111074 }}
10121075 />
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+20-1
......@@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE
6161import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
6262import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
6363import { WorkspaceContentWidthContext } from "./workspace-content-width";
64import {
65 NativeWorkspaceModeContext,
66 NativeWorkspaceInspectorContext,
67} from "../../native/v5-workspace-context";
6468
6569interface AdaptiveWorkspaceContextValue {
6670 readonly layout: Layout;
......@@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent(
234238 },
235239) {
236240 const projectGroupingMode = props.projectGroupingMode;
241 const nativeWorkspace = use(NativeWorkspaceModeContext);
237242 const { width, height } = useWindowDimensions();
238243 const pathname = props.pathname;
239244 const navigation = useNavigation();
......@@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent(
468473 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
469474 );
470475 useEffect(() => {
476 if (nativeWorkspace) return;
471477 const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0;
472478 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
473 }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
479 }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
474480 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
475481 opacity: Math.min(1, renderedSidebarWidth.value / 80),
476482 width: renderedSidebarWidth.value,
......@@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent(
565571 ],
566572 );
567573
574 if (nativeWorkspace) {
575 return (
576 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
577 <AdaptiveWorkspaceContext value={contextValue}>
578 <NativeWorkspaceInspectorContext
579 value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }}
580 >
581 {props.children}
582 </NativeWorkspaceInspectorContext>
583 </AdaptiveWorkspaceContext>
584 </HomeListOptionsProvider>
585 );
586 }
568587 return (
569588 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
570589 <AdaptiveWorkspaceContext.Provider value={contextValue}>
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx created+342
......@@ -0,0 +1,342 @@
1import {
2 createNavigatorFactory,
3 NavigationContext,
4 NavigationRouteContext,
5 StackRouter,
6 useNavigationBuilder,
7 type NavigatorTypeBagBase,
8 type ParamListBase,
9 type StackActionHelpers,
10 type StackNavigationState,
11 type StackRouterOptions,
12 type StaticConfig,
13 type TypedNavigator,
14} from "@react-navigation/native";
15import {
16 NativeStackView,
17 type NativeStackNavigationEventMap,
18 type NativeStackNavigationOptions,
19 type NativeStackNavigatorProps,
20 type NativeStackTypeBag,
21} from "@react-navigation/native-stack";
22import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
23import { View } from "react-native";
24import { Split, type SplitHostCommands } from "react-native-screens";
25import { EnvironmentId, ThreadId } from "@t3tools/contracts";
26import { scopedThreadKey } from "../../lib/scopedEntities";
27
28import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
29import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail";
30import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios";
31import {
32 modalEnvelopeOptions,
33 V5CardStackView,
34 V5StackView,
35} from "../../native/createV5StackNavigator.ios";
36import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns";
37import { V5StackHeader } from "../../native/V5StackHeader.ios";
38import {
39 nativeWorkspacePopAction,
40 projectWorkspaceStack,
41 partitionStackPresentations,
42} from "../../native/workspace-stack-projection";
43import {
44 NativePrimaryColumnContext,
45 NativeWorkspaceInspectorContext,
46 NativeWorkspaceModeContext,
47} from "../../native/v5-workspace-context";
48
49type ViewProps = ComponentProps<typeof NativeStackView>;
50type Descriptor = ViewProps["descriptors"][string];
51type Route = ViewProps["state"]["routes"][number];
52
53function ColumnScreen(props: {
54 readonly descriptor: Descriptor;
55 readonly primary?: boolean;
56 readonly selectedThreadKey?: string | null;
57 readonly onDismiss: (routeKey: string) => void;
58 readonly navigation: ViewProps["navigation"];
59}) {
60 const { descriptor } = props;
61 const primaryColumn = useMemo(
62 () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null),
63 [props.primary, props.selectedThreadKey],
64 );
65 const key = descriptor.route.key;
66 return (
67 <Split.Screen
68 screenKey={key}
69 onDismiss={(event) => {
70 if (event.nativeEvent.isNativeDismiss) props.onDismiss(key);
71 }}
72 onWillAppear={() =>
73 props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } })
74 }
75 onDidAppear={() =>
76 props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } })
77 }
78 onWillDisappear={() =>
79 props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } })
80 }
81 onDidDisappear={() =>
82 props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } })
83 }
84 >
85 <NavigationContext value={descriptor.navigation}>
86 <NavigationRouteContext value={descriptor.route}>
87 <V5StackHeader
88 options={descriptor.options}
89 canGoBack={!props.primary}
90 primary={props.primary}
91 />
92 <ColumnContent primary={props.primary}>
93 <NativePrimaryColumnContext value={primaryColumn}>
94 {descriptor.render()}
95 </NativePrimaryColumnContext>
96 </ColumnContent>
97 </NavigationRouteContext>
98 </NavigationContext>
99 </Split.Screen>
100 );
101}
102
103function WorkspaceColumns(
104 props: ViewProps & { readonly primary: Route; readonly detail: Route[] },
105) {
106 const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
107 const inspector = use(NativeWorkspaceInspectorContext);
108 const hostRef = useRef<SplitHostCommands>(null);
109 const activeDetailKey = props.detail.at(-1)?.key;
110 const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;
111 const selectedThreadKey =
112 threadParams &&
113 "environmentId" in threadParams &&
114 "threadId" in threadParams &&
115 typeof threadParams.environmentId === "string" &&
116 typeof threadParams.threadId === "string"
117 ? scopedThreadKey(
118 EnvironmentId.make(threadParams.environmentId),
119 ThreadId.make(threadParams.threadId),
120 )
121 : null;
122 const handleNativeDismiss = useCallback(
123 (key: string) => {
124 const state = props.navigation.getState();
125 const action = nativeWorkspacePopAction(state, key);
126 if (action) props.navigation.dispatch(action);
127 },
128 [props.navigation],
129 );
130
131 useEffect(() => {
132 // In compact size classes UIKit exposes one column. Selecting a thread
133 // changes the visible column without rebuilding either navigation stack.
134 if (layout.usesSplitView) return;
135 hostRef.current?.show(activeDetailKey ? "secondary" : "primary");
136 }, [activeDetailKey, layout.usesSplitView]);
137
138 const primary = props.descriptors[props.primary.key];
139 if (!primary) return null;
140 return (
141 <Split.Host
142 ref={hostRef}
143 testID="adaptive-workspace-layout"
144 preferredSplitBehavior="tile"
145 preferredDisplayMode={
146 panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly"
147 }
148 displayModeButtonVisibility="never"
149 presentsWithGesture
150 showInspector={inspector.visible}
151 onInspectorHide={() => {
152 if (inspector.visible) toggleAuxiliaryPane();
153 }}
154 columnMetrics={{
155 minimumPrimaryColumnWidth: 280,
156 maximumPrimaryColumnWidth: 380,
157 preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320,
158 minimumSecondaryColumnWidth: 320,
159 }}
160 >
161 <Split.Column>
162 <Split.Stack>
163 <ColumnScreen
164 descriptor={primary}
165 primary
166 selectedThreadKey={selectedThreadKey}
167 onDismiss={handleNativeDismiss}
168 navigation={props.navigation}
169 />
170 </Split.Stack>
171 </Split.Column>
172 <Split.Column>
173 <Split.Stack>
174 <Split.Screen
175 screenKey="workspace-empty"
176 activityMode={layout.usesSplitView && !activeDetailKey ? "attached" : "detached"}
177 >
178 <V5StackHeader
179 options={{
180 headerShown: true,
181 title: "",
182 unstable_headerLeftItems: () => [
183 {
184 type: "button",
185 label: "",
186 icon: { type: "sfSymbol", name: "sidebar.left" },
187 onPress: togglePrimarySidebar,
188 },
189 ],
190 }}
191 canGoBack={false}
192 />
193 <ColumnContent>
194 <WorkspaceEmptyDetail
195 onStartNewTask={() =>
196 props.navigation.navigate("NewTaskSheet", { screen: "NewTask" })
197 }
198 />
199 </ColumnContent>
200 </Split.Screen>
201 {props.detail.map((route) => {
202 const descriptor = props.descriptors[route.key];
203 return descriptor ? (
204 <ColumnScreen
205 key={route.key}
206 descriptor={descriptor}
207 onDismiss={handleNativeDismiss}
208 navigation={props.navigation}
209 />
210 ) : null;
211 })}
212 </Split.Stack>
213 </Split.Column>
214 <Split.Inspector>
215 {/* Inspector content supplies its own navigation header. */}
216 <Split.HeaderConfig hidden />
217 <ColumnContent>
218 <View className="flex-1 bg-screen">{inspector.render?.()}</View>
219 </ColumnContent>
220 </Split.Inspector>
221 </Split.Host>
222 );
223}
224
225function WorkspaceStackView(props: ViewProps) {
226 const projection = projectWorkspaceStack(props.state, (route) => {
227 const presentation = props.descriptors[route.key]?.options.presentation;
228 return presentation !== undefined && presentation !== "card";
229 });
230 // Linking normally restores Home via initialRouteName. A detail-only history
231 // must stay a single stack: placeholder descriptors cannot own an interactive
232 // sidebar because React Navigation rejects their actions and setOptions.
233 const primaryRoute = projection.primary;
234 if (!primaryRoute) return <V5StackView {...props} />;
235 const primaryDescriptor = props.descriptors[primaryRoute.key]!;
236 const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" };
237 const baseDescriptor: Descriptor = {
238 ...primaryDescriptor,
239 route: baseRoute,
240 options: { headerShown: false },
241 render: () => <WorkspaceColumns {...props} primary={primaryRoute} detail={projection.detail} />,
242 };
243 const overlays = partitionStackPresentations(projection.overlays, (route) => {
244 const presentation = props.descriptors[route.key]?.options.presentation;
245 return presentation !== undefined && presentation !== "card";
246 });
247 const routes = [baseRoute, ...overlays.map((group) => group[0]!)];
248 return (
249 <NativeStackView
250 {...props}
251 state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }}
252 descriptors={{
253 ...Object.fromEntries(
254 overlays.map((group) => {
255 const route = group[0]!;
256 const descriptor = props.descriptors[route.key]!;
257 return [
258 route.key,
259 {
260 ...descriptor,
261 options: modalEnvelopeOptions(descriptor.options),
262 render: () => (
263 <V5CardStackView
264 {...props}
265 state={{
266 ...props.state,
267 routes: group,
268 index: group.length - 1,
269 preloadedRoutes: [],
270 }}
271 />
272 ),
273 },
274 ];
275 }),
276 ),
277 [baseRoute.key]: baseDescriptor,
278 }}
279 />
280 );
281}
282
283function WorkspaceStackNavigator({
284 id,
285 initialRouteName,
286 UNSTABLE_routeNamesChangeBehavior,
287 children,
288 layout,
289 screenListeners,
290 screenOptions,
291 screenLayout,
292 UNSTABLE_router,
293 ...rest
294}: NativeStackNavigatorProps) {
295 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
296 StackNavigationState<ParamListBase>,
297 StackRouterOptions,
298 StackActionHelpers<ParamListBase>,
299 NativeStackNavigationOptions,
300 NativeStackNavigationEventMap
301 >(StackRouter, {
302 id,
303 initialRouteName,
304 UNSTABLE_routeNamesChangeBehavior,
305 children,
306 layout,
307 screenListeners,
308 screenOptions,
309 screenLayout,
310 UNSTABLE_router,
311 });
312 const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView;
313 return (
314 <NativeWorkspaceModeContext value={NATIVE_WORKSPACE_COLUMNS_SUPPORTED}>
315 <NavigationContent>
316 <WorkspaceView
317 {...rest}
318 state={state}
319 describe={describe}
320 descriptors={descriptors}
321 navigation={navigation}
322 />
323 </NavigationContent>
324 </NativeWorkspaceModeContext>
325 );
326}
327
328type WorkspaceTypeBag<
329 ParamList extends ParamListBase,
330 NavigatorID extends string | undefined,
331> = Omit<NativeStackTypeBag<ParamList, NavigatorID>, "Navigator"> & {
332 Navigator: typeof WorkspaceStackNavigator;
333};
334
335export function createWorkspaceStackNavigator<
336 const ParamList extends ParamListBase,
337 const NavigatorID extends string | undefined = string | undefined,
338 const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag<ParamList, NavigatorID>,
339 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
340>(config?: Config): TypedNavigator<TypeBag, Config> {
341 return createNavigatorFactory(WorkspaceStackNavigator)(config);
342}
apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx created+1
......@@ -0,0 +1 @@
1export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack";
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+4-11
......@@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"
33import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
44
55/**
6 * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass
7 * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side
8 * silently drops the item and hides the navigation toolbar entirely. Screens
9 * that send it must fall back to standard search/toolbar primitives when this
10 * is false.
6 * Group search, filtering, and composition on platforms with Liquid Glass.
7 * The v5 header adapter turns this intent into UISearchController and native
8 * toolbar items. Earlier iOS versions use separate search and toolbar options.
119 */
1210export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED;
1311
......@@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit<
2018>;
2119
2220/**
23 * Builds the patched react-native-screens Mail-style bottom search toolbar.
24 *
25 * Keeping this behind an app-level helper makes the iOS-only RNS patch an
26 * explicit layout primitive instead of a per-screen object literal. Android can
27 * keep using platform-specific header/search primitives without depending on
28 * this helper.
21 * Describe the thread-list search and actions for the native header adapter.
2922 */
3023export function createNativeMailSearchToolbarItem(
3124 input: NativeMailSearchToolbarInput,
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+13-8
......@@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
77
88import { hasCloudPublicConfig } from "../cloud/publicConfig";
99import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
10import { NativeHeaderToolbar } from "../../native/StackHeader";
10import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
1111import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1212import { SettingsRow } from "./components/SettingsRow";
1313import { SettingsSection } from "./components/SettingsSection";
......@@ -29,14 +29,19 @@ export function SettingsRouteScreen() {
2929
3030 return (
3131 <>
32 {Platform.OS === "ios" ? (
33 <NativeStackScreenOptions options={{ headerBackVisible: !layout.usesSplitView }} />
34 ) : null}
3235 {Platform.OS === "ios" && layout.usesSplitView ? (
33 <NativeHeaderToolbar placement="left">
34 <NativeHeaderToolbar.Button
35 accessibilityLabel="Go back"
36 icon="chevron.left"
37 onPress={() => navigation.goBack()}
38 />
39 </NativeHeaderToolbar>
36 <>
37 <NativeHeaderToolbar placement="left">
38 <NativeHeaderToolbar.Button
39 accessibilityLabel="Go back"
40 icon="chevron.left"
41 onPress={() => navigation.goBack()}
42 />
43 </NativeHeaderToolbar>
44 </>
4045 ) : null}
4146 <SettingsEnvironmentFilterHeader closeSettings />
4247 {Platform.OS === "android" ? (
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+6
......@@ -9,6 +9,7 @@ import {
99import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
1010import { SymbolView } from "../../components/AppSymbol";
1111import { ScreenHeader } from "../../components/ScreenHeader";
12import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
1213import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
1314import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1415import { Alert, Platform, Pressable, View } from "react-native";
......@@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{
254255
255256export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
256257 const insets = useSafeAreaInsets();
258 const nativeColumnMetrics = useNativeColumnLayoutMetrics();
257259 const navigation = useNavigation();
258260 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
259261 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
......@@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12021204 Platform.OS === "android"
12031205 ? themeVariables["--color-card-alt"]
12041206 : terminalTheme.background,
1207 // The v5 column extends behind UIKit chrome. Its measured top
1208 // reservation is zero when UIKit has already inset the body or moved the bar.
1209 paddingTop:
1210 Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0,
12051211 paddingBottom:
12061212 Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0,
12071213 }}
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+1-1
......@@ -29,7 +29,7 @@ import {
2929} from "@t3tools/client-runtime/state/thread-settled";
3030import { useState, type ReactNode } from "react";
3131import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
32import { createNativeStackNavigator } from "@react-navigation/native-stack";
32import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
3333import { ScrollView, useWindowDimensions, View } from "react-native";
3434import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
3535import { NativeHeaderToolbar } from "../../native/StackHeader";
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+3-3
......@@ -664,11 +664,11 @@ function ThreadRouteContent(
664664 () =>
665665 inspectorMode === null ? null : (
666666 <ThreadInspectorContentStack
667 Files={FilesInspector}
668 Git={GitInspector}
667 renderFiles={FilesInspector}
668 renderGit={GitInspector}
669669 mode={inspectorMode}
670670 resetKeys={[routeThreadIdentity, selectedThreadCwd]}
671 Route={props.renderInspector ? RouteInspector : undefined}
671 renderRoute={props.renderInspector ? RouteInspector : undefined}
672672 />
673673 ),
674674 [
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+2-4
......@@ -1,3 +1,4 @@
1import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
12import type {
23 EnvironmentId,
34 ModelSelection,
......@@ -15,10 +16,7 @@ import {
1516 getProviderOptionDescriptors,
1617} from "@t3tools/shared/model";
1718import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native";
18import {
19 createNativeStackNavigator,
20 type NativeStackNavigationProp,
21} from "@react-navigation/native-stack";
19import { type NativeStackNavigationProp } from "@react-navigation/native-stack";
2220import * as Haptics from "expo-haptics";
2321import { AsyncResult } from "effect/reactivity";
2422import {
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+2-4
......@@ -1,8 +1,6 @@
1import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
12import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
2import {
3 createNativeStackNavigator,
4 type NativeStackNavigationOptions,
5} from "@react-navigation/native-stack";
3import { type NativeStackNavigationOptions } from "@react-navigation/native-stack";
64import type { ReactNode } from "react";
75import { Platform } from "react-native";
86
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx+21-18
......@@ -1,4 +1,4 @@
1import { useEffect, useState, type ComponentType, type ReactNode } from "react";
1import { useEffect, useState, type ReactNode } from "react";
22import { View } from "react-native";
33
44import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
......@@ -43,11 +43,11 @@ function InspectorContentPane(props: {
4343}
4444
4545export function ThreadInspectorContentStack(props: {
46 readonly Files: ComponentType;
47 readonly Git: ComponentType;
46 readonly renderFiles: () => ReactNode;
47 readonly renderGit?: () => ReactNode;
4848 readonly mode: ThreadInspectorMode;
4949 readonly resetKeys: readonly [string | null, string | null];
50 readonly Route?: ComponentType;
50 readonly renderRoute?: () => ReactNode;
5151}) {
5252 const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(
5353 () => new Set([props.mode]),
......@@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: {
8181 return () => clearTimeout(timeout);
8282 }, [props.mode]);
8383
84 const Files = props.Files;
85 const Git = props.Git;
86 const Route = props.Route;
87
8884 return (
8985 <View className="flex-1">
9086 <InspectorContentPane
......@@ -92,24 +88,31 @@ export function ThreadInspectorContentStack(props: {
9288 resetKeys={props.resetKeys}
9389 visible={props.mode === "files"}
9490 >
95 <Files />
96 </InspectorContentPane>
97 <InspectorContentPane
98 mounted={mountedModes.has("git") || props.mode === "git"}
99 resetKeys={props.resetKeys}
100 visible={props.mode === "git"}
101 >
102 <Git />
91 <InspectorRenderer render={props.renderFiles} />
10392 </InspectorContentPane>
104 {Route ? (
93 {props.renderGit ? (
94 <InspectorContentPane
95 mounted={mountedModes.has("git") || props.mode === "git"}
96 resetKeys={props.resetKeys}
97 visible={props.mode === "git"}
98 >
99 <InspectorRenderer render={props.renderGit} />
100 </InspectorContentPane>
101 ) : null}
102 {props.renderRoute ? (
105103 <InspectorContentPane
106104 mounted={mountedModes.has("route") || props.mode === "route"}
107105 resetKeys={props.resetKeys}
108106 visible={props.mode === "route"}
109107 >
110 <Route />
108 <InspectorRenderer render={props.renderRoute} />
111109 </InspectorContentPane>
112110 ) : null}
113111 </View>
114112 );
115113}
114
115// Render callbacks carry changing route data; they are not component types.
116function InspectorRenderer(props: { readonly render: () => ReactNode }) {
117 return <>{props.render()}</>;
118}
apps/mobile/src/lib/reserved-regions.ts created+12
......@@ -0,0 +1,12 @@
1/** Geometry of the body inside a native navigation column. */
2export interface NativeLayoutMetrics {
3 readonly width: number;
4 readonly height: number;
5 readonly horizontalSizeClass: "compact" | "regular";
6 readonly safeArea: {
7 readonly top: number;
8 readonly bottom: number;
9 readonly left: number;
10 readonly right: number;
11 };
12}
apps/mobile/src/native/NativeColumnContent.ios.tsx created+31
......@@ -0,0 +1,31 @@
1import { HeaderHeightContext } from "@react-navigation/elements";
2import { useState, type ReactNode } from "react";
3import { View } from "react-native";
4import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
5import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics";
6import type { NativeLayoutMetrics } from "../lib/reserved-regions";
7import { NativeLayoutObserver } from "./NativeLayoutObserver";
8
9export function NativeColumnContent(props: {
10 readonly children: ReactNode;
11 readonly primary?: boolean;
12}) {
13 const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null);
14 return (
15 <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}>
16 <SafeAreaProvider style={{ flex: 1 }}>
17 <SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}>
18 {/* Observe the inset body. UIKit can reserve other columns in its safe area. */}
19 <View style={{ flex: 1 }}>
20 <NativeLayoutObserver onChange={setMetrics} />
21 <NativeColumnLayoutMetricsContext value={metrics}>
22 <HeaderHeightContext value={metrics?.safeArea.top ?? undefined}>
23 {props.children}
24 </HeaderHeightContext>
25 </NativeColumnLayoutMetricsContext>
26 </View>
27 </SafeAreaView>
28 </SafeAreaProvider>
29 </View>
30 );
31}
apps/mobile/src/native/NativeLayoutObserver.ios.tsx created+33
......@@ -0,0 +1,33 @@
1import { requireNativeView, requireOptionalNativeModule } from "expo";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import type { NativeLayoutMetrics } from "../lib/reserved-regions";
4
5interface ObserverProps extends ViewProps {
6 readonly onMetricsChange?: (event: NativeSyntheticEvent<NativeLayoutMetrics>) => void;
7}
8
9// An older development client can still run the compact app while it rebuilds.
10function resolveObserver() {
11 try {
12 const module = requireOptionalNativeModule<{
13 readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown };
14 }>("T3NativeControls");
15 if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null;
16 return requireNativeView<ObserverProps>("T3NativeControls", "LayoutMetrics");
17 } catch {
18 return null;
19 }
20}
21const Observer = resolveObserver();
22
23export function NativeLayoutObserver(props: {
24 readonly onChange?: (metrics: NativeLayoutMetrics) => void;
25}) {
26 return Observer ? (
27 <Observer
28 pointerEvents="none"
29 onMetricsChange={props.onChange ? (event) => props.onChange?.(event.nativeEvent) : undefined}
30 style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}
31 />
32 ) : null;
33}
apps/mobile/src/native/NativeLayoutObserver.tsx created+7
......@@ -0,0 +1,7 @@
1import type { NativeLayoutMetrics } from "../lib/reserved-regions";
2
3export function NativeLayoutObserver(_props: {
4 readonly onChange?: (metrics: NativeLayoutMetrics) => void;
5}) {
6 return null;
7}
apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx created+7
......@@ -0,0 +1,7 @@
1import { requireOptionalNativeModule } from "expo";
2import { Platform } from "react-native";
3const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>(
4 "T3NativeControls",
5);
6export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED =
7 Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true;
apps/mobile/src/native/NativeWorkspaceColumns.tsx created+3
......@@ -0,0 +1,3 @@
1import { Platform } from "react-native";
2// Android retains its existing navigator. The iOS file checks native availability.
3export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad;
apps/mobile/src/native/V5StackHeader.ios.tsx created+253
......@@ -0,0 +1,253 @@
1import type {
2 NativeStackHeaderItem,
3 NativeStackHeaderItemMenu,
4 NativeStackNavigationOptions,
5} from "@react-navigation/native-stack";
6import { isValidElement, useRef } from "react";
7import { Platform, StyleSheet } from "react-native";
8import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme";
9import {
10 SearchBar,
11 Stack,
12 type HeaderBarButtonMailSearchToolbarItem,
13 type SearchBarCommands,
14 type StackHeaderConfigProps,
15 type StackHeaderMenuElementIOS,
16 type StackHeaderMenuIOS,
17 type HeaderBarButtonItemWithMenu,
18 type HeaderBarButtonSearchBarPlacementItem,
19} from "react-native-screens";
20
21type HeaderItem = NonNullable<NonNullable<StackHeaderConfigProps["ios"]>["leadingItems"]>[number];
22type ToolbarItem =
23 | NativeStackHeaderItem
24 | HeaderBarButtonMailSearchToolbarItem
25 | HeaderBarButtonSearchBarPlacementItem;
26type Options = NativeStackNavigationOptions & {
27 readonly unstable_navigationItemStyle?: "editor" | "navigator";
28 readonly headerSubtitle?: string;
29 readonly unstable_headerSubtitle?: string;
30 readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string };
31 readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[];
32 readonly unstable_headerToolbarItems?: () => ToolbarItem[];
33};
34
35type MenuElementInput =
36 | NativeStackHeaderItemMenu["menu"]["items"][number]
37 | HeaderBarButtonItemWithMenu["menu"]["items"][number];
38type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] };
39
40function convertIcon(
41 icon:
42 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
43 | HeaderBarButtonItemWithMenu["icon"],
44) {
45 if (!icon) return undefined;
46 if (icon.type === "image")
47 return icon.tinted === false
48 ? { type: "imageSource" as const, imageSource: icon.source }
49 : { type: "templateSource" as const, templateSource: icon.source };
50 return icon;
51}
52
53function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS {
54 const children = menu.items.flatMap<StackHeaderMenuElementIOS>((item, index) => {
55 if ("hidden" in item && item.hidden) return [];
56 const childId = `${id}:${index}`;
57 if (item.type === "submenu") {
58 return [
59 {
60 ...convertMenu({ items: item.items }, childId),
61 title: "label" in item ? item.label : item.title,
62 displayInline:
63 "inline" in item
64 ? item.inline
65 : "displayInline" in item
66 ? item.displayInline
67 : undefined,
68 },
69 ];
70 }
71 // Use actions even for checked entries: the app owns their durable state.
72 return [
73 {
74 id: childId,
75 type: "menuItem" as const,
76 itemType: "action" as const,
77 title: "label" in item ? item.label : item.title,
78 icon: convertIcon(item.icon),
79 onPress: item.disabled ? undefined : item.onPress,
80 keepsMenuPresented: item.keepsMenuPresented,
81 disabled: item.disabled,
82 destructive: item.destructive,
83 subtitle:
84 ("subtitle" in item ? item.subtitle : undefined) ??
85 ("description" in item ? item.description : undefined),
86 state: item.state,
87 },
88 ];
89 });
90 return { id, type: "menu", title: menu.title, children };
91}
92
93function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] {
94 return items.map((item, index): HeaderItem => {
95 const id =
96 item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`);
97 switch (item.type) {
98 case "spacing":
99 return { type: "spacer", id, sizing: "fixed", width: item.spacing };
100 case "custom":
101 return {
102 type: "item",
103 id,
104 render: () => item.element,
105 hidesSharedBackground: item.hidesSharedBackground,
106 };
107 case "menu":
108 return {
109 type: "item",
110 id,
111 identifier: item.identifier,
112 title: item.label || item.accessibilityLabel || undefined,
113 icon: convertIcon(item.icon),
114 menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`),
115 hidesSharedBackground: item.hidesSharedBackground,
116 };
117 case "button":
118 return {
119 type: "item",
120 id,
121 identifier: item.identifier,
122 title: item.label || item.accessibilityLabel || undefined,
123 icon: convertIcon(item.icon),
124 onPress: item.disabled ? undefined : item.onPress,
125 hidesSharedBackground: item.hidesSharedBackground,
126 };
127 }
128 });
129}
130
131/** Translate the existing screen options to v5's native header API. */
132export function V5StackHeader(props: {
133 readonly options: Options;
134 readonly canGoBack: boolean;
135 readonly primary?: boolean;
136}) {
137 const { options } = props;
138 const theme = useMobileNavigationTheme();
139 const searchRef = useRef<SearchBarCommands>(null);
140 const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack };
141 const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading");
142 const trailing = convertItems(
143 [
144 ...(options.unstable_headerCenterItems?.() ?? []),
145 ...(options.unstable_headerRightItems?.(itemProps) ?? []),
146 ],
147 "trailing",
148 );
149 const toolbar = options.unstable_headerToolbarItems?.() ?? [];
150 const mailSearch = toolbar.find(
151 (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar",
152 );
153 const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad;
154 const bottom = toolbar.flatMap<HeaderItem>((item, index) => {
155 if (item.type === "mailSearchToolbar") return [];
156 if (item.type === "searchBarPlacement")
157 return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }];
158 return convertItems([item], `toolbar:${index}`);
159 });
160 if (mailSearch) {
161 if (mailSearch.filterMenu)
162 bottom.push({
163 type: "item",
164 id: mailSearch.filterButtonId ?? "filter",
165 icon: {
166 type: "sfSymbol",
167 name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease",
168 },
169 menu: convertMenu(mailSearch.filterMenu, "filter-menu"),
170 });
171 if (bottomSearch) {
172 if (bottom.length)
173 bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 });
174 bottom.push({ type: "item", id: "home-search", searchBarPlacement: true });
175 if (mailSearch.onComposePress)
176 bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });
177 }
178 if (mailSearch.onComposePress)
179 bottom.push({
180 type: "item",
181 id: mailSearch.composeButtonId ?? "compose",
182 icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" },
183 onPress: mailSearch.onComposePress,
184 });
185 }
186 const left = options.headerLeft?.(itemProps);
187 const right = options.headerRight?.(itemProps);
188 if (!options.unstable_headerLeftItems && isValidElement(left))
189 leading.push({ type: "item", id: "custom-left", render: () => left });
190 if (!options.unstable_headerRightItems && isValidElement(right))
191 trailing.push({ type: "item", id: "custom-right", render: () => right });
192 const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title;
193 const titleElement =
194 typeof options.headerTitle === "function"
195 ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor })
196 : undefined;
197 const searchOptions = options.headerSearchBarOptions;
198 const titleStyle = StyleSheet.flatten(options.headerTitleStyle);
199 const headerStyle = StyleSheet.flatten(options.headerStyle);
200 const appearance = {
201 backgroundColor:
202 headerStyle?.backgroundColor ??
203 (options.headerTransparent ? "transparent" : theme.colors.card),
204 shadowColor: options.headerShadowVisible === false ? "transparent" : undefined,
205 titleFontSize: titleStyle?.fontSize,
206 titleFontFamily: titleStyle?.fontFamily,
207 titleFontWeight: titleStyle?.fontWeight,
208 titleFontColor: titleStyle?.color,
209 subtitleFontSize: options.headerSubtitleStyle?.fontSize,
210 subtitleFontColor: options.headerSubtitleStyle?.color,
211 };
212 return (
213 <Stack.HeaderConfig
214 hidden={options.headerShown === false}
215 transparent={options.headerTransparent}
216 title={title}
217 subtitle={options.unstable_headerSubtitle ?? options.headerSubtitle}
218 backButtonHidden={options.headerBackVisible === false}
219 ios={{
220 navigationItemStyle: options.unstable_navigationItemStyle,
221 tintColor: options.headerTintColor,
222 searchBar:
223 mailSearch || searchOptions ? (
224 <SearchBar
225 {...searchOptions}
226 ref={searchOptions?.ref ?? searchRef}
227 hideNavigationBar={false}
228 hideWhenScrolling={false}
229 allowToolbarIntegration={searchOptions?.allowToolbarIntegration ?? true}
230 placement={bottomSearch ? "integrated" : (searchOptions?.placement ?? "automatic")}
231 placeholder={mailSearch?.placeholder ?? searchOptions?.placeholder ?? "Search"}
232 onChangeText={(event) => {
233 mailSearch?.onSearchTextChange?.(event.nativeEvent.text);
234 searchOptions?.onChangeText?.(event);
235 }}
236 />
237 ) : undefined,
238 toolbarItems: bottom,
239 leadingItems: leading,
240 trailingItems: trailing,
241 titleItem: isValidElement(titleElement)
242 ? { id: "title", render: () => titleElement }
243 : undefined,
244 largeTitleEnabled: options.headerLargeTitle,
245 backButtonDisplayMode: options.headerBackButtonDisplayMode ?? "minimal",
246 backButtonMenuEnabled: options.headerBackButtonMenuEnabled ?? true,
247 backButtonTitle: options.headerBackTitle,
248 standardAppearance: appearance,
249 scrollEdgeAppearance: appearance,
250 }}
251 />
252 );
253}
apps/mobile/src/native/createV5StackNavigator.ios.tsx created+422
......@@ -0,0 +1,422 @@
1import {
2 createNavigatorFactory,
3 NavigationContext,
4 NavigationRouteContext,
5 StackRouter,
6 useNavigationBuilder,
7 usePreventRemoveContext,
8 type NavigatorTypeBagBase,
9 type ParamListBase,
10 type StackActionHelpers,
11 type StackNavigationState,
12 type StackRouterOptions,
13 type StaticConfig,
14 type TypedNavigator,
15} from "@react-navigation/native";
16import {
17 NativeStackView,
18 type NativeStackNavigationEventMap,
19 type NativeStackNavigationOptions,
20 type NativeStackNavigatorProps,
21 type NativeStackTypeBag,
22} from "@react-navigation/native-stack";
23import { useCallback, useState, type ComponentProps } from "react";
24import { StyleSheet, View } from "react-native";
25import { FormSheet, Stack } from "react-native-screens";
26import { V5StackHeader } from "./V5StackHeader.ios";
27import { NativeColumnContent } from "./NativeColumnContent.ios";
28import {
29 nativeWorkspacePopAction,
30 nativeWorkspacePopCount,
31 partitionStackPresentations,
32 reconcileStackScreens,
33} from "./workspace-stack-projection";
34
35export type V5StackViewProps = ComponentProps<typeof NativeStackView>;
36
37/** The presentation envelope must not mount a second search bar or header. */
38export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
39 return {
40 ...Object.fromEntries(
41 Object.entries(options).filter(
42 ([key]) =>
43 !key.startsWith("header") &&
44 !key.startsWith("unstable_header") &&
45 key !== "unstable_navigationItemStyle",
46 ),
47 ),
48 headerShown: false,
49 };
50}
51
52/** Keep outgoing screens until UIKit completes its pop, as required by v5. */
53export function V5CardStackView(props: V5StackViewProps) {
54 const { preventedRoutes } = usePreventRemoveContext();
55 const [screens, setScreens] = useState({
56 completedNativeDismissals: new Set<string>(),
57 routes: props.state.routes,
58 descriptors: props.descriptors,
59 observedRoutes: props.state.routes,
60 observedDescriptors: props.descriptors,
61 });
62 if (
63 screens.observedRoutes !== props.state.routes ||
64 screens.observedDescriptors !== props.descriptors
65 ) {
66 const routes = reconcileStackScreens(
67 screens.routes,
68 props.state.routes,
69 screens.completedNativeDismissals,
70 );
71 const active = new Set(props.state.routes.map((route) => route.key));
72 const retainedKeys = new Set(routes.map((route) => route.key));
73 setScreens({
74 completedNativeDismissals: new Set(
75 [...screens.completedNativeDismissals].filter((key) => active.has(key)),
76 ),
77 routes,
78 descriptors: Object.fromEntries(
79 Object.entries({ ...screens.descriptors, ...props.descriptors }).filter(([key]) =>
80 retainedKeys.has(key),
81 ),
82 ),
83 observedRoutes: props.state.routes,
84 observedDescriptors: props.descriptors,
85 });
86 }
87 // Keep the real descriptor through dismissal. describe(route, true) creates
88 // a placeholder whose navigation rejects setOptions and dispatches.
89 const nativeDismiss = useCallback(
90 (key: string) => {
91 const state = props.navigation.getState();
92 const attached = state.routes.some((route) => route.key === key);
93 setScreens((current) => {
94 const completedNativeDismissals = new Set(current.completedNativeDismissals);
95 if (attached) {
96 completedNativeDismissals.add(key);
97 return { ...current, completedNativeDismissals };
98 }
99 // A delayed callback needs immediate cleanup: the router already
100 // removed the route, so no further router update will follow.
101 completedNativeDismissals.delete(key);
102 const descriptors = { ...current.descriptors };
103 delete descriptors[key];
104 return {
105 ...current,
106 completedNativeDismissals,
107 routes: current.routes.filter((route) => route.key !== key),
108 descriptors,
109 };
110 });
111 const action = nativeWorkspacePopAction(state, key);
112 if (action) props.navigation.dispatch(action);
113 },
114 [props.navigation, setScreens],
115 );
116 const removeDismissed = useCallback(
117 (key: string) => {
118 if (!props.navigation.getState().routes.some((route) => route.key === key)) {
119 setScreens((current) => {
120 const descriptors = { ...current.descriptors };
121 delete descriptors[key];
122 return {
123 ...current,
124 routes: current.routes.filter((route) => route.key !== key),
125 descriptors,
126 };
127 });
128 }
129 },
130 [props.navigation, setScreens],
131 );
132 return (
133 <View className="flex-1 bg-screen">
134 <Stack.Host>
135 {screens.routes.map((route, index) => {
136 const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key];
137 if (!descriptor) return null;
138 const attached = props.state.routes.some((current) => current.key === route.key);
139 return (
140 <Stack.Screen
141 key={route.key}
142 screenKey={route.key}
143 activityMode={attached ? "attached" : "detached"}
144 preventNativeDismiss={
145 preventedRoutes[route.key]?.preventRemove ||
146 descriptor.options.gestureEnabled === false
147 }
148 onDismiss={removeDismissed}
149 onNativeDismiss={nativeDismiss}
150 onNativeDismissPrevented={() => {
151 if (preventedRoutes[route.key]?.preventRemove) descriptor.navigation.goBack();
152 }}
153 onWillAppear={() =>
154 props.navigation.emit({
155 type: "transitionStart",
156 target: route.key,
157 data: { closing: false },
158 })
159 }
160 onDidAppear={() =>
161 props.navigation.emit({
162 type: "transitionEnd",
163 target: route.key,
164 data: { closing: false },
165 })
166 }
167 onWillDisappear={() =>
168 props.navigation.emit({
169 type: "transitionStart",
170 target: route.key,
171 data: { closing: true },
172 })
173 }
174 onDidDisappear={() =>
175 props.navigation.emit({
176 type: "transitionEnd",
177 target: route.key,
178 data: { closing: true },
179 })
180 }
181 >
182 <NavigationContext value={descriptor.navigation}>
183 <NavigationRouteContext value={descriptor.route}>
184 <V5StackHeader options={descriptor.options} canGoBack={index > 0} />
185 <NativeColumnContent>{descriptor.render()}</NativeColumnContent>
186 </NavigationRouteContext>
187 </NavigationContext>
188 </Stack.Screen>
189 );
190 })}
191 </Stack.Host>
192 </View>
193 );
194}
195
196/** v5 owns pushes and headers; the legacy renderer only presents modal groups. */
197export function V5StackView(props: V5StackViewProps) {
198 const groups = partitionStackPresentations(props.state.routes, (route) => {
199 const presentation = props.descriptors[route.key]?.options.presentation;
200 return (
201 ["SettingsSheet", "NewTaskSheet"].includes(route.name) ||
202 (presentation !== undefined && presentation !== "card")
203 );
204 });
205 const routes = groups.map((group) => group[0]!);
206 const descriptors = Object.fromEntries(
207 groups.map((group) => {
208 const first = group[0]!;
209 const descriptor = props.descriptors[first.key]!;
210 return [
211 first.key,
212 {
213 ...descriptor,
214 options: modalEnvelopeOptions(descriptor.options),
215 render: () => (
216 <V5CardStackView
217 {...props}
218 state={{
219 ...props.state,
220 routes: group,
221 index: group.length - 1,
222 preloadedRoutes: [],
223 }}
224 />
225 ),
226 },
227 ];
228 }),
229 );
230 return (
231 <NativeStackView
232 {...props}
233 descriptors={descriptors}
234 state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }}
235 />
236 );
237}
238
239/** Keep a direct card host mounted while v5 form sheets present above it. */
240export function V5SheetStackView(props: V5StackViewProps) {
241 const { preventedRoutes } = usePreventRemoveContext();
242 const groups = partitionStackPresentations(
243 props.state.routes,
244 (route) => props.descriptors[route.key]?.options.presentation === "formSheet",
245 );
246 const [retained, setRetained] = useState({
247 groups: groups.slice(1),
248 descriptors: props.descriptors,
249 observedRoutes: props.state.routes,
250 observedDescriptors: props.descriptors,
251 });
252 if (
253 retained.observedRoutes !== props.state.routes ||
254 retained.observedDescriptors !== props.descriptors
255 ) {
256 const active = new Set(groups.slice(1).map((group) => group[0]!.key));
257 const sheets = [
258 ...groups.slice(1),
259 ...retained.groups.filter((group) => !active.has(group[0]!.key)),
260 ];
261 const sheetKeys = new Set(sheets.flatMap((group) => group.map((route) => route.key)));
262 setRetained({
263 groups: sheets,
264 descriptors: Object.fromEntries(
265 Object.entries({ ...retained.descriptors, ...props.descriptors }).filter(([key]) =>
266 sheetKeys.has(key),
267 ),
268 ),
269 observedRoutes: props.state.routes,
270 observedDescriptors: props.descriptors,
271 });
272 }
273 const removeSheet = (key: string) => {
274 setRetained((current) => {
275 const sheets = current.groups.filter((group) => group[0]!.key !== key);
276 const sheetKeys = new Set(sheets.flatMap((group) => group.map((route) => route.key)));
277 return {
278 ...current,
279 groups: sheets,
280 descriptors: Object.fromEntries(
281 Object.entries(current.descriptors).filter(([routeKey]) => sheetKeys.has(routeKey)),
282 ),
283 };
284 });
285 };
286 const base = groups[0] ?? [];
287 return (
288 <View className="flex-1 bg-screen">
289 <V5CardStackView
290 {...props}
291 state={{ ...props.state, routes: base, index: base.length - 1, preloadedRoutes: [] }}
292 />
293 {retained.groups.map((group) => {
294 const first = group[0]!;
295 const descriptor = props.descriptors[first.key] ?? retained.descriptors[first.key];
296 if (!descriptor) return null;
297 const options = descriptor.options;
298 const attached = nativeWorkspacePopCount(props.state, first.key) > 0;
299 return (
300 <FormSheet
301 key={first.key}
302 isOpen={attached}
303 detents={options.sheetAllowedDetents}
304 initialDetentIndex={options.sheetInitialDetentIndex}
305 largestUndimmedDetentIndex={options.sheetLargestUndimmedDetentIndex}
306 prefersGrabberVisible={options.sheetGrabberVisible}
307 preferredCornerRadius={options.sheetCornerRadius}
308 prefersScrollingExpandsWhenScrolledToEdge={options.sheetExpandsWhenScrolledToEdge}
309 nativeContainerStyle={{
310 backgroundColor: StyleSheet.flatten(options.contentStyle)?.backgroundColor,
311 }}
312 preventNativeDismiss={
313 group.some((route) => preventedRoutes[route.key]?.preventRemove) ||
314 options.gestureEnabled === false
315 }
316 onNativeDismissPrevented={() => {
317 const guarded = group.findLast((route) => preventedRoutes[route.key]?.preventRemove);
318 if (guarded) {
319 const state = props.navigation.getState();
320 const action = nativeWorkspacePopAction(state, first.key);
321 if (action) props.navigation.dispatch(action);
322 }
323 }}
324 onDismiss={() => removeSheet(first.key)}
325 onNativeDismiss={() => {
326 const state = props.navigation.getState();
327 const action = nativeWorkspacePopAction(state, first.key);
328 if (action) props.navigation.dispatch(action);
329 removeSheet(first.key);
330 }}
331 >
332 <V5CardStackView
333 {...props}
334 descriptors={{ ...retained.descriptors, ...props.descriptors }}
335 state={{
336 ...props.state,
337 routes: group,
338 index: group.length - 1,
339 preloadedRoutes: [],
340 }}
341 />
342 </FormSheet>
343 );
344 })}
345 </View>
346 );
347}
348
349function V5Navigator({
350 nativeSheets,
351 id,
352 initialRouteName,
353 UNSTABLE_routeNamesChangeBehavior,
354 children,
355 layout,
356 screenListeners,
357 screenOptions,
358 screenLayout,
359 UNSTABLE_router,
360 ...rest
361}: NativeStackNavigatorProps & { readonly nativeSheets?: boolean }) {
362 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
363 StackNavigationState<ParamListBase>,
364 StackRouterOptions,
365 StackActionHelpers<ParamListBase>,
366 NativeStackNavigationOptions,
367 NativeStackNavigationEventMap
368 >(StackRouter, {
369 id,
370 initialRouteName,
371 UNSTABLE_routeNamesChangeBehavior,
372 children,
373 layout,
374 screenListeners,
375 screenOptions,
376 screenLayout,
377 UNSTABLE_router,
378 });
379 const StackView = nativeSheets ? V5SheetStackView : V5StackView;
380 return (
381 <NavigationContent>
382 <StackView
383 {...rest}
384 state={state}
385 describe={describe}
386 descriptors={descriptors}
387 navigation={navigation}
388 />
389 </NavigationContent>
390 );
391}
392
393function V5StackNavigator(props: NativeStackNavigatorProps) {
394 return <V5Navigator {...props} />;
395}
396
397function V5SheetStackNavigator(props: NativeStackNavigatorProps) {
398 return <V5Navigator {...props} nativeSheets />;
399}
400
401type V5TypeBag<ParamList extends ParamListBase, NavigatorID extends string | undefined> = Omit<
402 NativeStackTypeBag<ParamList, NavigatorID>,
403 "Navigator"
404> & { Navigator: typeof V5StackNavigator };
405export function createV5StackNavigator<
406 const ParamList extends ParamListBase,
407 const NavigatorID extends string | undefined = string | undefined,
408 const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>,
409 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
410>(config?: Config): TypedNavigator<TypeBag, Config> {
411 return createNavigatorFactory(V5StackNavigator)(config);
412}
413
414/** For navigators whose presentations are cards and form sheets only. */
415export function createV5SheetStackNavigator<
416 const ParamList extends ParamListBase,
417 const NavigatorID extends string | undefined = string | undefined,
418 const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>,
419 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
420>(config?: Config): TypedNavigator<TypeBag, Config> {
421 return createNavigatorFactory(V5SheetStackNavigator)(config);
422}
apps/mobile/src/native/createV5StackNavigator.tsx created+4
......@@ -0,0 +1,4 @@
1export {
2 createNativeStackNavigator as createV5StackNavigator,
3 createNativeStackNavigator as createV5SheetStackNavigator,
4} from "@react-navigation/native-stack";
apps/mobile/src/native/native-layout-metrics.tsx created+25
......@@ -0,0 +1,25 @@
1import { createContext, use, useState, type ReactNode } from "react";
2import type { NativeLayoutMetrics } from "../lib/reserved-regions";
3import { NativeLayoutObserver } from "./NativeLayoutObserver";
4
5const NativeLayoutContext = createContext<NativeLayoutMetrics | null>(null);
6// Column reservations come from UIKit's layout, outside the resizing navigator.
7export const NativeColumnLayoutMetricsContext = createContext<NativeLayoutMetrics | null>(null);
8
9export function NativeLayoutMetricsProvider(props: { readonly children: ReactNode }) {
10 const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null);
11 return (
12 <NativeLayoutContext value={metrics}>
13 <NativeLayoutObserver onChange={setMetrics} />
14 {props.children}
15 </NativeLayoutContext>
16 );
17}
18
19export function useNativeLayoutMetrics() {
20 return use(NativeLayoutContext);
21}
22
23export function useNativeColumnLayoutMetrics() {
24 return use(NativeColumnLayoutMetricsContext);
25}
apps/mobile/src/native/v5-workspace-context.tsx created+11
......@@ -0,0 +1,11 @@
1import { createContext, type ReactNode } from "react";
2
3// The router is shared with the other platforms; only iOS uses native columns.
4export const NativeWorkspaceModeContext = createContext(false);
5export const NativePrimaryColumnContext = createContext<{
6 readonly selectedThreadKey: string | null;
7} | null>(null);
8export const NativeWorkspaceInspectorContext = createContext<{
9 readonly render: (() => ReactNode) | undefined;
10 readonly visible: boolean;
11}>({ render: undefined, visible: false });
apps/mobile/src/native/workspace-stack-projection.test.ts created+175
......@@ -0,0 +1,175 @@
1import * as NodeModule from "node:module";
2import type { ParamListBase, StackNavigationState } from "@react-navigation/native";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 nativeWorkspacePopAction,
7 nativeWorkspacePopCount,
8 projectWorkspaceStack,
9 partitionStackPresentations,
10 reconcileStackScreens,
11} from "./workspace-stack-projection";
12
13// Load the same router as native navigation without importing React Native into Node.
14const requireNavigation = NodeModule.createRequire(
15 NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
16);
17const { StackRouter } = requireNavigation("@react-navigation/routers") as {
18 StackRouter: typeof import("@react-navigation/native").StackRouter;
19};
20
21const home = { key: "home", name: "Home" };
22const thread = { key: "thread", name: "Thread", params: { threadId: "draft-thread" } };
23const files = { key: "files", name: "ThreadFiles", params: thread.params };
24const settings = { key: "settings", name: "SettingsSheet" };
25const legal = { key: "legal", name: "SettingsLegal" };
26
27function history(
28 routes: StackNavigationState<ParamListBase>["routes"],
29): StackNavigationState<ParamListBase> {
30 return {
31 key: "router",
32 type: "stack",
33 stale: false,
34 index: routes.length - 1,
35 routeNames: ["Home", "Thread", "ThreadFiles", "SettingsSheet", "SettingsLegal"],
36 routes,
37 preloadedRoutes: [],
38 };
39}
40
41describe("workspace router projection", () => {
42 it("keeps workspace flows beside Home and restores them after a modal closes", () => {
43 const draft = { key: "draft", name: "NewTaskSheet" };
44 const modal = { key: "connect", name: "ConnectOnboarding" };
45 const routes = [home, thread, draft, settings, legal];
46 const isOverlay = (route: { name: string }) => route.name === "ConnectOnboarding";
47 expect(projectWorkspaceStack(history([...routes, modal]), isOverlay)).toEqual({
48 primary: home,
49 detail: [thread, draft, settings, legal],
50 overlays: [modal],
51 });
52 expect(projectWorkspaceStack(history(routes), isOverlay)).toEqual({
53 primary: home,
54 detail: [thread, draft, settings, legal],
55 overlays: [],
56 });
57 });
58
59 it("keeps the thread and file history in the detail column when a modal is opened", () => {
60 const state = history([home, thread, files, settings, legal]);
61 const projection = projectWorkspaceStack(state, (route) => route.name === "SettingsSheet");
62 expect(projection).toEqual({
63 primary: home,
64 detail: [thread, files],
65 overlays: [settings, legal],
66 });
67 expect(projection.detail[0]).toBe(thread);
68 expect(state.routes).toEqual([home, thread, files, settings, legal]);
69 });
70
71 it("retains a cold-linked detail route without changing the router history", () => {
72 const state = history([thread, files]);
73 expect(projectWorkspaceStack(state, () => false)).toEqual({
74 primary: undefined,
75 detail: [thread, files],
76 overlays: [],
77 });
78 expect(state.routes[0]).toBe(thread);
79 });
80
81 it("restores the empty detail column after Back reaches the thread list", () => {
82 expect(projectWorkspaceStack(history([home]), () => false)).toEqual({
83 primary: home,
84 detail: [],
85 overlays: [],
86 });
87 });
88});
89
90describe("native workspace dismissal", () => {
91 it("dismisses a sheet and its pushed pages without removing the underlying draft", () => {
92 const state = history([home, thread, settings, legal]);
93 const action = nativeWorkspacePopAction(state, settings.key)!;
94 const next = StackRouter({}).getStateForAction(state, action, {
95 routeNames: state.routeNames,
96 routeParamList: {},
97 routeGetIdList: {},
98 });
99 expect(next?.routes).toEqual([home, thread]);
100 expect(next?.routes[1]).toBe(thread);
101 expect(next?.index).toBe(1);
102 });
103
104 it("does not dismiss retained sheets beyond the active index", () => {
105 const state = { ...history([home, thread, settings, legal]), index: 1 };
106 expect(nativeWorkspacePopAction(state, settings.key)).toBeNull();
107 expect(nativeWorkspacePopAction(state, legal.key)).toBeNull();
108 });
109 it("pops a native dismissed file while keeping the conversation and its draft mounted", () => {
110 expect(nativeWorkspacePopCount(history([home, thread, files]), files.key)).toBe(1);
111 });
112
113 it("removes the descendants when UIKit dismisses their parent conversation", () => {
114 expect(nativeWorkspacePopCount(history([home, thread, files]), thread.key)).toBe(2);
115 });
116
117 it("ignores delayed callbacks from JS removal and replacement", () => {
118 expect(nativeWorkspacePopCount(history([home, thread]), files.key)).toBe(0);
119 expect(
120 nativeWorkspacePopCount(history([home, { ...thread, key: "replacement" }]), thread.key),
121 ).toBe(0);
122 });
123
124 it("never removes the root or a route ahead of the active index", () => {
125 const state = history([home, thread, files]);
126 expect(nativeWorkspacePopCount(state, home.key)).toBe(0);
127 expect(nativeWorkspacePopCount({ ...state, index: 1 }, files.key)).toBe(0);
128 });
129});
130
131describe("v5 stack handoff", () => {
132 it("releases a completed native pop when the router acknowledges it", () => {
133 const completed = new Set([files.key]);
134 expect(reconcileStackScreens([home, thread, files], [home, thread, files], completed)).toEqual([
135 home,
136 thread,
137 files,
138 ]);
139 expect(reconcileStackScreens([home, thread, files], [home, thread], completed)).toEqual([
140 home,
141 thread,
142 ]);
143 });
144 it("still retains an unfinished JS pop beside a completed native pop", () => {
145 expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([
146 thread,
147 home,
148 ]);
149 });
150 it("retains removed native screens through a pop followed immediately by a push", () => {
151 const popped = reconcileStackScreens([home, thread, files], [home, thread]);
152 const next = { ...files, key: "new-files" };
153 const screens = reconcileStackScreens(popped, [home, thread, next]);
154 expect(screens.map((route) => route.key)).toEqual(["files", "home", "thread", "new-files"]);
155 expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([
156 home,
157 thread,
158 next,
159 ]);
160 expect(screens[0]).toBe(files);
161 });
162 it("updates params without retaining duplicate copies of the same screen", () => {
163 const updated = { ...thread, params: { threadId: "another-thread" } };
164 expect(reconcileStackScreens([home, thread], [home, updated])).toEqual([home, updated]);
165 });
166 it("keeps card pushes inside the modal they belong to", () => {
167 const secondModal = { ...settings, key: "another-settings" };
168 expect(
169 partitionStackPresentations(
170 [home, thread, settings, legal, secondModal],
171 (route) => route.name === "SettingsSheet",
172 ),
173 ).toEqual([[home, thread], [settings, legal], [secondModal]]);
174 });
175});
apps/mobile/src/native/workspace-stack-projection.ts created+73
......@@ -0,0 +1,73 @@
1import type {
2 NavigationState,
3 ParamListBase,
4 StackNavigationState,
5} from "@react-navigation/native";
6
7type Route = StackNavigationState<ParamListBase>["routes"][number];
8
9/** Project one router history into native columns and a modal presentation stack. */
10export function projectWorkspaceStack(
11 state: StackNavigationState<ParamListBase>,
12 isOverlay: (route: Route) => boolean,
13) {
14 const firstOverlay = state.routes.findIndex(isOverlay);
15 const workspaceRoutes = state.routes.slice(0, firstOverlay < 0 ? undefined : firstOverlay);
16 const primary = workspaceRoutes.find((route) => route.name === "Home");
17 const detail = workspaceRoutes.filter((route) => route.name !== "Home");
18 const overlays = firstOverlay < 0 ? [] : state.routes.slice(firstOverlay);
19 return { primary, detail, overlays };
20}
21
22/** Native callbacks can arrive after a JS pop or replace; those must not pop the next screen. */
23export function nativeWorkspacePopCount(
24 state: Pick<NavigationState, "index" | "routes">,
25 dismissedKey: string,
26): number {
27 const index = state.routes.findIndex((route) => route.key === dismissedKey);
28 return index <= 0 || index > state.index ? 0 : state.index - index + 1;
29}
30
31/** POP counts backwards from its source, so dismiss a group from the active descendant. */
32export function nativeWorkspacePopAction(
33 state: Pick<NavigationState, "key" | "index" | "routes">,
34 dismissedKey: string,
35) {
36 const count = nativeWorkspacePopCount(state, dismissedKey);
37 return count
38 ? {
39 type: "POP" as const,
40 payload: { count },
41 source: state.routes[state.index]!.key,
42 target: state.key,
43 }
44 : null;
45}
46
47/** Group pushes with the modal that owns their native stack. */
48export function partitionStackPresentations<T>(
49 routes: readonly T[],
50 isModal: (route: T) => boolean,
51): T[][] {
52 const groups: T[][] = [];
53 for (const route of routes) {
54 if (groups.length === 0 || isModal(route)) groups.push([]);
55 groups.at(-1)!.push(route);
56 }
57 return groups;
58}
59
60/** Preserve UIKit's outgoing screens, before newly pushed screens, until dismissal. */
61export function reconcileStackScreens<T extends { readonly key: string }>(
62 previous: readonly T[],
63 current: readonly T[],
64 completedNativeDismissals: ReadonlySet<string> = new Set(),
65): T[] {
66 const active = new Set(current.map((route) => route.key));
67 return [
68 ...previous.filter(
69 (route) => !active.has(route.key) && !completedNativeDismissals.has(route.key),
70 ),
71 ...current,
72 ];
73}
docs/operations/mobile-screens-fork.md created+22
......@@ -0,0 +1,22 @@
1# Building the mobile Screens dependency
2
3The mobile v5 stack uses a committed tarball from the
4[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.7).
5The fork owns the integration changes; the app does not patch this package.
6Each archive includes compiled JavaScript, declarations, native source and
7`t3-fork.json` recording its version, source commit and upstream base.
8
9To reproduce the current archive with Node 24 and the repository's pinned Yarn:
10
11```sh
12git clone --branch t3-v5.0.0-t3.7 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork
13yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps
14```
15
16For an update, sync the fork's `main` with upstream, merge it and the required
17proposal branches into `t3/main`, increment the `5.0.0-t3.N` version, pin `t3Fork.upstreamCommit` to the
18integrated upstream SHA and commit.
19Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`.
20Replace the old archive, update `apps/mobile/package.json` and run `vp i` to
21regenerate the lockfile. Verify mobile types and affected navigation tests.
22Changes to native source also require rebuilding and testing the native client.
patches/react-native-gesture-handler@3.2.1.patch+53
......@@ -149,3 +149,56 @@ index 1e1246f709feb04c01cf3221472cc1219a8fffac..f526d4fe2d2738becbe8d29c59d1105a
149149 }>;
150150
151151 export interface SwipeableMethods {
152diff --git a/apple/Handlers/RNPanHandler.m b/apple/Handlers/RNPanHandler.m
153--- a/apple/Handlers/RNPanHandler.m
154+++ b/apple/Handlers/RNPanHandler.m
155@@ -31,6 +31,7 @@
156 @property (nonatomic) CGFloat failOffsetYStart;
157 @property (nonatomic) CGFloat failOffsetYEnd;
158 @property (nonatomic) CGFloat activateAfterLongPress;
159+@property (nonatomic) BOOL handlingTouchStart;
160
161 #if !TARGET_OS_OSX && !TARGET_OS_TV
162 @property (atomic, readonly, strong) RNGHStylusData *stylusData;
163@@ -126,9 +127,14 @@
164
165 - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event
166 {
167- if (self.state == UIGestureRecognizerStatePossible && ![self.delegate gestureRecognizerShouldBegin:self]) {
168- self.state = UIGestureRecognizerStateFailed;
169- return;
170+ if (self.state == UIGestureRecognizerStatePossible) {
171+ self.handlingTouchStart = touches.count > 0;
172+ BOOL shouldBegin = [self.delegate gestureRecognizerShouldBegin:self];
173+ self.handlingTouchStart = NO;
174+ if (!shouldBegin) {
175+ self.state = UIGestureRecognizerStateFailed;
176+ return;
177+ }
178 }
179
180 if (touches.count == 0) {
181@@ -476,6 +482,23 @@
182
183 - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
184 {
185+#if !TARGET_OS_OSX && !TARGET_OS_TV
186+ RNBetterPanGestureRecognizer *recognizer = (RNBetterPanGestureRecognizer *)gestureRecognizer;
187+ if (!recognizer.handlingTouchStart && recognizer.allowedScrollTypesMask != 0 && recognizer.numberOfTouches == 0) {
188+ // Trackpad scroll events can begin without touchesMoved enforcing failure
189+ // offsets. Reject vertical scrolling before a horizontal row pan cancels
190+ // the enclosing UIScrollView. The manual touch-start preflight above has
191+ // no current translation yet, so leave that path to the touch callbacks.
192+ CGPoint translation = [recognizer translationInView:recognizer.view.window];
193+ BOOL horizontalOnly = (!isnan(recognizer.activeOffsetXStart) || !isnan(recognizer.activeOffsetXEnd)) &&
194+ isnan(recognizer.activeOffsetYStart) && isnan(recognizer.activeOffsetYEnd);
195+ if ([recognizer shouldFailUnderCustomCriteria] ||
196+ (horizontalOnly && fabs(translation.y) > fabs(translation.x))) {
197+ return NO;
198+ }
199+ }
200+#endif
201+
202 RNGestureHandlerState savedState = _lastState;
203 BOOL shouldBegin = [super gestureRecognizerShouldBegin:gestureRecognizer];
204 _lastState = savedState;
patches/react-native-screens@4.28.0.patch deleted-2232
......@@ -1,2232 +0,0 @@
1diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt
2index fa1b773..270c81c 100644
3--- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt
4+++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt
5@@ -124,6 +124,34 @@ class ScreenStackHeaderConfigViewManager :
6 config.setTitle(title)
7 }
8
9+ // The t3 patch adds these iOS glass-chrome props to the native spec; they have
10+ // no Android implementation, but the codegen-generated interface requires the
11+ // setters to exist. No-ops keep the Android build compiling.
12+ override fun setSubtitle(
13+ config: ScreenStackHeaderConfig,
14+ subtitle: String?,
15+ ) = Unit
16+
17+ override fun setLargeSubtitle(
18+ config: ScreenStackHeaderConfig,
19+ largeSubtitle: String?,
20+ ) = Unit
21+
22+ override fun setNavigationItemStyle(
23+ config: ScreenStackHeaderConfig,
24+ navigationItemStyle: String?,
25+ ) = Unit
26+
27+ override fun setHeaderCenterBarButtonItems(
28+ config: ScreenStackHeaderConfig,
29+ headerCenterBarButtonItems: ReadableArray?,
30+ ) = Unit
31+
32+ override fun setHeaderToolbarItems(
33+ config: ScreenStackHeaderConfig,
34+ headerToolbarItems: ReadableArray?,
35+ ) = Unit
36+
37 override fun setTitleFontFamily(
38 config: ScreenStackHeaderConfig,
39 titleFontFamily: String?,
40diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt
41index 64f14f0..6cb1d69 100644
42--- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt
43+++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt
44@@ -47,6 +47,12 @@ class ScreenStackHeaderSubviewManager :
45 Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android")
46 }
47
48+ // Bar button transition identifiers only apply on iOS.
49+ override fun setIdentifier(
50+ view: ScreenStackHeaderSubview,
51+ identifier: String?,
52+ ) = Unit
53+
54 // synchronousShadowStateUpdatesEnabled is not available on Android atm,
55 // however we must override their setters
56 override fun setSynchronousShadowStateUpdatesEnabled(
57diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h
58index a26d125..62eea42 100644
59--- a/ios/RNSBarButtonItem.h
60+++ b/ios/RNSBarButtonItem.h
61@@ -15,6 +15,10 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId);
62 action:(RNSBarButtonItemAction)action
63 menuAction:(RNSBarButtonMenuItemAction)menuAction
64 imageLoader:(RCTImageLoader *)imageLoader;
65+
66++ (UIMenu *)initUIMenuWithDict:(NSDictionary<NSString *, id> *)dict
67+ menuAction:(RNSBarButtonMenuItemAction)menuAction
68+ imageLoader:(RCTImageLoader *)imageLoader;
69 #endif // defined(__cplusplus)
70
71 @end
72diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm
73index 76a39d4..15bd5a9 100644
74--- a/ios/RNSBarButtonItem.mm
75+++ b/ios/RNSBarButtonItem.mm
76@@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
77 if (dict[@"accessibilityHint"]) {
78 self.accessibilityHint = dict[@"accessibilityHint"];
79 }
80+#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
81+ NSNumber *glassEffectNum = dict[@"glassEffect"];
82+ if (@available(iOS 26.0, *)) {
83+ if ([glassEffectNum boolValue]) {
84+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
85+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
86+ UIBackgroundConfiguration *background = configuration.background;
87+ background.strokeWidth = 0.0;
88+ background.strokeColor = UIColor.clearColor;
89+ configuration.background = background;
90+ configuration.contentInsets = NSDirectionalEdgeInsetsZero;
91+ if (title != nil) {
92+ configuration.title = title;
93+ }
94+
95+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
96+ button.translatesAutoresizingMaskIntoConstraints = NO;
97+ button.enabled = self.enabled;
98+ button.accessibilityLabel = self.accessibilityLabel;
99+ button.accessibilityHint = self.accessibilityHint;
100+
101+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0;
102+ [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
103+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
104+
105+ [[self class] resolveImageFromConfig:dict
106+ imageLoader:imageLoader
107+ completionBlock:^(UIImage *img) {
108+ UIButtonConfiguration *updatedConfiguration = button.configuration;
109+ updatedConfiguration.image = img;
110+ button.configuration = updatedConfiguration;
111+ }];
112+
113+ NSDictionary *menu = dict[@"menu"];
114+ if (menu) {
115+ button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader];
116+ button.showsMenuAsPrimaryAction = YES;
117+ } else {
118+ NSString *buttonId = dict[@"buttonId"];
119+ if (buttonId && action) {
120+ UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) {
121+ action(buttonId);
122+ }];
123+ [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside];
124+ }
125+ }
126+
127+ self.customView = button;
128+ return self;
129+ }
130+ }
131+#endif
132
133 #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
134 if (@available(tvOS 17.0, *)) {
135diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
136index c233036..0c2db42 100644
137--- a/ios/RNSScreenStack.mm
138+++ b/ios/RNSScreenStack.mm
139@@ -1076,6 +1076,15 @@ - (BOOL)scrollViewHasHorizontalPart:(UIScrollView *)scrollView
140 return scrollView.contentSize.width > scrollView.frame.size.width;
141 }
142
143+// Whether a horizontally scrollable ScrollView is resting at its leading edge, so a
144+// pan in the back-gesture direction has nothing left to scroll. Checked in the
145+// ScrollView's own coordinate space: React Native mirrors the view for RTL layout,
146+// which keeps the leading edge at the minimum content offset in both directions.
147+- (BOOL)scrollViewIsAtLeadingEdge:(UIScrollView *)scrollView
148+{
149+ return scrollView.contentOffset.x <= -scrollView.adjustedContentInset.left + 1.0;
150+}
151+
152 // Custom method for compatibility with iOS < 13.4
153 // RNSScreenStackView is a UIGestureRecognizerDelegate for three types of gesture recognizers:
154 // RNSPanGestureRecognizer, RNSScreenEdgeGestureRecognizer, _UIParallaxTransitionPanGestureRecognizer
155@@ -1169,9 +1178,11 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
156 if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
157 [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) {
158 // ScrollView should take precedence when scrolling horizontally (it should be required to fail).
159- // However, if it does not allow for horizontal scrolling, there should be no such restriction,
160+ // However, if it does not allow for horizontal scrolling, or is already resting at its leading
161+ // edge so a back swipe has nothing left to scroll, there should be no such restriction,
162 // and swiping horizontally should dismiss the screen.
163- return [self scrollViewHasHorizontalPart:static_cast<UIScrollView *>(otherGestureRecognizer.view)];
164+ UIScrollView *scrollView = static_cast<UIScrollView *>(otherGestureRecognizer.view);
165+ return [self scrollViewHasHorizontalPart:scrollView] && ![self scrollViewIsAtLeadingEdge:scrollView];
166 }
167 }
168
169@@ -1191,6 +1202,15 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
170 // gestureRecognizer:shouldRequireFailureOfGestureRecognizer
171 isEdgeSwipeGestureRecognizer =
172 isEdgeSwipeGestureRecognizer && gestureRecognizer != _controller.interactiveContentPopGestureRecognizer;
173+
174+ // The exception is a horizontal ScrollView resting at its leading edge: its pan recognizer would
175+ // still begin (and merely bounce) on a back swipe, so it must yield to the pop gesture the same
176+ // way plain content does.
177+ if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
178+ [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) {
179+ UIScrollView *scrollView = static_cast<UIScrollView *>(otherGestureRecognizer.view);
180+ return [self scrollViewHasHorizontalPart:scrollView] && [self scrollViewIsAtLeadingEdge:scrollView];
181+ }
182 }
183 #endif // check for iOS >= 26
184
185diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h
186index 8a41697..309ba2f 100644
187--- a/ios/RNSScreenStackHeaderConfig.h
188+++ b/ios/RNSScreenStackHeaderConfig.h
189@@ -23,6 +23,8 @@
190 NS_ASSUME_NONNULL_BEGIN
191
192 @property (nonatomic, retain) NSString *title;
193+@property (nonatomic, retain) NSString *subtitle;
194+@property (nonatomic, retain) NSString *largeSubtitle;
195 @property (nonatomic, retain) NSString *titleFontFamily;
196 @property (nonatomic, retain) NSNumber *titleFontSize;
197 @property (nonatomic, retain) NSString *titleFontWeight;
198@@ -47,9 +49,12 @@ NS_ASSUME_NONNULL_BEGIN
199 @property (nonatomic) BOOL backButtonInCustomView;
200 @property (nonatomic) UISemanticContentAttribute direction;
201 @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode;
202+@property (nonatomic) NSInteger navigationItemStyle;
203 @property (nonatomic) RNSBlurEffectStyle blurEffect;
204 @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerRightBarButtonItems;
205 @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerLeftBarButtonItems;
206+@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerCenterBarButtonItems;
207+@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerToolbarItems;
208 @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled;
209
210 NS_ASSUME_NONNULL_END
211diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm
212index a54cb63..4ff30b3 100644
213--- a/ios/RNSScreenStackHeaderConfig.mm
214+++ b/ios/RNSScreenStackHeaderConfig.mm
215@@ -24,11 +24,46 @@
216 #import "RNSSearchBar.h"
217 #import "UINavigationBar+RNSUtility.h"
218
219+#import <objc/runtime.h>
220+
221 namespace react = facebook::react;
222
223 static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17;
224+
225+#if !TARGET_OS_TV
226+// Each placement keeps its own cache so changes to one side do not move live
227+// items on the other side into new groups during a navigation transition.
228+static char RNSAppliedLeadingHeaderItemsKey;
229+static char RNSAppliedTrailingHeaderItemsKey;
230+static char RNSAppliedCenterHeaderItemsKey;
231+
232+// Records a placement's owner and contents, returning whether they changed.
233+static BOOL RNSUpdateHeaderItemsCache(UINavigationItem *navitem, const void *key, NSArray *itemsKey)
234+{
235+ if ([objc_getAssociatedObject(navitem, key) isEqual:itemsKey]) {
236+ return NO;
237+ }
238+ objc_setAssociatedObject(navitem, key, itemsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
239+ return YES;
240+}
241+#endif
242+static char RNSAppliedToolbarConfigsKey;
243 static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34;
244
245+static NSInteger navigationItemStyleFromCppEquivalent(
246+ react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle)
247+{
248+ switch (navigationItemStyle) {
249+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser:
250+ return UINavigationItemStyleBrowser;
251+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor:
252+ return UINavigationItemStyleEditor;
253+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator:
254+ default:
255+ return UINavigationItemStyleNavigator;
256+ }
257+}
258+
259 @interface RCTImageLoader (Private)
260 - (id<RCTImageCache>)imageCache;
261 @end
262@@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string
263 @end
264
265 @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>
266+#if !TARGET_OS_TV
267+- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items;
268+#endif
269 @end
270
271 @implementation RNSScreenStackHeaderConfig {
272@@ -80,6 +118,7 @@ - (void)initProps
273 self.hidden = YES;
274 _reactSubviews = [NSMutableArray new];
275 _backTitleVisible = YES;
276+ _navigationItemStyle = UINavigationItemStyleNavigator;
277 _blurEffect = RNSBlurEffectStyleNone;
278 _synchronousShadowStateUpdatesEnabled = YES;
279 }
280@@ -507,6 +546,10 @@ + (void)updateViewController:(UIViewController *)vc
281
282 if (shouldHide) {
283 navitem.title = config.title;
284+ if (@available(iOS 26.0, *)) {
285+ navitem.subtitle = config.subtitle;
286+ navitem.largeSubtitle = config.largeSubtitle;
287+ }
288
289 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items.
290 [navctr setNavigationBarHidden:YES animated:animated];
291@@ -523,11 +566,19 @@ + (void)updateViewController:(UIViewController *)vc
292 }
293 navitem.largeTitleDisplayMode =
294 config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;
295+
296+ if (@available(iOS 16.0, *)) {
297+ navitem.style = (UINavigationItemStyle)config.navigationItemStyle;
298+ }
299 #endif
300
301 UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config];
302 navitem.standardAppearance = appearance;
303 navitem.compactAppearance = appearance;
304+ if (@available(iOS 26.0, *)) {
305+ navitem.subtitle = config.subtitle;
306+ navitem.largeSubtitle = config.largeSubtitle;
307+ }
308
309 // appearance does not apply to the tvOS so we need to use lagacy customization
310 #if TARGET_OS_TV
311@@ -559,8 +610,6 @@ + (void)updateViewController:(UIViewController *)vc
312 navitem.leftItemsSupplementBackButton = config.backButtonInCustomView;
313 #endif
314 navitem.titleView = nil;
315- navitem.leftBarButtonItems = nil;
316- navitem.rightBarButtonItems = nil;
317
318 #if !TARGET_OS_TV
319 // We want to set navitem.searchController to nil only if we are sure
320@@ -568,22 +617,18 @@ + (void)updateViewController:(UIViewController *)vc
321 bool searchBarPresent = false;
322 #endif /* !TARGET_OS_TV */
323
324+ NSMutableArray<UIBarButtonItem *> *subviewLeftItems = [NSMutableArray array];
325+ NSMutableArray<UIBarButtonItem *> *subviewRightItems = [NSMutableArray array];
326 for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) {
327 // This code should be kept in sync on Fabric with analogous switch statement in
328 // `- [RNSScreenStackHeaderConfig replaceNavigationBarViewsWithSnapshotOfSubview:]` method.
329 switch (subview.type) {
330 case RNSScreenStackHeaderSubviewTypeLeft: {
331- NSArray<UIBarButtonItem *> *currentItems = navitem.leftBarButtonItems ?: @[];
332- NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy];
333- [mutableItems addObject:[subview getUIBarButtonItem]];
334- navitem.leftBarButtonItems = mutableItems;
335+ [subviewLeftItems addObject:[subview getUIBarButtonItem]];
336 break;
337 }
338 case RNSScreenStackHeaderSubviewTypeRight: {
339- NSArray<UIBarButtonItem *> *currentItems = navitem.rightBarButtonItems ?: @[];
340- NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy];
341- [mutableItems addObject:[subview getUIBarButtonItem]];
342- navitem.rightBarButtonItems = mutableItems;
343+ [subviewRightItems addObject:[subview getUIBarButtonItem]];
344 break;
345 }
346 case RNSScreenStackHeaderSubviewTypeCenter:
347@@ -648,10 +693,479 @@ + (void)updateViewController:(UIViewController *)vc
348 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
349 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
350 navitem.title = config.title;
351- navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
352- withCurrentItems:navitem.leftBarButtonItems];
353- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
354- withCurrentItems:navitem.rightBarButtonItems];
355+ // Retain the config in the key so its address cannot be reused while cached.
356+ // Items capture this config's event emitter in their press handlers, so a
357+ // remounted header-config view with value-equal configs must still rebuild
358+ // instead of dispatching presses into the old config's emitter.
359+ // getUIBarButtonItem is stable for the lifetime of its React header
360+ // subview. Include those native identities in the key so React-backed
361+ // items can use the same cache without reusing a group after its view was
362+ // replaced.
363+ // A bar button item can belong to only one group. Even if the item itself
364+ // is reused, constructing a new group removes it from the old group that
365+ // UIKit may still be animating. Preserve each unchanged placement's group,
366+ // including the empty leading group beside the system back button.
367+#if !TARGET_OS_TV
368+ if (@available(iOS 16.0, *)) {
369+ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[];
370+ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[];
371+ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[];
372+ if (RNSUpdateHeaderItemsCache(
373+ navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) {
374+ NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
375+ withCurrentItems:subviewLeftItems
376+ navigationItem:navitem
377+ allowsSearchBarPlacement:NO];
378+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items];
379+ }
380+ if (RNSUpdateHeaderItemsCache(
381+ navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) {
382+ NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
383+ withCurrentItems:subviewRightItems
384+ navigationItem:navitem
385+ allowsSearchBarPlacement:NO];
386+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items];
387+ }
388+ if (@available(iOS 26.0, *)) {
389+ if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) {
390+ NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems
391+ withCurrentItems:@[]
392+ navigationItem:navitem
393+ allowsSearchBarPlacement:NO];
394+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items];
395+ }
396+ }
397+ } else
398+#endif
399+ {
400+ NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
401+ withCurrentItems:subviewLeftItems
402+ navigationItem:navitem
403+ allowsSearchBarPlacement:NO];
404+ NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
405+ withCurrentItems:subviewRightItems
406+ navigationItem:navitem
407+ allowsSearchBarPlacement:NO];
408+ navitem.leftBarButtonItems = leftBarButtonItems;
409+ navitem.rightBarButtonItems = rightBarButtonItems;
410+ }
411+ NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil;
412+ NSMutableArray<NSDictionary<NSString *, id> *> *nonMailSearchToolbarConfigs = [NSMutableArray array];
413+ for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) {
414+ if (toolbarConfig[@"mailSearchToolbar"]) {
415+ if (mailSearchToolbarConfig == nil) {
416+ mailSearchToolbarConfig = toolbarConfig;
417+ }
418+ } else {
419+ [nonMailSearchToolbarConfigs addObject:toolbarConfig];
420+ }
421+ }
422+ NSArray<NSDictionary<NSString *, id> *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy];
423+ if (mailSearchToolbarConfig == nil) {
424+ for (NSDictionary<NSString *, id> *rightConfig in config.headerRightBarButtonItems) {
425+ if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) {
426+ mailSearchToolbarConfig = rightConfig;
427+ break;
428+ }
429+ }
430+ }
431+
432+ static NSInteger const RNSMailSearchToolbarViewTag = 260628;
433+ UIView *chromeHostView = vc.view;
434+ UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag];
435+ if (existingMailSearchToolbar == nil) {
436+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];
437+ }
438+ // Reuse the live toolbar when nothing it was built from changed: rebuilding
439+ // replaces the fallback UISearchTextField with a fresh empty one, dropping
440+ // the user's in-progress search text and first responder on every unrelated
441+ // header update. Keyed on the config instance (the button/search blocks
442+ // capture its event emitter), the toolbar config values, and the host width
443+ // (the width constraint constant is resolved from it at build time).
444+ static char RNSAppliedMailSearchToolbarConfigKey;
445+ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil
446+ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ]
447+ : nil;
448+ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil &&
449+ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey)
450+ isEqual:mailSearchToolbarKey];
451+ if (!reuseMailSearchToolbar) {
452+ [existingMailSearchToolbar removeFromSuperview];
453+ }
454+
455+ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) {
456+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
457+ if (@available(iOS 26.0, *)) {
458+ CGFloat horizontalInset = 18.0;
459+ NSNumber *width = mailSearchToolbarConfig[@"width"];
460+ CGFloat hostWidth = chromeHostView.bounds.size.width;
461+ if (hostWidth <= 0.0) {
462+ hostWidth = vc.view.bounds.size.width;
463+ }
464+ if (hostWidth <= 0.0) {
465+ hostWidth = [UIScreen mainScreen].bounds.size.width;
466+ }
467+ CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0)));
468+ CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth;
469+ if (hostWidth > 0.0) {
470+ resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0));
471+ }
472+
473+ CGFloat toolbarHeight = 48.0;
474+ CGFloat toolbarRadius = toolbarHeight / 2.0;
475+ CGFloat buttonSize = toolbarHeight;
476+ CGFloat sideButtonReserve = buttonSize + 8.0;
477+ CGFloat searchFieldHeight = 42.0;
478+ CGFloat toolbarBottomSpacing = 4.0;
479+ UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3];
480+ NSDictionary<NSAttributedStringKey, id> *placeholderAttributes = @{
481+ NSForegroundColorAttributeName : UIColor.secondaryLabelColor,
482+ NSFontAttributeName : searchFont,
483+ };
484+ UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide;
485+
486+ UIView *toolbarHost = [[UIView alloc] init];
487+ toolbarHost.tag = RNSMailSearchToolbarViewTag;
488+ objc_setAssociatedObject(
489+ toolbarHost,
490+ &RNSAppliedMailSearchToolbarConfigKey,
491+ mailSearchToolbarKey,
492+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
493+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;
494+ [chromeHostView addSubview:toolbarHost];
495+ // The screen stays mounted beneath pushed routes, so its keyboard layout
496+ // guide can track a keyboard owned by another screen. Keep the toolbar at
497+ // rest unless its own search field is editing.
498+ NSLayoutConstraint *keyboardAvoidConstraint =
499+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor
500+ constant:-toolbarBottomSpacing];
501+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
502+ NSLayoutConstraint *restingBottomConstraint =
503+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
504+ constant:-toolbarBottomSpacing];
505+ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
506+ [NSLayoutConstraint activateConstraints:@[
507+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],
508+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
509+ constant:-toolbarBottomSpacing],
510+ keyboardAvoidConstraint,
511+ restingBottomConstraint,
512+ [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth],
513+ [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight],
514+ ]];
515+ [chromeHostView bringSubviewToFront:toolbarHost];
516+
517+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
518+ glassEffect.interactive = YES;
519+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
520+ glassView.clipsToBounds = YES;
521+ glassView.layer.cornerRadius = toolbarRadius;
522+ glassView.translatesAutoresizingMaskIntoConstraints = NO;
523+ [toolbarHost addSubview:glassView];
524+ BOOL hasFilterButton =
525+ mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil;
526+ BOOL hasComposeButton =
527+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;
528+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;
529+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;
530+ NSLayoutConstraint *glassLeadingConstraint =
531+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset];
532+ NSLayoutConstraint *glassTrailingConstraint =
533+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset];
534+ [NSLayoutConstraint activateConstraints:@[
535+ glassLeadingConstraint,
536+ glassTrailingConstraint,
537+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
538+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],
539+ ]];
540+
541+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
542+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
543+ config->_eventEmitter);
544+ if (eventEmitter && buttonId) {
545+ eventEmitter->onPressHeaderBarButtonItem(
546+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
547+ .buttonId = std::string([buttonId UTF8String])});
548+ }
549+ };
550+
551+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
552+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
553+ config->_eventEmitter);
554+ if (eventEmitter && menuId) {
555+ eventEmitter->onPressHeaderBarButtonMenuItem(
556+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
557+ .menuId = std::string([menuId UTF8String])});
558+ }
559+ };
560+
561+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
562+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
563+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
564+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
565+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
566+ configuration.baseForegroundColor = UIColor.labelColor;
567+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
568+ if (menuConfig != nil) {
569+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
570+ menuAction:emitMenuPress
571+ imageLoader:config->_imageLoader];
572+ button.showsMenuAsPrimaryAction = YES;
573+ } else if (buttonId != nil) {
574+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
575+ emitButtonPress(buttonId);
576+ }];
577+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
578+ }
579+ return button;
580+ };
581+
582+ BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue];
583+ UISearchBar *searchBar =
584+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
585+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
586+ UITextField *resolvedSearchTextField = nil;
587+ if (searchBar != nil) {
588+ if (placeholder != nil) {
589+ searchBar.placeholder = placeholder;
590+ }
591+ searchBar.hidden = NO;
592+ searchBar.alpha = 1.0;
593+ searchBar.userInteractionEnabled = YES;
594+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
595+ searchBar.backgroundImage = [UIImage new];
596+ searchBar.searchTextField.hidden = NO;
597+ searchBar.searchTextField.alpha = 1.0;
598+ searchBar.searchTextField.backgroundColor = UIColor.clearColor;
599+ searchBar.searchTextField.font = searchFont;
600+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
601+ searchBar.searchTextField.textColor = UIColor.labelColor;
602+ searchBar.searchTextField.tintColor = UIColor.labelColor;
603+ resolvedSearchTextField = searchBar.searchTextField;
604+ if (placeholder != nil) {
605+ searchBar.searchTextField.attributedPlaceholder =
606+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
607+ }
608+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
609+ [glassView.contentView addSubview:searchBar];
610+ [NSLayoutConstraint activateConstraints:@[
611+ [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0],
612+ [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0],
613+ [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
614+ [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight],
615+ ]];
616+ } else {
617+ UISearchTextField *searchField = [UISearchTextField new];
618+ if (placeholder != nil) {
619+ searchField.placeholder = placeholder;
620+ searchField.attributedPlaceholder =
621+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
622+ }
623+ searchField.borderStyle = UITextBorderStyleNone;
624+ searchField.font = searchFont;
625+ searchField.adjustsFontForContentSizeCategory = YES;
626+ searchField.textColor = UIColor.labelColor;
627+ searchField.tintColor = UIColor.labelColor;
628+ resolvedSearchTextField = searchField;
629+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
630+ [glassView.contentView addSubview:searchField];
631+ [NSLayoutConstraint activateConstraints:@[
632+ [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0],
633+ [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0],
634+ [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
635+ [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight],
636+ ]];
637+ }
638+
639+ // A bare UISearchTextField keeps UITextField defaults (sentence
640+ // capitalization, autocorrect), unlike UISearchBar. Search queries are
641+ // identifiers, so match the autoCapitalize "none" the JS fallbacks use.
642+ if (resolvedSearchTextField != nil) {
643+ resolvedSearchTextField.autocapitalizationType = UITextAutocapitalizationTypeNone;
644+ resolvedSearchTextField.autocorrectionType = UITextAutocorrectionTypeNo;
645+ resolvedSearchTextField.spellCheckingType = UITextSpellCheckingTypeNo;
646+ }
647+
648+ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"];
649+ NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change";
650+ if (resolvedSearchTextField != nil) {
651+ [resolvedSearchTextField removeActionForIdentifier:searchTextChangeActionIdentifier
652+ forControlEvents:UIControlEventEditingChanged];
653+ }
654+ if (searchTextChangeId != nil && resolvedSearchTextField != nil) {
655+ [resolvedSearchTextField
656+ addAction:[UIAction actionWithTitle:@""
657+ image:nil
658+ identifier:searchTextChangeActionIdentifier
659+ handler:^(__kindof UIAction *_Nonnull action) {
660+ UITextField *field = (UITextField *)action.sender;
661+ emitButtonPress(
662+ [NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]);
663+ }]
664+ forControlEvents:UIControlEventEditingChanged];
665+ }
666+
667+ UIButton *filterButton = nil;
668+ if (hasFilterButton) {
669+ filterButton = makeGlassButton(
670+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
671+ mailSearchToolbarConfig[@"filterButtonId"],
672+ mailSearchToolbarConfig[@"filterMenu"]);
673+ filterButton.translatesAutoresizingMaskIntoConstraints = NO;
674+ [toolbarHost addSubview:filterButton];
675+ [NSLayoutConstraint activateConstraints:@[
676+ [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor],
677+ [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
678+ [filterButton.widthAnchor constraintEqualToConstant:buttonSize],
679+ [filterButton.heightAnchor constraintEqualToConstant:buttonSize],
680+ ]];
681+ }
682+
683+ UIButton *composeButton = nil;
684+ if (hasComposeButton) {
685+ composeButton = makeGlassButton(
686+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
687+ mailSearchToolbarConfig[@"composeButtonId"],
688+ mailSearchToolbarConfig[@"composeMenu"]);
689+ composeButton.translatesAutoresizingMaskIntoConstraints = NO;
690+ [toolbarHost addSubview:composeButton];
691+ [NSLayoutConstraint activateConstraints:@[
692+ [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
693+ [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
694+ [composeButton.widthAnchor constraintEqualToConstant:buttonSize],
695+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
696+ ]];
697+ }
698+
699+ BOOL showsSearchDismissButton =
700+ [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil;
701+ UIButton *searchDismissButton = nil;
702+ if (showsSearchDismissButton) {
703+ searchDismissButton = makeGlassButton(@"xmark", nil, nil);
704+ searchDismissButton.accessibilityLabel = @"Dismiss search keyboard";
705+ searchDismissButton.alpha = 0.0;
706+ searchDismissButton.hidden = YES;
707+ searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO;
708+ [toolbarHost addSubview:searchDismissButton];
709+ [NSLayoutConstraint activateConstraints:@[
710+ [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
711+ [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
712+ [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize],
713+ [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize],
714+ ]];
715+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
716+ [searchDismissButton
717+ addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
718+ [weakSearchTextField resignFirstResponder];
719+ }]
720+ forControlEvents:UIControlEventTouchUpInside];
721+ }
722+
723+ __weak UIButton *weakFilterButton = filterButton;
724+ __weak UIButton *weakComposeButton = composeButton;
725+ __weak UIButton *weakSearchDismissButton = searchDismissButton;
726+ __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint;
727+ __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint;
728+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
729+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
730+ __weak UIView *weakToolbarHost = toolbarHost;
731+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
732+ void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) {
733+ weakKeyboardAvoidConstraint.priority =
734+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
735+ weakRestingBottomConstraint.priority =
736+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
737+
738+ if (showsSearchDismissButton) {
739+ if (isEditing) {
740+ weakSearchDismissButton.hidden = NO;
741+ } else {
742+ weakFilterButton.hidden = NO;
743+ weakComposeButton.hidden = NO;
744+ }
745+ weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset;
746+ weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset;
747+
748+ void (^changes)(void) = ^{
749+ weakFilterButton.alpha = isEditing ? 0.0 : 1.0;
750+ weakComposeButton.alpha = isEditing ? 0.0 : 1.0;
751+ weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0;
752+ [weakToolbarHost layoutIfNeeded];
753+ };
754+ void (^completion)(BOOL) = ^(BOOL finished) {
755+ if (!finished || weakSearchTextField.isFirstResponder != isEditing) {
756+ return;
757+ }
758+ weakFilterButton.hidden = isEditing;
759+ weakComposeButton.hidden = isEditing;
760+ weakSearchDismissButton.hidden = !isEditing;
761+ };
762+ if (animated) {
763+ [UIView animateWithDuration:0.2
764+ delay:0.0
765+ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut
766+ animations:changes
767+ completion:completion];
768+ } else {
769+ changes();
770+ completion(YES);
771+ }
772+ }
773+ [weakToolbarHost setNeedsLayout];
774+ };
775+
776+ setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO);
777+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
778+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
779+ [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier
780+ forControlEvents:UIControlEventEditingDidBegin];
781+ [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier
782+ forControlEvents:UIControlEventEditingDidEnd];
783+ [resolvedSearchTextField
784+ addAction:[UIAction actionWithTitle:@""
785+ image:nil
786+ identifier:beginActionIdentifier
787+ handler:^(__kindof UIAction *_Nonnull action) {
788+ setSearchEditingAppearance(YES, YES);
789+ }]
790+ forControlEvents:UIControlEventEditingDidBegin];
791+ [resolvedSearchTextField
792+ addAction:[UIAction actionWithTitle:@""
793+ image:nil
794+ identifier:endActionIdentifier
795+ handler:^(__kindof UIAction *_Nonnull action) {
796+ setSearchEditingAppearance(NO, YES);
797+ }]
798+ forControlEvents:UIControlEventEditingDidEnd];
799+ }
800+#endif
801+ }
802+
803+ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[];
804+ // Same reuse rule as the header item groups above (including the config
805+ // identity — cached toolbar items capture this config's event emitter).
806+ // The top-view-controller and hidden-state checks scope the skip to
807+ // same-screen refreshes, so transitions between screens with different
808+ // toolbars still reapply.
809+ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ];
810+ BOOL reuseToolbarItems = navctr.topViewController == vc &&
811+ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) &&
812+ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey];
813+ if (navctr.topViewController == vc && !reuseToolbarItems) {
814+ NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs
815+ withCurrentItems:@[]
816+ navigationItem:navitem
817+ allowsSearchBarPlacement:YES];
818+ if (toolbarItems.count > 0) {
819+ vc.toolbarItems = toolbarItems;
820+ [navctr setToolbarHidden:NO animated:animated];
821+ } else {
822+ vc.toolbarItems = nil;
823+ [navctr setToolbarHidden:YES animated:animated];
824+ }
825+ objc_setAssociatedObject(
826+ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
827+ }
828
829 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
830 // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
831@@ -784,6 +1298,8 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
832
833 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
834 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
835+ navigationItem:(UINavigationItem *)navitem
836+ allowsSearchBarPlacement:(BOOL)allowsSearchBarPlacement
837 {
838 if (dicts.count == 0) {
839 return currentItems;
840@@ -792,7 +1308,200 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
841 [items addObjectsFromArray:currentItems];
842 for (NSUInteger i = 0; i < dicts.count; i++) {
843 NSDictionary *dict = dicts[i];
844- if (dict[@"buttonId"] || dict[@"menu"]) {
845+ if (dict[@"mailSearchToolbar"]) {
846+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
847+ if (@available(iOS 26.0, *)) {
848+ NSNumber *width = dict[@"width"];
849+ CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0);
850+ CGFloat resolvedHeight = 58.0;
851+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
852+ container.translatesAutoresizingMaskIntoConstraints = NO;
853+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
854+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
855+
856+ UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds];
857+ stackView.axis = UILayoutConstraintAxisHorizontal;
858+ stackView.alignment = UIStackViewAlignmentCenter;
859+ stackView.distribution = UIStackViewDistributionFill;
860+ stackView.spacing = 10.0;
861+ stackView.translatesAutoresizingMaskIntoConstraints = NO;
862+ [container addSubview:stackView];
863+ [NSLayoutConstraint activateConstraints:@[
864+ [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
865+ [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
866+ [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
867+ [stackView.heightAnchor constraintEqualToConstant:50.0],
868+ ]];
869+
870+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
871+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
872+ self->_eventEmitter);
873+ if (eventEmitter && buttonId) {
874+ eventEmitter->onPressHeaderBarButtonItem(
875+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
876+ .buttonId = std::string([buttonId UTF8String])});
877+ }
878+ };
879+
880+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
881+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
882+ self->_eventEmitter);
883+ if (eventEmitter && menuId) {
884+ eventEmitter->onPressHeaderBarButtonMenuItem(
885+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
886+ .menuId = std::string([menuId UTF8String])});
887+ }
888+ };
889+
890+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
891+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
892+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
893+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
894+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
895+
896+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
897+ button.translatesAutoresizingMaskIntoConstraints = NO;
898+ [button.widthAnchor constraintEqualToConstant:58.0].active = YES;
899+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
900+ if (menuConfig != nil) {
901+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
902+ menuAction:emitMenuPress
903+ imageLoader:self->_imageLoader];
904+ button.showsMenuAsPrimaryAction = YES;
905+ } else if (buttonId != nil) {
906+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
907+ emitButtonPress(buttonId);
908+ }];
909+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
910+ }
911+ return button;
912+ };
913+
914+ BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil;
915+ BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil;
916+
917+ if (hasFilterButton) {
918+ UIButton *filterButton = makeGlassButton(
919+ dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
920+ dict[@"filterButtonId"],
921+ dict[@"filterMenu"]);
922+ [stackView addArrangedSubview:filterButton];
923+ }
924+
925+ UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new];
926+ NSString *placeholder = dict[@"placeholder"];
927+ if (placeholder != nil) {
928+ searchBar.placeholder = placeholder;
929+ }
930+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
931+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
932+ [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES;
933+ [stackView addArrangedSubview:searchBar];
934+
935+ if (hasComposeButton) {
936+ UIButton *composeButton = makeGlassButton(
937+ dict[@"composeSystemImageName"] ?: @"square.and.pencil",
938+ dict[@"composeButtonId"],
939+ dict[@"composeMenu"]);
940+ [stackView addArrangedSubview:composeButton];
941+ }
942+
943+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
944+ [items addObject:item];
945+ }
946+#endif
947+ } else if (dict[@"searchField"]) {
948+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
949+ if (@available(iOS 26.0, *)) {
950+ NSNumber *width = dict[@"width"];
951+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0;
952+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)];
953+ container.translatesAutoresizingMaskIntoConstraints = NO;
954+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
955+ [container.heightAnchor constraintEqualToConstant:44.0].active = YES;
956+
957+ UISearchTextField *searchField = [UISearchTextField new];
958+ NSString *placeholder = dict[@"placeholder"];
959+ if (placeholder != nil) {
960+ searchField.placeholder = placeholder;
961+ }
962+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
963+ [container addSubview:searchField];
964+ [NSLayoutConstraint activateConstraints:@[
965+ [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
966+ [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
967+ [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
968+ [searchField.heightAnchor constraintEqualToConstant:40.0],
969+ ]];
970+
971+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
972+ [items addObject:item];
973+ }
974+#endif
975+ } else if (dict[@"searchBarPlacement"]) {
976+ if (!allowsSearchBarPlacement) {
977+ continue;
978+ }
979+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
980+ if (@available(iOS 26.0, *)) {
981+ NSNumber *width = dict[@"width"];
982+ UIBarButtonItem *item = nil;
983+ if ([dict[@"activatesSearchController"] boolValue]) {
984+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
985+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
986+ configuration.image = [UIImage systemImageNamed:@"magnifyingglass"];
987+ configuration.baseForegroundColor = UIColor.labelColor;
988+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
989+ button.translatesAutoresizingMaskIntoConstraints = NO;
990+ [button.widthAnchor constraintEqualToConstant:56.0].active = YES;
991+ [button.heightAnchor constraintEqualToConstant:56.0].active = YES;
992+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
993+ if (navitem.searchController != nil) {
994+ [navitem.searchController setActive:YES];
995+ [navitem.searchController.searchBar becomeFirstResponder];
996+ }
997+ }];
998+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
999+ item = [[UIBarButtonItem alloc] initWithCustomView:button];
1000+ } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) {
1001+ UISearchBar *searchBar = navitem.searchController.searchBar;
1002+ CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width;
1003+ CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0;
1004+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
1005+ container.translatesAutoresizingMaskIntoConstraints = NO;
1006+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
1007+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
1008+ searchBar.frame = container.bounds;
1009+ searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
1010+ [container addSubview:searchBar];
1011+ item = [[UIBarButtonItem alloc] initWithCustomView:container];
1012+ } else {
1013+ item = navitem.searchBarPlacementBarButtonItem;
1014+ if (width != nil) {
1015+ item.width = width.doubleValue;
1016+ }
1017+ }
1018+ NSNumber *index = dict[@"index"];
1019+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
1020+ if (@available(iOS 26.0, *)) {
1021+ NSNumber *sharesBackground = dict[@"sharesBackground"];
1022+ if (sharesBackground != nil) {
1023+ item.sharesBackground = sharesBackground.boolValue;
1024+ }
1025+ NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"];
1026+ if (hidesSharedBackground != nil) {
1027+ item.hidesSharedBackground = hidesSharedBackground.boolValue;
1028+ }
1029+ }
1030+#endif
1031+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
1032+ [items insertObject:item atIndex:index.integerValue];
1033+ } else {
1034+ [items addObject:item];
1035+ }
1036+ }
1037+#endif
1038+ } else if (dict[@"buttonId"] || dict[@"menu"]) {
1039 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
1040 action:^(NSString *buttonId) {
1041 auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
1042@@ -814,19 +1523,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
1043 }
1044 imageLoader:_imageLoader];
1045 NSNumber *index = dict[@"index"];
1046- if (index.integerValue < items.count) {
1047+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
1048 [items insertObject:item atIndex:index.integerValue];
1049 } else {
1050 [items addObject:item];
1051 }
1052 } else if (dict[@"spacing"]) {
1053- UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace
1054- target:nil
1055- action:nil];
1056+ BOOL flexible = [dict[@"flexible"] boolValue];
1057+ UIBarButtonItem *item = [[UIBarButtonItem alloc]
1058+ initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace)
1059+ target:nil
1060+ action:nil];
1061 NSNumber *spacingValue = dict[@"spacing"];
1062- item.width = [spacingValue doubleValue];
1063+ if (!flexible) {
1064+ item.width = [spacingValue doubleValue];
1065+ }
1066 NSNumber *index = dict[@"index"];
1067- if (index.integerValue < items.count) {
1068+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
1069 [items insertObject:item atIndex:index.integerValue];
1070 } else {
1071 [items addObject:item];
1072@@ -836,6 +1549,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
1073 return items;
1074 }
1075
1076+#if !TARGET_OS_TV
1077+- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items
1078+{
1079+ if (items.count == 0) {
1080+ return @[];
1081+ }
1082+
1083+ NSMutableArray<UIBarButtonItemGroup *> *groups = [NSMutableArray array];
1084+ NSMutableArray<UIBarButtonItem *> *sharedRun = [NSMutableArray array];
1085+
1086+ void (^flushSharedRun)(void) = ^{
1087+ if (sharedRun.count == 0) {
1088+ return;
1089+ }
1090+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil
1091+ items:[sharedRun copy]]];
1092+ [sharedRun removeAllObjects];
1093+ };
1094+
1095+ for (UIBarButtonItem *item in items) {
1096+ BOOL shouldShareBackground = NO;
1097+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
1098+ if (@available(iOS 26.0, *)) {
1099+ shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground;
1100+ }
1101+#endif
1102+
1103+ if (shouldShareBackground) {
1104+ [sharedRun addObject:item];
1105+ continue;
1106+ }
1107+
1108+ flushSharedRun();
1109+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]];
1110+ }
1111+
1112+ flushSharedRun();
1113+ return groups;
1114+}
1115+#endif
1116+
1117 RNS_IGNORE_SUPER_CALL_BEGIN
1118 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
1119 {
1120@@ -1025,6 +1779,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
1121 }
1122
1123 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
1124+ _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle);
1125+ _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle);
1126 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
1127 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
1128 }
1129@@ -1050,6 +1806,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
1130 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
1131 _backButtonDisplayMode =
1132 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
1133+ _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle);
1134
1135 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
1136 _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
1137@@ -1096,6 +1853,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
1138 _headerRightBarButtonItems = array;
1139 }
1140
1141+ if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) {
1142+ const auto &vec = newScreenProps.headerCenterBarButtonItems;
1143+ NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
1144+ for (const auto &item : vec) {
1145+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
1146+ if ([dict isKindOfClass:[NSDictionary class]]) {
1147+ [array addObject:dict];
1148+ }
1149+ }
1150+ _headerCenterBarButtonItems = array;
1151+ }
1152+
1153+ if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) {
1154+ const auto &vec = newScreenProps.headerToolbarItems;
1155+ NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
1156+ for (const auto &item : vec) {
1157+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
1158+ if ([dict isKindOfClass:[NSDictionary class]]) {
1159+ [array addObject:dict];
1160+ }
1161+ }
1162+ _headerToolbarItems = array;
1163+ }
1164+
1165 [self updateViewControllerIfNeeded];
1166
1167 if (needsNavigationControllerLayout) {
1168diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm
1169index add33c4..489afa5 100644
1170--- a/ios/RNSScreenStackHeaderSubview.mm
1171+++ b/ios/RNSScreenStackHeaderSubview.mm
1172@@ -20,7 +20,9 @@ @implementation RNSScreenStackHeaderSubview {
1173 // This is a strong reference to UIBarButtonItem which creates a retain cycle.
1174 // The cycle is cleared via `invalidateUIBarButtonItem` method, called by `invalidate` callback.
1175 UIBarButtonItem *_barButtonItem;
1176+ NSString *_barButtonItemIdentifier;
1177 BOOL _hidesSharedBackground;
1178+ UILabel *_titleScrollEdgeEffectGuide;
1179 }
1180
1181 #pragma mark - Common
1182@@ -102,6 +104,25 @@ - (void)updateShadowStateWithFrame:(CGRect)frame
1183 - (void)layoutSubviews
1184 {
1185 [super layoutSubviews];
1186+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
1187+ if (@available(iOS 26.0, *)) {
1188+ if (_type == RNSScreenStackHeaderSubviewTypeTitle || _type == RNSScreenStackHeaderSubviewTypeCenter) {
1189+ // UIKit shapes the navigation bar's scroll-edge fade around native labels,
1190+ // but does not recognize Fabric's custom text views. An empty, non-interactive
1191+ // label supplies the title's geometry without drawing or becoming a bar item.
1192+ if (_titleScrollEdgeEffectGuide == nil) {
1193+ _titleScrollEdgeEffectGuide = [UILabel new];
1194+ _titleScrollEdgeEffectGuide.accessibilityElementsHidden = YES;
1195+ // Append after Fabric's children so their mount/unmount indices stay intact.
1196+ [self addSubview:_titleScrollEdgeEffectGuide];
1197+ }
1198+ _titleScrollEdgeEffectGuide.frame = self.bounds;
1199+ } else {
1200+ [_titleScrollEdgeEffectGuide removeFromSuperview];
1201+ _titleScrollEdgeEffectGuide = nil;
1202+ }
1203+ }
1204+#endif
1205 [self updateShadowStateInContextOfAncestorView:[self findNavigationBar]];
1206 }
1207
1208@@ -124,6 +145,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
1209 const auto &newHeaderSubviewProps = *std::static_pointer_cast<const react::RNSScreenStackHeaderSubviewProps>(props);
1210
1211 [self setType:[RNSConvert RNSScreenStackHeaderSubviewTypeFromCppEquivalent:newHeaderSubviewProps.type]];
1212+ [self setBarButtonItemIdentifier:RCTNSStringFromStringNilIfEmpty(newHeaderSubviewProps.identifier)];
1213 [self setHidesSharedBackground:newHeaderSubviewProps.hidesSharedBackground];
1214 [self setSynchronousShadowStateUpdatesEnabled:newHeaderSubviewProps.synchronousShadowStateUpdatesEnabled];
1215 [super updateProps:props oldProps:oldProps];
1216@@ -276,7 +298,14 @@ - (void)configureBarButtonItem
1217 #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
1218 if (@available(iOS 26.0, *)) {
1219 if (_barButtonItem != nil) {
1220- [_barButtonItem setHidesSharedBackground:_hidesSharedBackground];
1221+ NSString *currentIdentifier = _barButtonItem.identifier;
1222+ if (currentIdentifier != _barButtonItemIdentifier &&
1223+ ![currentIdentifier isEqualToString:_barButtonItemIdentifier]) {
1224+ _barButtonItem.identifier = _barButtonItemIdentifier;
1225+ }
1226+ if (_barButtonItem.hidesSharedBackground != _hidesSharedBackground) {
1227+ _barButtonItem.hidesSharedBackground = _hidesSharedBackground;
1228+ }
1229 }
1230 }
1231 #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
1232@@ -284,10 +313,22 @@ - (void)configureBarButtonItem
1233
1234 - (void)setHidesSharedBackground:(BOOL)hidesSharedBackground
1235 {
1236+ if (_hidesSharedBackground == hidesSharedBackground) {
1237+ return;
1238+ }
1239 _hidesSharedBackground = hidesSharedBackground;
1240 [self configureBarButtonItem];
1241 }
1242
1243+- (void)setBarButtonItemIdentifier:(nullable NSString *)identifier
1244+{
1245+ if (_barButtonItemIdentifier == identifier || [_barButtonItemIdentifier isEqualToString:identifier]) {
1246+ return;
1247+ }
1248+ _barButtonItemIdentifier = [identifier copy];
1249+ [self configureBarButtonItem];
1250+}
1251+
1252 #pragma mark - Dynamic frameworks support
1253
1254 // Needed because of this: https://github.com/facebook/react-native/pull/37274
1255diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js
1256index 6e68cd1..9e09485 100644
1257--- a/lib/commonjs/components/ScreenStackHeaderConfig.js
1258+++ b/lib/commonjs/components/ScreenStackHeaderConfig.js
1259@@ -31,17 +31,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
1260 } = (0, _EdgeInsetApplicationContext.useEdgeInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false);
1261 const {
1262 headerLeftBarButtonItems,
1263- headerRightBarButtonItems
1264+ headerRightBarButtonItems,
1265+ headerCenterBarButtonItems,
1266+ headerToolbarItems
1267 } = props;
1268 const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined;
1269 const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined;
1270- const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
1271+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'center') : undefined;
1272+ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'toolbar') : undefined;
1273+ const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
1274
1275 // Handle bar button item presses
1276 const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
1277- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
1278+ const buttonId = event.nativeEvent.buttonId;
1279+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
1280+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
1281 if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
1282 pressedItem.onPress();
1283+ return;
1284+ }
1285+ for (const item of allItems) {
1286+ if (!item || item.type !== 'mailSearchToolbar') {
1287+ continue;
1288+ }
1289+ if (item.filterButtonId === buttonId) {
1290+ item.onFilterPress?.();
1291+ return;
1292+ }
1293+ if (item.composeButtonId === buttonId) {
1294+ item.onComposePress?.();
1295+ return;
1296+ }
1297+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
1298+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
1299+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
1300+ return;
1301+ }
1302 }
1303 } : undefined;
1304
1305@@ -64,7 +89,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
1306 };
1307
1308 // Check each bar-button item with a menu
1309- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
1310+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
1311 for (const item of allItems) {
1312 if (item && item.type === 'menu' && item.menu) {
1313 const action = findInMenu(item.menu, event.nativeEvent.menuId);
1314@@ -72,6 +97,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
1315 action.onPress();
1316 return;
1317 }
1318+ } else if (item && item.type === 'mailSearchToolbar') {
1319+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
1320+ for (const toolbarMenu of toolbarMenus) {
1321+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
1322+ if (action) {
1323+ action.onPress();
1324+ return;
1325+ }
1326+ }
1327 }
1328 }
1329 } : undefined;
1330@@ -79,6 +113,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
1331 userInterfaceStyle: props.experimental_userInterfaceStyle,
1332 headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
1333 headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
1334+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
1335+ headerToolbarItems: preparedHeaderToolbarItems,
1336 onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
1337 onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
1338 ref: ref,
1339diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
1340index 589b6cc..b584624 100644
1341--- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
1342+++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
1343@@ -8,7 +8,7 @@ var _reactNative = require("react-native");
1344 // Local nominal type so declaration emit doesn't resolve the RN alias down to
1345 // the non-public `types_generated/.../AssetSourceResolver#ResolvedAssetSource`.
1346
1347-const prepareMenu = (menu, index, side, path = '') => {
1348+const prepareMenu = (menu, index, placement, path = '') => {
1349 return {
1350 ...menu,
1351 items: menu.items.map((menuItem, menuIndex) => {
1352@@ -29,7 +29,7 @@ const prepareMenu = (menu, index, side, path = '') => {
1353 xcassetName,
1354 imageSource,
1355 templateSource,
1356- ...prepareMenu(menuItem, index, side, currentPath)
1357+ ...prepareMenu(menuItem, index, placement, currentPath)
1358 };
1359 }
1360 return {
1361@@ -38,16 +38,40 @@ const prepareMenu = (menu, index, side, path = '') => {
1362 xcassetName,
1363 imageSource,
1364 templateSource,
1365- menuId: `${currentPath}-${index}-${side}`
1366+ menuId: `${currentPath}-${index}-${placement}`
1367 };
1368 })
1369 };
1370 };
1371-const prepareHeaderBarButtonItems = (barButtonItems, side) => {
1372- return barButtonItems?.map((item, index) => {
1373+const prepareHeaderBarButtonItems = (barButtonItems, placement) => {
1374+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
1375+ return items?.map((item, index) => {
1376 if (item.type === 'spacing') {
1377 return item;
1378 }
1379+ if (item.type === 'searchBarPlacement') {
1380+ if (placement !== 'toolbar') {
1381+ return null;
1382+ }
1383+ return {
1384+ ...item,
1385+ searchBarPlacement: true
1386+ };
1387+ }
1388+ if (item.type === 'searchField') {
1389+ return {
1390+ ...item,
1391+ searchField: true
1392+ };
1393+ }
1394+ if (item.type === 'mailSearchToolbar') {
1395+ return {
1396+ ...item,
1397+ mailSearchToolbar: true,
1398+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
1399+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined
1400+ };
1401+ }
1402 let imageSource, templateSource;
1403 if (item.icon?.type === 'imageSource') {
1404 imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource);
1405@@ -80,17 +104,17 @@ const prepareHeaderBarButtonItems = (barButtonItems, side) => {
1406 if (item.type === 'button') {
1407 return {
1408 ...processedItem,
1409- buttonId: `${index}-${side}`
1410+ buttonId: `${index}-${placement}`
1411 };
1412 }
1413 if (item.type === 'menu') {
1414 return {
1415 ...processedItem,
1416- menu: prepareMenu(item.menu, index, side)
1417+ menu: prepareMenu(item.menu, index, placement)
1418 };
1419 }
1420 return null;
1421- });
1422+ }).filter(item => item !== null);
1423 };
1424 exports.prepareHeaderBarButtonItems = prepareHeaderBarButtonItems;
1425 //# sourceMappingURL=prepareHeaderBarButtonItems.js.map
1426\ No newline at end of file
1427diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js
1428index 242898c..7cebaa3 100644
1429--- a/lib/module/components/ScreenStackHeaderConfig.js
1430+++ b/lib/module/components/ScreenStackHeaderConfig.js
1431@@ -27,17 +27,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
1432 } = useEdgeInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false);
1433 const {
1434 headerLeftBarButtonItems,
1435- headerRightBarButtonItems
1436+ headerRightBarButtonItems,
1437+ headerCenterBarButtonItems,
1438+ headerToolbarItems
1439 } = props;
1440 const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined;
1441 const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined;
1442- const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
1443+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') : undefined;
1444+ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') : undefined;
1445+ const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
1446
1447 // Handle bar button item presses
1448 const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
1449- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
1450+ const buttonId = event.nativeEvent.buttonId;
1451+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
1452+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
1453 if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
1454 pressedItem.onPress();
1455+ return;
1456+ }
1457+ for (const item of allItems) {
1458+ if (!item || item.type !== 'mailSearchToolbar') {
1459+ continue;
1460+ }
1461+ if (item.filterButtonId === buttonId) {
1462+ item.onFilterPress?.();
1463+ return;
1464+ }
1465+ if (item.composeButtonId === buttonId) {
1466+ item.onComposePress?.();
1467+ return;
1468+ }
1469+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
1470+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
1471+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
1472+ return;
1473+ }
1474 }
1475 } : undefined;
1476
1477@@ -60,7 +85,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
1478 };
1479
1480 // Check each bar-button item with a menu
1481- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
1482+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
1483 for (const item of allItems) {
1484 if (item && item.type === 'menu' && item.menu) {
1485 const action = findInMenu(item.menu, event.nativeEvent.menuId);
1486@@ -68,6 +93,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
1487 action.onPress();
1488 return;
1489 }
1490+ } else if (item && item.type === 'mailSearchToolbar') {
1491+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
1492+ for (const toolbarMenu of toolbarMenus) {
1493+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
1494+ if (action) {
1495+ action.onPress();
1496+ return;
1497+ }
1498+ }
1499 }
1500 }
1501 } : undefined;
1502@@ -75,6 +109,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
1503 userInterfaceStyle: props.experimental_userInterfaceStyle,
1504 headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
1505 headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
1506+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
1507+ headerToolbarItems: preparedHeaderToolbarItems,
1508 onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
1509 onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
1510 ref: ref,
1511diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
1512index 24bf271..693cfad 100644
1513--- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js
1514+++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
1515@@ -3,7 +3,7 @@ import { Image, processColor } from 'react-native';
1516 // Local nominal type so declaration emit doesn't resolve the RN alias down to
1517 // the non-public `types_generated/.../AssetSourceResolver#ResolvedAssetSource`.
1518
1519-const prepareMenu = (menu, index, side, path = '') => {
1520+const prepareMenu = (menu, index, placement, path = '') => {
1521 return {
1522 ...menu,
1523 items: menu.items.map((menuItem, menuIndex) => {
1524@@ -24,7 +24,7 @@ const prepareMenu = (menu, index, side, path = '') => {
1525 xcassetName,
1526 imageSource,
1527 templateSource,
1528- ...prepareMenu(menuItem, index, side, currentPath)
1529+ ...prepareMenu(menuItem, index, placement, currentPath)
1530 };
1531 }
1532 return {
1533@@ -33,16 +33,40 @@ const prepareMenu = (menu, index, side, path = '') => {
1534 xcassetName,
1535 imageSource,
1536 templateSource,
1537- menuId: `${currentPath}-${index}-${side}`
1538+ menuId: `${currentPath}-${index}-${placement}`
1539 };
1540 })
1541 };
1542 };
1543-export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
1544- return barButtonItems?.map((item, index) => {
1545+export const prepareHeaderBarButtonItems = (barButtonItems, placement) => {
1546+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
1547+ return items?.map((item, index) => {
1548 if (item.type === 'spacing') {
1549 return item;
1550 }
1551+ if (item.type === 'searchBarPlacement') {
1552+ if (placement !== 'toolbar') {
1553+ return null;
1554+ }
1555+ return {
1556+ ...item,
1557+ searchBarPlacement: true
1558+ };
1559+ }
1560+ if (item.type === 'searchField') {
1561+ return {
1562+ ...item,
1563+ searchField: true
1564+ };
1565+ }
1566+ if (item.type === 'mailSearchToolbar') {
1567+ return {
1568+ ...item,
1569+ mailSearchToolbar: true,
1570+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
1571+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined
1572+ };
1573+ }
1574 let imageSource, templateSource;
1575 if (item.icon?.type === 'imageSource') {
1576 imageSource = Image.resolveAssetSource(item.icon.imageSource);
1577@@ -75,16 +99,16 @@ export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
1578 if (item.type === 'button') {
1579 return {
1580 ...processedItem,
1581- buttonId: `${index}-${side}`
1582+ buttonId: `${index}-${placement}`
1583 };
1584 }
1585 if (item.type === 'menu') {
1586 return {
1587 ...processedItem,
1588- menu: prepareMenu(item.menu, index, side)
1589+ menu: prepareMenu(item.menu, index, placement)
1590 };
1591 }
1592 return null;
1593- });
1594+ }).filter(item => item !== null);
1595 };
1596 //# sourceMappingURL=prepareHeaderBarButtonItems.js.map
1597\ No newline at end of file
1598diff --git a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
1599index 4704a14..e2e2b78 100644
1600--- a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
1601+++ b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
1602@@ -2,7 +2,7 @@ import type { ImageResolvedAssetSource } from 'react-native';
1603 import type { HeaderBarButtonItem } from '../../types';
1604 export interface ResolvedImageAsset extends ImageResolvedAssetSource {
1605 }
1606-export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right") => (import("../..").HeaderBarButtonItemSpacing | {
1607+export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right" | "center" | "toolbar") => (import("../..").HeaderBarButtonItemSpacing | {
1608 buttonId: string;
1609 imageSource: ResolvedImageAsset | undefined;
1610 templateSource: ResolvedImageAsset | undefined;
1611@@ -164,5 +164,5 @@ export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButt
1612 identifier?: string | undefined;
1613 accessibilityLabel?: string | undefined;
1614 accessibilityHint?: string | undefined;
1615-} | null)[];
1616+})[];
1617 //# sourceMappingURL=prepareHeaderBarButtonItems.d.ts.map
1618\ No newline at end of file
1619diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
1620index ec39792..434bccf 100644
1621--- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
1622+++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
1623@@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{
1624 menuId: string;
1625 }>;
1626 type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
1627+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
1628 type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
1629 type UserInterfaceStyle = 'unspecified' | 'light' | 'dark';
1630 export interface NativeProps extends ViewProps {
1631@@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps {
1632 largeTitleColor?: ColorValue | undefined;
1633 translucent?: boolean | undefined;
1634 title?: string | undefined;
1635+ subtitle?: string | undefined;
1636+ largeSubtitle?: string | undefined;
1637 titleFontFamily?: string | undefined;
1638 titleFontSize?: CT.Int32 | undefined;
1639 titleFontWeight?: string | undefined;
1640 titleColor?: ColorValue | undefined;
1641 disableBackButtonMenu?: boolean | undefined;
1642 backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>;
1643+ navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>;
1644 hideBackButton?: boolean | undefined;
1645 backButtonInCustomView?: boolean | undefined;
1646 blurEffect?: CT.WithDefault<BlurEffect, 'none'>;
1647 topInsetEnabled?: boolean | undefined;
1648 headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
1649 headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
1650+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
1651+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
1652 onPressHeaderBarButtonItem?: CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | undefined;
1653 onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler<OnPressHeaderBarButtonMenuItemEvent> | undefined;
1654 synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>;
1655diff --git a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
1656index 2cb1a79..d6ca5a4 100644
1657--- a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
1658+++ b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
1659@@ -3,6 +3,7 @@ export type HeaderSubviewTypes = 'back' | 'right' | 'left' | 'title' | 'center'
1660 export interface NativeProps extends ViewProps {
1661 type?: CT.WithDefault<HeaderSubviewTypes, 'left'>;
1662 hidesSharedBackground?: boolean | undefined;
1663+ identifier?: string | undefined;
1664 synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>;
1665 }
1666 declare const _default: HostComponent<NativeProps>;
1667diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts
1668index ff37881..b487303 100644
1669--- a/lib/typescript/types.d.ts
1670+++ b/lib/typescript/types.d.ts
1671@@ -19,6 +19,7 @@ export type SearchBarCommands = {
1672 cancelSearch: () => void;
1673 };
1674 export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
1675+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
1676 export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet';
1677 export type StackAnimationTypes = 'default' | 'fade' | 'fade_from_bottom' | 'flip' | 'none' | 'simple_push' | 'slide_from_bottom' | 'slide_from_right' | 'slide_from_left' | 'ios_from_right' | 'ios_from_left';
1678 export type BlurEffectTypes = BlurEffect;
1679@@ -650,6 +651,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1680 * @platform ios
1681 */
1682 backButtonDisplayMode?: BackButtonDisplayMode | undefined;
1683+ /**
1684+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
1685+ *
1686+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
1687+ *
1688+ * @platform ios
1689+ */
1690+ navigationItemStyle?: NavigationItemStyle | undefined;
1691 /**
1692 * Array of UIBarButtomItems to the left side of the header.
1693 *
1694@@ -662,6 +671,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1695 * @platform ios
1696 */
1697 headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
1698+ /**
1699+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
1700+ *
1701+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
1702+ * document/editor-style chrome keep primary actions visually centered while
1703+ * leaving trailing search separate.
1704+ *
1705+ * @platform ios
1706+ */
1707+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
1708+ /**
1709+ * Array of UIBarButtonItems to display in the native toolbar attached to
1710+ * the current screen's navigation controller.
1711+ *
1712+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
1713+ * render integrated Mail-style search chrome using the attached SearchBar.
1714+ *
1715+ * @platform ios
1716+ */
1717+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
1718 /**
1719 * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
1720 */
1721@@ -723,6 +752,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1722 * String that can be displayed in the header as a fallback for `headerTitle`.
1723 */
1724 title?: string | undefined;
1725+ /**
1726+ * String displayed below the compact navigation title on iOS 26+.
1727+ *
1728+ * @platform ios
1729+ */
1730+ subtitle?: string | undefined;
1731+ /**
1732+ * String displayed below the large navigation title on iOS 26+.
1733+ *
1734+ * Falls back to `subtitle` when omitted.
1735+ *
1736+ * @platform ios
1737+ */
1738+ largeSubtitle?: string | undefined;
1739 /**
1740 * Allows for setting text color of the title.
1741 */
1742@@ -1018,6 +1061,13 @@ export interface SearchBarProps {
1743 shouldShowHintSearchIcon?: boolean | undefined;
1744 }
1745 export interface ScreenStackHeaderSubviewProps {
1746+ /**
1747+ * An identifier used to match this item across navigation bar transitions.
1748+ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later.
1749+ *
1750+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
1751+ */
1752+ identifier?: string | undefined;
1753 /**
1754 * A boolean value indicating whether the background this item may share with other items in the bar should be hidden.
1755 * Only applicable to type="right" and type="left" subviews.
1756@@ -1056,6 +1106,11 @@ interface SharedHeaderBarButtonItem {
1757 * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
1758 */
1759 variant?: 'plain' | 'done' | 'prominent' | undefined;
1760+ /**
1761+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
1762+ * Only available from iOS 26.0 and later.
1763+ */
1764+ glassEffect?: boolean | undefined;
1765 /**
1766 * The tint color to apply to the item.
1767 *
1768@@ -1201,8 +1256,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1769 export interface HeaderBarButtonItemSpacing {
1770 type: 'spacing';
1771 spacing: number;
1772+ flexible?: boolean | undefined;
1773+}
1774+export interface HeaderBarButtonSearchBarPlacementItem {
1775+ type: 'searchBarPlacement';
1776+ activatesSearchController?: boolean | undefined;
1777+ customView?: boolean | undefined;
1778+ index?: number | undefined;
1779+ width?: number | undefined;
1780+}
1781+export interface HeaderBarButtonSearchFieldItem {
1782+ type: 'searchField';
1783+ placeholder?: string | undefined;
1784+ width?: number | undefined;
1785+}
1786+export interface HeaderBarButtonMailSearchToolbarItem {
1787+ type: 'mailSearchToolbar';
1788+ composeButtonId?: string | undefined;
1789+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
1790+ composeSystemImageName?: string | undefined;
1791+ filterButtonId?: string | undefined;
1792+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
1793+ filterSystemImageName?: string | undefined;
1794+ onComposePress?: (() => void) | undefined;
1795+ onFilterPress?: (() => void) | undefined;
1796+ onSearchTextChange?: ((text: string) => void) | undefined;
1797+ placeholder?: string | undefined;
1798+ searchTextChangeId?: string | undefined;
1799+ showsSearchDismissButton?: boolean | undefined;
1800+ useFallbackSearchField?: boolean | undefined;
1801+ width?: number | undefined;
1802 }
1803-export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing;
1804+export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing;
1805 /**
1806 * Custom Screen Transition
1807 */
1808diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx
1809index 509d6cd..761e49b 100644
1810--- a/src/components/ScreenStackHeaderConfig.tsx
1811+++ b/src/components/ScreenStackHeaderConfig.tsx
1812@@ -54,7 +54,12 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1813 props.disableBottomInsetApplication ?? false,
1814 );
1815
1816- const { headerLeftBarButtonItems, headerRightBarButtonItems } = props;
1817+ const {
1818+ headerLeftBarButtonItems,
1819+ headerRightBarButtonItems,
1820+ headerCenterBarButtonItems,
1821+ headerToolbarItems,
1822+ } = props;
1823
1824 const preparedHeaderLeftBarButtonItems =
1825 headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
1826@@ -64,22 +69,36 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1827 headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
1828 ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right')
1829 : undefined;
1830+ const preparedHeaderCenterBarButtonItems =
1831+ headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
1832+ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center')
1833+ : undefined;
1834+ const preparedHeaderToolbarItems =
1835+ headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform
1836+ ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar')
1837+ : undefined;
1838 const hasHeaderBarButtonItems =
1839 isHeaderBarButtonsAvailableForCurrentPlatform &&
1840 (preparedHeaderLeftBarButtonItems?.length ||
1841- preparedHeaderRightBarButtonItems?.length);
1842+ preparedHeaderRightBarButtonItems?.length ||
1843+ preparedHeaderCenterBarButtonItems?.length ||
1844+ preparedHeaderToolbarItems?.length);
1845
1846 // Handle bar button item presses
1847 const onPressHeaderBarButtonItem = hasHeaderBarButtonItems
1848 ? (event: NativeSyntheticEvent<{ buttonId: string }>) => {
1849- const pressedItem = [
1850+ const buttonId = event.nativeEvent.buttonId;
1851+ const allItems = [
1852 ...(preparedHeaderLeftBarButtonItems ?? []),
1853 ...(preparedHeaderRightBarButtonItems ?? []),
1854- ].find(
1855+ ...(preparedHeaderCenterBarButtonItems ?? []),
1856+ ...(preparedHeaderToolbarItems ?? []),
1857+ ];
1858+ const pressedItem = allItems.find(
1859 item =>
1860 item &&
1861 'buttonId' in item &&
1862- item.buttonId === event.nativeEvent.buttonId,
1863+ item.buttonId === buttonId,
1864 );
1865 if (
1866 pressedItem &&
1867@@ -87,6 +106,32 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1868 pressedItem.onPress
1869 ) {
1870 pressedItem.onPress();
1871+ return;
1872+ }
1873+ for (const item of allItems) {
1874+ if (!item || item.type !== 'mailSearchToolbar') {
1875+ continue;
1876+ }
1877+ if (item.filterButtonId === buttonId) {
1878+ item.onFilterPress?.();
1879+ return;
1880+ }
1881+ if (item.composeButtonId === buttonId) {
1882+ item.onComposePress?.();
1883+ return;
1884+ }
1885+ const searchTextChangePrefix = item.searchTextChangeId
1886+ ? `${item.searchTextChangeId}:`
1887+ : undefined;
1888+ if (
1889+ searchTextChangePrefix &&
1890+ buttonId.startsWith(searchTextChangePrefix)
1891+ ) {
1892+ item.onSearchTextChange?.(
1893+ buttonId.slice(searchTextChangePrefix.length),
1894+ );
1895+ return;
1896+ }
1897 }
1898 }
1899 : undefined;
1900@@ -117,6 +162,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1901 const allItems = [
1902 ...(preparedHeaderLeftBarButtonItems ?? []),
1903 ...(preparedHeaderRightBarButtonItems ?? []),
1904+ ...(preparedHeaderCenterBarButtonItems ?? []),
1905+ ...(preparedHeaderToolbarItems ?? []),
1906 ];
1907 for (const item of allItems) {
1908 if (item && item.type === 'menu' && item.menu) {
1909@@ -125,6 +172,17 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1910 action.onPress();
1911 return;
1912 }
1913+ } else if (item && item.type === 'mailSearchToolbar') {
1914+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(
1915+ Boolean,
1916+ );
1917+ for (const toolbarMenu of toolbarMenus) {
1918+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
1919+ if (action) {
1920+ action.onPress();
1921+ return;
1922+ }
1923+ }
1924 }
1925 }
1926 }
1927@@ -136,6 +194,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1928 userInterfaceStyle={props.experimental_userInterfaceStyle}
1929 headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems}
1930 headerRightBarButtonItems={preparedHeaderRightBarButtonItems}
1931+ headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems}
1932+ headerToolbarItems={preparedHeaderToolbarItems}
1933 onPressHeaderBarButtonItem={onPressHeaderBarButtonItem}
1934 onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem}
1935 ref={ref}
1936diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts
1937index 19ab7bc..9fd5ceb 100644
1938--- a/src/components/helpers/prepareHeaderBarButtonItems.ts
1939+++ b/src/components/helpers/prepareHeaderBarButtonItems.ts
1940@@ -12,7 +12,7 @@ export interface ResolvedImageAsset extends ImageResolvedAssetSource {}
1941 const prepareMenu = (
1942 menu: HeaderBarButtonItemWithMenu['menu'],
1943 index: number,
1944- side: 'left' | 'right',
1945+ placement: 'left' | 'right' | 'center' | 'toolbar',
1946 path: string = '',
1947 ): HeaderBarButtonItemWithMenu['menu'] => {
1948 return {
1949@@ -39,7 +39,7 @@ const prepareMenu = (
1950 xcassetName,
1951 imageSource,
1952 templateSource,
1953- ...prepareMenu(menuItem, index, side, currentPath),
1954+ ...prepareMenu(menuItem, index, placement, currentPath),
1955 };
1956 }
1957 return {
1958@@ -48,20 +48,53 @@ const prepareMenu = (
1959 xcassetName,
1960 imageSource,
1961 templateSource,
1962- menuId: `${currentPath}-${index}-${side}`,
1963+ menuId: `${currentPath}-${index}-${placement}`,
1964 };
1965 }),
1966 };
1967 };
1968
1969 export const prepareHeaderBarButtonItems = (
1970- barButtonItems: HeaderBarButtonItem[],
1971- side: 'left' | 'right',
1972+ barButtonItems:
1973+ | HeaderBarButtonItem[]
1974+ | HeaderBarButtonItem
1975+ | null
1976+ | undefined,
1977+ placement: 'left' | 'right' | 'center' | 'toolbar',
1978 ) => {
1979- return barButtonItems?.map((item, index) => {
1980+ const items = Array.isArray(barButtonItems)
1981+ ? barButtonItems
1982+ : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems
1983+ ? [barButtonItems]
1984+ : undefined;
1985+
1986+ return items?.map((item, index) => {
1987 if (item.type === 'spacing') {
1988 return item;
1989 }
1990+ if (item.type === 'searchBarPlacement') {
1991+ if (placement !== 'toolbar') {
1992+ return null;
1993+ }
1994+ return {
1995+ ...item,
1996+ searchBarPlacement: true,
1997+ };
1998+ }
1999+ if (item.type === 'searchField') {
2000+ return {
2001+ ...item,
2002+ searchField: true,
2003+ };
2004+ }
2005+ if (item.type === 'mailSearchToolbar') {
2006+ return {
2007+ ...item,
2008+ mailSearchToolbar: true,
2009+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
2010+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined,
2011+ };
2012+ }
2013 let imageSource: ResolvedImageAsset | undefined,
2014 templateSource: ResolvedImageAsset | undefined;
2015 if (item.icon?.type === 'imageSource') {
2016@@ -97,15 +130,15 @@ export const prepareHeaderBarButtonItems = (
2017 if (item.type === 'button') {
2018 return {
2019 ...processedItem,
2020- buttonId: `${index}-${side}`,
2021+ buttonId: `${index}-${placement}`,
2022 };
2023 }
2024 if (item.type === 'menu') {
2025 return {
2026 ...processedItem,
2027- menu: prepareMenu(item.menu, index, side),
2028+ menu: prepareMenu(item.menu, index, placement),
2029 };
2030 }
2031 return null;
2032- });
2033+ }).filter(item => item !== null);
2034 };
2035diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
2036index da28c51..1136622 100644
2037--- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
2038+++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
2039@@ -19,6 +19,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>;
2040 type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>;
2041
2042 type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
2043+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
2044
2045 type BlurEffect =
2046 | 'none'
2047@@ -66,12 +67,15 @@ export interface NativeProps extends ViewProps {
2048 largeTitleColor?: ColorValue | undefined;
2049 translucent?: boolean | undefined;
2050 title?: string | undefined;
2051+ subtitle?: string | undefined;
2052+ largeSubtitle?: string | undefined;
2053 titleFontFamily?: string | undefined;
2054 titleFontSize?: CT.Int32 | undefined;
2055 titleFontWeight?: string | undefined;
2056 titleColor?: ColorValue | undefined;
2057 disableBackButtonMenu?: boolean | undefined;
2058 backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>;
2059+ navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>;
2060 hideBackButton?: boolean | undefined;
2061 backButtonInCustomView?: boolean | undefined;
2062 blurEffect?: CT.WithDefault<BlurEffect, 'none'>;
2063@@ -79,6 +83,8 @@ export interface NativeProps extends ViewProps {
2064 topInsetEnabled?: boolean | undefined;
2065 headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
2066 headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
2067+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
2068+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
2069 onPressHeaderBarButtonItem?:
2070 | CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent>
2071 | undefined;
2072diff --git a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
2073index 9a6af57..03aa2b2 100644
2074--- a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
2075+++ b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
2076@@ -18,6 +18,7 @@ export type HeaderSubviewTypes =
2077 export interface NativeProps extends ViewProps {
2078 type?: CT.WithDefault<HeaderSubviewTypes, 'left'>;
2079 hidesSharedBackground?: boolean | undefined;
2080+ identifier?: string | undefined;
2081 synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>;
2082 }
2083
2084diff --git a/src/types.tsx b/src/types.tsx
2085index 8594b73..20c6e88 100644
2086--- a/src/types.tsx
2087+++ b/src/types.tsx
2088@@ -35,6 +35,7 @@ export type SearchBarCommands = {
2089 };
2090
2091 export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
2092+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
2093 export type StackPresentationTypes =
2094 | 'push'
2095 | 'modal'
2096@@ -754,6 +755,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
2097 * @platform ios
2098 */
2099 backButtonDisplayMode?: BackButtonDisplayMode | undefined;
2100+ /**
2101+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
2102+ *
2103+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
2104+ *
2105+ * @platform ios
2106+ */
2107+ navigationItemStyle?: NavigationItemStyle | undefined;
2108 /**
2109 * Array of UIBarButtomItems to the left side of the header.
2110 *
2111@@ -766,6 +775,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
2112 * @platform ios
2113 */
2114 headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
2115+ /**
2116+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
2117+ *
2118+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
2119+ * document/editor-style chrome keep primary actions visually centered while
2120+ * leaving trailing search separate.
2121+ *
2122+ * @platform ios
2123+ */
2124+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
2125+ /**
2126+ * Array of UIBarButtonItems to display in the native toolbar attached to
2127+ * the current screen's navigation controller.
2128+ *
2129+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
2130+ * render integrated Mail-style search chrome using the attached SearchBar.
2131+ *
2132+ * @platform ios
2133+ */
2134+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
2135 /**
2136 * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
2137 */
2138@@ -827,6 +856,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
2139 * String that can be displayed in the header as a fallback for `headerTitle`.
2140 */
2141 title?: string | undefined;
2142+ /**
2143+ * String displayed below the compact navigation title on iOS 26+.
2144+ *
2145+ * @platform ios
2146+ */
2147+ subtitle?: string | undefined;
2148+ /**
2149+ * String displayed below the large navigation title on iOS 26+.
2150+ *
2151+ * Falls back to `subtitle` when omitted.
2152+ *
2153+ * @platform ios
2154+ */
2155+ largeSubtitle?: string | undefined;
2156 /**
2157 * Allows for setting text color of the title.
2158 */
2159@@ -1139,6 +1182,13 @@ export interface SearchBarProps {
2160 }
2161
2162 export interface ScreenStackHeaderSubviewProps {
2163+ /**
2164+ * An identifier used to match this item across navigation bar transitions.
2165+ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later.
2166+ *
2167+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
2168+ */
2169+ identifier?: string | undefined;
2170 /**
2171 * A boolean value indicating whether the background this item may share with other items in the bar should be hidden.
2172 * Only applicable to type="right" and type="left" subviews.
2173@@ -1180,6 +1230,11 @@ interface SharedHeaderBarButtonItem {
2174 * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
2175 */
2176 variant?: 'plain' | 'done' | 'prominent' | undefined;
2177+ /**
2178+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
2179+ * Only available from iOS 26.0 and later.
2180+ */
2181+ glassEffect?: boolean | undefined;
2182 /**
2183 * The tint color to apply to the item.
2184 *
2185@@ -1335,11 +1390,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
2186 export interface HeaderBarButtonItemSpacing {
2187 type: 'spacing';
2188 spacing: number;
2189+ flexible?: boolean | undefined;
2190+}
2191+
2192+export interface HeaderBarButtonSearchBarPlacementItem {
2193+ type: 'searchBarPlacement';
2194+ activatesSearchController?: boolean | undefined;
2195+ customView?: boolean | undefined;
2196+ index?: number | undefined;
2197+ width?: number | undefined;
2198+}
2199+
2200+export interface HeaderBarButtonSearchFieldItem {
2201+ type: 'searchField';
2202+ placeholder?: string | undefined;
2203+ width?: number | undefined;
2204+}
2205+
2206+export interface HeaderBarButtonMailSearchToolbarItem {
2207+ type: 'mailSearchToolbar';
2208+ composeButtonId?: string | undefined;
2209+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
2210+ composeSystemImageName?: string | undefined;
2211+ filterButtonId?: string | undefined;
2212+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
2213+ filterSystemImageName?: string | undefined;
2214+ onComposePress?: (() => void) | undefined;
2215+ onFilterPress?: (() => void) | undefined;
2216+ onSearchTextChange?: ((text: string) => void) | undefined;
2217+ placeholder?: string | undefined;
2218+ searchTextChangeId?: string | undefined;
2219+ showsSearchDismissButton?: boolean | undefined;
2220+ useFallbackSearchField?: boolean | undefined;
2221+ width?: number | undefined;
2222 }
2223
2224 export type HeaderBarButtonItem =
2225 | HeaderBarButtonItemWithAction
2226 | HeaderBarButtonItemWithMenu
2227+ | HeaderBarButtonMailSearchToolbarItem
2228+ | HeaderBarButtonSearchFieldItem
2229+ | HeaderBarButtonSearchBarPlacementItem
2230 | HeaderBarButtonItemSpacing;
2231
2232 /**
pnpm-lock.yaml+12-12
......@@ -116,11 +116,10 @@ patchedDependencies:
116116 expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6
117117 expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed
118118 node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414
119 react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9
119 react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad
120120 react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d
121121 react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c
122122 react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2
123 react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483
124123
125124importers:
126125
......@@ -292,7 +291,7 @@ importers:
292291 version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
293292 '@react-navigation/native-stack':
294293 specifier: 7.17.6
295 version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650)
294 version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06)
296295 '@shikijs/core':
297296 specifier: 4.2.0
298297 version: 4.2.0
......@@ -454,7 +453,7 @@ importers:
454453 version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
455454 react-native-gesture-handler:
456455 specifier: ~3.2.1
457 version: 3.2.1(patch_hash=d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
456 version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
458457 react-native-image-viewing:
459458 specifier: ^0.2.2
460459 version: 0.2.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -474,8 +473,8 @@ importers:
474473 specifier: ~5.9.1
475474 version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
476475 react-native-screens:
477 specifier: ~4.28.0
478 version: 4.28.0(patch_hash=cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
476 specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz
477 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
479478 react-native-shiki-engine:
480479 specifier: ^0.3.12
481480 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -10071,8 +10070,9 @@ packages:
1007110070 react: '*'
1007210071 react-native: '*'
1007310072
10074 react-native-screens@4.28.0:
10075 resolution: {integrity: sha512-0/79Z8RCibuaAHti+DJ2Dq0m6UFWh4+p1bH58K3cjiQ7IDWs5kU9tCAj4lXcJMHHonbF2mIE2BeaPSEvOpIiFg==}
10073 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz:
10074 resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz}
10075 version: 5.0.0-t3.7
1007610076 peerDependencies:
1007710077 react: '*'
1007810078 react-native: '*'
......@@ -15198,7 +15198,7 @@ snapshots:
1519815198 optionalDependencies:
1519915199 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1520015200
15201 '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(cdeee8948497d4b80ffe015e2a003650)':
15201 '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06)':
1520215202 dependencies:
1520315203 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)
1520415204 '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -15206,7 +15206,7 @@ snapshots:
1520615206 react: 19.3.0
1520715207 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
1520815208 react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
15209 react-native-screens: 4.28.0(patch_hash=cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
15209 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1521015210 sf-symbols-typescript: 2.2.0
1521115211 warn-once: 0.1.1
1521215212 transitivePeerDependencies:
......@@ -21123,7 +21123,7 @@ snapshots:
2112321123 transitivePeerDependencies:
2112421124 - supports-color
2112521125
21126 react-native-gesture-handler@3.2.1(patch_hash=d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
21126 react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
2112721127 dependencies:
2112821128 '@types/react-test-renderer': 19.1.0
2112921129 invariant: 2.2.4
......@@ -21173,7 +21173,7 @@ snapshots:
2117321173 react: 19.3.0
2117421174 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
2117521175
21176 react-native-screens@4.28.0(patch_hash=cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
21176 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
2117721177 dependencies:
2117821178 react: 19.3.0
2117921179 react-freeze: 1.0.4(react@19.3.0)
pnpm-workspace.yaml-1
......@@ -284,7 +284,6 @@ patchedDependencies:
284284 react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch
285285 react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch
286286 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
287 react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch
288287
289288peerDependencyRules:
290289 allowAny:
scripts/release-smoke.ts+7
......@@ -41,6 +41,13 @@ function copyWorkspaceManifestFixture(targetRoot: string): void {
4141 NodeFS.cpSync(sourcePath, destinationPath);
4242 }
4343
44 const mobileDependencies = NodePath.resolve(repoRoot, "apps/mobile/deps");
45 if (NodeFS.existsSync(mobileDependencies)) {
46 NodeFS.cpSync(mobileDependencies, NodePath.resolve(targetRoot, "apps/mobile/deps"), {
47 recursive: true,
48 });
49 }
50
4451 const patchesDirectory = NodePath.resolve(repoRoot, "patches");
4552 if (NodeFS.existsSync(patchesDirectory)) {
4653 NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true });