react-native-platform-components 0.5.5 → 0.6.1
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.
- package/README.md +342 -72
- package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +419 -0
- package/android/src/main/java/com/platformcomponents/PCContextMenuViewManager.kt +200 -0
- package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +4 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
- package/app.plugin.cjs +4 -0
- package/expo-module.config.json +4 -0
- package/ios/PCContextMenu.h +12 -0
- package/ios/PCContextMenu.mm +247 -0
- package/ios/PCContextMenu.swift +389 -0
- package/ios/PCDatePickerView.swift +25 -11
- package/lib/commonjs/ContextMenu.js +118 -0
- package/lib/commonjs/ContextMenu.js.map +1 -0
- package/lib/commonjs/ContextMenuNativeComponent.ts +141 -0
- package/lib/commonjs/DatePicker.js +86 -0
- package/lib/commonjs/DatePicker.js.map +1 -0
- package/lib/commonjs/DatePickerNativeComponent.ts +69 -0
- package/lib/commonjs/SelectionMenu.js +73 -0
- package/lib/commonjs/SelectionMenu.js.map +1 -0
- package/lib/commonjs/SelectionMenuNativeComponent.ts +97 -0
- package/lib/commonjs/index.js +50 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/sharedTypes.js +6 -0
- package/lib/commonjs/sharedTypes.js.map +1 -0
- package/lib/module/ContextMenu.js +111 -0
- package/lib/module/ContextMenu.js.map +1 -0
- package/lib/module/ContextMenuNativeComponent.ts +141 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/ContextMenu.d.ts +79 -0
- package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts +122 -0
- package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/{src → commonjs/src}/index.d.ts +1 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -0
- package/lib/typescript/module/src/ContextMenu.d.ts +79 -0
- package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +122 -0
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/DatePicker.d.ts +40 -0
- package/lib/typescript/module/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +54 -0
- package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/SelectionMenu.d.ts +47 -0
- package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +78 -0
- package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +5 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/sharedTypes.d.ts +12 -0
- package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -0
- package/package.json +32 -11
- package/plugin/build/index.cjs +26 -0
- package/plugin/build/index.d.ts +22 -0
- package/plugin/build/index.d.ts.map +1 -0
- package/plugin/tsconfig.json +16 -0
- package/src/ContextMenu.tsx +209 -0
- package/src/ContextMenuNativeComponent.ts +141 -0
- package/src/index.tsx +1 -0
- package/lib/typescript/src/DatePicker.d.ts.map +0 -1
- package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/SelectionMenu.d.ts.map +0 -1
- package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/index.d.ts.map +0 -1
- package/lib/typescript/src/sharedTypes.d.ts.map +0 -1
- /package/lib/typescript/{src → commonjs/src}/DatePicker.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/DatePickerNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/SelectionMenu.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/SelectionMenuNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/sharedTypes.d.ts +0 -0
- /package/lib/typescript/{package.json → module/package.json} +0 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// PCContextMenu.mm
|
|
2
|
+
|
|
3
|
+
#import "PCContextMenu.h"
|
|
4
|
+
|
|
5
|
+
#import <React/RCTComponentViewFactory.h>
|
|
6
|
+
#import <React/RCTConversions.h>
|
|
7
|
+
#import <React/RCTFabricComponentsPlugins.h>
|
|
8
|
+
|
|
9
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
|
|
10
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
|
|
11
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
|
|
12
|
+
|
|
13
|
+
#if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
|
|
14
|
+
#import <PlatformComponents/PlatformComponents-Swift.h>
|
|
15
|
+
#else
|
|
16
|
+
#import "PlatformComponents-Swift.h"
|
|
17
|
+
#endif
|
|
18
|
+
|
|
19
|
+
using namespace facebook::react;
|
|
20
|
+
|
|
21
|
+
namespace {
|
|
22
|
+
// Helper to convert subaction struct to NSDictionary
|
|
23
|
+
static NSDictionary *SubactionToDict(const PCContextMenuActionsSubactionsStruct &action) {
|
|
24
|
+
NSMutableDictionary *dict = [NSMutableDictionary new];
|
|
25
|
+
|
|
26
|
+
dict[@"id"] = action.id.empty() ? @"" : [NSString stringWithUTF8String:action.id.c_str()];
|
|
27
|
+
dict[@"title"] = action.title.empty() ? @"" : [NSString stringWithUTF8String:action.title.c_str()];
|
|
28
|
+
|
|
29
|
+
if (!action.subtitle.empty()) {
|
|
30
|
+
dict[@"subtitle"] = [NSString stringWithUTF8String:action.subtitle.c_str()];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!action.image.empty()) {
|
|
34
|
+
dict[@"image"] = [NSString stringWithUTF8String:action.image.c_str()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!action.imageColor.empty()) {
|
|
38
|
+
dict[@"imageColor"] = [NSString stringWithUTF8String:action.imageColor.c_str()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if any attributes field is non-empty
|
|
42
|
+
if (!action.attributes.destructive.empty() ||
|
|
43
|
+
!action.attributes.disabled.empty() ||
|
|
44
|
+
!action.attributes.hidden.empty()) {
|
|
45
|
+
NSMutableDictionary *attrs = [NSMutableDictionary new];
|
|
46
|
+
attrs[@"destructive"] = action.attributes.destructive.empty() ? @"false" :
|
|
47
|
+
[NSString stringWithUTF8String:action.attributes.destructive.c_str()];
|
|
48
|
+
attrs[@"disabled"] = action.attributes.disabled.empty() ? @"false" :
|
|
49
|
+
[NSString stringWithUTF8String:action.attributes.disabled.c_str()];
|
|
50
|
+
attrs[@"hidden"] = action.attributes.hidden.empty() ? @"false" :
|
|
51
|
+
[NSString stringWithUTF8String:action.attributes.hidden.c_str()];
|
|
52
|
+
dict[@"attributes"] = attrs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!action.state.empty()) {
|
|
56
|
+
dict[@"state"] = [NSString stringWithUTF8String:action.state.c_str()];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return dict;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Helper to convert action struct to NSDictionary
|
|
63
|
+
static NSDictionary *ActionToDict(const PCContextMenuActionsStruct &action) {
|
|
64
|
+
NSMutableDictionary *dict = [NSMutableDictionary new];
|
|
65
|
+
|
|
66
|
+
dict[@"id"] = action.id.empty() ? @"" : [NSString stringWithUTF8String:action.id.c_str()];
|
|
67
|
+
dict[@"title"] = action.title.empty() ? @"" : [NSString stringWithUTF8String:action.title.c_str()];
|
|
68
|
+
|
|
69
|
+
if (!action.subtitle.empty()) {
|
|
70
|
+
dict[@"subtitle"] = [NSString stringWithUTF8String:action.subtitle.c_str()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!action.image.empty()) {
|
|
74
|
+
dict[@"image"] = [NSString stringWithUTF8String:action.image.c_str()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!action.imageColor.empty()) {
|
|
78
|
+
dict[@"imageColor"] = [NSString stringWithUTF8String:action.imageColor.c_str()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check if any attributes field is non-empty
|
|
82
|
+
if (!action.attributes.destructive.empty() ||
|
|
83
|
+
!action.attributes.disabled.empty() ||
|
|
84
|
+
!action.attributes.hidden.empty()) {
|
|
85
|
+
NSMutableDictionary *attrs = [NSMutableDictionary new];
|
|
86
|
+
attrs[@"destructive"] = action.attributes.destructive.empty() ? @"false" :
|
|
87
|
+
[NSString stringWithUTF8String:action.attributes.destructive.c_str()];
|
|
88
|
+
attrs[@"disabled"] = action.attributes.disabled.empty() ? @"false" :
|
|
89
|
+
[NSString stringWithUTF8String:action.attributes.disabled.c_str()];
|
|
90
|
+
attrs[@"hidden"] = action.attributes.hidden.empty() ? @"false" :
|
|
91
|
+
[NSString stringWithUTF8String:action.attributes.hidden.c_str()];
|
|
92
|
+
dict[@"attributes"] = attrs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!action.state.empty()) {
|
|
96
|
+
dict[@"state"] = [NSString stringWithUTF8String:action.state.c_str()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Convert subactions (vector, not optional)
|
|
100
|
+
if (!action.subactions.empty()) {
|
|
101
|
+
NSMutableArray *subs = [NSMutableArray new];
|
|
102
|
+
for (const auto &sub : action.subactions) {
|
|
103
|
+
[subs addObject:SubactionToDict(sub)];
|
|
104
|
+
}
|
|
105
|
+
dict[@"subactions"] = subs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return dict;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static bool ActionsEqual(
|
|
112
|
+
const std::vector<PCContextMenuActionsStruct> &a,
|
|
113
|
+
const std::vector<PCContextMenuActionsStruct> &b) {
|
|
114
|
+
if (a.size() != b.size()) return false;
|
|
115
|
+
for (size_t i = 0; i < a.size(); i++) {
|
|
116
|
+
if (a[i].id != b[i].id) return false;
|
|
117
|
+
if (a[i].title != b[i].title) return false;
|
|
118
|
+
// Simplified comparison - in production, compare all fields
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
} // namespace
|
|
123
|
+
|
|
124
|
+
@implementation PCContextMenu {
|
|
125
|
+
PCContextMenuView *_view;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
129
|
+
return concreteComponentDescriptorProvider<PCContextMenuComponentDescriptor>();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
133
|
+
if (self = [super initWithFrame:frame]) {
|
|
134
|
+
_view = [PCContextMenuView new];
|
|
135
|
+
self.contentView = _view;
|
|
136
|
+
|
|
137
|
+
__weak __typeof(self) weakSelf = self;
|
|
138
|
+
|
|
139
|
+
_view.onPressAction = ^(NSString *actionId, NSString *actionTitle) {
|
|
140
|
+
__typeof(self) strongSelf = weakSelf;
|
|
141
|
+
if (!strongSelf) return;
|
|
142
|
+
|
|
143
|
+
auto eventEmitter =
|
|
144
|
+
std::static_pointer_cast<const PCContextMenuEventEmitter>(
|
|
145
|
+
strongSelf->_eventEmitter);
|
|
146
|
+
if (!eventEmitter) return;
|
|
147
|
+
|
|
148
|
+
PCContextMenuEventEmitter::OnPressAction payload = {
|
|
149
|
+
.actionId = actionId.UTF8String,
|
|
150
|
+
.actionTitle = actionTitle.UTF8String,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
eventEmitter->onPressAction(payload);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
_view.onMenuOpen = ^{
|
|
157
|
+
__typeof(self) strongSelf = weakSelf;
|
|
158
|
+
if (!strongSelf) return;
|
|
159
|
+
|
|
160
|
+
auto eventEmitter =
|
|
161
|
+
std::static_pointer_cast<const PCContextMenuEventEmitter>(
|
|
162
|
+
strongSelf->_eventEmitter);
|
|
163
|
+
if (!eventEmitter) return;
|
|
164
|
+
|
|
165
|
+
eventEmitter->onMenuOpen({});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
_view.onMenuClose = ^{
|
|
169
|
+
__typeof(self) strongSelf = weakSelf;
|
|
170
|
+
if (!strongSelf) return;
|
|
171
|
+
|
|
172
|
+
auto eventEmitter =
|
|
173
|
+
std::static_pointer_cast<const PCContextMenuEventEmitter>(
|
|
174
|
+
strongSelf->_eventEmitter);
|
|
175
|
+
if (!eventEmitter) return;
|
|
176
|
+
|
|
177
|
+
eventEmitter->onMenuClose({});
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return self;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
- (void)updateProps:(Props::Shared const &)props
|
|
184
|
+
oldProps:(Props::Shared const &)oldProps {
|
|
185
|
+
const auto &newProps =
|
|
186
|
+
*std::static_pointer_cast<const PCContextMenuProps>(props);
|
|
187
|
+
const auto prevProps =
|
|
188
|
+
std::static_pointer_cast<const PCContextMenuProps>(oldProps);
|
|
189
|
+
|
|
190
|
+
// title
|
|
191
|
+
if (!prevProps || newProps.title != prevProps->title) {
|
|
192
|
+
if (!newProps.title.empty()) {
|
|
193
|
+
_view.menuTitle = [NSString stringWithUTF8String:newProps.title.c_str()];
|
|
194
|
+
} else {
|
|
195
|
+
_view.menuTitle = nil;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// actions
|
|
200
|
+
if (!prevProps || !ActionsEqual(newProps.actions, prevProps->actions)) {
|
|
201
|
+
NSMutableArray *arr = [NSMutableArray new];
|
|
202
|
+
for (const auto &action : newProps.actions) {
|
|
203
|
+
[arr addObject:ActionToDict(action)];
|
|
204
|
+
}
|
|
205
|
+
_view.actions = arr;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// interactivity: "enabled" | "disabled"
|
|
209
|
+
if (!prevProps || newProps.interactivity != prevProps->interactivity) {
|
|
210
|
+
if (!newProps.interactivity.empty()) {
|
|
211
|
+
_view.interactivity =
|
|
212
|
+
[NSString stringWithUTF8String:newProps.interactivity.c_str()];
|
|
213
|
+
} else {
|
|
214
|
+
_view.interactivity = @"enabled";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// trigger: "longPress" | "tap"
|
|
219
|
+
if (!prevProps || newProps.trigger != prevProps->trigger) {
|
|
220
|
+
if (!newProps.trigger.empty()) {
|
|
221
|
+
_view.trigger =
|
|
222
|
+
[NSString stringWithUTF8String:newProps.trigger.c_str()];
|
|
223
|
+
} else {
|
|
224
|
+
_view.trigger = @"longPress";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// iOS-specific props
|
|
229
|
+
const auto &newIOS = newProps.ios;
|
|
230
|
+
const auto &oldIOS = prevProps ? prevProps->ios : PCContextMenuIosStruct{};
|
|
231
|
+
if (!prevProps || newIOS.enablePreview != oldIOS.enablePreview) {
|
|
232
|
+
if (!newIOS.enablePreview.empty()) {
|
|
233
|
+
_view.enablePreview =
|
|
234
|
+
[NSString stringWithUTF8String:newIOS.enablePreview.c_str()];
|
|
235
|
+
} else {
|
|
236
|
+
_view.enablePreview = @"false";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
[super updateProps:props oldProps:oldProps];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@end
|
|
244
|
+
|
|
245
|
+
Class<RCTComponentViewProtocol> PCContextMenuCls(void) {
|
|
246
|
+
return PCContextMenu.class;
|
|
247
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import os.log
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
private let logger = Logger(subsystem: "com.platformcomponents", category: "ContextMenu")
|
|
5
|
+
|
|
6
|
+
// MARK: - Action model (bridged from ObjC++ as dictionaries)
|
|
7
|
+
|
|
8
|
+
struct PCContextMenuAction {
|
|
9
|
+
let id: String
|
|
10
|
+
let title: String
|
|
11
|
+
let subtitle: String?
|
|
12
|
+
let image: String?
|
|
13
|
+
let imageColor: String?
|
|
14
|
+
let destructive: Bool
|
|
15
|
+
let disabled: Bool
|
|
16
|
+
let hidden: Bool
|
|
17
|
+
let state: String? // "off" | "on" | "mixed"
|
|
18
|
+
let subactions: [PCContextMenuAction]
|
|
19
|
+
|
|
20
|
+
init(from dict: [String: Any]) {
|
|
21
|
+
self.id = (dict["id"] as? String) ?? ""
|
|
22
|
+
self.title = (dict["title"] as? String) ?? ""
|
|
23
|
+
self.subtitle = dict["subtitle"] as? String
|
|
24
|
+
self.image = dict["image"] as? String
|
|
25
|
+
self.imageColor = dict["imageColor"] as? String
|
|
26
|
+
|
|
27
|
+
// Parse attributes
|
|
28
|
+
if let attrs = dict["attributes"] as? [String: Any] {
|
|
29
|
+
self.destructive = (attrs["destructive"] as? String) == "true"
|
|
30
|
+
self.disabled = (attrs["disabled"] as? String) == "true"
|
|
31
|
+
self.hidden = (attrs["hidden"] as? String) == "true"
|
|
32
|
+
} else {
|
|
33
|
+
self.destructive = false
|
|
34
|
+
self.disabled = false
|
|
35
|
+
self.hidden = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
self.state = dict["state"] as? String
|
|
39
|
+
|
|
40
|
+
// Parse subactions recursively
|
|
41
|
+
if let subs = dict["subactions"] as? [[String: Any]] {
|
|
42
|
+
self.subactions = subs.map { PCContextMenuAction(from: $0) }
|
|
43
|
+
} else {
|
|
44
|
+
self.subactions = []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Main View
|
|
50
|
+
|
|
51
|
+
@objcMembers
|
|
52
|
+
public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
53
|
+
// MARK: - Props (set from ObjC++)
|
|
54
|
+
|
|
55
|
+
/// Menu title (shown as header on iOS)
|
|
56
|
+
public var menuTitle: String? { didSet { sync() } }
|
|
57
|
+
|
|
58
|
+
/// ObjC++ sets this as an array of dictionaries
|
|
59
|
+
public var actions: [Any] = [] { didSet { sync() } }
|
|
60
|
+
|
|
61
|
+
/// "enabled" | "disabled"
|
|
62
|
+
public var interactivity: String = "enabled" {
|
|
63
|
+
didSet {
|
|
64
|
+
updateEnabled()
|
|
65
|
+
sync()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// "longPress" | "tap"
|
|
70
|
+
public var trigger: String = "longPress" { didSet { updateTrigger() } }
|
|
71
|
+
|
|
72
|
+
/// iOS-specific: enable preview
|
|
73
|
+
public var enablePreview: String = "false"
|
|
74
|
+
|
|
75
|
+
// MARK: - Events back to ObjC++
|
|
76
|
+
|
|
77
|
+
public var onPressAction: ((String, String) -> Void)? // (id, title)
|
|
78
|
+
public var onMenuOpen: (() -> Void)?
|
|
79
|
+
public var onMenuClose: (() -> Void)?
|
|
80
|
+
|
|
81
|
+
// MARK: - Internal
|
|
82
|
+
|
|
83
|
+
private var contextMenuInteraction: UIContextMenuInteraction?
|
|
84
|
+
private var parsedActions: [PCContextMenuAction] {
|
|
85
|
+
(actions as? [[String: Any]])?.map { PCContextMenuAction(from: $0) } ?? []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Tap mode: UIButton with UIMenu for tap-to-show
|
|
89
|
+
private var tapMenuButton: UIButton?
|
|
90
|
+
|
|
91
|
+
// MARK: - Init
|
|
92
|
+
|
|
93
|
+
public override init(frame: CGRect) {
|
|
94
|
+
super.init(frame: frame)
|
|
95
|
+
setup()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public required init?(coder: NSCoder) {
|
|
99
|
+
super.init(coder: coder)
|
|
100
|
+
setup()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func setup() {
|
|
104
|
+
backgroundColor = .clear
|
|
105
|
+
updateEnabled()
|
|
106
|
+
updateTrigger()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public override func layoutSubviews() {
|
|
110
|
+
super.layoutSubviews()
|
|
111
|
+
// Ensure tap button stays on top of React Native children
|
|
112
|
+
if let button = tapMenuButton {
|
|
113
|
+
bringSubviewToFront(button)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
private func updateEnabled() {
|
|
120
|
+
let disabled = (interactivity == "disabled")
|
|
121
|
+
alpha = disabled ? 0.5 : 1.0
|
|
122
|
+
isUserInteractionEnabled = !disabled
|
|
123
|
+
accessibilityTraits = disabled ? [.notEnabled] : []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func sync() {
|
|
127
|
+
// Re-setup trigger mode if needed
|
|
128
|
+
updateTrigger()
|
|
129
|
+
// Update tap menu content if in tap mode
|
|
130
|
+
if trigger == "tap" {
|
|
131
|
+
updateTapMenuButton()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// MARK: - Trigger Mode
|
|
136
|
+
|
|
137
|
+
private func updateTrigger() {
|
|
138
|
+
if trigger == "longPress" {
|
|
139
|
+
// Install context menu interaction for long-press
|
|
140
|
+
installContextMenuInteraction()
|
|
141
|
+
removeTapMenuButton()
|
|
142
|
+
} else {
|
|
143
|
+
// Tap mode: use UIButton with UIMenu for tap-to-show
|
|
144
|
+
removeContextMenuInteraction()
|
|
145
|
+
installTapMenuButton()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private func installContextMenuInteraction() {
|
|
150
|
+
guard contextMenuInteraction == nil else { return }
|
|
151
|
+
|
|
152
|
+
// Install interaction on self so that long-press on our view triggers the menu.
|
|
153
|
+
// We use UITargetedPreview in the delegate methods to show the parent view
|
|
154
|
+
// (which contains the React content) as the preview.
|
|
155
|
+
let interaction = UIContextMenuInteraction(delegate: self)
|
|
156
|
+
addInteraction(interaction)
|
|
157
|
+
contextMenuInteraction = interaction
|
|
158
|
+
logger.debug("Installed UIContextMenuInteraction on PCContextMenuView")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func removeContextMenuInteraction() {
|
|
162
|
+
guard let interaction = contextMenuInteraction else { return }
|
|
163
|
+
removeInteraction(interaction)
|
|
164
|
+
contextMenuInteraction = nil
|
|
165
|
+
logger.debug("Removed UIContextMenuInteraction")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// MARK: - Tap Mode Button
|
|
169
|
+
|
|
170
|
+
private func installTapMenuButton() {
|
|
171
|
+
guard tapMenuButton == nil else {
|
|
172
|
+
updateTapMenuButton()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let button = UIButton(type: .system)
|
|
177
|
+
button.backgroundColor = .clear
|
|
178
|
+
button.showsMenuAsPrimaryAction = true
|
|
179
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
180
|
+
// Make button invisible but still tappable
|
|
181
|
+
button.tintColor = .clear
|
|
182
|
+
|
|
183
|
+
addSubview(button)
|
|
184
|
+
NSLayoutConstraint.activate([
|
|
185
|
+
button.topAnchor.constraint(equalTo: topAnchor),
|
|
186
|
+
button.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
187
|
+
button.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
188
|
+
button.trailingAnchor.constraint(equalTo: trailingAnchor)
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
tapMenuButton = button
|
|
192
|
+
updateTapMenuButton()
|
|
193
|
+
logger.debug("Installed tap menu button")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private func removeTapMenuButton() {
|
|
197
|
+
tapMenuButton?.removeFromSuperview()
|
|
198
|
+
tapMenuButton = nil
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func updateTapMenuButton() {
|
|
202
|
+
guard let button = tapMenuButton else { return }
|
|
203
|
+
|
|
204
|
+
let actions = parsedActions.filter { !$0.hidden }
|
|
205
|
+
guard !actions.isEmpty else {
|
|
206
|
+
button.menu = nil
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let menu = buildMenu(from: actions, title: menuTitle)
|
|
211
|
+
button.menu = menu
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// MARK: - UIContextMenuInteractionDelegate
|
|
215
|
+
|
|
216
|
+
public func contextMenuInteraction(
|
|
217
|
+
_ interaction: UIContextMenuInteraction,
|
|
218
|
+
configurationForMenuAtLocation location: CGPoint
|
|
219
|
+
) -> UIContextMenuConfiguration? {
|
|
220
|
+
guard interactivity != "disabled" else { return nil }
|
|
221
|
+
|
|
222
|
+
let actions = parsedActions.filter { !$0.hidden }
|
|
223
|
+
guard !actions.isEmpty else { return nil }
|
|
224
|
+
|
|
225
|
+
let actionCountStr = String(actions.count)
|
|
226
|
+
logger.debug("contextMenuInteraction: creating configuration with \(actionCountStr) actions")
|
|
227
|
+
|
|
228
|
+
// Use nil previewProvider - we'll use UITargetedPreview via delegate instead
|
|
229
|
+
return UIContextMenuConfiguration(
|
|
230
|
+
identifier: nil,
|
|
231
|
+
previewProvider: nil,
|
|
232
|
+
actionProvider: { [weak self] suggestedActions in
|
|
233
|
+
guard let self else { return nil }
|
|
234
|
+
return self.buildMenu(from: actions, title: self.menuTitle)
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Get the parent component view (PCContextMenu) which contains all React content
|
|
240
|
+
private func getParentComponentView() -> UIView? {
|
|
241
|
+
return superview
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public func contextMenuInteraction(
|
|
245
|
+
_ interaction: UIContextMenuInteraction,
|
|
246
|
+
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
|
247
|
+
) -> UITargetedPreview? {
|
|
248
|
+
let parameters = UIPreviewParameters()
|
|
249
|
+
parameters.backgroundColor = .clear
|
|
250
|
+
|
|
251
|
+
// Check if preview is enabled via the enablePreview prop
|
|
252
|
+
if enablePreview == "true" {
|
|
253
|
+
// Target the parent view (PCContextMenu) which contains the React content
|
|
254
|
+
guard let parentView = getParentComponentView() else {
|
|
255
|
+
logger.debug("previewForHighlighting: no parent found")
|
|
256
|
+
return nil
|
|
257
|
+
}
|
|
258
|
+
let parentType = String(describing: type(of: parentView))
|
|
259
|
+
logger.debug("previewForHighlighting: targeting parent \(parentType)")
|
|
260
|
+
parameters.visiblePath = UIBezierPath(roundedRect: parentView.bounds, cornerRadius: 8)
|
|
261
|
+
return UITargetedPreview(view: parentView, parameters: parameters)
|
|
262
|
+
} else {
|
|
263
|
+
// When preview is disabled, target self with zero-size path
|
|
264
|
+
// This prevents iOS from manipulating the parent view and causing white flashes
|
|
265
|
+
logger.debug("previewForHighlighting: preview disabled, targeting self with zero path")
|
|
266
|
+
parameters.visiblePath = UIBezierPath(rect: .zero)
|
|
267
|
+
return UITargetedPreview(view: self, parameters: parameters)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
public func contextMenuInteraction(
|
|
272
|
+
_ interaction: UIContextMenuInteraction,
|
|
273
|
+
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
|
274
|
+
) -> UITargetedPreview? {
|
|
275
|
+
let parameters = UIPreviewParameters()
|
|
276
|
+
parameters.backgroundColor = .clear
|
|
277
|
+
|
|
278
|
+
if enablePreview == "true" {
|
|
279
|
+
guard let parentView = getParentComponentView() else {
|
|
280
|
+
return nil
|
|
281
|
+
}
|
|
282
|
+
parameters.visiblePath = UIBezierPath(roundedRect: parentView.bounds, cornerRadius: 8)
|
|
283
|
+
return UITargetedPreview(view: parentView, parameters: parameters)
|
|
284
|
+
} else {
|
|
285
|
+
parameters.visiblePath = UIBezierPath(rect: .zero)
|
|
286
|
+
return UITargetedPreview(view: self, parameters: parameters)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
public func contextMenuInteraction(
|
|
291
|
+
_ interaction: UIContextMenuInteraction,
|
|
292
|
+
willDisplayMenuFor configuration: UIContextMenuConfiguration,
|
|
293
|
+
animator: UIContextMenuInteractionAnimating?
|
|
294
|
+
) {
|
|
295
|
+
logger.debug("contextMenuInteraction: willDisplayMenu")
|
|
296
|
+
onMenuOpen?()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
public func contextMenuInteraction(
|
|
300
|
+
_ interaction: UIContextMenuInteraction,
|
|
301
|
+
willEndFor configuration: UIContextMenuConfiguration,
|
|
302
|
+
animator: UIContextMenuInteractionAnimating?
|
|
303
|
+
) {
|
|
304
|
+
logger.debug("contextMenuInteraction: willEnd")
|
|
305
|
+
onMenuClose?()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// MARK: - Menu Building
|
|
309
|
+
|
|
310
|
+
private func buildMenu(from actions: [PCContextMenuAction], title: String?) -> UIMenu {
|
|
311
|
+
let menuElements = actions.compactMap { buildMenuElement(from: $0) }
|
|
312
|
+
|
|
313
|
+
return UIMenu(
|
|
314
|
+
title: title ?? "",
|
|
315
|
+
children: menuElements
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private func buildMenuElement(from action: PCContextMenuAction) -> UIMenuElement? {
|
|
320
|
+
guard !action.hidden else { return nil }
|
|
321
|
+
|
|
322
|
+
// If has subactions, create a submenu
|
|
323
|
+
if !action.subactions.isEmpty {
|
|
324
|
+
let children = action.subactions.compactMap { buildMenuElement(from: $0) }
|
|
325
|
+
return UIMenu(
|
|
326
|
+
title: action.title,
|
|
327
|
+
image: imageForAction(action),
|
|
328
|
+
children: children
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Build action attributes
|
|
333
|
+
var attributes: UIMenuElement.Attributes = []
|
|
334
|
+
if action.destructive { attributes.insert(.destructive) }
|
|
335
|
+
if action.disabled { attributes.insert(.disabled) }
|
|
336
|
+
|
|
337
|
+
// Build state
|
|
338
|
+
let state: UIMenuElement.State
|
|
339
|
+
switch action.state {
|
|
340
|
+
case "on": state = .on
|
|
341
|
+
case "mixed": state = .mixed
|
|
342
|
+
default: state = .off
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let uiAction = UIAction(
|
|
346
|
+
title: action.title,
|
|
347
|
+
subtitle: action.subtitle,
|
|
348
|
+
image: imageForAction(action),
|
|
349
|
+
attributes: attributes,
|
|
350
|
+
state: state
|
|
351
|
+
) { [weak self] _ in
|
|
352
|
+
logger.debug("UIAction selected: id=\(action.id), title=\(action.title)")
|
|
353
|
+
self?.onPressAction?(action.id, action.title)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return uiAction
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private func imageForAction(_ action: PCContextMenuAction) -> UIImage? {
|
|
360
|
+
guard let imageName = action.image, !imageName.isEmpty else { return nil }
|
|
361
|
+
|
|
362
|
+
var image = UIImage(systemName: imageName)
|
|
363
|
+
|
|
364
|
+
// Apply tint color if specified
|
|
365
|
+
if let colorStr = action.imageColor, !colorStr.isEmpty, let color = colorFromString(colorStr) {
|
|
366
|
+
image = image?.withTintColor(color, renderingMode: .alwaysOriginal)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return image
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private func colorFromString(_ str: String) -> UIColor? {
|
|
373
|
+
// Support hex colors like "#FF0000" or "FF0000"
|
|
374
|
+
var hex = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
375
|
+
if hex.hasPrefix("#") {
|
|
376
|
+
hex = String(hex.dropFirst())
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
guard hex.count == 6, let rgbValue = UInt64(hex, radix: 16) else {
|
|
380
|
+
return nil
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let r = CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0
|
|
384
|
+
let g = CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0
|
|
385
|
+
let b = CGFloat(rgbValue & 0x0000FF) / 255.0
|
|
386
|
+
|
|
387
|
+
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -283,31 +283,45 @@ public final class PCDatePickerView: UIControl,
|
|
|
283
283
|
// Prevent "settle" events right as we present.
|
|
284
284
|
suppressNextChangesBriefly()
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
// Check if using inline style (full calendar) - needs larger popover size
|
|
287
|
+
var isInlineStyle = false
|
|
288
|
+
if #available(iOS 13.4, *) {
|
|
289
|
+
isInlineStyle = picker.preferredDatePickerStyle == .inline
|
|
290
|
+
}
|
|
289
291
|
|
|
292
|
+
let vc = UIViewController()
|
|
290
293
|
picker.translatesAutoresizingMaskIntoConstraints = false
|
|
291
294
|
vc.view.addSubview(picker)
|
|
292
295
|
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
// For inline style, use system background and constrain all edges
|
|
297
|
+
// For other styles, use clear background and only top/leading constraints
|
|
298
|
+
if isInlineStyle {
|
|
299
|
+
vc.view.backgroundColor = .systemBackground
|
|
300
|
+
vc.view.isOpaque = true
|
|
301
|
+
NSLayoutConstraint.activate([
|
|
302
|
+
picker.topAnchor.constraint(equalTo: vc.view.topAnchor, constant: 8),
|
|
303
|
+
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor, constant: 8),
|
|
304
|
+
picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor, constant: -8),
|
|
305
|
+
picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor, constant: -8),
|
|
306
|
+
])
|
|
307
|
+
} else {
|
|
308
|
+
vc.view.backgroundColor = .clear
|
|
309
|
+
vc.view.isOpaque = false
|
|
310
|
+
NSLayoutConstraint.activate([
|
|
311
|
+
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
|
|
312
|
+
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
|
|
313
|
+
])
|
|
314
|
+
}
|
|
298
315
|
|
|
299
316
|
let size = popoverContentSize()
|
|
300
317
|
vc.preferredContentSize = size
|
|
301
318
|
|
|
302
|
-
// ✅ Anchored popover-style (not a full sheet)
|
|
303
319
|
vc.modalPresentationStyle = .popover
|
|
304
320
|
vc.presentationController?.delegate = self
|
|
305
321
|
|
|
306
322
|
if let pop = vc.popoverPresentationController {
|
|
307
323
|
pop.delegate = self
|
|
308
324
|
pop.sourceView = self
|
|
309
|
-
// Use a minimum height for sourceRect to help popover positioning
|
|
310
|
-
// (modal presentation views have zero intrinsic height)
|
|
311
325
|
let sourceRect = CGRect(
|
|
312
326
|
x: bounds.minX,
|
|
313
327
|
y: bounds.minY,
|