expo-tvos-search 1.3.0 → 1.4.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.
- package/CHANGELOG.md +111 -0
- package/README.md +212 -312
- package/build/index.d.ts +57 -5
- package/build/index.d.ts.map +1 -1
- package/ios/CachedAsyncImage.swift +78 -0
- package/ios/ExpoTvosSearchModule.swift +31 -7
- package/ios/ExpoTvosSearchView.swift +250 -421
- package/ios/HexColorParser.swift +55 -0
- package/ios/MarqueeText.swift +28 -51
- package/ios/SearchResultCard.swift +172 -0
- package/ios/SearchResultItem.swift +8 -0
- package/ios/Tests/HexColorParserTests.swift +183 -0
- package/ios/Tests/SearchViewModelTests.swift +1 -1
- package/ios/TvosSearchContentView.swift +125 -0
- package/package.json +2 -1
- package/src/__tests__/events.test.ts +128 -0
- package/src/__tests__/index.test.tsx +47 -1
- package/src/index.tsx +61 -5
|
@@ -1,49 +1,12 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
import SwiftUI
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
struct SelectiveRoundedRectangle: Shape {
|
|
9
|
-
var topLeadingRadius: CGFloat
|
|
10
|
-
var topTrailingRadius: CGFloat
|
|
11
|
-
var bottomLeadingRadius: CGFloat
|
|
12
|
-
var bottomTrailingRadius: CGFloat
|
|
13
|
-
|
|
14
|
-
func path(in rect: CGRect) -> Path {
|
|
15
|
-
var path = Path()
|
|
16
|
-
|
|
17
|
-
let tl = min(topLeadingRadius, min(rect.width, rect.height) / 2)
|
|
18
|
-
let tr = min(topTrailingRadius, min(rect.width, rect.height) / 2)
|
|
19
|
-
let bl = min(bottomLeadingRadius, min(rect.width, rect.height) / 2)
|
|
20
|
-
let br = min(bottomTrailingRadius, min(rect.width, rect.height) / 2)
|
|
21
|
-
|
|
22
|
-
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
|
|
23
|
-
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
|
|
24
|
-
path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr),
|
|
25
|
-
radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
|
|
26
|
-
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
|
|
27
|
-
path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br),
|
|
28
|
-
radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
|
29
|
-
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
|
|
30
|
-
path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl),
|
|
31
|
-
radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
|
32
|
-
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
|
|
33
|
-
path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl),
|
|
34
|
-
radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
|
35
|
-
path.closeSubpath()
|
|
36
|
-
|
|
37
|
-
return path
|
|
38
|
-
}
|
|
39
|
-
}
|
|
4
|
+
// React Native tvOS notification names for controlling gesture handler behavior
|
|
5
|
+
// These match the constants in RCTTVRemoteHandler.h
|
|
6
|
+
private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name("RCTTVDisableGestureHandlersCancelTouchesNotification")
|
|
7
|
+
private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name("RCTTVEnableGestureHandlersCancelTouchesNotification")
|
|
40
8
|
|
|
41
|
-
|
|
42
|
-
let id: String
|
|
43
|
-
let title: String
|
|
44
|
-
let subtitle: String?
|
|
45
|
-
let imageUrl: String?
|
|
46
|
-
}
|
|
9
|
+
#if os(tvOS)
|
|
47
10
|
|
|
48
11
|
/// ObservableObject that holds state for the search view.
|
|
49
12
|
/// This allows updating properties without recreating the entire view hierarchy.
|
|
@@ -54,323 +17,79 @@ class SearchViewModel: ObservableObject {
|
|
|
54
17
|
|
|
55
18
|
var onSearch: ((String) -> Void)?
|
|
56
19
|
var onSelectItem: ((String) -> Void)?
|
|
57
|
-
var columns: Int = 5
|
|
58
|
-
var placeholder: String = "Search
|
|
20
|
+
@Published var columns: Int = 5
|
|
21
|
+
@Published var placeholder: String = "Search..."
|
|
59
22
|
|
|
60
23
|
// Card styling options (configurable from JS)
|
|
61
|
-
var showTitle: Bool = false
|
|
62
|
-
var showSubtitle: Bool = false
|
|
63
|
-
var showFocusBorder: Bool = false
|
|
64
|
-
var topInset: CGFloat = 0 // Extra top padding for tab bar
|
|
24
|
+
@Published var showTitle: Bool = false
|
|
25
|
+
@Published var showSubtitle: Bool = false
|
|
26
|
+
@Published var showFocusBorder: Bool = false
|
|
27
|
+
@Published var topInset: CGFloat = 0 // Extra top padding for tab bar
|
|
65
28
|
|
|
66
29
|
// Title overlay options (configurable from JS)
|
|
67
|
-
var showTitleOverlay: Bool = true
|
|
68
|
-
var enableMarquee: Bool = true
|
|
69
|
-
var marqueeDelay: Double = 1.5
|
|
30
|
+
@Published var showTitleOverlay: Bool = true
|
|
31
|
+
@Published var enableMarquee: Bool = true
|
|
32
|
+
@Published var marqueeDelay: Double = 1.5
|
|
70
33
|
|
|
71
34
|
// State text options (configurable from JS)
|
|
72
|
-
var emptyStateText: String = "Search
|
|
73
|
-
var searchingText: String = "Searching..."
|
|
74
|
-
var noResultsText: String = "No results found"
|
|
75
|
-
var noResultsHintText: String = "Try a different search term"
|
|
35
|
+
@Published var emptyStateText: String = "Search your library"
|
|
36
|
+
@Published var searchingText: String = "Searching..."
|
|
37
|
+
@Published var noResultsText: String = "No results found"
|
|
38
|
+
@Published var noResultsHintText: String = "Try a different search term"
|
|
76
39
|
|
|
77
40
|
// Color customization options (configurable from JS)
|
|
78
|
-
var textColor: Color? = nil
|
|
79
|
-
var accentColor: Color = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312 (gold)
|
|
41
|
+
@Published var textColor: Color? = nil
|
|
42
|
+
@Published var accentColor: Color = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312 (gold)
|
|
80
43
|
|
|
81
44
|
// Card dimension options (configurable from JS)
|
|
82
|
-
var cardWidth: CGFloat = 280
|
|
83
|
-
var cardHeight: CGFloat = 420
|
|
45
|
+
@Published var cardWidth: CGFloat = 280
|
|
46
|
+
@Published var cardHeight: CGFloat = 420
|
|
84
47
|
|
|
85
48
|
// Image display options (configurable from JS)
|
|
86
|
-
var imageContentMode: ContentMode = .fill
|
|
49
|
+
@Published var imageContentMode: ContentMode = .fill
|
|
87
50
|
|
|
88
51
|
// Layout spacing options (configurable from JS)
|
|
89
|
-
var cardMargin: CGFloat = 40 // Spacing between cards
|
|
90
|
-
var cardPadding: CGFloat = 16 // Padding inside cards
|
|
91
|
-
var overlayTitleSize: CGFloat = 20 // Font size for overlay title
|
|
52
|
+
@Published var cardMargin: CGFloat = 40 // Spacing between cards
|
|
53
|
+
@Published var cardPadding: CGFloat = 16 // Padding inside cards
|
|
54
|
+
@Published var overlayTitleSize: CGFloat = 20 // Font size for overlay title
|
|
92
55
|
}
|
|
93
56
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
private var gridColumns: [GridItem] {
|
|
98
|
-
Array(repeating: GridItem(.flexible(), spacing: viewModel.cardMargin), count: viewModel.columns)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
var body: some View {
|
|
102
|
-
NavigationView {
|
|
103
|
-
ZStack {
|
|
104
|
-
Group {
|
|
105
|
-
if viewModel.results.isEmpty && viewModel.searchText.isEmpty {
|
|
106
|
-
emptyStateView
|
|
107
|
-
} else if viewModel.results.isEmpty && !viewModel.searchText.isEmpty {
|
|
108
|
-
if viewModel.isLoading {
|
|
109
|
-
searchingStateView
|
|
110
|
-
} else {
|
|
111
|
-
noResultsView
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
resultsGridView
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Loading overlay when loading with results
|
|
119
|
-
if viewModel.isLoading && !viewModel.results.isEmpty {
|
|
120
|
-
loadingOverlay
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
.searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
|
|
124
|
-
.onChange(of: viewModel.searchText) { newValue in
|
|
125
|
-
viewModel.onSearch?(newValue)
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
.padding(.top, viewModel.topInset)
|
|
129
|
-
.ignoresSafeArea(.all, edges: .top)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
private var emptyStateView: some View {
|
|
133
|
-
VStack(spacing: 20) {
|
|
134
|
-
Image(systemName: "magnifyingglass")
|
|
135
|
-
.font(.system(size: 80))
|
|
136
|
-
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
137
|
-
Text(viewModel.emptyStateText)
|
|
138
|
-
.font(.headline)
|
|
139
|
-
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
140
|
-
}
|
|
141
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private var searchingStateView: some View {
|
|
145
|
-
VStack(spacing: 20) {
|
|
146
|
-
ProgressView()
|
|
147
|
-
.scaleEffect(1.5)
|
|
148
|
-
Text(viewModel.searchingText)
|
|
149
|
-
.font(.headline)
|
|
150
|
-
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
151
|
-
}
|
|
152
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private var noResultsView: some View {
|
|
156
|
-
VStack(spacing: 20) {
|
|
157
|
-
Image(systemName: "film.stack")
|
|
158
|
-
.font(.system(size: 80))
|
|
159
|
-
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
160
|
-
Text(viewModel.noResultsText)
|
|
161
|
-
.font(.headline)
|
|
162
|
-
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
163
|
-
Text(viewModel.noResultsHintText)
|
|
164
|
-
.font(.subheadline)
|
|
165
|
-
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
166
|
-
}
|
|
167
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private var loadingOverlay: some View {
|
|
171
|
-
VStack {
|
|
172
|
-
HStack {
|
|
173
|
-
Spacer()
|
|
174
|
-
ProgressView()
|
|
175
|
-
.padding(16)
|
|
176
|
-
.background(Color.black.opacity(0.6))
|
|
177
|
-
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
178
|
-
}
|
|
179
|
-
.padding(.trailing, 60)
|
|
180
|
-
.padding(.top, 20)
|
|
181
|
-
Spacer()
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private var resultsGridView: some View {
|
|
186
|
-
ScrollView {
|
|
187
|
-
LazyVGrid(columns: gridColumns, spacing: viewModel.cardMargin) {
|
|
188
|
-
ForEach(viewModel.results) { item in
|
|
189
|
-
SearchResultCard(
|
|
190
|
-
item: item,
|
|
191
|
-
showTitle: viewModel.showTitle,
|
|
192
|
-
showSubtitle: viewModel.showSubtitle,
|
|
193
|
-
showFocusBorder: viewModel.showFocusBorder,
|
|
194
|
-
showTitleOverlay: viewModel.showTitleOverlay,
|
|
195
|
-
enableMarquee: viewModel.enableMarquee,
|
|
196
|
-
marqueeDelay: viewModel.marqueeDelay,
|
|
197
|
-
textColor: viewModel.textColor,
|
|
198
|
-
accentColor: viewModel.accentColor,
|
|
199
|
-
cardWidth: viewModel.cardWidth,
|
|
200
|
-
cardHeight: viewModel.cardHeight,
|
|
201
|
-
imageContentMode: viewModel.imageContentMode,
|
|
202
|
-
cardPadding: viewModel.cardPadding,
|
|
203
|
-
overlayTitleSize: viewModel.overlayTitleSize,
|
|
204
|
-
onSelect: { viewModel.onSelectItem?(item.id) }
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
.padding(.horizontal, 60)
|
|
209
|
-
.padding(.vertical, 40)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
57
|
+
class ExpoTvosSearchView: ExpoView {
|
|
58
|
+
private var hostingController: UIHostingController<TvosSearchContentView>?
|
|
59
|
+
private var viewModel = SearchViewModel()
|
|
213
60
|
|
|
214
|
-
|
|
215
|
-
let
|
|
216
|
-
let showTitle: Bool
|
|
217
|
-
let showSubtitle: Bool
|
|
218
|
-
let showFocusBorder: Bool
|
|
219
|
-
let showTitleOverlay: Bool
|
|
220
|
-
let enableMarquee: Bool
|
|
221
|
-
let marqueeDelay: Double
|
|
222
|
-
let textColor: Color?
|
|
223
|
-
let accentColor: Color
|
|
224
|
-
let cardWidth: CGFloat
|
|
225
|
-
let cardHeight: CGFloat
|
|
226
|
-
let imageContentMode: ContentMode
|
|
227
|
-
let cardPadding: CGFloat
|
|
228
|
-
let overlayTitleSize: CGFloat
|
|
229
|
-
let onSelect: () -> Void
|
|
230
|
-
@FocusState private var isFocused: Bool
|
|
231
|
-
|
|
232
|
-
private let placeholderColor = Color(white: 0.2)
|
|
233
|
-
|
|
234
|
-
// Title overlay constants
|
|
235
|
-
private var overlayHeight: CGFloat { cardHeight * 0.25 } // 25% of card
|
|
236
|
-
|
|
237
|
-
var body: some View {
|
|
238
|
-
Button(action: onSelect) {
|
|
239
|
-
VStack(alignment: .leading, spacing: showTitle || showSubtitle ? 12 : 0) {
|
|
240
|
-
ZStack(alignment: .bottom) {
|
|
241
|
-
// Card image content
|
|
242
|
-
ZStack {
|
|
243
|
-
placeholderColor
|
|
244
|
-
|
|
245
|
-
if let imageUrl = item.imageUrl, let url = URL(string: imageUrl) {
|
|
246
|
-
AsyncImage(url: url) { phase in
|
|
247
|
-
switch phase {
|
|
248
|
-
case .empty:
|
|
249
|
-
ProgressView()
|
|
250
|
-
case .success(let image):
|
|
251
|
-
image
|
|
252
|
-
.resizable()
|
|
253
|
-
.aspectRatio(contentMode: imageContentMode)
|
|
254
|
-
.frame(width: cardWidth, height: cardHeight)
|
|
255
|
-
case .failure:
|
|
256
|
-
placeholderIcon
|
|
257
|
-
@unknown default:
|
|
258
|
-
EmptyView()
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
.frame(width: cardWidth, height: cardHeight)
|
|
262
|
-
} else {
|
|
263
|
-
placeholderIcon
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
.frame(width: cardWidth, height: cardHeight)
|
|
267
|
-
.clipped()
|
|
268
|
-
|
|
269
|
-
// Title overlay with native material blur
|
|
270
|
-
if showTitleOverlay {
|
|
271
|
-
ZStack {
|
|
272
|
-
Rectangle()
|
|
273
|
-
.fill(.ultraThinMaterial)
|
|
274
|
-
.frame(width: cardWidth, height: overlayHeight)
|
|
275
|
-
|
|
276
|
-
if enableMarquee {
|
|
277
|
-
MarqueeText(
|
|
278
|
-
item.title,
|
|
279
|
-
font: .system(size: overlayTitleSize, weight: .semibold),
|
|
280
|
-
leftFade: 12,
|
|
281
|
-
rightFade: 12,
|
|
282
|
-
startDelay: marqueeDelay,
|
|
283
|
-
animate: isFocused
|
|
284
|
-
)
|
|
285
|
-
.foregroundColor(.white)
|
|
286
|
-
.padding(.horizontal, cardPadding)
|
|
287
|
-
} else {
|
|
288
|
-
Text(item.title)
|
|
289
|
-
.font(.system(size: overlayTitleSize, weight: .semibold))
|
|
290
|
-
.foregroundColor(.white)
|
|
291
|
-
.lineLimit(2)
|
|
292
|
-
.multilineTextAlignment(.center)
|
|
293
|
-
.padding(.horizontal, cardPadding)
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
.frame(width: cardWidth, height: overlayHeight)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
.frame(width: cardWidth, height: cardHeight)
|
|
300
|
-
.clipShape(
|
|
301
|
-
SelectiveRoundedRectangle(
|
|
302
|
-
topLeadingRadius: 12,
|
|
303
|
-
topTrailingRadius: 12,
|
|
304
|
-
bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
|
|
305
|
-
bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
|
|
306
|
-
)
|
|
307
|
-
)
|
|
308
|
-
.overlay(
|
|
309
|
-
SelectiveRoundedRectangle(
|
|
310
|
-
topLeadingRadius: 12,
|
|
311
|
-
topTrailingRadius: 12,
|
|
312
|
-
bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
|
|
313
|
-
bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
|
|
314
|
-
)
|
|
315
|
-
.stroke(showFocusBorder && isFocused ? accentColor : Color.clear, lineWidth: 4)
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
if showTitle || showSubtitle {
|
|
319
|
-
VStack(alignment: .leading, spacing: 4) {
|
|
320
|
-
if showTitle {
|
|
321
|
-
Text(item.title)
|
|
322
|
-
.font(.callout)
|
|
323
|
-
.fontWeight(.medium)
|
|
324
|
-
.lineLimit(2)
|
|
325
|
-
.multilineTextAlignment(.leading)
|
|
326
|
-
.foregroundColor(.primary)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if showSubtitle, let subtitle = item.subtitle {
|
|
330
|
-
Text(subtitle)
|
|
331
|
-
.font(.caption)
|
|
332
|
-
.foregroundColor(textColor ?? .secondary)
|
|
333
|
-
.lineLimit(1)
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
.padding(cardPadding)
|
|
337
|
-
.frame(width: cardWidth, alignment: .leading)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
.buttonStyle(.card)
|
|
342
|
-
.focused($isFocused)
|
|
343
|
-
}
|
|
61
|
+
/// Maximum length for string fields (id, title, subtitle) to prevent memory issues.
|
|
62
|
+
private static let maxStringFieldLength = 500
|
|
344
63
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
Circle()
|
|
348
|
-
.fill(Color.white.opacity(0.1))
|
|
349
|
-
.frame(width: 120, height: 120)
|
|
64
|
+
/// Maximum number of results to process to prevent memory exhaustion.
|
|
65
|
+
private static let maxResultsCount = 500
|
|
350
66
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
.foregroundColor(.white.opacity(0.7))
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
67
|
+
// Track if we've disabled RN gesture handlers for keyboard input
|
|
68
|
+
private var gestureHandlersDisabled = false
|
|
357
69
|
|
|
358
|
-
|
|
359
|
-
private var
|
|
360
|
-
private let viewModel = SearchViewModel()
|
|
70
|
+
// Store references to disabled gesture recognizers so we can re-enable them
|
|
71
|
+
private var disabledGestureRecognizers: [UIGestureRecognizer] = []
|
|
361
72
|
|
|
73
|
+
// Validation is handled by ExpoTvosSearchModule
|
|
362
74
|
var columns: Int = 5 {
|
|
363
75
|
didSet {
|
|
364
76
|
viewModel.columns = columns
|
|
365
77
|
}
|
|
366
78
|
}
|
|
367
79
|
|
|
368
|
-
var placeholder: String = "Search
|
|
80
|
+
var placeholder: String = "Search..." {
|
|
369
81
|
didSet {
|
|
370
82
|
viewModel.placeholder = placeholder
|
|
371
83
|
}
|
|
372
84
|
}
|
|
373
85
|
|
|
86
|
+
var searchTextProp: String? = nil {
|
|
87
|
+
didSet {
|
|
88
|
+
guard let text = searchTextProp, text != viewModel.searchText else { return }
|
|
89
|
+
viewModel.searchText = text
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
374
93
|
var isLoading: Bool = false {
|
|
375
94
|
didSet {
|
|
376
95
|
viewModel.isLoading = isLoading
|
|
@@ -419,7 +138,7 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
419
138
|
}
|
|
420
139
|
}
|
|
421
140
|
|
|
422
|
-
var emptyStateText: String = "Search
|
|
141
|
+
var emptyStateText: String = "Search your library" {
|
|
423
142
|
didSet {
|
|
424
143
|
viewModel.emptyStateText = emptyStateText
|
|
425
144
|
}
|
|
@@ -474,10 +193,8 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
474
193
|
var imageContentMode: String = "fill" {
|
|
475
194
|
didSet {
|
|
476
195
|
switch imageContentMode.lowercased() {
|
|
477
|
-
case "fit":
|
|
196
|
+
case "fit", "contain":
|
|
478
197
|
viewModel.imageContentMode = .fit
|
|
479
|
-
case "contain":
|
|
480
|
-
viewModel.imageContentMode = .fit // SwiftUI uses .fit for contain
|
|
481
198
|
default:
|
|
482
199
|
viewModel.imageContentMode = .fill
|
|
483
200
|
}
|
|
@@ -506,6 +223,8 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
506
223
|
let onSelectItem = EventDispatcher()
|
|
507
224
|
let onError = EventDispatcher()
|
|
508
225
|
let onValidationWarning = EventDispatcher()
|
|
226
|
+
let onSearchFieldFocused = EventDispatcher()
|
|
227
|
+
let onSearchFieldBlurred = EventDispatcher()
|
|
509
228
|
|
|
510
229
|
required init(appContext: AppContext? = nil) {
|
|
511
230
|
super.init(appContext: appContext)
|
|
@@ -513,14 +232,29 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
513
232
|
}
|
|
514
233
|
|
|
515
234
|
deinit {
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
235
|
+
// Remove notification observers explicitly (also auto-removed on dealloc, but explicit is safer)
|
|
236
|
+
NotificationCenter.default.removeObserver(self)
|
|
237
|
+
|
|
238
|
+
// Re-enable any disabled gesture recognizers (only needed on real hardware)
|
|
239
|
+
#if !targetEnvironment(simulator)
|
|
240
|
+
enableParentGestureRecognizers()
|
|
241
|
+
#endif
|
|
242
|
+
|
|
243
|
+
// Post notification to re-enable cancelsTouchesInView if needed
|
|
244
|
+
if gestureHandlersDisabled {
|
|
245
|
+
NotificationCenter.default.post(
|
|
246
|
+
name: RCTTVEnableGestureHandlersCancelTouchesNotification,
|
|
247
|
+
object: nil
|
|
248
|
+
)
|
|
249
|
+
}
|
|
521
250
|
}
|
|
522
251
|
|
|
523
252
|
private func setupView() {
|
|
253
|
+
let contentView = TvosSearchContentView(viewModel: viewModel)
|
|
254
|
+
let controller = UIHostingController(rootView: contentView)
|
|
255
|
+
controller.view.backgroundColor = .clear
|
|
256
|
+
hostingController = controller
|
|
257
|
+
|
|
524
258
|
// Configure viewModel callbacks
|
|
525
259
|
viewModel.onSearch = { [weak self] query in
|
|
526
260
|
self?.onSearch(["query": query])
|
|
@@ -529,12 +263,8 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
529
263
|
self?.onSelectItem(["id": id])
|
|
530
264
|
}
|
|
531
265
|
|
|
532
|
-
//
|
|
533
|
-
let
|
|
534
|
-
let controller = UIHostingController(rootView: contentView)
|
|
535
|
-
controller.view.backgroundColor = .clear
|
|
536
|
-
hostingController = controller
|
|
537
|
-
|
|
266
|
+
// Add hosting controller view with constraints
|
|
267
|
+
guard let controller = hostingController else { return }
|
|
538
268
|
addSubview(controller.view)
|
|
539
269
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
|
540
270
|
NSLayoutConstraint.activate([
|
|
@@ -543,15 +273,138 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
543
273
|
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
544
274
|
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor)
|
|
545
275
|
])
|
|
276
|
+
|
|
277
|
+
// Observe text field editing to detect when search keyboard is active
|
|
278
|
+
NotificationCenter.default.addObserver(
|
|
279
|
+
self,
|
|
280
|
+
selector: #selector(handleTextFieldDidBeginEditing),
|
|
281
|
+
name: UITextField.textDidBeginEditingNotification,
|
|
282
|
+
object: nil
|
|
283
|
+
)
|
|
284
|
+
NotificationCenter.default.addObserver(
|
|
285
|
+
self,
|
|
286
|
+
selector: #selector(handleTextFieldDidEndEditing),
|
|
287
|
+
name: UITextField.textDidEndEditingNotification,
|
|
288
|
+
object: nil
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@objc private func handleTextFieldDidBeginEditing(_ notification: Notification) {
|
|
293
|
+
guard let textField = notification.object as? UITextField,
|
|
294
|
+
let hostingView = hostingController?.view,
|
|
295
|
+
textField.isDescendant(of: hostingView) else {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Skip if already disabled
|
|
300
|
+
guard !gestureHandlersDisabled else { return }
|
|
301
|
+
gestureHandlersDisabled = true
|
|
302
|
+
|
|
303
|
+
// Post notification to RN to stop cancelling touches
|
|
304
|
+
NotificationCenter.default.post(
|
|
305
|
+
name: RCTTVDisableGestureHandlersCancelTouchesNotification,
|
|
306
|
+
object: nil
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
// Only disable parent gesture recognizers on real hardware.
|
|
310
|
+
// On the Simulator, the RCT notification alone is sufficient and
|
|
311
|
+
// disabling gesture recognizers interferes with keyboard input
|
|
312
|
+
// (Mac keyboard events are delivered as UIPress events through the
|
|
313
|
+
// responder chain, which breaks when recognizers are disabled).
|
|
314
|
+
#if !targetEnvironment(simulator)
|
|
315
|
+
disableParentGestureRecognizers()
|
|
316
|
+
#endif
|
|
317
|
+
|
|
318
|
+
// Fire JS event
|
|
319
|
+
onSearchFieldFocused([:])
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
@objc private func handleTextFieldDidEndEditing(_ notification: Notification) {
|
|
323
|
+
guard let textField = notification.object as? UITextField,
|
|
324
|
+
let hostingView = hostingController?.view,
|
|
325
|
+
textField.isDescendant(of: hostingView) else {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Skip if not disabled
|
|
330
|
+
guard gestureHandlersDisabled else { return }
|
|
331
|
+
gestureHandlersDisabled = false
|
|
332
|
+
|
|
333
|
+
// Re-enable gesture recognizers (only needed on real hardware)
|
|
334
|
+
#if !targetEnvironment(simulator)
|
|
335
|
+
enableParentGestureRecognizers()
|
|
336
|
+
#endif
|
|
337
|
+
|
|
338
|
+
// Post notification to RN
|
|
339
|
+
NotificationCenter.default.post(
|
|
340
|
+
name: RCTTVEnableGestureHandlersCancelTouchesNotification,
|
|
341
|
+
object: nil
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
// Fire JS event
|
|
345
|
+
onSearchFieldBlurred([:])
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// MARK: - Validation Warning Helper
|
|
349
|
+
|
|
350
|
+
/// Emits a validation warning event with optional debug-only context
|
|
351
|
+
private func emitWarning(type: String, message: String, context: String? = nil, debugContext: String? = nil) {
|
|
352
|
+
#if DEBUG
|
|
353
|
+
let ctx = debugContext ?? context ?? "validation completed"
|
|
354
|
+
#else
|
|
355
|
+
let ctx = context ?? "validation completed"
|
|
356
|
+
#endif
|
|
357
|
+
onValidationWarning(["type": type, "message": message, "context": ctx])
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// MARK: - Gesture Recognizer Management
|
|
361
|
+
|
|
362
|
+
/// Walks up the view hierarchy and disables tap/long-press gesture recognizers.
|
|
363
|
+
/// Swipe/pan recognizers are kept enabled for keyboard navigation.
|
|
364
|
+
private func disableParentGestureRecognizers() {
|
|
365
|
+
disabledGestureRecognizers.removeAll()
|
|
366
|
+
|
|
367
|
+
var currentView: UIView? = self.superview
|
|
368
|
+
while let view = currentView {
|
|
369
|
+
for recognizer in view.gestureRecognizers ?? [] {
|
|
370
|
+
// Only disable tap and long press recognizers
|
|
371
|
+
let isTapOrPress = recognizer is UITapGestureRecognizer ||
|
|
372
|
+
recognizer is UILongPressGestureRecognizer
|
|
373
|
+
if isTapOrPress && recognizer.isEnabled {
|
|
374
|
+
recognizer.isEnabled = false
|
|
375
|
+
disabledGestureRecognizers.append(recognizer)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
currentView = view.superview
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Re-enables all gesture recognizers that were previously disabled.
|
|
383
|
+
private func enableParentGestureRecognizers() {
|
|
384
|
+
for recognizer in disabledGestureRecognizers {
|
|
385
|
+
recognizer.isEnabled = true
|
|
386
|
+
}
|
|
387
|
+
disabledGestureRecognizers.removeAll()
|
|
546
388
|
}
|
|
547
389
|
|
|
548
390
|
func updateResults(_ results: [[String: Any]]) {
|
|
391
|
+
// Limit results to prevent memory exhaustion from malicious/buggy input
|
|
392
|
+
let limitedResults = results.prefix(Self.maxResultsCount)
|
|
393
|
+
let truncatedResultsCount = results.count - limitedResults.count
|
|
394
|
+
|
|
549
395
|
var validResults: [SearchResultItem] = []
|
|
550
396
|
var skippedCount = 0
|
|
551
397
|
var urlValidationFailures = 0
|
|
398
|
+
var httpUrlCount = 0
|
|
552
399
|
var truncatedFields = 0
|
|
553
400
|
|
|
554
|
-
|
|
401
|
+
#if DEBUG
|
|
402
|
+
if truncatedResultsCount > 0 {
|
|
403
|
+
print("[expo-tvos-search] Truncated \(truncatedResultsCount) results (max \(Self.maxResultsCount) allowed)")
|
|
404
|
+
}
|
|
405
|
+
#endif
|
|
406
|
+
|
|
407
|
+
for (index, dict) in limitedResults.enumerated() {
|
|
555
408
|
// Validate required fields
|
|
556
409
|
guard let id = dict["id"] as? String, !id.isEmpty else {
|
|
557
410
|
skippedCount += 1
|
|
@@ -572,29 +425,32 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
572
425
|
// Validate and sanitize imageUrl if present
|
|
573
426
|
var validatedImageUrl: String? = nil
|
|
574
427
|
if let imageUrl = dict["imageUrl"] as? String, !imageUrl.isEmpty {
|
|
575
|
-
// Accept HTTP/HTTPS URLs
|
|
428
|
+
// Accept HTTP/HTTPS URLs and data: URIs, reject other schemes for security
|
|
576
429
|
if let url = URL(string: imageUrl),
|
|
577
430
|
let scheme = url.scheme?.lowercased(),
|
|
578
|
-
scheme == "http" || scheme == "https" {
|
|
431
|
+
scheme == "http" || scheme == "https" || scheme == "data" {
|
|
579
432
|
validatedImageUrl = imageUrl
|
|
433
|
+
// Warn about insecure HTTP URLs (HTTPS recommended)
|
|
434
|
+
if scheme == "http" {
|
|
435
|
+
httpUrlCount += 1
|
|
436
|
+
#if DEBUG
|
|
437
|
+
print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): using insecure HTTP URL. HTTPS is recommended for security.")
|
|
438
|
+
#endif
|
|
439
|
+
}
|
|
580
440
|
} else {
|
|
581
441
|
urlValidationFailures += 1
|
|
582
442
|
#if DEBUG
|
|
583
|
-
print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): invalid imageUrl '\(imageUrl)'. Only HTTP/HTTPS URLs
|
|
443
|
+
print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): invalid imageUrl '\(imageUrl)'. Only HTTP/HTTPS URLs and data: URIs are supported.")
|
|
584
444
|
#endif
|
|
585
445
|
}
|
|
586
446
|
}
|
|
587
447
|
|
|
588
|
-
// Limit string lengths to prevent memory issues
|
|
589
|
-
let maxIdLength = 500
|
|
590
|
-
let maxTitleLength = 500
|
|
591
|
-
let maxSubtitleLength = 500
|
|
592
|
-
|
|
593
448
|
// Track if any fields were truncated
|
|
594
|
-
let
|
|
595
|
-
let titleTruncated = title.count > maxTitleLength
|
|
449
|
+
let maxLen = Self.maxStringFieldLength
|
|
596
450
|
let subtitle = dict["subtitle"] as? String
|
|
597
|
-
let
|
|
451
|
+
let idTruncated = id.count > maxLen
|
|
452
|
+
let titleTruncated = title.count > maxLen
|
|
453
|
+
let subtitleTruncated = (subtitle?.count ?? 0) > maxLen
|
|
598
454
|
|
|
599
455
|
if idTruncated || titleTruncated || subtitleTruncated {
|
|
600
456
|
truncatedFields += 1
|
|
@@ -608,9 +464,9 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
608
464
|
}
|
|
609
465
|
|
|
610
466
|
validResults.append(SearchResultItem(
|
|
611
|
-
id: String(id.prefix(
|
|
612
|
-
title: String(title.prefix(
|
|
613
|
-
subtitle: subtitle.map { String($0.prefix(
|
|
467
|
+
id: String(id.prefix(maxLen)),
|
|
468
|
+
title: String(title.prefix(maxLen)),
|
|
469
|
+
subtitle: subtitle.map { String($0.prefix(maxLen)) },
|
|
614
470
|
imageUrl: validatedImageUrl
|
|
615
471
|
))
|
|
616
472
|
}
|
|
@@ -618,58 +474,47 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
618
474
|
// Log summary of validation issues and emit warnings
|
|
619
475
|
#if DEBUG
|
|
620
476
|
if skippedCount > 0 {
|
|
621
|
-
print("[expo-tvos-search]
|
|
477
|
+
print("[expo-tvos-search] Skipped \(skippedCount) result(s) due to missing required fields (id or title)")
|
|
622
478
|
}
|
|
623
479
|
if urlValidationFailures > 0 {
|
|
624
|
-
print("[expo-tvos-search]
|
|
480
|
+
print("[expo-tvos-search] \(urlValidationFailures) image URL(s) failed validation (non-HTTP/HTTPS or malformed)")
|
|
481
|
+
}
|
|
482
|
+
if httpUrlCount > 0 {
|
|
483
|
+
print("[expo-tvos-search] \(httpUrlCount) image URL(s) use insecure HTTP. HTTPS is recommended.")
|
|
625
484
|
}
|
|
626
485
|
if truncatedFields > 0 {
|
|
627
|
-
print("[expo-tvos-search]
|
|
486
|
+
print("[expo-tvos-search] Truncated \(truncatedFields) result(s) with fields exceeding maximum length (500 chars)")
|
|
628
487
|
}
|
|
629
488
|
if validResults.count > 0 {
|
|
630
|
-
print("[expo-tvos-search]
|
|
489
|
+
print("[expo-tvos-search] Processed \(validResults.count) valid result(s)")
|
|
631
490
|
}
|
|
632
491
|
#endif
|
|
633
492
|
|
|
634
493
|
// Emit validation warnings for production monitoring
|
|
494
|
+
if truncatedResultsCount > 0 {
|
|
495
|
+
emitWarning(type: "results_truncated",
|
|
496
|
+
message: "Truncated \(truncatedResultsCount) result(s) exceeding maximum of \(Self.maxResultsCount)",
|
|
497
|
+
context: "Consider implementing pagination")
|
|
498
|
+
}
|
|
635
499
|
if skippedCount > 0 {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
let skipContext = "validation completed"
|
|
640
|
-
#endif
|
|
641
|
-
|
|
642
|
-
onValidationWarning([
|
|
643
|
-
"type": "validation_failed",
|
|
644
|
-
"message": "Skipped \(skippedCount) result(s) due to missing required fields",
|
|
645
|
-
"context": skipContext
|
|
646
|
-
])
|
|
500
|
+
emitWarning(type: "validation_failed",
|
|
501
|
+
message: "Skipped \(skippedCount) result(s) due to missing required fields",
|
|
502
|
+
debugContext: "validResults=\(validResults.count), skipped=\(skippedCount)")
|
|
647
503
|
}
|
|
648
504
|
if urlValidationFailures > 0 {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
"message": "\(urlValidationFailures) image URL(s) failed validation",
|
|
658
|
-
"context": urlContext
|
|
659
|
-
])
|
|
505
|
+
emitWarning(type: "url_invalid",
|
|
506
|
+
message: "\(urlValidationFailures) image URL(s) failed validation",
|
|
507
|
+
debugContext: "Non-HTTP/HTTPS or malformed URLs")
|
|
508
|
+
}
|
|
509
|
+
if httpUrlCount > 0 {
|
|
510
|
+
emitWarning(type: "url_insecure",
|
|
511
|
+
message: "\(httpUrlCount) image URL(s) use insecure HTTP. HTTPS is recommended.",
|
|
512
|
+
context: "Consider using HTTPS URLs")
|
|
660
513
|
}
|
|
661
514
|
if truncatedFields > 0 {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
let truncContext = "validation completed"
|
|
666
|
-
#endif
|
|
667
|
-
|
|
668
|
-
onValidationWarning([
|
|
669
|
-
"type": "field_truncated",
|
|
670
|
-
"message": "Truncated \(truncatedFields) result(s) with fields exceeding 500 characters",
|
|
671
|
-
"context": truncContext
|
|
672
|
-
])
|
|
515
|
+
emitWarning(type: "field_truncated",
|
|
516
|
+
message: "Truncated \(truncatedFields) result(s) with fields exceeding 500 characters",
|
|
517
|
+
debugContext: "Check id, title, or subtitle field lengths")
|
|
673
518
|
}
|
|
674
519
|
|
|
675
520
|
// Ensure UI updates happen on main thread
|
|
@@ -682,34 +527,13 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
682
527
|
// MARK: - Color Extension for Hex String Parsing
|
|
683
528
|
extension Color {
|
|
684
529
|
/// Initialize a Color from a hex string (e.g., "#FFFFFF", "#FF5733", "FFC312")
|
|
685
|
-
/// Returns nil if the string cannot be parsed as a valid hex color
|
|
530
|
+
/// Returns nil if the string cannot be parsed as a valid hex color.
|
|
531
|
+
/// Parsing logic is in HexColorParser for testability.
|
|
686
532
|
init?(hex: String) {
|
|
687
|
-
let
|
|
688
|
-
var int: UInt64 = 0
|
|
689
|
-
|
|
690
|
-
guard Scanner(string: hex).scanHexInt64(&int) else {
|
|
691
|
-
return nil
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
let a, r, g, b: UInt64
|
|
695
|
-
switch hex.count {
|
|
696
|
-
case 3: // RGB (12-bit)
|
|
697
|
-
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
|
698
|
-
case 6: // RGB (24-bit)
|
|
699
|
-
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
|
700
|
-
case 8: // ARGB (32-bit)
|
|
701
|
-
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
702
|
-
default:
|
|
533
|
+
guard let rgba = HexColorParser.parse(hex) else {
|
|
703
534
|
return nil
|
|
704
535
|
}
|
|
705
|
-
|
|
706
|
-
self.init(
|
|
707
|
-
.sRGB,
|
|
708
|
-
red: Double(r) / 255,
|
|
709
|
-
green: Double(g) / 255,
|
|
710
|
-
blue: Double(b) / 255,
|
|
711
|
-
opacity: Double(a) / 255
|
|
712
|
-
)
|
|
536
|
+
self.init(.sRGB, red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha)
|
|
713
537
|
}
|
|
714
538
|
}
|
|
715
539
|
|
|
@@ -718,7 +542,8 @@ extension Color {
|
|
|
718
542
|
// Fallback for non-tvOS platforms (iOS)
|
|
719
543
|
class ExpoTvosSearchView: ExpoView {
|
|
720
544
|
var columns: Int = 5
|
|
721
|
-
var placeholder: String = "Search
|
|
545
|
+
var placeholder: String = "Search..."
|
|
546
|
+
var searchTextProp: String? = nil
|
|
722
547
|
var isLoading: Bool = false
|
|
723
548
|
var showTitle: Bool = false
|
|
724
549
|
var showSubtitle: Bool = false
|
|
@@ -727,7 +552,7 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
727
552
|
var showTitleOverlay: Bool = true
|
|
728
553
|
var enableMarquee: Bool = true
|
|
729
554
|
var marqueeDelay: Double = 1.5
|
|
730
|
-
var emptyStateText: String = "Search
|
|
555
|
+
var emptyStateText: String = "Search your library"
|
|
731
556
|
var searchingText: String = "Searching..."
|
|
732
557
|
var noResultsText: String = "No results found"
|
|
733
558
|
var noResultsHintText: String = "Try a different search term"
|
|
@@ -740,10 +565,14 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
740
565
|
var cardPadding: CGFloat = 16
|
|
741
566
|
var overlayTitleSize: CGFloat = 20
|
|
742
567
|
|
|
568
|
+
// Event dispatchers required by ExpoTvosSearchModule's Event() registration.
|
|
569
|
+
// Intentionally no-ops on non-tvOS — the fallback view never fires events.
|
|
743
570
|
let onSearch = EventDispatcher()
|
|
744
571
|
let onSelectItem = EventDispatcher()
|
|
745
572
|
let onError = EventDispatcher()
|
|
746
573
|
let onValidationWarning = EventDispatcher()
|
|
574
|
+
let onSearchFieldFocused = EventDispatcher()
|
|
575
|
+
let onSearchFieldBlurred = EventDispatcher()
|
|
747
576
|
|
|
748
577
|
required init(appContext: AppContext? = nil) {
|
|
749
578
|
super.init(appContext: appContext)
|