Skip to content
Source
Sign inSign up

@react-native-menu__menu@2.0.0.patch

595 lines27.6 KB

1diff --git a/ios/MenuViewManager.mm b/ios/MenuViewManager.mm
2index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..73b9e8bbe30e6647b03cc1890806e8d48a1cbd8c 100644
3--- a/ios/MenuViewManager.mm
4+++ b/ios/MenuViewManager.mm
5@@ -1,6 +1,6 @@
6 #import <React/RCTViewManager.h>
7 #import <React/RCTUIManager.h>
8-#import "RCTBridge.h"
9+#import <React/RCTBridge.h>
11 #ifdef RCT_NEW_ARCH_ENABLED
12 // NEW ARCH
13@@ -65,6 +65,10 @@ - (UIView *)view
14 * onOpenMenu: callback to be called when the menu is opened
15 */
16 RCT_EXPORT_VIEW_PROPERTY(onOpenMenu, RCTDirectEventBlock);
17+/**
18+ * onMenuInteractionStart: callback to be called when UIKit starts preparing a menu interaction
19+ */
20+RCT_EXPORT_VIEW_PROPERTY(onMenuInteractionStart, RCTDirectEventBlock);
21 /**
22 * shouldOpenOnLongPress: determines whether menu should be opened after long press or normal press
23 */
24diff --git a/ios/NewArch/FabricActionSheetView.swift b/ios/NewArch/FabricActionSheetView.swift
25index 484ff8a1d73a6d445273883c5b28b1c8e80901ec..bc92310d00369b853a80f4d2080578f3903191d2 100644
26--- a/ios/NewArch/FabricActionSheetView.swift
27+++ b/ios/NewArch/FabricActionSheetView.swift
28@@ -3,6 +3,7 @@ public class FabricActionSheetView: ActionSheetView, FabricViewImplementationPro
29 public var onPressAction: ((String) -> Void)?
30 public var onCloseMenu: (() -> Void)?
31 public var onOpenMenu: (() -> Void)?
32+ public var onMenuInteractionStart: (() -> Void)?
34 @objc override func sendButtonAction(_ action: String) {
35 if let onPress = onPressAction {
36diff --git a/ios/NewArch/FabricMenuViewImplementation.swift b/ios/NewArch/FabricMenuViewImplementation.swift
37index 33d27c5a66593ae1a9b143804413578eb79f1a7d..359cd19fa53d10c0374b2b537517ea8629e79d0c 100644
38--- a/ios/NewArch/FabricMenuViewImplementation.swift
39+++ b/ios/NewArch/FabricMenuViewImplementation.swift
40@@ -12,6 +12,7 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
41 public var onPressAction: ((String) -> Void)?
42 public var onCloseMenu: (() -> Void)?
43 public var onOpenMenu: (() -> Void)?
44+ public var onMenuInteractionStart: (() -> Void)?
46 @objc override func sendButtonAction(_ action: UIAction) {
47 if let onPress = onPressAction {
48@@ -31,4 +32,10 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
49 }
50 }
52+ @objc override func sendMenuInteractionStart() {
53+ if let onMenuInteractionStart = onMenuInteractionStart {
54+ onMenuInteractionStart()
55+ }
56+ }
58 }
59diff --git a/ios/NewArch/FabricViewImplementationProtocol.swift b/ios/NewArch/FabricViewImplementationProtocol.swift
60index 2cef2fb8c74497da46775a490edc58cf8cbf29cc..54429c756eeecf6e96b55d62cc1e70213096f62a 100644
61--- a/ios/NewArch/FabricViewImplementationProtocol.swift
62+++ b/ios/NewArch/FabricViewImplementationProtocol.swift
63@@ -8,4 +8,5 @@ import Foundation
64 var onPressAction: ((String) -> Void)? { get set }
65 var onCloseMenu: (() -> Void)? { get set }
66 var onOpenMenu: (() -> Void)? { get set }
67+ var onMenuInteractionStart: (() -> Void)? { get set }
68 }
69diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm
70index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f3935e445 100644
71--- a/ios/NewArch/MenuView.mm
72+++ b/ios/NewArch/MenuView.mm
73@@ -46,6 +46,10 @@ - (instancetype)initWithFrame:(CGRect)frame
74 [self onOpenMenu];
75 };
77+ _view.onMenuInteractionStart = ^{
78+ [self onMenuInteractionStart];
79+ };
81 _view.onCloseMenu = ^{
82 [self onCloseMenu];
83 };
84@@ -92,6 +96,14 @@ - (void)onOpenMenu {
85 }
86 }
88+- (void)onMenuInteractionStart {
89+ // If screen is already unmounted then there will be no event emitter
90+ const auto eventEmitter = [self getEventEmitter];
91+ if (eventEmitter != nullptr) {
92+ eventEmitter->onMenuInteractionStart({});
93+ }
94+}
96 /**
97 Responsible for iterating through the C++ vector<struct> and convert each struct element to NSDictionary, then return it all in an NSArray
98 */
99@@ -105,6 +117,27 @@ - (void)onOpenMenu {
100 NSMutableArray<NSDictionary *> *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()];
101 if (action.subactions.size() > 0) {
102 for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) {
103+ NSMutableArray<NSDictionary *> *subSubactionsArray =
104+ [NSMutableArray arrayWithCapacity:subaction.subactions.size()];
105+ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) {
106+ NSDictionary *subSubactionDict = @{
107+ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()],
108+ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()],
109+ @"titleColor": @(subSubaction.titleColor),
110+ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()],
111+ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()],
112+ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()],
113+ @"imageColor": @(subSubaction.imageColor),
114+ @"displayInline": @(subSubaction.displayInline),
115+ @"attributes": @{
116+ @"destructive": @(subSubaction.attributes.destructive),
117+ @"disabled": @(subSubaction.attributes.disabled),
118+ @"hidden": @(subSubaction.attributes.hidden),
119+ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented),
120+ },
121+ };
122+ [subSubactionsArray addObject:subSubactionDict];
123+ }
124 NSDictionary *subactionDict = @{
125 @"id": [NSString stringWithUTF8String:subaction.id.c_str()],
126 @"title": [NSString stringWithUTF8String:subaction.title.c_str()],
127@@ -118,7 +151,9 @@ - (void)onOpenMenu {
128 @"destructive": @(subaction.attributes.destructive),
129 @"disabled": @(subaction.attributes.disabled),
130 @"hidden": @(subaction.attributes.hidden),
131+ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented),
132 },
133+ @"subactions": subSubactionsArray,
134 };
135 [subactionsArray addObject:subactionDict];
136 }
137@@ -138,6 +173,7 @@ - (void)onOpenMenu {
138 @"destructive": @(action.attributes.destructive),
139 @"disabled": @(action.attributes.disabled),
140 @"hidden": @(action.attributes.hidden),
141+ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented),
142 },
143 @"subactions": subactionsArray,
144 };
145diff --git a/ios/OldArch/LegacyActionSheetView.swift b/ios/OldArch/LegacyActionSheetView.swift
146index 3cd1a2517624d99b51331c564fba9145d7c8d799..e458504bea6ef167d9b0db0c889f0f76ea965287 100644
147--- a/ios/OldArch/LegacyActionSheetView.swift
148+++ b/ios/OldArch/LegacyActionSheetView.swift
149@@ -3,6 +3,7 @@ public class LegacyActionSheetView: ActionSheetView {
150 @objc var onPressAction: RCTDirectEventBlock?
151 @objc var onCloseMenu: RCTDirectEventBlock?
152 @objc var onOpenMenu: RCTDirectEventBlock?
153+ @objc var onMenuInteractionStart: RCTDirectEventBlock?
157diff --git a/ios/OldArch/LegacyMenuViewImplementation.swift b/ios/OldArch/LegacyMenuViewImplementation.swift
158index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256a76aab9c 100644
159--- a/ios/OldArch/LegacyMenuViewImplementation.swift
160+++ b/ios/OldArch/LegacyMenuViewImplementation.swift
161@@ -5,6 +5,7 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
162 @objc var onPressAction: RCTDirectEventBlock?
163 @objc var onCloseMenu: RCTDirectEventBlock?
164 @objc var onOpenMenu: RCTDirectEventBlock?
165+ @objc var onMenuInteractionStart: RCTDirectEventBlock?
167 @objc override func sendButtonAction(_ action: UIAction) {
168 if let onPress = onPressAction {
169@@ -24,4 +25,10 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
170 }
171 }
173+ @objc override func sendMenuInteractionStart() {
174+ if let onMenuInteractionStart = onMenuInteractionStart {
175+ onMenuInteractionStart([:])
176+ }
177+ }
179 }
180diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
181index 5c4e0da4292b15d3a27b5ea1555f11452a470815..381aa8e2900aeb0d2453dba514360304a79e95f1 100644
182--- a/ios/Shared/MenuViewImplementation.swift
183+++ b/ios/Shared/MenuViewImplementation.swift
184@@ -17,7 +17,7 @@ public class MenuViewImplementation: UIButton {
185 }
186 _actions.removeAll()
187 actions.forEach { menuAction in
188- _actions.append(RCTMenuAction(details: menuAction).createUIMenuElement({action in self.sendButtonAction(action)}))
189+ _actions.append(RCTMenuAction(details: menuAction).createUIMenuElement({action in self.didSelectAction = true; self.sendButtonAction(action)}))
190 }
191 self.setup()
192 }
193@@ -60,17 +60,98 @@ public class MenuViewImplementation: UIButton {
194 }
196 public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
197- sendMenuOpen()
198+ // Flush updates deferred by the presented-guard before the action
199+ // provider snapshots self.menu (covers a stuck flag from an
200+ // interaction that never ended cleanly).
201+ if pendingMenu != nil {
202+ pendingMenu = nil
203+ isMenuPresented = false
204+ self.setup()
205+ }
206+ isMenuPresented = true
207+ didSelectAction = false
208+ sendMenuInteractionStart()
209 return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
210 guard let self = self else { return nil }
211 return self.menu
212 }
213 }
214-
216+ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
217+ // UIKit can request a configuration for a press that never opens a
218+ // menu. Notify React only once presentation actually begins, and
219+ // preserve UIButton's presentation behavior by calling super.
220+ super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator)
221+ sendMenuOpen()
222+ }
224 public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
225+ super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
226 sendMenuClose()
227+ isMenuPresented = false
228+ if pendingMenu != nil {
229+ // Re-run the full assignment now that presentation is over, so
230+ // any props deferred by the presented-guard (menu contents, press
231+ // mode) land on the button.
232+ pendingMenu = nil
233+ self.setup()
234+ }
235+ }
237+ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
238+ return contentPreview() ?? super.contextMenuInteraction(interaction, previewForHighlightingMenuWithConfiguration: configuration)
239+ }
241+ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
242+ guard let preview = contentPreview() else {
243+ return super.contextMenuInteraction(interaction, previewForDismissingMenuWithConfiguration: configuration)
244+ }
245+ // A selection usually re-renders the trigger, so fade instead of landing on its old look.
246+ return didSelectAction ? nil : preview
247 }
249+ // The button is a transparent overlay: morph from the React content so UIKit hides and restores it.
250+ // iOS 26 only, and only for content with its own corner radius; otherwise keep UIButton's default.
251+ private func contentPreview() -> UITargetedPreview? {
252+ guard #available(iOS 26.0, *) else {
253+ return nil
254+ }
255+ guard !shouldOpenOnLongPress, let siblings = self.superview?.subviews.filter({ $0 !== self }), siblings.count == 1, let content = siblings.first, content.window != nil, let (target, radius) = previewTarget(content) else {
256+ return nil
257+ }
258+ let bounds = target.bounds
259+ let parameters = UIPreviewParameters()
260+ parameters.backgroundColor = .clear
261+ parameters.visiblePath = UIBezierPath(roundedRect: bounds, cornerRadius: min(radius, bounds.height / 2, bounds.width / 2))
262+ return UITargetedPreview(view: target, parameters: parameters)
263+ }
265+ // Walks the content's full-size single-child chain for its corner radius. A preview of a view
266+ // above a glass effect view drops the glass until UIKit restores the source, so target the glass view.
267+ private func previewTarget(_ content: UIView) -> (UIView, CGFloat)? {
268+ var radius: CGFloat?
269+ var view = content
270+ for depth in 0...4 {
271+ if radius == nil, view.layer.cornerRadius > 0 {
272+ radius = view.layer.cornerRadius
273+ }
274+ if let radius, view is UIVisualEffectView {
275+ return (view, radius)
276+ }
277+ guard depth < 4, view.subviews.count == 1, let child = view.subviews.first,
278+ abs(child.frame.size.width - view.bounds.size.width) <= 0.5,
279+ abs(child.frame.size.height - view.bounds.size.height) <= 0.5 else {
280+ break
281+ }
282+ view = child
283+ }
284+ return radius.map { (content, $0) }
285+ }
287+ private var didSelectAction = false
288+ private var isMenuPresented = false
289+ private var pendingMenu: UIMenu?
291 func setup () {
292 let menu = UIMenu(title: _title,
293 identifier: nil,
294@@ -86,8 +167,102 @@ public class MenuViewImplementation: UIButton {
295 }
296 }
298+ if isMenuPresented {
299+ // An action fired with keepsMenuPresented leaves the menu on
300+ // screen, and reassigning self.menu while it is presented makes
301+ // UIKit dismiss it — the update triggered by a selection would
302+ // defeat the keep-presented attribute. Update the visible copy in
303+ // place instead and defer the button assignment to dismissal.
304+ pendingMenu = menu
305+ self.refreshPresentedMenu(menu)
306+ return
307+ }
309 self.menu = menu
310 self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress
311+ // In long-press mode the button must not intercept touches: as a
312+ // full-bounds contentView it sits IN FRONT of the React children of
313+ // the hosting component view, which would swallow their taps (e.g. a
314+ // row Pressable wrapped by MenuView). The context-menu interaction is
315+ // hosted by the superview instead (see updateLongPressInteraction) so
316+ // long-press and pointer secondary-click still present the menu, with
317+ // the wrapped content as the zoom preview.
318+ self.isUserInteractionEnabled = !shouldOpenOnLongPress
319+ self.updateLongPressInteraction()
320+ }
322+ private func refreshPresentedMenu(_ menu: UIMenu) {
323+ // Tap mode presents through the button's built-in interaction,
324+ // long-press mode through the superview-hosted one; cover both (plus
325+ // the interaction added in init) and dedupe by identity.
326+ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction }
327+ if let builtin = self.contextMenuInteraction {
328+ candidates.append(builtin)
329+ }
330+ if let host = longPressInteraction {
331+ candidates.append(host)
332+ }
333+ var visited: Set<ObjectIdentifier> = []
334+ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted {
335+ // The block receives whichever menu level is currently on screen —
336+ // the navigated submenu when the user picked inside one, not the
337+ // root. Swap in the matching node from the rebuilt tree (stable
338+ // identifiers from the JS action ids) so that level updates in
339+ // place; returning an unrelated menu instead makes UIKit render it
340+ // as navigation into a foreign menu, with a stale or blank
341+ // expanded-submenu header row above the actions. The root carries
342+ // an auto-generated identifier that never matches, so it falls
343+ // through to a children-only replacement.
344+ interaction.updateVisibleMenu { [weak self] visibleMenu in
345+ guard let self = self else { return visibleMenu }
346+ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) {
347+ return replacement
348+ }
349+ return visibleMenu.replacingChildren(menu.children)
350+ }
351+ }
352+ }
354+ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? {
355+ if menu.identifier == identifier {
356+ return menu
357+ }
358+ for element in menu.children {
359+ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) {
360+ return match
361+ }
362+ }
363+ return nil
364+ }
366+ private var longPressInteraction: UIContextMenuInteraction?
368+ public override func didMoveToSuperview() {
369+ super.didMoveToSuperview()
370+ self.updateLongPressInteraction()
371+ }
373+ private func updateLongPressInteraction() {
374+ if shouldOpenOnLongPress, let host = self.superview {
375+ if let existing = longPressInteraction, existing.view === host {
376+ return
377+ }
378+ if let existing = longPressInteraction {
379+ existing.view?.removeInteraction(existing)
380+ }
381+ // UIControl's built-in interaction presents a compact menu that
382+ // preserves the keyboard; a separate view interaction presents a
383+ // rich context menu and temporarily hides it.
384+ guard let interaction = self.contextMenuInteraction else { return }
385+ host.addInteraction(interaction)
386+ longPressInteraction = interaction
387+ return
388+ }
389+ if let existing = longPressInteraction {
390+ existing.view?.removeInteraction(existing)
391+ self.addInteraction(existing)
392+ longPressInteraction = nil
393+ }
394 }
396 public override func reactSetFrame(_ frame: CGRect) {
397@@ -124,4 +299,8 @@ public class MenuViewImplementation: UIButton {
398 @objc func sendMenuOpen() {
399 // NO-OP (should be overriden by parent)
400 }
402+ @objc func sendMenuInteractionStart() {
403+ // NO-OP (should be overriden by parent)
404+ }
405 }
406diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift
407index bb6bb2b7ad56135089f267587c974b166760539d..5675067ecb2db0239b74c66ff2e095471a721699 100644
408--- a/ios/Shared/RCTMenuItem.swift
409+++ b/ios/Shared/RCTMenuItem.swift
410@@ -27,7 +27,9 @@ class RCTMenuAction {
411 self.identifier = UIAction.Identifier(rawValue: identifier as String);
412 }
414- if let image = details["image"] as? NSString {
415+ // Fabric sends an absent image as "", and every asset lookup for "" logs a
416+ // CoreUI fault with a backtrace.
417+ if let image = details["image"] as? NSString, image.length > 0 {
418 self.image = UIImage(systemName: image as String);
419 if self.image === nil {
420 self.image = UIImage(named: image as String)
421@@ -103,10 +105,18 @@ class RCTMenuAction {
422 subMenuActions.append(subaction.createUIMenuElement(handler))
423 }
424 var menu: UIMenu;
425+ // Stable identifiers let updateVisibleMenu match submenu nodes in
426+ // place, so a refresh while the menu is presented keeps the user's
427+ // current submenu level instead of popping back to the root.
428+ // Inline sections don't navigate, so they stay stock.
429+ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) }
430 if self.displayInline {
431 menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions)
432 } else {
433- menu = UIMenu(title: title, image: image, children: subMenuActions)
434+ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions)
435+ }
436+ if #available(iOS 15.0, *) {
437+ menu.subtitle = subtitle
438 }
440 if #available(iOS 16.0, *) {
441diff --git a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
442index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644
443--- a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
444+++ b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
445@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps {
446 onPressAction?: DirectEventHandler<{ event: string }>;
447 onCloseMenu?: DirectEventHandler<{ event: string }>;
448 onOpenMenu?: DirectEventHandler<{ event: string }>;
449+ onMenuInteractionStart?: DirectEventHandler<{ event: string }>;
450 actions: Array<MenuAction>;
451 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
452 title?: string;
453diff --git a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
454index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644
455--- a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
456+++ b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
457@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps {
458 onPressAction?: DirectEventHandler<{ event: string }>;
459 onCloseMenu?: DirectEventHandler<{ event: string }>;
460 onOpenMenu?: DirectEventHandler<{ event: string }>;
461+ onMenuInteractionStart?: DirectEventHandler<{ event: string }>;
462 actions: Array<MenuAction>;
463 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
464 title?: string;
465diff --git a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
466index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..c1b4ea53b29193283295d35c18e068f2836c6176 100644
467--- a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
468+++ b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
469@@ -41,6 +41,9 @@ export interface NativeProps extends ViewProps {
470 onOpenMenu?: DirectEventHandler<{
471 event: string;
472 }>;
473+ onMenuInteractionStart?: DirectEventHandler<{
474+ event: string;
475+ }>;
476 actions: Array<MenuAction>;
477 actionsHash: string;
478 title?: string;
479diff --git a/lib/typescript/src/index.d.ts b/lib/typescript/src/index.d.ts
480index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..0672bea3352b9ad0623124e308f740c70a7e6940 100644
481--- a/lib/typescript/src/index.d.ts
482+++ b/lib/typescript/src/index.d.ts
483@@ -4,6 +4,7 @@ declare const MenuView: import("react").ForwardRefExoticComponent<{
484 onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
485 onCloseMenu?: () => void;
486 onOpenMenu?: () => void;
487+ onMenuInteractionStart?: () => void;
488 actions: MenuAction[];
489 title?: string;
490 isAnchoredToRight?: boolean;
491diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts
492index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..19e82df15b042bc0cda27349b2b8a8ee1d3f0b51 100644
493--- a/lib/typescript/src/types.d.ts
494+++ b/lib/typescript/src/types.d.ts
495@@ -113,6 +113,12 @@ type MenuComponentPropsBase = {
496 * Callback function that will be called when the menu opens.
497 */
498 onOpenMenu?: () => void;
499+ /**
500+ * Callback function that will be called when UIKit starts preparing a menu interaction.
501+ * Preparation can end without the menu opening.
502+ * @platform iOS
503+ */
504+ onMenuInteractionStart?: () => void;
505 /**
506 * Actions to be displayed in the menu.
507 */
508@@ -170,6 +176,7 @@ export type NativeMenuComponentProps = {
509 onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
510 onCloseMenu?: () => void;
511 onOpenMenu?: () => void;
512+ onMenuInteractionStart?: () => void;
513 actions: ProcessedMenuAction[];
514 actionsHash: string;
515 title?: string;
516diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
517index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565de2391d4 100644
518--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts
519+++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
520@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati
521 types here, to avoid issues while `pod install` takes place.
522 */
524+type SubSubAction = {
525+ id?: string;
526+ title: string;
527+ titleColor?: Int32;
528+ subtitle?: string;
529+ state?: string;
530+ image?: string;
531+ imageColor?: Int32;
532+ displayInline?: boolean;
533+ attributes?: {
534+ destructive?: boolean;
535+ disabled?: boolean;
536+ hidden?: boolean;
537+ keepsMenuPresented?: boolean;
538+ };
539+};
540 type SubAction = {
541 id?: string;
542 title: string;
543@@ -26,7 +42,11 @@ type SubAction = {
544 destructive?: boolean;
545 disabled?: boolean;
546 hidden?: boolean;
547+ keepsMenuPresented?: boolean;
548 };
549+ // One extra nesting level (menu → submenu → nested submenu leaves); the
550+ // codegen structs can't recurse, so depth is capped explicitly.
551+ subactions?: Array<SubSubAction>;
552 };
553 type MenuAction = {
554 id?: string;
555@@ -41,6 +61,7 @@ type MenuAction = {
556 destructive?: boolean;
557 disabled?: boolean;
558 hidden?: boolean;
559+ keepsMenuPresented?: boolean;
560 };
561 subactions?: Array<SubAction>;
562 };
563@@ -48,6 +69,7 @@ export interface NativeProps extends ViewProps {
564 onPressAction?: DirectEventHandler<{ event: string }>;
565 onCloseMenu?: DirectEventHandler<{ event: string }>;
566 onOpenMenu?: DirectEventHandler<{ event: string }>;
567+ onMenuInteractionStart?: DirectEventHandler<{ event: string }>;
568 actions: Array<MenuAction>;
569 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
570 title?: string;
571diff --git a/src/types.ts b/src/types.ts
572index 9c323d9c510e7359c83d4d6633affde673570632..5d5fdfb8b8235fcaa8295b85630443eaf5991a17 100644
573--- a/src/types.ts
574+++ b/src/types.ts
575@@ -123,6 +123,12 @@ type MenuComponentPropsBase = {
576 * Callback function that will be called when the menu opens.
577 */
578 onOpenMenu?: () => void;
579+ /**
580+ * Callback function that will be called when UIKit starts preparing a menu interaction.
581+ * Preparation can end without the menu opening.
582+ * @platform iOS
583+ */
584+ onMenuInteractionStart?: () => void;
585 /**
586 * Actions to be displayed in the menu.
587 */
588@@ -189,6 +195,7 @@ export type NativeMenuComponentProps = {
589 onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
590 onCloseMenu?: () => void;
591 onOpenMenu?: () => void;
592+ onMenuInteractionStart?: () => void;
593 actions: ProcessedMenuAction[];
594 actionsHash: string;
595 title?: string;