diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts
index 6321f45e2b..c0ae244de4 100644
--- a/apps/src/tests/single-feature-tests/stack-v5/index.ts
+++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts
@@ -5,6 +5,7 @@ import AnimationAndroid from './test-animation-android';
import TestStackSimpleNav from './test-stack-simple-nav';
import TestStackSubviewsAndroid from './test-stack-subviews-android';
import TestStackSubviewsIOS from './test-stack-subviews-ios';
+import TestStackHeaderMenuIOS from './test-stack-header-menu-ios';
import TestStackBackButton from './test-stack-back-button-android';
import TestStackToolbarMenuCommands from './test-stack-toolbar-menu-commands-android';
import TestStackToolbarMenuShowAsAction from './test-stack-toolbar-menu-show-as-action-android';
@@ -16,6 +17,7 @@ const scenarios = {
TestStackSimpleNav,
TestStackSubviewsAndroid,
TestStackSubviewsIOS,
+ TestStackHeaderMenuIOS,
TestStackBackButton,
TestStackToolbarMenuCommands,
TestStackToolbarMenuShowAsAction,
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx
new file mode 100644
index 0000000000..e0f53f1abb
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx
@@ -0,0 +1,98 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { createScenario } from '@apps/tests/shared/helpers';
+import {
+ StackContainer,
+ useStackNavigationContext,
+} from '@apps/shared/gamma/containers/stack';
+import { StackHeaderConfigProps } from 'react-native-screens/components/gamma/stack/header';
+import { Button, ScrollView } from 'react-native';
+import LongText from '@apps/shared/LongText';
+import { scenarioDescription } from './scenario-description';
+import PressableWithFeedback from '@apps/shared/PressableWithFeedback';
+
+const DEFAULT_TRAILING_ITEMS_COUNT = 2;
+
+export function App() {
+ return (
+
+ );
+}
+
+function buildHeaderConfig(trailingItemsCount: number): StackHeaderConfigProps {
+ const trailingItems: NonNullable<
+ StackHeaderConfigProps['ios']
+ >['trailingItems'] = Array.from({ length: trailingItemsCount }).map(
+ (_, i) => ({
+ type: 'item',
+ key: `trailing-${i}`,
+ label: `Menu ${i}`,
+ // every second item is custom
+ ...(i % 2 === 0 && {
+ render: () => (
+
+ ),
+ }),
+ menu: {
+ type: 'menu',
+ children: [
+ { type: 'menuItem', title: `Item ${i}.1` },
+ { type: 'menuItem', title: `Item ${i}.2` },
+ {
+ type: 'menu',
+ title: `Submenu ${i}`,
+ children: [
+ { type: 'menuItem', title: `Nested ${i}.1` },
+ { type: 'menuItem', title: `Nested ${i}.2` },
+ ],
+ },
+ ],
+ },
+ }),
+ );
+
+ return {
+ title: 'Header Menu',
+ ios: {
+ trailingItems,
+ },
+ };
+}
+
+function ConfigScreen() {
+ const navigation = useStackNavigationContext();
+ const [trailingItemsCount, setTrailingItemsCount] = useState(
+ DEFAULT_TRAILING_ITEMS_COUNT,
+ );
+
+ const { setRouteOptions, routeKey } = navigation;
+ const headerConfig = useMemo(
+ () => buildHeaderConfig(trailingItemsCount),
+ [trailingItemsCount],
+ );
+
+ useEffect(() => {
+ setRouteOptions(routeKey, {
+ headerConfig,
+ });
+ }, [headerConfig, setRouteOptions, routeKey]);
+
+ return (
+
+
+ );
+}
+
+export default createScenario(App, scenarioDescription);
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/scenario-description.ts b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/scenario-description.ts
new file mode 100644
index 0000000000..35b855139c
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/scenario-description.ts
@@ -0,0 +1,10 @@
+import type { ScenarioDescription } from '@apps/tests/shared/helpers';
+
+export const scenarioDescription: ScenarioDescription = {
+ name: 'Stack Header Menu (iOS)',
+ key: 'test-stack-header-menu-ios',
+ details: 'Tests header item menus with nesting.',
+ platforms: ['ios'],
+ e2eCoverage: 'tbd',
+ smokeTest: false,
+};
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/scenario.md
new file mode 100644
index 0000000000..873814bfbe
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/scenario.md
@@ -0,0 +1,28 @@
+# Test Scenario: Stack Header Menu (iOS)
+
+## Details
+
+**Description:** This test focuses on handling menus attached to items in the header on iOS.
+
+**OS test creation version:** iOS 26.4, iPadOS 26.4
+
+## E2E test
+
+TBD
+
+## Prerequisites
+
+- iOS / iPadOS emulator
+
+## Note (Optional)
+
+- For now, menus don't appear on items with custom views
+
+## Steps on iPhone
+
+1. Open Dev Console
+2. Reload the application (dev console causes some layout-related callbacks to trigger which may hide regressions)
+3. Click on the Menu 1 item
+ - [ ] The bubble morphs into a menu with two items and a submenu
+4. While the menu is opened, click on the Submenu 1
+ - [ ] A nested menu appears, containing two items
diff --git a/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm b/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
index 1b40c09dbd..899c2cf56f 100644
--- a/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
@@ -1,11 +1,24 @@
#import "RNSStackHeaderContentFactory.h"
#import "RNSDefines.h"
#import "RNSStackHeaderItemWrapperView.h"
+#import "RNSStackHeaderMenuCoordinator.h"
@implementation RNSStackHeaderContentFactory
+ (UIBarButtonItem *)barButtonItemForHeaderItem:(id)item
withFrameChangeDelegate:(id)delegate
+{
+ UIBarButtonItem *barButtonItem = [RNSStackHeaderContentFactory internalBarButtonItemForHeaderItem:item
+ withFrameChangeDelegate:delegate];
+ if (item.menu != nil) {
+ [RNSStackHeaderMenuCoordinator applyMenu:item.menu toBarButtonItem:barButtonItem];
+ }
+
+ return barButtonItem;
+}
+
++ (UIBarButtonItem *)internalBarButtonItemForHeaderItem:(id)item
+ withFrameChangeDelegate:(id)delegate
{
if (item.customView != nil) {
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
index 7efcabfc4f..0c57046f62 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
+++ b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
@@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, readonly) RNSHeaderItemPlacement placement;
@property (nonatomic, readonly, nullable) NSString *label;
+@property (nonatomic, readonly, nullable) RNSStackHeaderMenuData *menu;
@property (nonatomic, readonly, nullable) UIView *customView;
@property (nonatomic, weak, nullable) id invalidationDelegate;
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
index bbf6935fd4..b78951a587 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
@@ -1,7 +1,10 @@
#import "RNSStackHeaderItemComponentView.h"
#import "RNSConversions-Stack.h"
+#import "RNSConversions.h"
#import "RNSDefines.h"
#import "RNSStackHeaderItemShadowStateProxy.h"
+#import "RNSStackHeaderMenuData.h"
+#import "RNSStackHeaderMenuMapper.h"
#import
#import
@@ -40,6 +43,7 @@ - (instancetype)initWithFrame:(CGRect)frame
- (void)resetProps
{
_label = nil;
+ _menu = nil;
_placement = RNSHeaderItemPlacementTrailing;
_didSetHeaderItemPlacement = NO;
}
@@ -148,6 +152,12 @@ - (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::P
needsUpdate = YES;
}
+ if (oldItemProps.menu != newItemProps.menu) {
+ _menu = [RNSStackHeaderMenuMapper
+ menuFromDictionary:rnscreens::conversion::RNSConvertFollyDynamicToId(newItemProps.menu)];
+ needsUpdate = YES;
+ }
+
[super updateProps:props oldProps:oldProps];
if (needsUpdate) {
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h b/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
index 34611e863d..2358338b63 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
+++ b/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
@@ -3,6 +3,7 @@
#import
#import "RNSHeaderItemPlacement.h"
+#import "RNSStackHeaderMenuData.h"
NS_ASSUME_NONNULL_BEGIN
@@ -10,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, readonly) RNSHeaderItemPlacement placement;
@property (nonatomic, readonly, nullable) NSString *label;
+@property (nonatomic, readonly, nullable) RNSStackHeaderMenuData *menu;
@property (nonatomic, readonly, nullable) UIView *customView;
@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h
new file mode 100644
index 0000000000..17e62d2629
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#import
+#import "RNSStackHeaderMenuData.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface RNSStackHeaderMenuCoordinator : NSObject
+
++ (void)applyMenu:(nonnull RNSStackHeaderMenuData *)data toBarButtonItem:(nonnull UIBarButtonItem *)item;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm
new file mode 100644
index 0000000000..840d8fd4a1
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm
@@ -0,0 +1,46 @@
+#import "RNSStackHeaderMenuCoordinator.h"
+
+@implementation RNSStackHeaderMenuCoordinator
+
++ (void)applyMenu:(nonnull RNSStackHeaderMenuData *)data toBarButtonItem:(nonnull UIBarButtonItem *)item
+{
+#if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
+ if (@available(tvOS 17.0, *)) {
+ item.menu = [self buildMenuFromData:data];
+ }
+#endif // !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
+}
+
++ (UIMenu *)buildMenuFromData:(RNSStackHeaderMenuData *)data
+{
+ NSMutableArray *elements = [NSMutableArray arrayWithCapacity:data.children.count];
+ for (id child in data.children) {
+ UIMenuElement *element = [self buildElementFromData:child];
+ if (element != nil) {
+ [elements addObject:element];
+ }
+ }
+
+ return [UIMenu menuWithTitle:data.title ?: @"" children:elements];
+}
+
++ (nullable UIMenuElement *)buildElementFromData:(id)element
+{
+ if ([element isKindOfClass:[RNSStackHeaderMenuData class]]) {
+ return [self buildMenuFromData:(RNSStackHeaderMenuData *)element];
+ }
+
+ if ([element isKindOfClass:[RNSStackHeaderMenuItemData class]]) {
+ RNSStackHeaderMenuItemData *itemData = (RNSStackHeaderMenuItemData *)element;
+ return [UIAction actionWithTitle:itemData.title ?: @""
+ image:nil
+ identifier:nil
+ handler:^(__kindof UIAction *_Nonnull action){
+ // noop
+ }];
+ }
+
+ return nil;
+}
+
+@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuData.h b/ios/gamma/stack/header/RNSStackHeaderMenuData.h
new file mode 100644
index 0000000000..61f08ef7b9
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuData.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol RNSStackHeaderMenuElement
+@end
+
+@interface RNSStackHeaderMenuItemData : NSObject
+
+@property (nonatomic, copy, readonly, nullable) NSString *title;
+
+- (instancetype)initWithTitle:(nullable NSString *)title;
+
+@end
+
+@interface RNSStackHeaderMenuData : NSObject
+
+@property (nonatomic, copy, readonly, nullable) NSString *title;
+@property (nonatomic, copy, readonly) NSArray> *children;
+
+- (instancetype)initWithTitle:(nullable NSString *)title children:(NSArray> *)children;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuData.mm b/ios/gamma/stack/header/RNSStackHeaderMenuData.mm
new file mode 100644
index 0000000000..bbe2facd3a
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuData.mm
@@ -0,0 +1,26 @@
+#import "RNSStackHeaderMenuData.h"
+
+@implementation RNSStackHeaderMenuItemData
+
+- (instancetype)initWithTitle:(nullable NSString *)title
+{
+ if (self = [super init]) {
+ _title = [title copy];
+ }
+ return self;
+}
+
+@end
+
+@implementation RNSStackHeaderMenuData
+
+- (instancetype)initWithTitle:(nullable NSString *)title children:(NSArray> *)children
+{
+ if (self = [super init]) {
+ _title = [title copy];
+ _children = [children copy];
+ }
+ return self;
+}
+
+@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuMapper.h b/ios/gamma/stack/header/RNSStackHeaderMenuMapper.h
new file mode 100644
index 0000000000..ef5d2bdd87
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuMapper.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#import
+
+#import "RNSStackHeaderMenuData.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface RNSStackHeaderMenuMapper : NSObject
+
++ (nullable RNSStackHeaderMenuData *)menuFromDictionary:(nullable id)dictionary;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm b/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm
new file mode 100644
index 0000000000..90d83d947e
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm
@@ -0,0 +1,78 @@
+#import "RNSStackHeaderMenuMapper.h"
+
+#import
+#import
+
+@implementation RNSStackHeaderMenuMapper
+
++ (nullable RNSStackHeaderMenuData *)menuFromDictionary:(nullable id)dictionary
+{
+ if (![dictionary isKindOfClass:[NSDictionary class]]) {
+ return nil;
+ }
+ NSDictionary *dict = (NSDictionary *)dictionary;
+
+ if (![dict[@"type"] isEqual:@"menu"]) {
+ return nil;
+ }
+
+ [RNSStackHeaderMenuMapper validateMenuKeys:dict];
+
+ NSMutableArray> *children = [NSMutableArray new];
+ id childrenValue = dict[@"children"];
+ if ([childrenValue isKindOfClass:[NSArray class]]) {
+ for (id child in (NSArray *)childrenValue) {
+ id element = [self elementFromDictionary:child];
+ if (element != nil) {
+ [children addObject:element];
+ }
+ }
+ }
+
+ return [[RNSStackHeaderMenuData alloc] initWithTitle:[self stringForKey:@"title" in:dict] children:children];
+}
+
++ (nullable id)elementFromDictionary:(nullable id)dictionary
+{
+ if (![dictionary isKindOfClass:[NSDictionary class]]) {
+ return nil;
+ }
+ NSDictionary *dict = (NSDictionary *)dictionary;
+
+ id type = dict[@"type"];
+ if ([type isEqual:@"menu"]) {
+ return [self menuFromDictionary:dict];
+ } else if ([type isEqual:@"menuItem"]) {
+ [RNSStackHeaderMenuMapper validateMenuItemKeys:dict];
+ return [[RNSStackHeaderMenuItemData alloc] initWithTitle:[self stringForKey:@"title" in:dict]];
+ }
+
+ return nil;
+}
+
+#pragma mark - Helpers
+
++ (void)validateMenuKeys:(NSDictionary *)dict
+{
+ for (NSString *key in dict) {
+ RCTAssert([key isEqualToString:@"type"] || [key isEqualToString:@"title"] || [key isEqualToString:@"children"],
+ @"Invalid key \"%@\" found in menu",
+ key);
+ }
+}
+
++ (void)validateMenuItemKeys:(NSDictionary *)dict
+{
+ for (NSString *key in dict) {
+ RCTAssert(
+ [key isEqualToString:@"type"] || [key isEqualToString:@"title"], @"Invalid key \"%@\" found in menu item", key);
+ }
+}
+
++ (nullable NSString *)stringForKey:(NSString *)key in:(NSDictionary *)dict
+{
+ id value = dict[key];
+ return [value isKindOfClass:[NSString class]] ? value : nil;
+}
+
+@end
diff --git a/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts
index 4db22b20c3..4cc7dc07dd 100644
--- a/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts
+++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react';
+import type { StackHeaderMenu } from './ios/StackHeaderMenu.ios.types';
export interface StackHeaderBaseItemIOS {
key: string;
@@ -7,12 +8,14 @@ export interface StackHeaderBaseItemIOS {
export interface StackHeaderInlineItemIOS extends StackHeaderBaseItemIOS {
type: 'item';
+ menu?: StackHeaderMenu | undefined;
}
export interface StackHeaderInlineCustomItemIOS {
key: string;
type: 'item';
render: () => ReactElement;
+ menu?: StackHeaderMenu | undefined;
}
interface StackHeaderFixedSpacerItemIOS {
diff --git a/src/components/gamma/stack/header/ios/StackHeaderItem.ios.types.ts b/src/components/gamma/stack/header/ios/StackHeaderItem.ios.types.ts
index 36b4154f8c..eefdc9a24a 100644
--- a/src/components/gamma/stack/header/ios/StackHeaderItem.ios.types.ts
+++ b/src/components/gamma/stack/header/ios/StackHeaderItem.ios.types.ts
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react';
+import type { StackHeaderMenu } from './StackHeaderMenu.ios.types';
export type StackHeaderItemPlacement =
| 'leading'
@@ -11,4 +12,5 @@ export type StackHeaderItemProps = {
placement: StackHeaderItemPlacement;
label?: string | undefined;
render?: (() => ReactElement) | undefined;
+ menu?: StackHeaderMenu | undefined;
};
diff --git a/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts b/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts
new file mode 100644
index 0000000000..dafc908565
--- /dev/null
+++ b/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts
@@ -0,0 +1,12 @@
+export interface StackHeaderMenuItem {
+ type: 'menuItem';
+ title?: string | undefined;
+}
+
+export interface StackHeaderMenu {
+ type: 'menu';
+ title?: string | undefined;
+ children: StackHeaderMenuElement[];
+}
+
+export type StackHeaderMenuElement = StackHeaderMenu | StackHeaderMenuItem;
diff --git a/src/fabric/gamma/stack/StackHeaderItemIOSNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderItemIOSNativeComponent.ts
index 89d611bda8..ee75e86297 100644
--- a/src/fabric/gamma/stack/StackHeaderItemIOSNativeComponent.ts
+++ b/src/fabric/gamma/stack/StackHeaderItemIOSNativeComponent.ts
@@ -13,6 +13,7 @@ type Placement =
export interface NativeProps extends ViewProps {
placement?: CT.WithDefault;
label?: string | undefined;
+ menu?: CT.UnsafeMixed;
}
export default codegenNativeComponent('RNSStackHeaderItemIOS', {