Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/stack-v5/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +17,7 @@ const scenarios = {
TestStackSimpleNav,
TestStackSubviewsAndroid,
TestStackSubviewsIOS,
TestStackHeaderMenuIOS,
TestStackBackButton,
TestStackToolbarMenuCommands,
TestStackToolbarMenuShowAsAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<StackContainer
routeConfigs={[
{
name: 'Home',
Component: ConfigScreen,
options: {},
},
]}
/>
);
}

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: () => (
<PressableWithFeedback style={{ width: 30, height: 30 }} />
),
}),
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<number>(
DEFAULT_TRAILING_ITEMS_COUNT,
);

const { setRouteOptions, routeKey } = navigation;
const headerConfig = useMemo(
() => buildHeaderConfig(trailingItemsCount),
[trailingItemsCount],
);

useEffect(() => {
setRouteOptions(routeKey, {
headerConfig,
});
}, [headerConfig, setRouteOptions, routeKey]);

return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<Button
title={`Toggle trailing items count (${trailingItemsCount}/4)`}
onPress={() => setTrailingItemsCount(count => (count + 1) % 5)}
/>
<LongText />
</ScrollView>
);
}

export default createScenario(App, scenarioDescription);
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
#import "RNSStackHeaderContentFactory.h"
#import "RNSDefines.h"
#import "RNSStackHeaderItemWrapperView.h"
#import "RNSStackHeaderMenuCoordinator.h"

@implementation RNSStackHeaderContentFactory

+ (UIBarButtonItem *)barButtonItemForHeaderItem:(id<RNSStackHeaderItemDataProviding>)item
withFrameChangeDelegate:(id<RNSViewFrameChangeDelegate>)delegate
{
UIBarButtonItem *barButtonItem = [RNSStackHeaderContentFactory internalBarButtonItemForHeaderItem:item
withFrameChangeDelegate:delegate];
if (item.menu != nil) {
[RNSStackHeaderMenuCoordinator applyMenu:item.menu toBarButtonItem:barButtonItem];
}

return barButtonItem;
}

+ (UIBarButtonItem *)internalBarButtonItemForHeaderItem:(id<RNSStackHeaderItemDataProviding>)item
withFrameChangeDelegate:(id<RNSViewFrameChangeDelegate>)delegate
{
if (item.customView != nil) {
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
Expand Down
1 change: 1 addition & 0 deletions ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<RNSStackHeaderItemInvalidationDelegate> invalidationDelegate;
Expand Down
10 changes: 10 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
Original file line number Diff line number Diff line change
@@ -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 <React/RCTConversions.h>
#import <React/RCTLog.h>
Expand Down Expand Up @@ -40,6 +43,7 @@ - (instancetype)initWithFrame:(CGRect)frame
- (void)resetProps
{
_label = nil;
_menu = nil;
_placement = RNSHeaderItemPlacementTrailing;
_didSetHeaderItemPlacement = NO;
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
#import <UIKit/UIKit.h>

#import "RNSHeaderItemPlacement.h"
#import "RNSStackHeaderMenuData.h"

NS_ASSUME_NONNULL_BEGIN

@protocol RNSStackHeaderItemDataProviding <NSObject>

@property (nonatomic, readonly) RNSHeaderItemPlacement placement;
@property (nonatomic, readonly, nullable) NSString *label;
@property (nonatomic, readonly, nullable) RNSStackHeaderMenuData *menu;
@property (nonatomic, readonly, nullable) UIView *customView;

@end
Expand Down
14 changes: 14 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#pragma once

#import <UIKit/UIKit.h>
#import "RNSStackHeaderMenuData.h"

NS_ASSUME_NONNULL_BEGIN

@interface RNSStackHeaderMenuCoordinator : NSObject

+ (void)applyMenu:(nonnull RNSStackHeaderMenuData *)data toBarButtonItem:(nonnull UIBarButtonItem *)item;

@end

NS_ASSUME_NONNULL_END
46 changes: 46 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm
Original file line number Diff line number Diff line change
@@ -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<UIMenuElement *> *elements = [NSMutableArray arrayWithCapacity:data.children.count];
for (id<RNSStackHeaderMenuElement> 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<RNSStackHeaderMenuElement>)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
27 changes: 27 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderMenuData.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#pragma once

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@protocol RNSStackHeaderMenuElement <NSObject>
@end

@interface RNSStackHeaderMenuItemData : NSObject <RNSStackHeaderMenuElement>

@property (nonatomic, copy, readonly, nullable) NSString *title;

- (instancetype)initWithTitle:(nullable NSString *)title;

@end

@interface RNSStackHeaderMenuData : NSObject <RNSStackHeaderMenuElement>

@property (nonatomic, copy, readonly, nullable) NSString *title;
@property (nonatomic, copy, readonly) NSArray<id<RNSStackHeaderMenuElement>> *children;

- (instancetype)initWithTitle:(nullable NSString *)title children:(NSArray<id<RNSStackHeaderMenuElement>> *)children;

@end

NS_ASSUME_NONNULL_END
26 changes: 26 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderMenuData.mm
Original file line number Diff line number Diff line change
@@ -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<id<RNSStackHeaderMenuElement>> *)children
{
if (self = [super init]) {
_title = [title copy];
_children = [children copy];
}
return self;
}

@end
15 changes: 15 additions & 0 deletions ios/gamma/stack/header/RNSStackHeaderMenuMapper.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#pragma once

#import <Foundation/Foundation.h>

#import "RNSStackHeaderMenuData.h"

NS_ASSUME_NONNULL_BEGIN

@interface RNSStackHeaderMenuMapper : NSObject

+ (nullable RNSStackHeaderMenuData *)menuFromDictionary:(nullable id)dictionary;

@end

NS_ASSUME_NONNULL_END
Loading
Loading