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 index e0f53f1abb..6b01e4b199 100644 --- 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 @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createScenario } from '@apps/tests/shared/helpers'; import { StackContainer, @@ -9,24 +9,31 @@ import { Button, ScrollView } from 'react-native'; import LongText from '@apps/shared/LongText'; import { scenarioDescription } from './scenario-description'; import PressableWithFeedback from '@apps/shared/PressableWithFeedback'; +import { ToastProvider, useToast } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; const DEFAULT_TRAILING_ITEMS_COUNT = 2; export function App() { return ( - + + + ); } -function buildHeaderConfig(trailingItemsCount: number): StackHeaderConfigProps { +function buildHeaderConfig( + trailingItemsCount: number, + showToast: (text: string) => void, +): StackHeaderConfigProps { const trailingItems: NonNullable< StackHeaderConfigProps['ios'] >['trailingItems'] = Array.from({ length: trailingItemsCount }).map( @@ -42,15 +49,37 @@ function buildHeaderConfig(trailingItemsCount: number): StackHeaderConfigProps { }), menu: { type: 'menu', + menuElementId: `menu-${i}`, children: [ - { type: 'menuItem', title: `Item ${i}.1` }, - { type: 'menuItem', title: `Item ${i}.2` }, { + menuElementId: `subitem-${i}-1`, + type: 'menuItem', + title: `Item ${i}.1`, + onPress: () => showToast(`Clicked Item ${i}.1`), + }, + { + menuElementId: `subitem-${i}-2`, + type: 'menuItem', + title: `Item ${i}.2`, + onPress: () => showToast(`Clicked Item ${i}.2`), + }, + { + menuElementId: `submenu-${i}`, type: 'menu', title: `Submenu ${i}`, children: [ - { type: 'menuItem', title: `Nested ${i}.1` }, - { type: 'menuItem', title: `Nested ${i}.2` }, + { + menuElementId: `subsubitem-${i}-1`, + type: 'menuItem', + title: `Nested ${i}.1`, + onPress: () => showToast(`Clicked Nested ${i}.1`), + }, + { + menuElementId: `subsubitem-${i}-2`, + type: 'menuItem', + title: `Nested ${i}.2`, + onPress: () => showToast(`Clicked Nested ${i}.2`), + }, ], }, ], @@ -68,14 +97,22 @@ function buildHeaderConfig(trailingItemsCount: number): StackHeaderConfigProps { function ConfigScreen() { const navigation = useStackNavigationContext(); + const toast = useToast(); const [trailingItemsCount, setTrailingItemsCount] = useState( DEFAULT_TRAILING_ITEMS_COUNT, ); + const showToast = useCallback( + (text: string) => { + toast.push({ backgroundColor: Colors.GreenDark120, message: text }); + }, + [toast], + ); + const { setRouteOptions, routeKey } = navigation; const headerConfig = useMemo( - () => buildHeaderConfig(trailingItemsCount), - [trailingItemsCount], + () => buildHeaderConfig(trailingItemsCount, showToast), + [trailingItemsCount, showToast], ); useEffect(() => { 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 index 873814bfbe..0c45b7f5bb 100644 --- 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 @@ -24,5 +24,18 @@ TBD 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 + - [ ] Clicking on Item 1.1 triggers a Toast "Clicked Item 1.1" + - [ ] Clicking on Item 1.2 triggers a Toast "Clicked Item 1.2" 4. While the menu is opened, click on the Submenu 1 - [ ] A nested menu appears, containing two items + - [ ] Clicking on Nested 1.1 triggers a Toast "Clicked Nested 1.1" + - [ ] Clicking on Nested 1.2 triggers a Toast "Clicked Nested 1.2" +5. Click on "Toggle trailing items count" 2 times to get 4 items +6. Click on the Menu 3 item + - [ ] The bubble morphs into a menu with two items and a submenu + - [ ] Clicking on Item 3.1 triggers a Toast "Clicked Item 3.1" + - [ ] Clicking on Item 3.2 triggers a Toast "Clicked Item 3.2" +7. While the menu is opened, click on the Submenu 3 + - [ ] A nested menu appears, containing two items + - [ ] Clicking on Nested 3.1 triggers a Toast "Clicked Nested 3.1" + - [ ] Clicking on Nested 3.2 triggers a Toast "Clicked Nested 3.2" diff --git a/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm b/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm index 2cf4d9355d..ecda54428a 100644 --- a/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm +++ b/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm @@ -1,11 +1,13 @@ #import "RNSStackHeaderConfigComponentView.h" #import "RNSLog.h" +#import "RNSStackHeaderConfigEventEmitter.h" #import "RNSStackHeaderConfigShadowStateProxy.h" #import "RNSStackHeaderContentFactory.h" #import "RNSStackHeaderData.h" #import "RNSStackHeaderItemComponentView.h" #import "RNSStackHeaderItemInvalidationDelegate.h" #import "RNSStackHeaderItemSpacerComponentView.h" +#import "RNSStackHeaderMenuEventsDelegate.h" #import "RNSStackNavigationController.h" #import "RNSStackScreenComponentView.h" #import "RNSStackScreenController.h" @@ -28,7 +30,8 @@ static void RNSAssertIsValidHeaderChild(UIView *child) RNSStackHeaderItemSpacerComponentView.class); } -@interface RNSStackHeaderConfigComponentView () +@interface RNSStackHeaderConfigComponentView () @end @implementation RNSStackHeaderConfigComponentView { @@ -43,6 +46,7 @@ @implementation RNSStackHeaderConfigComponentView { std::shared_ptr _state; RNSStackHeaderConfigShadowStateProxy *_Nonnull _shadowStateProxy; + RNSStackHeaderConfigEventEmitter *_Nonnull _reactEventEmitter; } - (instancetype)initWithFrame:(CGRect)frame @@ -52,6 +56,7 @@ - (instancetype)initWithFrame:(CGRect)frame _props = defaultProps; _children = [NSMutableArray new]; _shadowStateProxy = [[RNSStackHeaderConfigShadowStateProxy alloc] initWithHeaderConfigView:self]; + _reactEventEmitter = [RNSStackHeaderConfigEventEmitter new]; [self resetProps]; } return self; @@ -111,13 +116,20 @@ - (void)unmountChildComponentView:(UIView *)childCompo [self submitCurrentDataIfMounted]; } -#pragma mark RNSStackHeaderItemInvalidationDelegate +#pragma mark - RNSStackHeaderItemInvalidationDelegate - (void)headerItemDidInvalidate { [self submitCurrentDataIfMounted]; } +#pragma mark - RNSStackHeaderMenuEventsDelegate + +- (void)didPressMenuElement:(NSString *)menuElementId +{ + [_reactEventEmitter emitOnPressMenuItem:menuElementId]; +} + #pragma mark - RNSViewFrameChangeDelegate - (void)viewFrameDidChange:(nonnull UINavigationBar *)navigationBar @@ -193,6 +205,13 @@ + (BOOL)shouldBeRecycled return NO; } +- (void)updateEventEmitter:(const facebook::react::EventEmitter::Shared &)eventEmitter +{ + [super updateEventEmitter:eventEmitter]; + [_reactEventEmitter + updateEventEmitter:std::static_pointer_cast(eventEmitter)]; +} + #pragma mark - Private - (void)submitCurrentDataIfMounted @@ -244,11 +263,13 @@ - (void)buildBarButtonItemsWithLeadingItems:(NSMutableArray * switch (item.placement) { case RNSHeaderItemPlacementLeading: [leadingItems addObject:[RNSStackHeaderContentFactory barButtonItemForHeaderItem:item - withFrameChangeDelegate:self]]; + withFrameChangeDelegate:self + withMenuEventsDelegate:self]]; break; case RNSHeaderItemPlacementTrailing: [trailingItems addObject:[RNSStackHeaderContentFactory barButtonItemForHeaderItem:item - withFrameChangeDelegate:self]]; + withFrameChangeDelegate:self + withMenuEventsDelegate:self]]; break; case RNSHeaderItemPlacementTitle: if (item.customView != nil) { diff --git a/ios/gamma/stack/header/RNSStackHeaderConfigEventEmitter.h b/ios/gamma/stack/header/RNSStackHeaderConfigEventEmitter.h new file mode 100644 index 0000000000..391417621a --- /dev/null +++ b/ios/gamma/stack/header/RNSStackHeaderConfigEventEmitter.h @@ -0,0 +1,32 @@ +#pragma once + +#import + +// Hide C++ symbols from C compiler used when building Swift module +#if defined(__cplusplus) +#import + +namespace react = facebook::react; +#endif // __cplusplus + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSStackHeaderConfigEventEmitter : NSObject + +- (BOOL)emitOnPressMenuItem:(NSString *)menuElementId; + +@end + +#pragma mark - Hidden from Swift + +#if defined(__cplusplus) + +@interface RNSStackHeaderConfigEventEmitter () + +- (void)updateEventEmitter:(const std::shared_ptr &)emitter; + +@end + +#endif // __cplusplus + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/stack/header/RNSStackHeaderConfigEventEmitter.mm b/ios/gamma/stack/header/RNSStackHeaderConfigEventEmitter.mm new file mode 100644 index 0000000000..e081572353 --- /dev/null +++ b/ios/gamma/stack/header/RNSStackHeaderConfigEventEmitter.mm @@ -0,0 +1,24 @@ +#import "RNSStackHeaderConfigEventEmitter.h" +#import + +@implementation RNSStackHeaderConfigEventEmitter { + std::shared_ptr _reactEventEmitter; +} + +- (BOOL)emitOnPressMenuItem:(NSString *)menuElementId +{ + if (_reactEventEmitter != nullptr) { + _reactEventEmitter->onPressMenuItem({.menuElementId = menuElementId.cString}); + return YES; + } else { + RCTLogWarn(@"[RNScreens] Skipped OnPressMenuItem event emission due to nullish emitter"); + return NO; + } +} + +- (void)updateEventEmitter:(const std::shared_ptr &)emitter +{ + _reactEventEmitter = emitter; +} + +@end diff --git a/ios/gamma/stack/header/RNSStackHeaderContentFactory.h b/ios/gamma/stack/header/RNSStackHeaderContentFactory.h index fd31b68545..3dc24be664 100644 --- a/ios/gamma/stack/header/RNSStackHeaderContentFactory.h +++ b/ios/gamma/stack/header/RNSStackHeaderContentFactory.h @@ -4,6 +4,7 @@ #import "RNSStackHeaderItemDataProviding.h" #import "RNSStackHeaderItemSpacerDataProviding.h" +#import "RNSStackHeaderMenuEventsDelegate.h" #import "RNSViewFrameChangeDelegate.h" NS_ASSUME_NONNULL_BEGIN @@ -11,7 +12,8 @@ NS_ASSUME_NONNULL_BEGIN @interface RNSStackHeaderContentFactory : NSObject + (UIBarButtonItem *)barButtonItemForHeaderItem:(id)item - withFrameChangeDelegate:(id)delegate; + withFrameChangeDelegate:(id)delegate + withMenuEventsDelegate:(id)menuEventsDelegate; + (UIView *)wrappedViewForHeaderItem:(id)item frameChangeDelegate:(id)delegate; diff --git a/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm b/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm index 899c2cf56f..9435411a5f 100644 --- a/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm +++ b/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm @@ -7,11 +7,14 @@ @implementation RNSStackHeaderContentFactory + (UIBarButtonItem *)barButtonItemForHeaderItem:(id)item withFrameChangeDelegate:(id)delegate + withMenuEventsDelegate:(id)menuEventsDelegate { UIBarButtonItem *barButtonItem = [RNSStackHeaderContentFactory internalBarButtonItemForHeaderItem:item withFrameChangeDelegate:delegate]; if (item.menu != nil) { - [RNSStackHeaderMenuCoordinator applyMenu:item.menu toBarButtonItem:barButtonItem]; + [RNSStackHeaderMenuCoordinator applyMenu:item.menu + toBarButtonItem:barButtonItem + withMenuEventsDelegate:menuEventsDelegate]; } return barButtonItem; diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h index 17e62d2629..6a66adfcbf 100644 --- a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h +++ b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h @@ -2,12 +2,15 @@ #import #import "RNSStackHeaderMenuData.h" +#import "RNSStackHeaderMenuEventsDelegate.h" NS_ASSUME_NONNULL_BEGIN @interface RNSStackHeaderMenuCoordinator : NSObject -+ (void)applyMenu:(nonnull RNSStackHeaderMenuData *)data toBarButtonItem:(nonnull UIBarButtonItem *)item; ++ (void)applyMenu:(RNSStackHeaderMenuData *)data + toBarButtonItem:(UIBarButtonItem *)item + withMenuEventsDelegate:(id)delegate; @end diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm index 840d8fd4a1..bdff6f4bef 100644 --- a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm +++ b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm @@ -2,20 +2,23 @@ @implementation RNSStackHeaderMenuCoordinator -+ (void)applyMenu:(nonnull RNSStackHeaderMenuData *)data toBarButtonItem:(nonnull UIBarButtonItem *)item ++ (void)applyMenu:(RNSStackHeaderMenuData *)data + toBarButtonItem:(UIBarButtonItem *)item + withMenuEventsDelegate:(id)delegate { #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 if (@available(tvOS 17.0, *)) { - item.menu = [self buildMenuFromData:data]; + item.menu = [self buildMenuFromData:data withMenuEventsDelegate:delegate]; } #endif // !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 } + (UIMenu *)buildMenuFromData:(RNSStackHeaderMenuData *)data + withMenuEventsDelegate:(id)delegate { NSMutableArray *elements = [NSMutableArray arrayWithCapacity:data.children.count]; for (id child in data.children) { - UIMenuElement *element = [self buildElementFromData:child]; + UIMenuElement *element = [self buildElementFromData:child withMenuEventsDelegate:delegate]; if (element != nil) { [elements addObject:element]; } @@ -25,18 +28,20 @@ + (UIMenu *)buildMenuFromData:(RNSStackHeaderMenuData *)data } + (nullable UIMenuElement *)buildElementFromData:(id)element + withMenuEventsDelegate:(id)delegate { if ([element isKindOfClass:[RNSStackHeaderMenuData class]]) { - return [self buildMenuFromData:(RNSStackHeaderMenuData *)element]; + return [self buildMenuFromData:(RNSStackHeaderMenuData *)element withMenuEventsDelegate:delegate]; } if ([element isKindOfClass:[RNSStackHeaderMenuItemData class]]) { RNSStackHeaderMenuItemData *itemData = (RNSStackHeaderMenuItemData *)element; + __weak id weakDelegate = delegate; return [UIAction actionWithTitle:itemData.title ?: @"" image:nil identifier:nil - handler:^(__kindof UIAction *_Nonnull action){ - // noop + handler:^(__kindof UIAction *_Nonnull action) { + [weakDelegate didPressMenuElement:itemData.menuElementId]; }]; } diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuData.h b/ios/gamma/stack/header/RNSStackHeaderMenuData.h index 61f08ef7b9..d0aa434385 100644 --- a/ios/gamma/stack/header/RNSStackHeaderMenuData.h +++ b/ios/gamma/stack/header/RNSStackHeaderMenuData.h @@ -5,13 +5,16 @@ NS_ASSUME_NONNULL_BEGIN @protocol RNSStackHeaderMenuElement + +@property (nonatomic, copy, readonly) NSString *menuElementId; + @end @interface RNSStackHeaderMenuItemData : NSObject @property (nonatomic, copy, readonly, nullable) NSString *title; -- (instancetype)initWithTitle:(nullable NSString *)title; +- (instancetype)initWithId:(NSString *)menuElementId title:(nullable NSString *)title; @end @@ -20,7 +23,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, readonly, nullable) NSString *title; @property (nonatomic, copy, readonly) NSArray> *children; -- (instancetype)initWithTitle:(nullable NSString *)title children:(NSArray> *)children; +- (instancetype)initWithId:(NSString *)menuElementId + title:(nullable NSString *)title + children:(NSArray> *)children; @end diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuData.mm b/ios/gamma/stack/header/RNSStackHeaderMenuData.mm index bbe2facd3a..33b54f385c 100644 --- a/ios/gamma/stack/header/RNSStackHeaderMenuData.mm +++ b/ios/gamma/stack/header/RNSStackHeaderMenuData.mm @@ -2,9 +2,12 @@ @implementation RNSStackHeaderMenuItemData -- (instancetype)initWithTitle:(nullable NSString *)title +@synthesize menuElementId = _menuElementId; + +- (instancetype)initWithId:(NSString *)menuElementId title:(nullable NSString *)title { if (self = [super init]) { + _menuElementId = [menuElementId copy]; _title = [title copy]; } return self; @@ -14,9 +17,14 @@ - (instancetype)initWithTitle:(nullable NSString *)title @implementation RNSStackHeaderMenuData -- (instancetype)initWithTitle:(nullable NSString *)title children:(NSArray> *)children +@synthesize menuElementId = _menuElementId; + +- (instancetype)initWithId:(NSString *)menuElementId + title:(nullable NSString *)title + children:(NSArray> *)children { if (self = [super init]) { + _menuElementId = [menuElementId copy]; _title = [title copy]; _children = [children copy]; } diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuEventsDelegate.h b/ios/gamma/stack/header/RNSStackHeaderMenuEventsDelegate.h new file mode 100644 index 0000000000..829859191e --- /dev/null +++ b/ios/gamma/stack/header/RNSStackHeaderMenuEventsDelegate.h @@ -0,0 +1,13 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol RNSStackHeaderMenuEventsDelegate + +- (void)didPressMenuElement:(NSString *)menuElementId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm b/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm index 90d83d947e..151dcab1e8 100644 --- a/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm +++ b/ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm @@ -29,7 +29,9 @@ + (nullable RNSStackHeaderMenuData *)menuFromDictionary:(nullable id)dictionary } } - return [[RNSStackHeaderMenuData alloc] initWithTitle:[self stringForKey:@"title" in:dict] children:children]; + return [[RNSStackHeaderMenuData alloc] initWithId:[self stringForKey:@"menuElementId" in:dict] + title:[self stringForKey:@"title" in:dict] + children:children]; } + (nullable id)elementFromDictionary:(nullable id)dictionary @@ -44,7 +46,8 @@ + (nullable RNSStackHeaderMenuData *)menuFromDictionary:(nullable id)dictionary return [self menuFromDictionary:dict]; } else if ([type isEqual:@"menuItem"]) { [RNSStackHeaderMenuMapper validateMenuItemKeys:dict]; - return [[RNSStackHeaderMenuItemData alloc] initWithTitle:[self stringForKey:@"title" in:dict]]; + return [[RNSStackHeaderMenuItemData alloc] initWithId:[self stringForKey:@"menuElementId" in:dict] + title:[self stringForKey:@"title" in:dict]]; } return nil; @@ -55,18 +58,22 @@ + (nullable RNSStackHeaderMenuData *)menuFromDictionary:(nullable id)dictionary + (void)validateMenuKeys:(NSDictionary *)dict { for (NSString *key in dict) { - RCTAssert([key isEqualToString:@"type"] || [key isEqualToString:@"title"] || [key isEqualToString:@"children"], + RCTAssert([key isEqualToString:@"menuElementId"] || [key isEqualToString:@"type"] || + [key isEqualToString:@"title"] || [key isEqualToString:@"children"], @"Invalid key \"%@\" found in menu", key); } + RCTAssert(dict[@"menuElementId"], @"[RNScreens] missing menuElementId on one of menu elements"); } + (void)validateMenuItemKeys:(NSDictionary *)dict { for (NSString *key in dict) { - RCTAssert( - [key isEqualToString:@"type"] || [key isEqualToString:@"title"], @"Invalid key \"%@\" found in menu item", key); + RCTAssert([key isEqualToString:@"menuElementId"] || [key isEqualToString:@"type"] || [key isEqualToString:@"title"], + @"Invalid key \"%@\" found in menu item", + key); } + RCTAssert(dict[@"menuElementId"], @"[RNScreens] missing menuElementId on one of menu elements"); } + (nullable NSString *)stringForKey:(NSString *)key in:(NSDictionary *)dict diff --git a/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx index 1e3e2ff31e..65ac02535f 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx @@ -1,17 +1,20 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import type { StackHeaderConfigProps } from './StackHeaderConfig.types'; -import StackHeaderConfigIOSNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigIOSNativeComponent'; +import StackHeaderConfigIOSNativeComponent, { + PressMenuItemEvent, +} from '../../../../fabric/gamma/stack/StackHeaderConfigIOSNativeComponent'; import type { StackHeaderItemPlacement } from './ios/StackHeaderItem.ios.types'; import { StackHeaderItemSpacerPlacement } from './ios/StackHeaderItemSpacer.ios.types'; import StackHeaderItemSpacer from './ios/StackHeaderItemSpacer.ios'; import StackHeaderItem from './ios/StackHeaderItem.ios'; -import { StyleSheet } from 'react-native'; +import { NativeSyntheticEvent, StyleSheet } from 'react-native'; import type { StackHeaderInlineCustomItemIOS, StackHeaderInlineItemIOS, StackHeaderSpacerItemIOS, StackHeaderTitleCustomItemIOS, } from './StackHeaderConfig.ios.types'; +import { findMenuElementByIdInItems } from './utils'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -32,6 +35,23 @@ export default function StackHeaderConfig(props: StackHeaderConfigProps) { largeTitleEnabled, } = ios ?? {}; + const handleMenuPress = useCallback( + (event: NativeSyntheticEvent) => { + const items = Array.of( + ...(leadingItems ?? []).filter(it => it && it.type === 'item'), + ...(trailingItems ?? []).filter(it => it && it.type === 'item'), + ); + const menu = findMenuElementByIdInItems( + items, + event.nativeEvent.menuElementId, + ); + if (menu && menu.type === 'menuItem') { + menu.onPress?.(); + } + }, + [leadingItems, trailingItems], + ); + return ( + style={styles.config} + onPressMenuItem={handleMenuPress}> {leadingItems?.map(item => makeItemViewFromItem(item, 'leading'))} {titleItem && makeItemViewFromItem(titleItem, 'title')} {subtitleItem && makeItemViewFromItem(subtitleItem, 'subtitle')} diff --git a/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts index 4cc7dc07dd..936649c425 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts @@ -6,16 +6,20 @@ export interface StackHeaderBaseItemIOS { label?: string | undefined; } -export interface StackHeaderInlineItemIOS extends StackHeaderBaseItemIOS { - type: 'item'; +export interface SupportsMenuIOS { menu?: StackHeaderMenu | undefined; } -export interface StackHeaderInlineCustomItemIOS { +export interface StackHeaderInlineItemIOS + extends StackHeaderBaseItemIOS, + SupportsMenuIOS { + type: 'item'; +} + +export interface StackHeaderInlineCustomItemIOS extends SupportsMenuIOS { key: string; type: 'item'; render: () => ReactElement; - menu?: StackHeaderMenu | undefined; } interface StackHeaderFixedSpacerItemIOS { diff --git a/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts b/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts index dafc908565..6d21e5b3ba 100644 --- a/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts +++ b/src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts @@ -1,9 +1,12 @@ export interface StackHeaderMenuItem { + menuElementId: string; type: 'menuItem'; title?: string | undefined; + onPress?: () => void; } export interface StackHeaderMenu { + menuElementId: string; type: 'menu'; title?: string | undefined; children: StackHeaderMenuElement[]; diff --git a/src/components/gamma/stack/header/utils.ts b/src/components/gamma/stack/header/utils.ts new file mode 100644 index 0000000000..b5ee454b0b --- /dev/null +++ b/src/components/gamma/stack/header/utils.ts @@ -0,0 +1,40 @@ +import { StackHeaderMenuElement } from './ios/StackHeaderMenu.ios.types'; +import { SupportsMenuIOS } from './StackHeaderConfig.ios.types'; + +export function findMenuElementByIdInItems( + items: SupportsMenuIOS[], + menuElementId: string, +): StackHeaderMenuElement | null { + for (const item of items) { + if (item.menu === undefined) { + continue; + } + + const menu = findMenuElementById(item.menu, menuElementId); + if (menu !== null) { + return menu; + } + } + + return null; +} + +export function findMenuElementById( + menu: StackHeaderMenuElement, + menuElementId: string, +): StackHeaderMenuElement | null { + if (menu.menuElementId === menuElementId) { + return menu; + } + + if (menu.type === 'menu') { + for (const child of menu.children) { + const result = findMenuElementById(child, menuElementId); + if (result !== null) { + return result; + } + } + } + + return null; +} diff --git a/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts index b3c651c47d..f30b8783b2 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts @@ -3,6 +3,8 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; import { codegenNativeComponent } from 'react-native'; +export type PressMenuItemEvent = Readonly<{ menuElementId: string }>; + export interface NativeProps extends ViewProps { title?: string | undefined; subtitle?: string | undefined; @@ -14,6 +16,8 @@ export interface NativeProps extends ViewProps { largeTitle?: string | undefined; largeSubtitle?: string | undefined; largeTitleEnabled?: CT.WithDefault; + + onPressMenuItem?: CT.DirectEventHandler | undefined; } export default codegenNativeComponent('RNSStackHeaderConfigIOS', {