react-native-platform-components 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +19 -18
  2. package/android/src/main/java/com/platformcomponents/PCActionSheetView.kt +309 -0
  3. package/android/src/main/java/com/platformcomponents/PCActionSheetViewManager.kt +131 -0
  4. package/android/src/main/java/com/platformcomponents/PCButtonGroupView.kt +5 -1
  5. package/android/src/main/java/com/platformcomponents/PCButtonView.kt +5 -1
  6. package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +82 -27
  7. package/android/src/main/java/com/platformcomponents/PCContextMenuViewManager.kt +12 -13
  8. package/android/src/main/java/com/platformcomponents/PCMenuSupport.kt +66 -2
  9. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
  10. package/android/src/main/res/values/ids.xml +2 -0
  11. package/ios/PCActionSheet.h +12 -0
  12. package/ios/PCActionSheet.mm +160 -0
  13. package/ios/PCActionSheetView.swift +280 -0
  14. package/ios/PCContextMenu.mm +22 -1
  15. package/ios/PCContextMenu.swift +218 -15
  16. package/ios/PCMenuItems.h +2 -1
  17. package/ios/PCMenuSupport.swift +22 -3
  18. package/lib/commonjs/ActionSheet.js +85 -0
  19. package/lib/commonjs/ActionSheet.js.map +1 -0
  20. package/lib/commonjs/ActionSheetNativeComponent.ts +94 -0
  21. package/lib/commonjs/ButtonGroupNativeComponent.ts +2 -0
  22. package/lib/commonjs/ButtonNativeComponent.ts +2 -0
  23. package/lib/commonjs/ContextMenu.js +50 -4
  24. package/lib/commonjs/ContextMenu.js.map +1 -1
  25. package/lib/commonjs/ContextMenuNativeComponent.ts +29 -8
  26. package/lib/commonjs/index.js +11 -0
  27. package/lib/commonjs/index.js.map +1 -1
  28. package/lib/commonjs/index.web.js +7 -0
  29. package/lib/commonjs/index.web.js.map +1 -1
  30. package/lib/commonjs/jest.js +92 -5
  31. package/lib/commonjs/jest.js.map +1 -1
  32. package/lib/commonjs/menuItems.js +2 -1
  33. package/lib/commonjs/menuItems.js.map +1 -1
  34. package/lib/commonjs/selectionIdentifiers.js.map +1 -1
  35. package/lib/commonjs/web/ActionSheet.js +131 -0
  36. package/lib/commonjs/web/ActionSheet.js.map +1 -0
  37. package/lib/commonjs/web/components.js +3 -1
  38. package/lib/commonjs/web/components.js.map +1 -1
  39. package/lib/commonjs/web/containers.js +1 -0
  40. package/lib/commonjs/web/containers.js.map +1 -1
  41. package/lib/module/ActionSheet.js +78 -0
  42. package/lib/module/ActionSheet.js.map +1 -0
  43. package/lib/module/ActionSheetNativeComponent.ts +94 -0
  44. package/lib/module/ButtonGroupNativeComponent.ts +2 -0
  45. package/lib/module/ButtonNativeComponent.ts +2 -0
  46. package/lib/module/ContextMenu.js +51 -4
  47. package/lib/module/ContextMenu.js.map +1 -1
  48. package/lib/module/ContextMenuNativeComponent.ts +29 -8
  49. package/lib/module/index.js +1 -0
  50. package/lib/module/index.js.map +1 -1
  51. package/lib/module/index.web.js +1 -1
  52. package/lib/module/index.web.js.map +1 -1
  53. package/lib/module/jest.js +90 -5
  54. package/lib/module/jest.js.map +1 -1
  55. package/lib/module/menuItems.js +2 -1
  56. package/lib/module/menuItems.js.map +1 -1
  57. package/lib/module/selectionIdentifiers.js.map +1 -1
  58. package/lib/module/web/ActionSheet.js +126 -0
  59. package/lib/module/web/ActionSheet.js.map +1 -0
  60. package/lib/module/web/components.js +2 -0
  61. package/lib/module/web/components.js.map +1 -1
  62. package/lib/module/web/containers.js +1 -0
  63. package/lib/module/web/containers.js.map +1 -1
  64. package/lib/typescript/commonjs/docs/sidebars.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/ActionSheet.d.ts +98 -0
  66. package/lib/typescript/commonjs/src/ActionSheet.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/src/ActionSheetNativeComponent.d.ts +78 -0
  68. package/lib/typescript/commonjs/src/ActionSheetNativeComponent.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts +2 -0
  70. package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts +2 -0
  72. package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/ContextMenu.d.ts +22 -6
  74. package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts +20 -7
  76. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/src/index.d.ts +1 -0
  78. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/src/index.web.d.ts +2 -1
  80. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/src/jest.d.ts +17 -3
  82. package/lib/typescript/commonjs/src/jest.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/src/menuItems.d.ts +12 -0
  84. package/lib/typescript/commonjs/src/menuItems.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/src/selectionIdentifiers.d.ts +1 -1
  86. package/lib/typescript/commonjs/src/selectionIdentifiers.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/src/web/ActionSheet.d.ts +11 -0
  88. package/lib/typescript/commonjs/src/web/ActionSheet.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/src/web/components.d.ts +1 -0
  90. package/lib/typescript/commonjs/src/web/components.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/src/web/containers.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/src/webComponents.d.ts +2 -0
  93. package/lib/typescript/commonjs/src/webComponents.d.ts.map +1 -1
  94. package/lib/typescript/module/docs/sidebars.d.ts.map +1 -1
  95. package/lib/typescript/module/src/ActionSheet.d.ts +98 -0
  96. package/lib/typescript/module/src/ActionSheet.d.ts.map +1 -0
  97. package/lib/typescript/module/src/ActionSheetNativeComponent.d.ts +78 -0
  98. package/lib/typescript/module/src/ActionSheetNativeComponent.d.ts.map +1 -0
  99. package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts +2 -0
  100. package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts.map +1 -1
  101. package/lib/typescript/module/src/ButtonNativeComponent.d.ts +2 -0
  102. package/lib/typescript/module/src/ButtonNativeComponent.d.ts.map +1 -1
  103. package/lib/typescript/module/src/ContextMenu.d.ts +22 -6
  104. package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -1
  105. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +20 -7
  106. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -1
  107. package/lib/typescript/module/src/index.d.ts +1 -0
  108. package/lib/typescript/module/src/index.d.ts.map +1 -1
  109. package/lib/typescript/module/src/index.web.d.ts +2 -1
  110. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  111. package/lib/typescript/module/src/jest.d.ts +17 -3
  112. package/lib/typescript/module/src/jest.d.ts.map +1 -1
  113. package/lib/typescript/module/src/menuItems.d.ts +12 -0
  114. package/lib/typescript/module/src/menuItems.d.ts.map +1 -1
  115. package/lib/typescript/module/src/selectionIdentifiers.d.ts +1 -1
  116. package/lib/typescript/module/src/selectionIdentifiers.d.ts.map +1 -1
  117. package/lib/typescript/module/src/web/ActionSheet.d.ts +11 -0
  118. package/lib/typescript/module/src/web/ActionSheet.d.ts.map +1 -0
  119. package/lib/typescript/module/src/web/components.d.ts +1 -0
  120. package/lib/typescript/module/src/web/components.d.ts.map +1 -1
  121. package/lib/typescript/module/src/web/containers.d.ts.map +1 -1
  122. package/lib/typescript/module/src/webComponents.d.ts +2 -0
  123. package/lib/typescript/module/src/webComponents.d.ts.map +1 -1
  124. package/package.json +9 -2
  125. package/plugin/build/index.cjs +79 -6
  126. package/plugin/build/index.d.ts +24 -0
  127. package/plugin/build/index.d.ts.map +1 -1
  128. package/plugin/tsconfig.json +2 -1
  129. package/src/ActionSheet.tsx +193 -0
  130. package/src/ActionSheetNativeComponent.ts +94 -0
  131. package/src/ButtonGroupNativeComponent.ts +2 -0
  132. package/src/ButtonNativeComponent.ts +2 -0
  133. package/src/ContextMenu.tsx +71 -12
  134. package/src/ContextMenuNativeComponent.ts +29 -8
  135. package/src/index.tsx +1 -0
  136. package/src/index.web.tsx +2 -0
  137. package/src/jest.tsx +108 -4
  138. package/src/menuItems.ts +13 -0
  139. package/src/selectionIdentifiers.ts +1 -1
  140. package/src/web/ActionSheet.tsx +130 -0
  141. package/src/web/components.tsx +5 -0
  142. package/src/web/containers.tsx +1 -0
  143. package/src/webComponents.ts +2 -0
@@ -0,0 +1,280 @@
1
+ import os.log
2
+ import UIKit
3
+
4
+ private let logger = Logger(subsystem: "com.platformcomponents", category: "ActionSheet")
5
+
6
+ /// One action bridged from ObjC++ (src/ActionSheet.tsx). Icons aren't read:
7
+ /// UIAlertAction has no public image API.
8
+ struct PCActionSheetItem {
9
+ let id: String
10
+ let title: String
11
+ let destructive: Bool
12
+ let disabled: Bool
13
+ /// The action's own `haptics`; "" = the component's
14
+ let haptics: String
15
+ let testID: String
16
+
17
+ init(dictionary dict: [String: Any]) {
18
+ func string(_ key: String) -> String { (dict[key] as? String) ?? "" }
19
+ id = string("id")
20
+ title = string("title")
21
+ destructive = string("destructive") == "true"
22
+ disabled = string("disabled") == "true"
23
+ haptics = string("haptics")
24
+ testID = string("testID")
25
+ }
26
+ }
27
+
28
+ /// The ActionSheet: a `UIAlertController` in the `.actionSheet` style,
29
+ /// presented from the nearest view controller while `visible` is "open".
30
+ ///
31
+ /// The view covers the component's frame (the React children are mounted
32
+ /// next to it in the Fabric view) and takes no touches. Wherever UIKit shows
33
+ /// the sheet as a popover (iPad, and iPhone on iOS 26), the popover points
34
+ /// at the children; without children the sheet has no source.
35
+ @objcMembers
36
+ public final class PCActionSheetView: UIView {
37
+ // MARK: - Props (set from ObjC++)
38
+
39
+ public var sheetTitle: String = ""
40
+ public var message: String = ""
41
+
42
+ /// ObjC++ sets this as an array of dictionaries (see PCActionSheet.mm)
43
+ public var actions: [Any] = [] {
44
+ didSet {
45
+ items = actions.compactMap { any in
46
+ guard let dict = any as? [String: Any] else { return nil }
47
+ return PCActionSheetItem(dictionary: dict)
48
+ }
49
+ }
50
+ }
51
+
52
+ /// "" = the system's localized "Cancel"
53
+ public var cancelTitle: String = ""
54
+ public var cancelTestID: String = ""
55
+
56
+ /// "open" | "closed"
57
+ public var visible: String = "closed" {
58
+ didSet {
59
+ guard visible != oldValue else { return }
60
+ updatePresentation()
61
+ }
62
+ }
63
+
64
+ /// The `haptics` prop, played when an action is picked (an action's own
65
+ /// `haptics` wins)
66
+ public let haptics = PCHaptics()
67
+
68
+ // MARK: - Events back to ObjC++
69
+
70
+ public var onSelect: ((String) -> Void)?
71
+ public var onCancel: (() -> Void)?
72
+
73
+ // MARK: - Internal
74
+
75
+ private var items: [PCActionSheetItem] = []
76
+ /// The sheet on screen (or on its way), until an action ends it or it is
77
+ /// dismissed through `visible`
78
+ private var alert: UIAlertController?
79
+ /// Bumped on every visibility change, so a deferred presentation that
80
+ /// has been overtaken does nothing
81
+ private var presentationToken = 0
82
+
83
+ // MARK: - Init
84
+
85
+ public override init(frame: CGRect) {
86
+ super.init(frame: frame)
87
+ setup()
88
+ }
89
+
90
+ public required init?(coder: NSCoder) {
91
+ super.init(coder: coder)
92
+ setup()
93
+ }
94
+
95
+ private func setup() {
96
+ backgroundColor = .clear
97
+ // Only the popover's source: touches reach the children.
98
+ isUserInteractionEnabled = false
99
+ isAccessibilityElement = false
100
+ }
101
+
102
+ public override func didMoveToWindow() {
103
+ super.didMoveToWindow()
104
+ if window == nil {
105
+ // Unmounted, or its screen went away: the sheet goes too, without
106
+ // a callback.
107
+ dismissIfNeeded()
108
+ } else if visible == "open" {
109
+ // A sheet asked for before the view was on screen opens now.
110
+ updatePresentation()
111
+ }
112
+ }
113
+
114
+ /// Called by PCActionSheet.mm before Fabric reuses the view.
115
+ public func resetForRecycle() {
116
+ presentationToken += 1
117
+ dismissIfNeeded()
118
+ visible = "closed"
119
+ sheetTitle = ""
120
+ message = ""
121
+ actions = []
122
+ cancelTitle = ""
123
+ cancelTestID = ""
124
+ haptics.kind = ""
125
+ }
126
+
127
+ // MARK: - Presentation
128
+
129
+ private func updatePresentation() {
130
+ presentationToken += 1
131
+ if visible == "open" {
132
+ presentIfNeeded(token: presentationToken)
133
+ } else {
134
+ dismissIfNeeded()
135
+ }
136
+ }
137
+
138
+ private func dismissIfNeeded() {
139
+ guard let alert else { return }
140
+ self.alert = nil
141
+ // A programmatic dismissal calls no action handler, so neither
142
+ // onSelect nor onCancel fires.
143
+ if alert.presentingViewController != nil, !alert.isBeingDismissed {
144
+ alert.dismiss(animated: true)
145
+ }
146
+ }
147
+
148
+ /// Presents on the next run loop turn, so the frame the popover points
149
+ /// at is final and a sheet that is still closing has started to go.
150
+ private func presentIfNeeded(token: Int) {
151
+ DispatchQueue.main.async { [weak self] in
152
+ guard let self, self.presentationToken == token else { return }
153
+ guard self.visible == "open", self.window != nil else { return }
154
+ if let alert = self.alert {
155
+ // Still up (or presenting): nothing to do. A sheet dismissed
156
+ // behind our back (its presenter went away) is replaced.
157
+ if alert.presentingViewController != nil || alert.isBeingPresented { return }
158
+ self.alert = nil
159
+ }
160
+ guard let presenter = self.presenter() else {
161
+ logger.debug("presentIfNeeded: no view controller to present from")
162
+ return
163
+ }
164
+ // A sheet (ours or another controller) is still animating away:
165
+ // present once that transition ends.
166
+ if let coordinator = presenter.transitionCoordinator {
167
+ coordinator.animate(alongsideTransition: nil) { [weak self] _ in
168
+ self?.presentIfNeeded(token: token)
169
+ }
170
+ return
171
+ }
172
+ if presenter.presentedViewController != nil {
173
+ logger.debug("presentIfNeeded: the presenter is busy")
174
+ return
175
+ }
176
+ self.present(from: presenter)
177
+ }
178
+ }
179
+
180
+ private func present(from presenter: UIViewController) {
181
+ let alert = UIAlertController(
182
+ title: sheetTitle.isEmpty ? nil : sheetTitle,
183
+ message: message.isEmpty ? nil : message,
184
+ preferredStyle: .actionSheet
185
+ )
186
+
187
+ for item in items {
188
+ let action = UIAlertAction(
189
+ title: item.title,
190
+ style: item.destructive ? .destructive : .default
191
+ ) { [weak self, weak alert] _ in
192
+ guard let self, let alert, self.alert === alert else { return }
193
+ self.alert = nil
194
+ self.haptics.perform(in: self, override: item.haptics)
195
+ self.onSelect?(item.id)
196
+ }
197
+ action.isEnabled = !item.disabled
198
+ action.accessibilityIdentifier = item.testID.isEmpty ? nil : item.testID
199
+ alert.addAction(action)
200
+ }
201
+
202
+ // The cancel action: a row on iPhone, and on a popover (iPad) UIKit
203
+ // calls it for a tap outside instead of showing it.
204
+ let cancel = UIAlertAction(
205
+ title: cancelTitle.isEmpty ? Self.systemCancelTitle : cancelTitle,
206
+ style: .cancel
207
+ ) { [weak self, weak alert] _ in
208
+ guard let self, let alert, self.alert === alert else { return }
209
+ self.alert = nil
210
+ self.onCancel?()
211
+ }
212
+ cancel.accessibilityIdentifier = cancelTestID.isEmpty ? nil : cancelTestID
213
+ alert.addAction(cancel)
214
+
215
+ // With a source, UIKit shows the sheet as a popover pointing at it: on
216
+ // iPad, and on iPhone from iOS 26. Without children there is nothing
217
+ // to point at; an iPhone then shows the sheet with its Cancel row (in
218
+ // the middle of the screen on iOS 26, at the bottom before), and an
219
+ // iPad (which needs a source) a centered popover.
220
+ if let popover = alert.popoverPresentationController {
221
+ if let (host, frame) = childrenFrame() {
222
+ // Point at the children
223
+ popover.sourceView = host
224
+ popover.sourceRect = frame
225
+ } else if traitCollection.userInterfaceIdiom == .pad {
226
+ // Nothing to point at: the middle of the screen, no arrow
227
+ popover.sourceView = presenter.view
228
+ popover.sourceRect = CGRect(
229
+ x: presenter.view.bounds.midX,
230
+ y: presenter.view.bounds.midY,
231
+ width: 0,
232
+ height: 0
233
+ )
234
+ popover.permittedArrowDirections = []
235
+ }
236
+ }
237
+
238
+ logger.debug("present: \(self.items.count) actions")
239
+ self.alert = alert
240
+ presenter.present(alert, animated: true)
241
+ haptics.prepare(in: self)
242
+ }
243
+
244
+ /// The view the React children are mounted in and the frame they cover
245
+ /// there, or nil without children. Fabric mounts them next to this view
246
+ /// (its content view), so they are its siblings. The component's own
247
+ /// frame says nothing: a childless sheet in a column is full width and
248
+ /// 0 high, and must not become a popover pointing at that line.
249
+ private func childrenFrame() -> (UIView, CGRect)? {
250
+ guard let host = superview else { return nil }
251
+ let children = host.subviews.filter { $0 !== self }
252
+ guard let first = children.first else { return nil }
253
+ let frame = children.dropFirst().reduce(first.frame) { $0.union($1.frame) }
254
+ return (host, frame)
255
+ }
256
+
257
+ /// The controller to present from: the nearest one, or whatever it is
258
+ /// presenting already (a modal screen).
259
+ private func presenter() -> UIViewController? {
260
+ var responder: UIResponder? = self
261
+ var nearest: UIViewController?
262
+ while let next = responder?.next {
263
+ if let vc = next as? UIViewController {
264
+ nearest = vc
265
+ break
266
+ }
267
+ responder = next
268
+ }
269
+ guard var vc = nearest ?? window?.rootViewController else { return nil }
270
+ while let presented = vc.presentedViewController, !presented.isBeingDismissed {
271
+ vc = presented
272
+ }
273
+ return vc
274
+ }
275
+
276
+ /// UIKit's own localized "Cancel".
277
+ private static var systemCancelTitle: String {
278
+ Bundle(for: UIApplication.self).localizedString(forKey: "Cancel", value: "Cancel", table: nil)
279
+ }
280
+ }
@@ -9,6 +9,7 @@
9
9
  #import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
10
10
  #import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
11
11
  #import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
12
+ #import <react/renderer/components/PlatformComponentsViewSpec/RCTComponentViewHelpers.h>
12
13
 
13
14
  #if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
14
15
  #import <PlatformComponents/PlatformComponents-Swift.h>
@@ -21,6 +22,9 @@
21
22
  using namespace facebook::react;
22
23
  using namespace platformcomponents;
23
24
 
25
+ @interface PCContextMenu () <RCTPCContextMenuViewProtocol>
26
+ @end
27
+
24
28
  @implementation PCContextMenu {
25
29
  PCContextMenuView *_view;
26
30
  }
@@ -129,7 +133,7 @@ using namespace platformcomponents;
129
133
  }
130
134
  }
131
135
 
132
- // trigger: "longPress" | "tap"
136
+ // trigger: "longPress" | "tap" | "none"
133
137
  if (!prevProps || newProps.trigger != prevProps->trigger) {
134
138
  if (!newProps.trigger.empty()) {
135
139
  _view.trigger =
@@ -139,6 +143,12 @@ using namespace platformcomponents;
139
143
  }
140
144
  }
141
145
 
146
+ // visible: "open" | "closed" | "" (unset, treated as closed). Set only on
147
+ // a change, so a menu the user opened isn't dismissed by an unrelated update.
148
+ if (!prevProps || newProps.visible != prevProps->visible) {
149
+ _view.visible = newProps.visible == "open" ? @"open" : @"closed";
150
+ }
151
+
142
152
  if (!prevProps || newProps.haptics != prevProps->haptics) {
143
153
  _view.haptics.kind = [NSString stringWithUTF8String:newProps.haptics.c_str()];
144
154
  }
@@ -158,6 +168,17 @@ using namespace platformcomponents;
158
168
  [super updateProps:props oldProps:oldProps];
159
169
  }
160
170
 
171
+ #pragma mark - Commands
172
+
173
+ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
174
+ RCTPCContextMenuHandleCommand(self, commandName, args);
175
+ }
176
+
177
+ // trigger "tap": a tap on the content that no inner pressable took
178
+ - (void)handleTap {
179
+ [_view handleTap];
180
+ }
181
+
161
182
  @end
162
183
 
163
184
  Class<RCTComponentViewProtocol> PCContextMenuCls(void) {
@@ -6,7 +6,7 @@ private let logger = Logger(subsystem: "com.platformcomponents", category: "Cont
6
6
  // MARK: - Main View
7
7
 
8
8
  @objcMembers
9
- public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
9
+ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate, UIGestureRecognizerDelegate {
10
10
  // MARK: - Props (set from ObjC++)
11
11
 
12
12
  /// Menu title (shown as header on iOS)
@@ -28,9 +28,13 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
28
28
  }
29
29
  }
30
30
 
31
- /// "longPress" | "tap"
31
+ /// "longPress" | "tap" | "none"
32
32
  public var trigger: String = "longPress" { didSet { updateTrigger() } }
33
33
 
34
+ /// "open" | "closed". ObjC++ sets it only when the prop changes, so a
35
+ /// menu the user opened stays open while the prop stays "closed".
36
+ public var visible: String = "closed" { didSet { updatePresentation() } }
37
+
34
38
  /// iOS-specific: enable preview
35
39
  public var enablePreview: String = "false"
36
40
 
@@ -55,8 +59,22 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
55
59
  /// The long-press menu last handed to UIKit, to find the showing root.
56
60
  private var presentedMenu: UIMenu?
57
61
 
58
- // Tap mode: UIButton with UIMenu for tap-to-show
62
+ // Tap mode: UIButton with UIMenu for tap-to-show. From iOS 17.4 it takes
63
+ // no touches (see hitTest), and JS opens it (see handleTap).
59
64
  private var tapMenuButton: PCMenuButton?
65
+ private var touchRecorder: UIGestureRecognizer?
66
+ /// Where the last touch on the content went down (see installTouchRecorder)
67
+ private weak var lastTouchView: UIView?
68
+
69
+ /// Invisible anchor of a menu opened through `visible` in longPress and
70
+ /// none modes (tap mode opens its own button). It never takes a touch;
71
+ /// see hitTest.
72
+ private var anchorMenuButton: PCMenuButton?
73
+ private var presentationToken = 0
74
+
75
+ private var isAnyMenuVisible: Bool {
76
+ isMenuVisible || tapMenuButton?.isMenuVisible == true || anchorMenuButton?.isMenuVisible == true
77
+ }
60
78
 
61
79
  // MARK: - Init
62
80
 
@@ -84,13 +102,41 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
84
102
  }
85
103
  }
86
104
 
105
+ /// This view lies over the React content and lets its touches through,
106
+ /// so the content's own pressables get them: the long-press interaction
107
+ /// sits on the parent component view, around the content, and JS opens
108
+ /// a tap menu for taps no pressable took. Only the tap button keeps its
109
+ /// touches, before iOS 17.4, where JS can't open its menu.
110
+ public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
111
+ guard let button = tapMenuButton, !PCMenuButton.canPresentMenuProgrammatically else { return nil }
112
+ let view = super.hitTest(point, with: event)
113
+ return view?.isDescendant(of: button) == true ? view : nil
114
+ }
115
+
116
+ public override func willMove(toSuperview newSuperview: UIView?) {
117
+ super.willMove(toSuperview: newSuperview)
118
+ removeContextMenuInteraction()
119
+ removeTouchRecorder()
120
+ }
121
+
122
+ public override func didMoveToSuperview() {
123
+ super.didMoveToSuperview()
124
+ // The long-press interaction goes on the new parent
125
+ updateTrigger()
126
+ }
87
127
 
128
+ public override func didMoveToWindow() {
129
+ super.didMoveToWindow()
130
+ // A menu asked for before the view was on screen opens now.
131
+ if window != nil, visible == "open" { updatePresentation() }
132
+ }
88
133
 
89
134
  private func updateEnabled() {
90
135
  let disabled = (interactivity == "disabled")
91
136
  alpha = disabled ? 0.5 : 1.0
92
137
  isUserInteractionEnabled = !disabled
93
138
  accessibilityTraits = disabled ? [.notEnabled] : []
139
+ if disabled { dismissMenu() }
94
140
  }
95
141
 
96
142
  private func sync() {
@@ -100,6 +146,9 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
100
146
  // Update tap menu content if in tap mode
101
147
  if trigger == "tap" {
102
148
  updateTapMenuButton()
149
+ } else if let anchor = anchorMenuButton, anchor.isMenuVisible {
150
+ // A menu opened through `visible`; updates it in place.
151
+ anchor.setMenu(buildMenu())
103
152
  } else if isMenuVisible, let interaction = contextMenuInteraction {
104
153
  // An action that keeps the menu presented changed a title or state.
105
154
  let menu = buildMenu()
@@ -117,36 +166,63 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
117
166
  // MARK: - Trigger Mode
118
167
 
119
168
  private func updateTrigger() {
120
- if trigger == "longPress" {
121
- // Install context menu interaction for long-press
122
- installContextMenuInteraction()
123
- removeTapMenuButton()
124
- } else {
169
+ switch trigger {
170
+ case "tap":
125
171
  // Tap mode: use UIButton with UIMenu for tap-to-show
126
172
  removeContextMenuInteraction()
127
173
  installTapMenuButton()
174
+ installTouchRecorder()
175
+ case "none":
176
+ // Only `visible` opens the menu, from the anchor
177
+ removeContextMenuInteraction()
178
+ removeTapMenuButton()
179
+ removeTouchRecorder()
180
+ default:
181
+ // Install context menu interaction for long-press
182
+ installContextMenuInteraction()
183
+ removeTapMenuButton()
184
+ removeTouchRecorder()
128
185
  }
129
186
  }
130
187
 
131
188
  private func installContextMenuInteraction() {
132
- guard contextMenuInteraction == nil else { return }
189
+ guard contextMenuInteraction == nil, let parentView = getParentComponentView() else { return }
133
190
 
134
- // Install interaction on self so that long-press on our view triggers the menu.
191
+ // Install interaction on the parent component view, which holds the React
192
+ // content, so a long-press anywhere on it (an inner button too) triggers the
193
+ // menu while taps still reach the content.
135
194
  // We use UITargetedPreview in the delegate methods to show the parent view
136
- // (which contains the React content) as the preview.
195
+ // as the preview.
137
196
  let interaction = UIContextMenuInteraction(delegate: self)
138
- addInteraction(interaction)
197
+ parentView.addInteraction(interaction)
139
198
  contextMenuInteraction = interaction
140
- logger.debug("Installed UIContextMenuInteraction on PCContextMenuView")
199
+ logger.debug("Installed UIContextMenuInteraction on the parent component view")
141
200
  }
142
201
 
143
202
  private func removeContextMenuInteraction() {
144
203
  guard let interaction = contextMenuInteraction else { return }
145
- removeInteraction(interaction)
204
+ interaction.view?.removeInteraction(interaction)
146
205
  contextMenuInteraction = nil
147
206
  logger.debug("Removed UIContextMenuInteraction")
148
207
  }
149
208
 
209
+ /// The long-press menu took the touch over, but React Native's touch
210
+ /// handler would still end it as a press of the pressable under the
211
+ /// finger. Cancels it the way RN does for its own touches: toggling
212
+ /// `isEnabled` cancels a gesture recognizer, and RN sends touchcancel.
213
+ private func cancelReactTouches() {
214
+ guard let handlerClass = NSClassFromString("RCTSurfaceTouchHandler") else { return }
215
+ var view = superview
216
+ while let current = view {
217
+ if let handler = current.gestureRecognizers?.first(where: { $0.isKind(of: handlerClass) }) {
218
+ handler.isEnabled = false
219
+ handler.isEnabled = true
220
+ return
221
+ }
222
+ view = current.superview
223
+ }
224
+ }
225
+
150
226
  // MARK: - Tap Mode Button
151
227
 
152
228
  private func installTapMenuButton() {
@@ -160,7 +236,13 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
160
236
  button.translatesAutoresizingMaskIntoConstraints = false
161
237
  // Make button invisible but still tappable
162
238
  button.tintColor = .clear
163
- button.onMenuOpen = { [weak self] in self?.onMenuOpen?() }
239
+ // Still VoiceOver's element for the menu where it lets touches through
240
+ button.opensMenuOnAccessibilityActivate = true
241
+ button.onMenuOpen = { [weak self] in
242
+ guard let self else { return }
243
+ self.haptics.prepare(in: self)
244
+ self.onMenuOpen?()
245
+ }
164
246
  button.onMenuClose = { [weak self] in self?.onMenuClose?() }
165
247
 
166
248
  addSubview(button)
@@ -186,6 +268,126 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
186
268
  button.setMenu(items.isEmpty ? nil : buildMenu())
187
269
  }
188
270
 
271
+ /// Tap mode, iOS 17.4+: JS calls this for a tap on the content that no
272
+ /// inner pressable took (React Native's responder system knows that: the
273
+ /// deepest responder wins). Earlier versions open the menu from the tap
274
+ /// button's own touch, and do nothing here.
275
+ public func handleTap() {
276
+ guard trigger == "tap", interactivity != "disabled", !items.isEmpty else { return }
277
+ guard let button = tapMenuButton, !isAnyMenuVisible else { return }
278
+ // A native control (this library's Button, a switch) handles its own
279
+ // taps, outside the JS responder system
280
+ guard !isInsideControl(lastTouchView) else { return }
281
+ logger.debug("handleTap: presenting \(self.items.count) items")
282
+ button.presentMenu()
283
+ }
284
+
285
+ // MARK: - Tap Mode Touch Recorder
286
+
287
+ /// Notes the view each touch on the content lands on, for handleTap,
288
+ /// without taking part in the touch.
289
+ private func installTouchRecorder() {
290
+ guard touchRecorder == nil, let parentView = getParentComponentView() else { return }
291
+ let recorder = UIGestureRecognizer(target: nil, action: nil)
292
+ recorder.cancelsTouchesInView = false
293
+ recorder.delegate = self
294
+ parentView.addGestureRecognizer(recorder)
295
+ touchRecorder = recorder
296
+ }
297
+
298
+ private func removeTouchRecorder() {
299
+ guard let recorder = touchRecorder else { return }
300
+ recorder.view?.removeGestureRecognizer(recorder)
301
+ touchRecorder = nil
302
+ lastTouchView = nil
303
+ }
304
+
305
+ public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
306
+ lastTouchView = touch.view
307
+ return false
308
+ }
309
+
310
+ /// Whether the view is, or is inside, an enabled UIControl of the content.
311
+ private func isInsideControl(_ view: UIView?) -> Bool {
312
+ var current = view
313
+ while let candidate = current, candidate !== superview {
314
+ if let control = candidate as? UIControl, control !== tapMenuButton, control.isEnabled {
315
+ return true
316
+ }
317
+ current = candidate.superview
318
+ }
319
+ return false
320
+ }
321
+
322
+ // MARK: - Programmatic presentation (`visible`)
323
+
324
+ private func installAnchorMenuButton() -> PCMenuButton {
325
+ if let button = anchorMenuButton { return button }
326
+
327
+ // Over the view's bounds, so the system menu attaches to the content
328
+ let button = PCMenuButton(type: .custom)
329
+ button.translatesAutoresizingMaskIntoConstraints = false
330
+ button.backgroundColor = .clear
331
+ button.isAccessibilityElement = false
332
+ button.onMenuOpen = { [weak self] in
333
+ guard let self else { return }
334
+ self.haptics.prepare(in: self)
335
+ self.onMenuOpen?()
336
+ }
337
+ button.onMenuClose = { [weak self] in self?.onMenuClose?() }
338
+
339
+ addSubview(button)
340
+ NSLayoutConstraint.activate([
341
+ button.topAnchor.constraint(equalTo: topAnchor),
342
+ button.bottomAnchor.constraint(equalTo: bottomAnchor),
343
+ button.leadingAnchor.constraint(equalTo: leadingAnchor),
344
+ button.trailingAnchor.constraint(equalTo: trailingAnchor)
345
+ ])
346
+
347
+ anchorMenuButton = button
348
+ return button
349
+ }
350
+
351
+ private func updatePresentation() {
352
+ presentationToken += 1
353
+ if visible == "open" {
354
+ presentMenuIfNeeded(token: presentationToken)
355
+ } else {
356
+ dismissMenu()
357
+ }
358
+ }
359
+
360
+ /// iOS 17.4+: opens the menu from the tap button or the anchor with
361
+ /// `UIControl.performPrimaryAction()`. Earlier versions have no public
362
+ /// API to open a menu without a touch, and do nothing.
363
+ private func presentMenuIfNeeded(token: Int) {
364
+ guard PCMenuButton.canPresentMenuProgrammatically else {
365
+ logger.debug("presentMenuIfNeeded: needs iOS 17.4")
366
+ return
367
+ }
368
+
369
+ // Next run loop turn, so the anchor has its final frame.
370
+ DispatchQueue.main.async { [weak self] in
371
+ guard let self else { return }
372
+ guard self.presentationToken == token, self.visible == "open" else { return }
373
+ guard self.interactivity != "disabled", !self.items.isEmpty else { return }
374
+ guard self.window != nil, !self.isAnyMenuVisible else { return }
375
+
376
+ let button = self.tapMenuButton ?? self.installAnchorMenuButton()
377
+ logger.debug("presentMenuIfNeeded: presenting \(self.items.count) items")
378
+ self.layoutIfNeeded()
379
+ button.setMenu(self.buildMenu())
380
+ button.presentMenu()
381
+ }
382
+ }
383
+
384
+ /// Closes the open menu, whichever way it opened.
385
+ private func dismissMenu() {
386
+ tapMenuButton?.dismissMenu()
387
+ anchorMenuButton?.dismissMenu()
388
+ if isMenuVisible { contextMenuInteraction?.dismissMenu() }
389
+ }
390
+
189
391
  // MARK: - UIContextMenuInteractionDelegate
190
392
 
191
393
  public func contextMenuInteraction(
@@ -281,6 +483,7 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
281
483
  ) {
282
484
  logger.debug("contextMenuInteraction: willDisplayMenu")
283
485
  isMenuVisible = true
486
+ cancelReactTouches()
284
487
  haptics.prepare(in: self)
285
488
  onMenuOpen?()
286
489
  }
package/ios/PCMenuItems.h CHANGED
@@ -38,6 +38,7 @@ NSDictionary *PCMenuItemToDictionary(const Item &item) {
38
38
  @"keepsMenuPresented" : PCMenuString(item.keepsMenuPresented),
39
39
  @"state" : PCMenuString(item.state),
40
40
  @"haptics" : PCMenuString(item.haptics),
41
+ @"testID" : PCMenuString(item.testID),
41
42
  };
42
43
  }
43
44
 
@@ -59,7 +60,7 @@ bool PCMenuItemEqual(const Item &a, const Item &b) {
59
60
  a.imageColor == b.imageColor && a.destructive == b.destructive &&
60
61
  a.disabled == b.disabled &&
61
62
  a.keepsMenuPresented == b.keepsMenuPresented && a.state == b.state &&
62
- a.haptics == b.haptics;
63
+ a.haptics == b.haptics && a.testID == b.testID;
63
64
  }
64
65
 
65
66
  /// Compares every field, so a changed title or state (a stepper or toggle